diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 71460a17a7540ca7bc08e0bb889e0fd2ad9cee5a..a6064239ead98e04c250571467dff73f93f8bb4f 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -77,6 +77,26 @@ def runner_setup_scripts private_runner_setup_scripts end + def export_job_token_authorizations + response = ::Ci::JobToken::ExportAuthorizationsService + .new(current_user: current_user, accessed_project: @project) + .execute + + respond_to do |format| + format.csv do + if response.success? + send_data(response.payload.fetch(:data), + type: 'text/csv; charset=utf-8', + filename: response.payload.fetch(:filename)) + else + flash[:alert] = _('Failed to generate export') + + redirect_to project_settings_ci_cd_path(@project) + end + end + end + end + private def authorize_reset_cache! diff --git a/app/models/ci/job_token/authorization.rb b/app/models/ci/job_token/authorization.rb index 7a6b48b823428dcffbe8326ab0119616499f0ffa..106f93da4286a8d88c63fa75c39b3435f525b87a 100644 --- a/app/models/ci/job_token/authorization.rb +++ b/app/models/ci/job_token/authorization.rb @@ -17,6 +17,9 @@ class Authorization < Ci::ApplicationRecord REQUEST_CACHE_KEY = :job_token_authorizations CAPTURE_DELAY = 5.minutes + scope :for_project, ->(accessed_project) { where(accessed_project: accessed_project) } + scope :preload_origin_project, -> { includes(origin_project: :route) } + # Record in SafeRequestStore a cross-project access attempt def self.capture(origin_project:, accessed_project:) # Skip self-referential accesses as they are always allowed and don't need diff --git a/app/services/ci/job_token/export_authorizations_service.rb b/app/services/ci/job_token/export_authorizations_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb18548e5c0ba2c7ee13537a36cdada91c48ab78 --- /dev/null +++ b/app/services/ci/job_token/export_authorizations_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# This class exports CI job token authorizations to a given project. +module Ci + module JobToken + class ExportAuthorizationsService + include Gitlab::Allowable + + def initialize(current_user:, accessed_project:) + @current_user = current_user + @accessed_project = accessed_project + end + + def execute + unless can?(current_user, :admin_project, accessed_project) + return ServiceResponse.error(message: _('Access denied'), reason: :forbidden) + end + + csv_data = CsvBuilder.new(authorizations, header_to_value_hash).render + + ServiceResponse.success(payload: { data: csv_data, filename: csv_filename }) + end + + private + + attr_reader :accessed_project, :current_user + + def authorizations + Ci::JobToken::Authorization.for_project(accessed_project).preload_origin_project + end + + def header_to_value_hash + { + 'Origin Project Path' => ->(auth) { auth.origin_project.full_path }, + 'Last Authorized At (UTC)' => ->(auth) { auth.last_authorized_at.utc.iso8601 } + } + end + + def csv_filename + "job-token-authorizations-#{accessed_project.id}-#{Time.current.to_fs(:number)}.csv" + end + end + end +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 1ece758fa261cd5fc549a2eaae97a885ec9bb2c3..c823079da71afb807611bd26be5176a24c96a8f9 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -118,6 +118,7 @@ put :reset_registration_token post :create_deploy_token, path: 'deploy_token/create', to: 'repository#create_deploy_token' get :runner_setup_scripts, format: :json + get :export_job_token_authorizations, format: :csv end resource :operations, only: [:show, :update] do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 56a2488692be6bcec665db7edfd0667204dc1e83..8d975bf58b5a1f081fc9141dd13f9993cc64ffd0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22935,6 +22935,9 @@ msgstr "" msgid "Failed to generate description" msgstr "" +msgid "Failed to generate export" +msgstr "" + msgid "Failed to generate export, please try again later." msgstr "" diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index 18e57e78724afc2eb3de4db51943d5a80f28856b..d5b5f137267e932880cee06c914929e5c357b61a 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -401,6 +401,49 @@ def show expect(json_response).to have_key("errors") end end + + describe 'GET #export_job_token_authorizations' do + subject(:get_authorizations) do + get :export_job_token_authorizations, params: { + namespace_id: project.namespace, + project_id: project + }, format: :csv + end + + let!(:authorizations) do + create_list(:ci_job_token_authorization, 3, accessed_project: project) + end + + context 'when the export is successful' do + it 'renders the CSV' do + get_authorizations + + expect(response).to have_gitlab_http_status(:ok) + rows = response.body.lines + + expect(rows[0]).to include('Origin Project Path,Last Authorized At (UTC)') + expect(rows[1]).to include(authorizations.first.origin_project.full_path) + expect(rows[1]).to include(authorizations.first.last_authorized_at.utc.iso8601) + end + end + + context 'when the export fails' do + let(:export_service) { instance_double(Ci::JobToken::ExportAuthorizationsService) } + let(:failed_response) { ServiceResponse.error(message: 'Export failed') } + + before do + allow(::Ci::JobToken::ExportAuthorizationsService).to receive(:new).and_return(export_service) + allow(export_service).to receive(:execute).and_return(failed_response) + end + + it 'sets a flash alert and redirects to the project CI/CD settings' do + get_authorizations + + expect(flash[:alert]).to eq('Failed to generate export') + expect(response).to redirect_to(project_settings_ci_cd_path(project)) + end + end + end end context 'as a developer' do diff --git a/spec/models/ci/job_token/authorization_spec.rb b/spec/models/ci/job_token/authorization_spec.rb index 933010bfbab3e666f9239fdc8bd43fce05db7946..be3bc2f34abbda0f436d0786f91543d90041cab7 100644 --- a/spec/models/ci/job_token/authorization_spec.rb +++ b/spec/models/ci/job_token/authorization_spec.rb @@ -107,4 +107,39 @@ end end end + + describe '.preload_origin_project' do + before do + create_list(:ci_job_token_authorization, 5) + end + + it 'does not perform N+1 queries' do + control = ActiveRecord::QueryRecorder.new do + described_class.preload_origin_project.map { |a| a.origin_project.full_path } + end + + create(:ci_job_token_authorization) + + expect do + described_class.preload_origin_project.map { |a| a.origin_project.full_path } + end.not_to exceed_query_limit(control) + end + end + + describe '.for_project scope' do + let(:project) { create(:project) } + + let!(:current_authorizations) do + create_list(:ci_job_token_authorization, 2, accessed_project: project) + end + + let!(:other_authorization) { create(:ci_job_token_authorization) } + + it 'contains only the authorizations targeting the project' do + authorizations = described_class.for_project(project) + expect(authorizations).to eq(current_authorizations) + + expect(authorizations).not_to include(other_authorization) + end + end end diff --git a/spec/services/ci/job_token/export_authorizations_service_spec.rb b/spec/services/ci/job_token/export_authorizations_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..936f5a54eab1fb68b971f74b6b146cefed627e58 --- /dev/null +++ b/spec/services/ci/job_token/export_authorizations_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobToken::ExportAuthorizationsService, feature_category: :secrets_management do + let_it_be_with_refind(:user) { create(:user) } + let_it_be_with_refind(:project) { create(:project) } + let_it_be(:origin_project) { create(:project) } + + let(:accessed_project) { project } + let(:service) { described_class.new(current_user: user, accessed_project: accessed_project) } + + before_all do + project.add_maintainer(user) + end + + describe '#execute' do + context 'when user has admin access to the project' do + let!(:authorizations) do + create_list(:ci_job_token_authorization, 2, accessed_project: accessed_project) + end + + it 'returns a success response with CSV data' do + result = service.execute + + expect(result).to be_success + + expect(result.payload[:filename]).to start_with("job-token-authorizations-#{accessed_project.id}-") + + rows = result.payload[:data].lines + + expect(rows.count).to eq(3) # 2 authorizations + 1 header row + + expect(rows[0]).to include('Origin Project Path,Last Authorized At (UTC)') + expect(rows[1]).to include(authorizations.first.origin_project.full_path) + expect(rows[1]).to include(authorizations.first.last_authorized_at.utc.iso8601) + end + end + + context 'when user does not have admin access to the project' do + let(:accessed_project) { create(:project) } + + it 'returns an error response' do + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Access denied') + expect(result.reason).to eq(:forbidden) + end + end + end +end