diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb index d6ba7cb290dc6b2723ae8fad849dbc9af1370985..6fd914c88cdf6568a96fe2cf665bcb2e2f217956 100644 --- a/app/finders/keys_finder.rb +++ b/app/finders/keys_finder.rb @@ -15,15 +15,43 @@ def initialize(current_user, params) 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 diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index bd95dcd323fd52e22d2a6569329edf0763024c8d..7b15a3b0c1034e88bc36b38675f4eff354fe25bf 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -13,18 +13,26 @@ def execute 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] diff --git a/app/models/key.rb b/app/models/key.rb index f66aa4fb329f1d47c6cbe0cc2e9421aa3d5c01bd..e549c59b58fc7a91d2917fdd0f9d2ae4134b0f1b 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -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 diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 9ccc90fb74d2302ff70d460f69a0cd45d9b80d90..af079f7ebc4a8ed40f86ee8aefb140eff09a271c 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -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 diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 5bb92c8d1cf443e48d9fbc98e622c365852b0b82..71fef5df5bc09ce009b46a366fb5476eac962b5a 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -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 diff --git a/db/migrate/20191205060723_add_index_to_keys.rb b/db/migrate/20191205060723_add_index_to_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..8e8c725f62e3326f897440fcf2c2a25fe50b6a7b --- /dev/null +++ b/db/migrate/20191205060723_add_index_to_keys.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index 870eb22e1f1ef1d661ee6cf652fb1b9948b4afd6..7b0ecce825cb430e9cc05193599cd86d170741d5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2217,6 +2217,7 @@ 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 diff --git a/doc/administration/compliance.md b/doc/administration/compliance.md index 246addb6dc9a1f1e13896593a3b882cbd8c476e3..44e1cc8059adda25da44b8a9e00cdfdc90732c60 100644 --- a/doc/administration/compliance.md +++ b/doc/administration/compliance.md @@ -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|| diff --git a/doc/administration/index.md b/doc/administration/index.md index 1652b2872585d0d86d147f5323566a0c0b949e8f..2a9980cddb33ec0f2c168c8aa9e5076df4b78d88 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -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 diff --git a/doc/user/admin_area/credentials_inventory.md b/doc/user/admin_area/credentials_inventory.md new file mode 100644 index 0000000000000000000000000000000000000000..30ebbb5b6dbd4e8bb489a77375e749d471943fdd --- /dev/null +++ b/doc/user/admin_area/credentials_inventory.md @@ -0,0 +1,19 @@ +# 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: + + diff --git a/doc/user/admin_area/img/credentials_inventory_v12_6.png b/doc/user/admin_area/img/credentials_inventory_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..ff46db61cdb6ffa159c44ac3a80aef1413b13d7c Binary files /dev/null and b/doc/user/admin_area/img/credentials_inventory_v12_6.png differ diff --git a/ee/app/controllers/admin/credentials_controller.rb b/ee/app/controllers/admin/credentials_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..895766acdc4d7cc0a38720d70a4d51eedf1eea1b --- /dev/null +++ b/ee/app/controllers/admin/credentials_controller.rb @@ -0,0 +1,25 @@ +# 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 diff --git a/ee/app/helpers/admin/credentials_helper.rb b/ee/app/helpers/admin/credentials_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..3219388d7ea0dc3bd0eb6f10d3465d01d64d9859 --- /dev/null +++ b/ee/app/helpers/admin/credentials_helper.rb @@ -0,0 +1,21 @@ +# 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 diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index 4bb47ef0344283414496c6f9ab2220d17024897a..7bb944ccd82140c98803f0db651b2b77d35cbfae 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -106,6 +106,7 @@ class License < ApplicationRecord EEU_FEATURES = EEP_FEATURES + %i[ cluster_health container_scanning + credentials_inventory dast dependency_scanning epics diff --git a/ee/app/views/admin/credentials/_personal_access_tokens.html.haml b/ee/app/views/admin/credentials/_personal_access_tokens.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..613952f0cf5ff461154c3d09bbf229c107de3fb6 --- /dev/null +++ b/ee/app/views/admin/credentials/_personal_access_tokens.html.haml @@ -0,0 +1,8 @@ +.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 diff --git a/ee/app/views/admin/credentials/_ssh_keys.html.haml b/ee/app/views/admin/credentials/_ssh_keys.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..d7a7d608e0c8ccde16ff2b0fab3c536ccd9aacfa --- /dev/null +++ b/ee/app/views/admin/credentials/_ssh_keys.html.haml @@ -0,0 +1,7 @@ +.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 diff --git a/ee/app/views/admin/credentials/index.html.haml b/ee/app/views/admin/credentials/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..6104fc98687bfb28319dc87ee81ef5c264c481a8 --- /dev/null +++ b/ee/app/views/admin/credentials/index.html.haml @@ -0,0 +1,25 @@ +- 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 diff --git a/ee/app/views/admin/credentials/personal_access_tokens/_personal_access_token.html.haml b/ee/app/views/admin/credentials/personal_access_tokens/_personal_access_token.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b1e1a82e36c69466c9ca40b7d82d8ed7abb85dbb --- /dev/null +++ b/ee/app/views/admin/credentials/personal_access_tokens/_personal_access_token.html.haml @@ -0,0 +1,25 @@ +.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') diff --git a/ee/app/views/admin/credentials/ssh_keys/_ssh_key.html.haml b/ee/app/views/admin/credentials/ssh_keys/_ssh_key.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..fe0be36500896bfdd7e35743d3754d5965e836ab --- /dev/null +++ b/ee/app/views/admin/credentials/ssh_keys/_ssh_key.html.haml @@ -0,0 +1,16 @@ +.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') diff --git a/ee/app/views/layouts/nav/sidebar/_credentials_link.html.haml b/ee/app/views/layouts/nav/sidebar/_credentials_link.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..25acdc66299d159cc26d77476c0e83f03a7d1c46 --- /dev/null +++ b/ee/app/views/layouts/nav/sidebar/_credentials_link.html.haml @@ -0,0 +1,12 @@ +- 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') diff --git a/ee/changelogs/unreleased/36742-pat-ssh-inventory-mvc.yml b/ee/changelogs/unreleased/36742-pat-ssh-inventory-mvc.yml new file mode 100644 index 0000000000000000000000000000000000000000..77eb5b345f52656482779a3bd01e1dfed65aa4f7 --- /dev/null +++ b/ee/changelogs/unreleased/36742-pat-ssh-inventory-mvc.yml @@ -0,0 +1,5 @@ +--- +title: Introduce Credentials Inventory +merge_request: 20912 +author: +type: added diff --git a/ee/config/routes/admin.rb b/ee/config/routes/admin.rb index fba5a82b00203684cfaca51cb7cf71686b113e5b..bd8174c56076334ef9399b365c7792d417a0b41c 100644 --- a/ee/config/routes/admin.rb +++ b/ee/config/routes/admin.rb @@ -20,6 +20,7 @@ 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 diff --git a/ee/spec/controllers/admin/credentials_controller_spec.rb b/ee/spec/controllers/admin/credentials_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ab732cfc6d2480caac22ee063117531ba900f44 --- /dev/null +++ b/ee/spec/controllers/admin/credentials_controller_spec.rb @@ -0,0 +1,89 @@ +# 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 diff --git a/ee/spec/helpers/admin/credentials_helper_spec.rb b/ee/spec/helpers/admin/credentials_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a6487850706c64fb1e010722a10ddcf55548a1af --- /dev/null +++ b/ee/spec/helpers/admin/credentials_helper_spec.rb @@ -0,0 +1,75 @@ +# 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 diff --git a/lib/api/keys.rb b/lib/api/keys.rb index 8f2fd8cbae26ed5e43878e6795aa80848937981b..af73fd4f0dc96da0320b0ff17569bcdb1bf4f107 100644 --- a/lib/api/keys.rb +++ b/lib/api/keys.rb @@ -26,7 +26,9 @@ class Keys < Grape::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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 86f838f0eb987d0f4c82234edff378fd6a6c736c..006e5df4d7e70f18546caa48eca28d8ea700a060 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/finders/keys_finder_spec.rb b/spec/finders/keys_finder_spec.rb index 147e6ee3d844e489e07e8c5d2bd5aca7d25ed678..f80abdcdb386be8fdfc9ba105ea004e4fba7a538 100644 --- a/spec/finders/keys_finder_spec.rb +++ b/spec/finders/keys_finder_spec.rb @@ -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 diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb index a44daf585ba7dcfa42245425d873e3fa165a8781..ce8ef80bb998b35aea4c1083d77ec5e8811bcf79 100644 --- a/spec/finders/personal_access_tokens_finder_spec.rb +++ b/spec/finders/personal_access_tokens_finder_spec.rb @@ -26,6 +26,16 @@ def finder(options = {}) 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 diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 559dc95768a1526b83fb9c8e66160de0d4c96545..2dd9583087f7ed99e4b2d2a6a123379a2b0535d0 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -50,6 +50,32 @@ 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) } diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index aaf9ecb80895e3de6357e6aa90f5f9eb889ec3d3..b16d1f58be515e925ab9f0046ede66dd7c1a848b 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -21,6 +21,18 @@ 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) }