diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 56193d97cb3d298ca263343e842f9601f833b2c1..01a1cf5cd2e82843cf523aaffbe36a4e1ea1aba3 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -309,3 +309,5 @@ def prevent_concurrent_inserts connection.execute("SELECT pg_advisory_xact_lock(#{lock_expression})") end end + +Packages::Package.prepend_mod diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 1c26e9254c470b3ad279294a6405d9ccf626b6db..5c9cca7515ebb3406a2e435aa6af349fc2216b27 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -443,6 +443,12 @@ Audit event types belong to the following product categories. |:----------|:---------------------|:------------------|:--------------|:------| | [`experiment_features_enabled_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118222) | Enabling experiment AI features setting is toggled | **{check-circle}** Yes | GitLab [16.0](https://gitlab.com/gitlab-org/gitlab/-/issues/404856/) | Group | +### Package registry + +| Type name | Event triggered when | Saved to database | Introduced in | Scope | +|:----------|:---------------------|:------------------|:--------------|:------| +| [`package_registry_package_published`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178181) | A package was published to GitLab package registry. Available only when the feature flag `package_registry_audit_events` is enabled. | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/329588) | Project, Group | + ### Permissions | Type name | Event triggered when | Saved to database | Introduced in | Scope | diff --git a/ee/app/models/ee/packages/package.rb b/ee/app/models/ee/packages/package.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec4bcfd25b63a549a811324881b47154e525abfa --- /dev/null +++ b/ee/app/models/ee/packages/package.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module EE + module Packages + module Package + extend ActiveSupport::Concern + + PROCESSING_TO_DEFAULT = ::Packages::Package.statuses.invert.values_at(2, 0).freeze + + prepended do + after_commit :create_audit_event, on: %i[create update] + + private + + def create_audit_event + return unless default? + return if maven? && version.nil? + return unless previously_new_record? || saved_change_to_status? + return if saved_change_to_status? && saved_change_to_status != PROCESSING_TO_DEFAULT + + ::Packages::CreateAuditEventService.new(self).execute + end + end + end + end +end diff --git a/ee/app/services/packages/create_audit_event_service.rb b/ee/app/services/packages/create_audit_event_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..6923081ce3d64df02398688be7f816658d0e7aed --- /dev/null +++ b/ee/app/services/packages/create_audit_event_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Packages + class CreateAuditEventService + FEATURE_FLAG_DISABLED_ERROR = ServiceResponse.error(message: 'Feature flag is not enabled').freeze + + delegate :project, :creator, to: :package, private: true + + def initialize(package, event_name: 'package_registry_package_published') + @package = package + @event_name = event_name + end + + def execute + return FEATURE_FLAG_DISABLED_ERROR if ::Feature.disabled?(:package_registry_audit_events, project) + + ::Gitlab::Audit::Auditor.audit(audit_context) + + ServiceResponse.success + end + + private + + attr_reader :package, :event_name + + def audit_context + { + name: event_name, + author: creator || ::Gitlab::Audit::DeployTokenAuthor.new, + scope: project.group || project, + target: package, + target_details: target_details, + message: "#{package.package_type.humanize} package published", + additional_details: { auth_token_type: } + } + end + + def target_details + "#{project.full_path}/#{package.name}-#{package.version}" + end + + def auth_token_type + ::Current.token_info&.dig(:token_type) || token_type_from_package_creator + end + + def token_type_from_package_creator + return 'DeployToken' unless creator + return 'CiJobToken' if creator.from_ci_job_token? + + 'PersonalAccessToken or CiJobToken' + end + end +end diff --git a/ee/config/audit_events/types/package_registry_package_published.yml b/ee/config/audit_events/types/package_registry_package_published.yml new file mode 100644 index 0000000000000000000000000000000000000000..d6ee0ebfd1d52c4dd72fb4ca97c60704748b7b8e --- /dev/null +++ b/ee/config/audit_events/types/package_registry_package_published.yml @@ -0,0 +1,10 @@ +--- +name: package_registry_package_published +description: A package was published to GitLab package registry. Available only when the feature flag `package_registry_audit_events` is enabled. +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/329588 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178181 +feature_category: package_registry +milestone: '17.9' +saved_to_database: true +streamed: true +scope: [Project, Group] diff --git a/ee/config/feature_flags/wip/package_registry_audit_events.yml b/ee/config/feature_flags/wip/package_registry_audit_events.yml new file mode 100644 index 0000000000000000000000000000000000000000..a2793d787c00de8d1864f65190a31c010917c8c6 --- /dev/null +++ b/ee/config/feature_flags/wip/package_registry_audit_events.yml @@ -0,0 +1,9 @@ +--- +name: package_registry_audit_events +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329588 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178181 +rollout_issue_url: +milestone: '17.9' +group: group::package registry +type: wip +default_enabled: false diff --git a/ee/spec/models/ee/packages/package_spec.rb b/ee/spec/models/ee/packages/package_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec14970028a0c7413fc51e4f2483094361ba17b4 --- /dev/null +++ b/ee/spec/models/ee/packages/package_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Package, type: :model, feature_category: :package_registry do + describe '#create_audit_event callback' do + before do + allow(::Packages::CreateAuditEventService).to receive(:new).and_call_original + end + + shared_examples 'creates audit event' do + it 'calls CreateAuditEventService' do + subject + + expect(::Packages::CreateAuditEventService).to have_received(:new).with(package) + end + end + + shared_examples 'does not create audit event' do + it 'does not call CreateAuditEventService' do + subject + + expect(::Packages::CreateAuditEventService).not_to have_received(:new).with(package) + end + end + + context 'on create' do + let(:package) { build(:generic_package) } + + subject { package.save! } + + context 'with default status' do + it_behaves_like 'creates audit event' + end + + context 'with non-default status' do + before do + package.status = :processing + end + + it_behaves_like 'does not create audit event' + end + + context 'with versionless maven package' do + subject { create(:maven_package, version: nil) } + + it_behaves_like 'does not create audit event' + end + end + + context 'on update' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:package) { create(:generic_package) } + + context 'when changing status' do + subject { package.public_send(:"#{to_status}!") } + + where(:from_status, :to_status, :creates_event) do + :default | :pending_destruction | false + :default | :hidden | false + :hidden | :default | false + :processing | :default | true + end + + before do + package.public_send(:"#{from_status}!") + end + + with_them do + it_behaves_like params[:creates_event] ? 'creates audit event' : 'does not create audit event' + end + end + + context 'when updating attributes' do + subject { package.update!(updates) } + + where(:scenario, :updates, :initial_status, :creates_event) do + 'name only' | { name: 'new_name' } | :default | false + 'name and default to hidden' | { name: 'new_name', status: :hidden } | :default | false + 'name and processing to default' | { name: 'new_name', status: :default } | :processing | true + 'name and hidden to default' | { name: 'new_name', status: :default } | :hidden | false + end + + before do + package.public_send(:"#{initial_status}!") + end + + with_them do + it_behaves_like params[:creates_event] ? 'creates audit event' : 'does not create audit event' + end + end + end + end +end diff --git a/ee/spec/services/packages/create_audit_event_service_spec.rb b/ee/spec/services/packages/create_audit_event_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..29ef77c3b865dfc15ab4c8a2580e1d6ba12f8758 --- /dev/null +++ b/ee/spec/services/packages/create_audit_event_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::CreateAuditEventService, feature_category: :package_registry do + let_it_be(:project) { build_stubbed(:project, group: build_stubbed(:group)) } + let_it_be(:user) { build_stubbed(:user) } + let_it_be(:package) { build_stubbed(:generic_package, project: project, creator: user) } + let_it_be(:deploy_token) { build_stubbed(:deploy_token) } + + let(:service) { described_class.new(package) } + + describe '#execute' do + subject(:execute) { service.execute } + + include_examples 'audit event logging' do + let(:operation) { execute } + let(:event_type) { 'package_registry_package_published' } + let(:fail_condition!) { stub_feature_flags(package_registry_audit_events: false) } + let(:attributes) do + { + author_id: user.id, + entity_id: project.group.id, + entity_type: 'Group', + details: { + author_name: user.name, + event_name: 'package_registry_package_published', + target_id: package.id, + target_type: package.class.name, + target_details: "#{project.full_path}/#{package.name}-#{package.version}", + author_class: user.class.name, + custom_message: "#{package.package_type.humanize} package published", + auth_token_type: 'PersonalAccessToken or CiJobToken' + } + } + end + end + + context 'when project does not belong to a group' do + before do + project.group = nil + end + + it 'uses project as scope' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including(scope: project) + ) + + execute + end + end + + context 'for auth token type detection' do + context 'when Current.token_info is present' do + before do + allow(::Current).to receive(:token_info).and_return({ token_type: 'SomeToken' }) + end + + it 'uses token type from Current' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including(additional_details: { auth_token_type: 'SomeToken' }) + ) + + execute + end + end + + context 'when package has no creator' do + before do + allow(package).to receive(:creator).and_return(nil) + end + + it 'uses DeployTokenAuthor as author' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including( + author: kind_of(::Gitlab::Audit::DeployTokenAuthor), + additional_details: { auth_token_type: 'DeployToken' } + ) + ) + + execute + end + end + + context 'when package has a creator' do + it 'sets auth_token_type as PersonalAccessToken or CiJobToken' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including(additional_details: { auth_token_type: 'PersonalAccessToken or CiJobToken' }) + ) + + execute + end + + context 'when user is from ci job token' do + before do + allow(user).to receive(:from_ci_job_token?).and_return(true) + end + + it 'sets auth_token_type as CiJobToken' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including(additional_details: { auth_token_type: 'CiJobToken' }) + ) + + execute + end + end + end + end + end +end diff --git a/spec/services/ml/destroy_model_version_service_spec.rb b/spec/services/ml/destroy_model_version_service_spec.rb index f384eb81761ca342d3f02ab2feac7af60eb4fced..9a2075049160516a3a7563379ffce64395aef113 100644 --- a/spec/services/ml/destroy_model_version_service_spec.rb +++ b/spec/services/ml/destroy_model_version_service_spec.rb @@ -53,7 +53,7 @@ it 'does not delete the model version' do is_expected.to be_error.and have_attributes(message: "You don't have access to this package") expect(Ml::ModelVersion.find_by(id: model_version.id)).to eq(model_version) - expect(Gitlab::Audit::Auditor).not_to have_received(:audit) + expect(Gitlab::Audit::Auditor).not_to have_received(:audit).with(audit_event) end end end