Commit 911f2085 authored by Manoj M J's avatar Manoj M J Committed by Imre Farkas

Introduce Credentials Inventory

This change introduces new Credentials
Inventory feature, available for admins.
parent 34e8f5c6
......@@ -15,15 +15,43 @@ class KeysFinder
def execute
raise GitLabAccessDeniedError unless current_user.admin?
raise InvalidFingerprint unless valid_fingerprint_param?
Key.where(fingerprint_query).first # rubocop: disable CodeReuse/ActiveRecord
keys = by_key_type
keys = by_user(keys)
keys = sort(keys)
by_fingerprint(keys)
end
private
attr_reader :current_user, :params
def by_key_type
if params[:key_type] == 'ssh'
Key.regular_keys
else
Key.all
end
end
def sort(keys)
keys.order_last_used_at_desc
end
def by_user(keys)
return keys unless params[:user]
keys.for_user(params[:user])
end
def by_fingerprint(keys)
return keys unless params[:fingerprint].present?
raise InvalidFingerprint unless valid_fingerprint_param?
keys.where(fingerprint_query).first # rubocop: disable CodeReuse/ActiveRecord
end
def valid_fingerprint_param?
if fingerprint_type == "sha256"
Base64.decode64(fingerprint).length == 32
......
......@@ -13,18 +13,26 @@ class PersonalAccessTokensFinder
tokens = PersonalAccessToken.all
tokens = by_user(tokens)
tokens = by_impersonation(tokens)
by_state(tokens)
tokens = by_state(tokens)
sort(tokens)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def by_user(tokens)
return tokens unless @params[:user]
tokens.where(user: @params[:user])
tokens.for_user(@params[:user])
end
def sort(tokens)
available_sort_orders = PersonalAccessToken.simple_sorts.keys
return tokens unless available_sort_orders.include?(params[:sort])
tokens.order_by(params[:sort])
end
# rubocop: enable CodeReuse/ActiveRecord
def by_impersonation(tokens)
case @params[:impersonation]
......
......@@ -39,6 +39,10 @@ class Key < ApplicationRecord
alias_attribute :fingerprint_md5, :fingerprint
scope :preload_users, -> { preload(:user) }
scope :for_user, -> (user) { where(user: user) }
scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
def self.regular_keys
where(type: ['Key', nil])
end
......
......@@ -3,6 +3,7 @@
class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
add_authentication_token_field :token, digest: true
......@@ -20,6 +21,8 @@ class PersonalAccessToken < ApplicationRecord
scope :inactive, -> { where("revoked = true OR expires_at < NOW()") }
scope :with_impersonation, -> { where(impersonation: true) }
scope :without_impersonation, -> { where(impersonation: false) }
scope :for_user, -> (user) { where(user: user) }
scope :preload_users, -> { preload(:user) }
validates :scopes, presence: true
validate :validate_scopes
......
......@@ -182,6 +182,8 @@
%strong.fly-out-top-item-name
= _('Deploy Keys')
= render_if_exists 'layouts/nav/sidebar/credentials_link'
= nav_link(controller: :services) do
= link_to admin_application_settings_services_path do
.nav-icon-container
......
# frozen_string_literal: true
class AddIndexToKeys < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :keys, :last_used_at, order: { last_used_at: 'DESC NULLS LAST' }
end
def down
remove_concurrent_index :keys, :last_used_at
end
end
......@@ -2217,6 +2217,7 @@ ActiveRecord::Schema.define(version: 2019_12_14_175727) do
t.index ["fingerprint"], name: "index_keys_on_fingerprint", unique: true
t.index ["fingerprint_sha256"], name: "index_keys_on_fingerprint_sha256"
t.index ["id", "type"], name: "index_on_deploy_keys_id_and_type_and_public", unique: true, where: "(public = true)"
t.index ["last_used_at"], name: "index_keys_on_last_used_at", order: "DESC NULLS LAST"
t.index ["user_id"], name: "index_keys_on_user_id"
end
......
......@@ -16,3 +16,4 @@ GitLab’s [security features](../security/README.md) may also help you meet rel
|**[LDAP group sync filters](auth/ldap-ee.md#group-sync)**<br>GitLab Enterprise Edition Premium gives more flexibility to synchronize with LDAP based on filters, meaning you can leverage LDAP attributes to map GitLab permissions.|Premium+||
|**[Audit logs](audit_events.md)**<br>To maintain the integrity of your code, GitLab Enterprise Edition Premium gives admins the ability to view any modifications made within the GitLab server in an advanced audit log system, so you can control, analyze and track every change.|Premium+||
|**[Auditor users](auditor_users.md)**<br>Auditor users are users who are given read-only access to all projects, groups, and other resources on the GitLab instance.|Premium+||
|**[Credentials inventory](../user/admin_area/credentials_inventory.md)**<br>With a credentials inventory, GitLab administrators can keep track of the credentials used by all of the users in their GitLab instance. |Ultimate||
......@@ -124,6 +124,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
basic Postfix mail server with IMAP authentication on Ubuntu for incoming
emails.
- [Abuse reports](../user/admin_area/abuse_reports.md): View and resolve abuse reports from your users.
- [Credentials Inventory](../user/admin_area/credentials_inventory.md): With Credentials inventory, GitLab administrators can keep track of the credentials used by their users in their GitLab self-managed instance. **(ULTIMATE ONLY)**
## Project settings
......
# Credentials inventory **(ULTIMATE ONLY)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/20912) in GitLab 12.6.
## Overview
GitLab administrators are responsible for the overall security of their instance. To assist, GitLab provides a Credentials inventory to keep track of all the credentials that can be used to access their self-managed instance.
Using Credentials inventory, GitLab administrators can see all the personal access tokens and SSH keys that exist in their instance and:
- Who they belong to.
- Their access scope.
- Their usage pattern.
To access the Credentials inventory, navigate to **Admin Area > Credentials**.
The following is an example of the Credentials inventory page:
![Credentials inventory page](img/credentials_inventory_v12_6.png)
# frozen_string_literal: true
class Admin::CredentialsController < Admin::ApplicationController
include Admin::CredentialsHelper
before_action :check_license_credentials_inventory_available!
def index
@credentials = filter_credentials.page(params[:page]).preload_users.without_count
end
private
def filter_credentials
if show_personal_access_tokens?
::PersonalAccessTokensFinder.new({ user: nil, impersonation: false, state: 'active', sort: 'id_desc' }).execute
elsif show_ssh_keys?
::KeysFinder.new(current_user, { user: nil, key_type: 'ssh' }).execute
end
end
def check_license_credentials_inventory_available!
render_404 unless credentials_inventory_feature_available?
end
end
# frozen_string_literal: true
module Admin
module CredentialsHelper
VALID_FILTERS = %w(ssh_keys personal_access_tokens).freeze
def show_personal_access_tokens?
return true if params[:filter] == 'personal_access_tokens'
VALID_FILTERS.exclude? params[:filter]
end
def show_ssh_keys?
params[:filter] == 'ssh_keys'
end
def credentials_inventory_feature_available?
License.feature_available?(:credentials_inventory)
end
end
end
......@@ -106,6 +106,7 @@ class License < ApplicationRecord
EEU_FEATURES = EEP_FEATURES + %i[
cluster_health
container_scanning
credentials_inventory
dast
dependency_scanning
epics
......
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-25{ role: 'rowheader' }= _('Owner')
.table-section.section-30{ role: 'rowheader' }= _('Scope')
.table-section.section-10{ role: 'rowheader' }= _('Created On')
.table-section.section-10{ role: 'rowheader' }= _('Expiration')
= render partial: 'admin/credentials/personal_access_tokens/personal_access_token', collection: credentials
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Owner')
.table-section.section-15{ role: 'rowheader' }= _('Created On')
.table-section.section-15{ role: 'rowheader' }= _('Last Accessed On')
= render partial: 'admin/credentials/ssh_keys/ssh_key', collection: credentials
- page_title "Credentials"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
= icon('angle-left')
.fade-right
= icon('angle-right')
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(show_personal_access_tokens?) }) do
= link_to admin_credentials_path(filter: 'personal_access_tokens') do
= s_('AdminCredentials|Personal Access Tokens')
= nav_link(html_options: { class: active_when(show_ssh_keys?) }) do
= link_to admin_credentials_path(filter: 'ssh_keys') do
= s_('AdminCredentials|SSH Keys')
- if @credentials.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No credentials found')
- else
- if show_personal_access_tokens?
= render partial: 'admin/credentials/personal_access_tokens', locals: { credentials: @credentials }
- elsif show_ssh_keys?
= render partial: 'admin/credentials/ssh_keys', locals: { credentials: @credentials }
= paginate_without_count @credentials
.gl-responsive-table-row{ role: 'row', data: { qa_selector: 'credentials_personal_access_token_row_content' } }
.table-section.section-25
.table-mobile-header{ role: 'rowheader' }
= _('Owner')
.table-mobile-content
= render 'admin/users/user_detail', user: personal_access_token.user
.table-section.section-30
.table-mobile-header{ role: 'rowheader' }
= _('Scope')
.table-mobile-content
- scopes = personal_access_token.scopes
= scopes.present? ? scopes.join(", ") : _('No Scopes')
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }
= _('Created On')
.table-mobile-content
= personal_access_token.created_at.to_date
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }
= _('Expiration')
.table-mobile-content
- if personal_access_token.expires?
= personal_access_token.expires_at
- else
= _('Never')
.gl-responsive-table-row{ role: 'row', data: { qa_selector: 'credentials_ssh_key_row_content' } }
.table-section.section-40
.table-mobile-header{ role: 'rowheader' }
= _('Owner')
.table-mobile-content
= render 'admin/users/user_detail', user: ssh_key.user
.table-section.section-15
.table-mobile-header{ role: 'rowheader' }
= _('Created On')
.table-mobile-content
= ssh_key.created_at.to_date
.table-section.section-15
.table-mobile-header{ role: 'rowheader' }
= _('Last Accessed On')
.table-mobile-content
= (last_used_at = ssh_key.last_used_at).present? ? last_used_at.to_date : _('Never')
- if credentials_inventory_feature_available?
= nav_link(controller: :credentials) do
= link_to admin_credentials_path do
.nav-icon-container
= sprite_icon('lock')
%span.nav-item-name
= _('Credentials')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :credentials, html_options: { class: "fly-out-top-item" } ) do
= link_to admin_credentials_path do
%strong.fly-out-top-item-name
= _('Credentials')
---
title: Introduce Credentials Inventory
merge_request: 20912
author:
type: added
......@@ -20,6 +20,7 @@ namespace :admin do
resource :push_rule, only: [:show, :update]
resource :email, only: [:show, :create]
resources :audit_logs, controller: 'audit_logs', only: [:index]
resources :credentials, only: [:index]
resource :license, only: [:show, :new, :create, :destroy] do
get :download, on: :member
......
# frozen_string_literal: true
require 'spec_helper'
describe Admin::CredentialsController do
describe 'GET #index' do
context 'admin user' do
before do
sign_in(create(:admin))
end
context 'when `credentials_inventory` feature is enabled' do
before do
stub_licensed_features(credentials_inventory: true)
end
it 'responds with 200' do
get :index
expect(response).to have_gitlab_http_status(200)
end
describe 'filtering by type of credential' do
let_it_be(:personal_access_tokens) { create_list(:personal_access_token, 2) }
shared_examples_for 'filtering by `personal_access_tokens`' do
it do
get :index, params: params
expect(assigns(:credentials)).to match_array(personal_access_tokens)
end
end
context 'no credential type specified' do
let(:params) { {} }
it_behaves_like 'filtering by `personal_access_tokens`'
end
context 'non-existent credential type specified' do
let(:params) { { filter: 'non_existent_credential_type' } }
it_behaves_like 'filtering by `personal_access_tokens`'
end
context 'credential type specified as `personal_access_tokens`' do
let(:params) { { filter: 'personal_access_tokens' } }
it_behaves_like 'filtering by `personal_access_tokens`'
end
context 'credential type specified as `ssh_keys`' do
it 'filters by ssh keys' do
ssh_keys = create_list(:personal_key, 2)
get :index, params: { filter: 'ssh_keys' }
expect(assigns(:credentials)).to match_array(ssh_keys)
end
end
end
end
context 'when `credentials_inventory` feature is disabled' do
before do
stub_licensed_features(credentials_inventory: false)
end
it 'returns 404' do
get :index
expect(response).to have_gitlab_http_status(404)
end
end
end
context 'non-admin user' do
before do
sign_in(create(:user))
end
it 'returns 404' do
get :index
expect(response).to have_gitlab_http_status(404)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Admin::CredentialsHelper do
let(:filter) { nil }
before do
controller.params[:filter] = filter
end
describe '#credentials_inventory_feature_available?' do
subject { credentials_inventory_feature_available? }
context 'when credentials inventory feature is enabled' do
before do
stub_licensed_features(credentials_inventory: true)
end
it { is_expected.to be_truthy }
end
context 'when credentials inventory feature is disabled' do
before do
stub_licensed_features(credentials_inventory: false)
end
it { is_expected.to be_falsey }
end
end
describe '#show_ssh_keys?' do
subject { show_ssh_keys? }
context 'when filtering by ssh_keys' do
let(:filter) { 'ssh_keys' }
it { is_expected.to be_truthy }
end
context 'when filtering by a different, existent credential type' do
let(:filter) { 'personal_access_tokens' }
it { is_expected.to be_falsey }
end
context 'when filtering by a different, non-existent credential type' do
let(:filter) { 'non-existent-filter' }
it { is_expected.to be_falsey }
end
end
describe '#show_personal_access_tokens?' do
subject { show_personal_access_tokens? }
context 'when filtering by personal_access_tokens' do
let(:filter) { 'personal_access_tokens' }
it { is_expected.to be_truthy }
end
context 'when filtering by a different, existent credential type' do
let(:filter) { 'ssh_keys' }
it { is_expected.to be_falsey }
end
context 'when filtering by a different, non-existent credential type' do
let(:filter) { 'non-existent-filter' }
it { is_expected.to be_truthy }
end
end
end
......@@ -26,7 +26,9 @@ module API
get do
authenticated_with_full_private_access!
key = KeysFinder.new(current_user, params).execute
finder_params = params.merge(key_type: 'ssh')
key = KeysFinder.new(current_user, finder_params).execute
not_found!('Key') unless key
present key, with: Entities::SSHKeyWithUser, current_user: current_user
......
......@@ -1184,6 +1184,12 @@ msgstr ""
msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
msgstr ""
msgid "AdminCredentials|Personal Access Tokens"
msgstr ""
msgid "AdminCredentials|SSH Keys"
msgstr ""
msgid "AdminDashboard|Error loading the statistics. Please try again"
msgstr ""
......@@ -1328,6 +1334,9 @@ msgstr ""
msgid "AdminUsers|New user"
msgstr ""
msgid "AdminUsers|No credentials found"
msgstr ""
msgid "AdminUsers|No users found"
msgstr ""
......@@ -5117,6 +5126,9 @@ msgstr ""
msgid "Created At"
msgstr ""
msgid "Created On"
msgstr ""
msgid "Created a branch and a merge request to resolve this issue."
msgstr ""
......@@ -5165,6 +5177,9 @@ msgstr ""
msgid "Creation date"
msgstr ""
msgid "Credentials"
msgstr ""
msgid "Critical vulnerabilities present"
msgstr ""
......@@ -7208,6 +7223,9 @@ msgstr ""
msgid "Expand up"
msgstr ""
msgid "Expiration"
msgstr ""
msgid "Expiration date"
msgstr ""
......@@ -10237,6 +10255,9 @@ msgstr[1] ""
msgid "Last %{days} days"
msgstr ""
msgid "Last Accessed On"
msgstr ""
msgid "Last Pipeline"
msgstr ""
......@@ -11721,6 +11742,9 @@ msgstr ""
msgid "No Milestone"
msgstr ""
msgid "No Scopes"
msgstr ""
msgid "No Tag"
msgstr ""
......
......@@ -3,74 +3,145 @@
require 'spec_helper'
describe KeysFinder do
subject(:keys_finder) { described_class.new(user, params) }
subject { described_class.new(user, params).execute }
let(:user) { create(:user) }
let(:fingerprint_type) { 'md5' }
let(:fingerprint) { 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' }
let(:params) do
{
type: fingerprint_type,
fingerprint: fingerprint
}
end
let(:params) { {} }
let!(:key) do
create(:key, user: user,
let!(:key_1) do
create(:personal_key,
last_used_at: 7.days.ago,
user: user,
key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1',
fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg'
)
fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg')
end
let!(:key_2) { create(:personal_key, last_used_at: nil, user: user) }
let!(:key_3) { create(:personal_key, last_used_at: 2.days.ago) }
context 'with a regular user' do
it 'raises GitLabAccessDeniedError' do
expect do
keys_finder.execute
end.to raise_error(KeysFinder::GitLabAccessDeniedError)
expect { subject }.to raise_error(KeysFinder::GitLabAccessDeniedError)
end
end
context 'with an admin user' do
let(:user) {create(:admin)}
context 'with invalid MD5 fingerprint' do
let(:fingerprint) { '11:11:11:11' }
context 'key_type' do
let!(:deploy_key) { create(:deploy_key) }
it 'raises InvalidFingerprint' do
expect { keys_finder.execute }
.to raise_error(KeysFinder::InvalidFingerprint)
end
end
context 'when `key_type` is `ssh`' do
before do
params[:key_type] = 'ssh'
end
context 'with invalid SHA fingerprint' do
let(:fingerprint_type) { 'sha256' }
let(:fingerprint) { 'nUhzNyftwAAKs7HufskYTte2g' }
it 'returns only SSH keys' do
expect(subject).to contain_exactly(key_1, key_2, key_3)
end
end
it 'raises InvalidFingerprint' do
expect { keys_finder.execute }
.to raise_error(KeysFinder::InvalidFingerprint)
context 'when `key_type` is not specified' do
it 'returns all types of keys' do
expect(subject).to contain_exactly(key_1, key_2, key_3, deploy_key)
end
end
end
context 'with valid MD5 params' do
it 'returns key if the fingerprint is found' do
result = keys_finder.execute
context 'fingerprint' do
context 'with invalid fingerprint' do
context 'with invalid MD5 fingerprint' do
before do
params[:fingerprint] = '11:11:11:11'
end
it 'raises InvalidFingerprint' do
expect { subject }.to raise_error(KeysFinder::InvalidFingerprint)
end
end
context 'with invalid SHA fingerprint' do
before do
params[:fingerprint] = 'nUhzNyftwAAKs7HufskYTte2g'
end
it 'raises InvalidFingerprint' do
expect { subject }.to raise_error(KeysFinder::InvalidFingerprint)
end
end
end
expect(result).to eq(key)
expect(key.user).to eq(user)
context 'with valid fingerprints' do
context 'with valid MD5 params' do
context 'with an existent fingerprint' do
before do
params[:fingerprint] = 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1'
end
it 'returns the key' do
expect(subject).to eq(key_1)
expect(subject.user).to eq(user)
end
end
context 'with a non-existent fingerprint' do
before do
params[:fingerprint] = 'bb:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d2'
end
it 'returns nil' do
expect(subject).to be_nil
end
end
end
context 'with valid SHA256 params' do
context 'with an existent fingerprint' do
before do
params[:fingerprint] = 'SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg'
end
it 'returns key' do
expect(subject).to eq(key_1)
expect(subject.user).to eq(user)
end
end
context 'with a non-existent fingerprint' do
before do
params[:fingerprint] = 'SHA256:xTjuFqftwADy8AH3wFY31tAKs7HufskYTte2aXi/mNp'
end
it 'returns nil' do
expect(subject).to be_nil
end
end
end
end
end
context 'with valid SHA256 params' do
let(:fingerprint) { 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' }
context 'user' do
context 'without user' do
it 'contains ssh_keys of all users in the system' do
expect(subject).to contain_exactly(key_1, key_2, key_3)
end
end
context 'with user' do
before do
params[:user] = user
end
it 'returns key if the fingerprint is found' do
result = keys_finder.execute
it 'contains ssh_keys of only the specified users' do
expect(subject).to contain_exactly(key_1, key_2)
end
end
end
expect(result).to eq(key)
expect(key.user).to eq(user)
context 'sort order' do
it 'sorts in last_used_at_desc order' do
expect(subject).to eq([key_3, key_1, key_2])
end
end
end
......
......@@ -26,6 +26,16 @@ describe PersonalAccessTokensFinder do
revoked_impersonation_token, expired_impersonation_token)
end
describe 'with sort order' do
before do
params[:sort] = 'id_asc'
end
it 'sorts records as per the specified sort order' do
expect(subject).to match_array(PersonalAccessToken.all.order(id: :asc))
end
end
describe 'without impersonation' do
before do
params[:impersonation] = false
......
......@@ -50,6 +50,32 @@ describe Key, :mailer do
end
end
describe 'scopes' do
describe '.for_user' do
let(:user_1) { create(:user) }
let(:key_of_user_1) { create(:personal_key, user: user_1) }
before do
create_list(:personal_key, 2, user: create(:user))
end
it 'returns keys of the specified user only' do
expect(described_class.for_user(user_1)).to contain_exactly(key_of_user_1)
end
end
describe '.order_last_used_at_desc' do
it 'sorts by last_used_at descending, with null values at last' do
key_1 = create(:personal_key, last_used_at: 7.days.ago)
key_2 = create(:personal_key, last_used_at: nil)
key_3 = create(:personal_key, last_used_at: 2.days.ago)
expect(described_class.order_last_used_at_desc)
.to eq([key_3, key_1, key_2])
end
end
end
context "validation of uniqueness (based on fingerprint uniqueness)" do
let(:user) { create(:user) }
......
......@@ -21,6 +21,18 @@ describe PersonalAccessToken do
end
end
describe 'scopes' do
describe '.for_user' do
it 'returns personal access tokens of specified user only' do
user_1 = create(:user)
token_of_user_1 = create(:personal_access_token, user: user_1)
create_list(:personal_access_token, 2)
expect(described_class.for_user(user_1)).to contain_exactly(token_of_user_1)
end
end
end
describe ".active?" do
let(:active_personal_access_token) { build(:personal_access_token) }
let(:revoked_personal_access_token) { build(:personal_access_token, :revoked) }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment