diff --git a/db/docs/security_policy_dismissals.yml b/db/docs/security_policy_dismissals.yml new file mode 100644 index 0000000000000000000000000000000000000000..07eb27e06c3d3006b1843fecf8c35cb42366b76f --- /dev/null +++ b/db/docs/security_policy_dismissals.yml @@ -0,0 +1,13 @@ +--- +table_name: security_policy_dismissals +classes: + - Security::PolicyDismissal +feature_categories: + - security_policy_management +description: Stores the relation between security policies and dismissed findings +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/202431 +milestone: '18.4' +gitlab_schema: gitlab_main_org +sharding_key: + project_id: projects +table_size: small diff --git a/db/migrate/20250821205145_create_security_policy_dismissals.rb b/db/migrate/20250821205145_create_security_policy_dismissals.rb new file mode 100644 index 0000000000000000000000000000000000000000..d53bafdcceb2e328158d07c27442b2c13bbb6a86 --- /dev/null +++ b/db/migrate/20250821205145_create_security_policy_dismissals.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CreateSecurityPolicyDismissals < Gitlab::Database::Migration[2.3] + milestone '18.4' + + INDEX_NAME = 'i_policy_dismissals_on_merge_request_id_and_security_policy_id' + + def up + # Factory: /ee/spec/factories/security/policy_dismissal.rb + create_table :security_policy_dismissals do |t| # rubocop:disable Migration/EnsureFactoryForTable -- reason above + t.timestamps_with_timezone null: false + t.bigint :project_id, null: false + t.bigint :merge_request_id, null: false + t.bigint :security_policy_id, null: false + t.bigint :user_id, null: true + t.text :security_findings_uuids, array: true, default: [], null: false + + t.index [:merge_request_id, :security_policy_id], unique: true, name: INDEX_NAME + t.index :project_id + t.index :security_policy_id + t.index :user_id + end + end + + def down + drop_table :security_policy_dismissals + end +end diff --git a/db/migrate/20250821210738_add_merge_request_foreign_key_to_security_policy_dismissals.rb b/db/migrate/20250821210738_add_merge_request_foreign_key_to_security_policy_dismissals.rb new file mode 100644 index 0000000000000000000000000000000000000000..a490ffb821a3ad905ca79ad8dc823376091ed9f4 --- /dev/null +++ b/db/migrate/20250821210738_add_merge_request_foreign_key_to_security_policy_dismissals.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddMergeRequestForeignKeyToSecurityPolicyDismissals < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.4' + + def up + add_concurrent_foreign_key :security_policy_dismissals, :merge_requests, + column: :merge_request_id, + on_delete: :cascade + end + + def down + remove_foreign_key_if_exists :security_policy_dismissals, column: :merge_request_id + end +end diff --git a/db/migrate/20250821211206_add_security_policy_foreign_key_to_security_policy_dismissals.rb b/db/migrate/20250821211206_add_security_policy_foreign_key_to_security_policy_dismissals.rb new file mode 100644 index 0000000000000000000000000000000000000000..f5760a101c74137d49d6fd3f1091456bddae68f6 --- /dev/null +++ b/db/migrate/20250821211206_add_security_policy_foreign_key_to_security_policy_dismissals.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddSecurityPolicyForeignKeyToSecurityPolicyDismissals < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.4' + + def up + add_concurrent_foreign_key :security_policy_dismissals, :security_policies, + column: :security_policy_id, + on_delete: :cascade + end + + def down + remove_foreign_key_if_exists :security_policy_dismissals, column: :security_policy_id + end +end diff --git a/db/migrate/20250821211452_add_user_foreign_key_to_security_policy_dismissals.rb b/db/migrate/20250821211452_add_user_foreign_key_to_security_policy_dismissals.rb new file mode 100644 index 0000000000000000000000000000000000000000..c14d69c126d662d4548eee1607d1051fe4330ade --- /dev/null +++ b/db/migrate/20250821211452_add_user_foreign_key_to_security_policy_dismissals.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddUserForeignKeyToSecurityPolicyDismissals < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.4' + + def up + add_concurrent_foreign_key :security_policy_dismissals, :users, + column: :user_id, + on_delete: :nullify + end + + def down + remove_foreign_key_if_exists :security_policy_dismissals, column: :user_id + end +end diff --git a/db/migrate/20250822200745_add_project_foreign_key_to_security_policy_dismissals.rb b/db/migrate/20250822200745_add_project_foreign_key_to_security_policy_dismissals.rb new file mode 100644 index 0000000000000000000000000000000000000000..f30a84249d6c23ba95467950aa25a56eabcaf650 --- /dev/null +++ b/db/migrate/20250822200745_add_project_foreign_key_to_security_policy_dismissals.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddProjectForeignKeyToSecurityPolicyDismissals < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.4' + + def up + add_concurrent_foreign_key :security_policy_dismissals, :projects, + column: :project_id, + on_delete: :cascade + end + + def down + remove_foreign_key_if_exists :security_policy_dismissals, column: :project_id + end +end diff --git a/db/schema_migrations/20250821205145 b/db/schema_migrations/20250821205145 new file mode 100644 index 0000000000000000000000000000000000000000..2240966ca291ade4db93ab34704733b524005b5a --- /dev/null +++ b/db/schema_migrations/20250821205145 @@ -0,0 +1 @@ +83d53f9ee72944febea52690f3962f61ba73d97aa471b3fe0152875dce2737ec \ No newline at end of file diff --git a/db/schema_migrations/20250821210738 b/db/schema_migrations/20250821210738 new file mode 100644 index 0000000000000000000000000000000000000000..e7215bc9aff24a2b2fbb01b2c8af5f7fcdc3d183 --- /dev/null +++ b/db/schema_migrations/20250821210738 @@ -0,0 +1 @@ +1f097772d190fb90c64be219572ad1b4a7427ac4fd14ef773f20814328d6406d \ No newline at end of file diff --git a/db/schema_migrations/20250821211206 b/db/schema_migrations/20250821211206 new file mode 100644 index 0000000000000000000000000000000000000000..c566b889c85ab38272b881e9297eabac7681d219 --- /dev/null +++ b/db/schema_migrations/20250821211206 @@ -0,0 +1 @@ +0a036d3c5dfddf7e178cf070c011e2554c9bd25e833ff4d9bce402aabc2e858b \ No newline at end of file diff --git a/db/schema_migrations/20250821211452 b/db/schema_migrations/20250821211452 new file mode 100644 index 0000000000000000000000000000000000000000..709453f294241b585ed43a54a5b06612c9b7b955 --- /dev/null +++ b/db/schema_migrations/20250821211452 @@ -0,0 +1 @@ +e7d1baccc513297e86e377a49b787430ce0288e332096fcdc0379d88478d7b09 \ No newline at end of file diff --git a/db/schema_migrations/20250822200745 b/db/schema_migrations/20250822200745 new file mode 100644 index 0000000000000000000000000000000000000000..6394c7f7c52627331106b912a8d91b66f74cb104 --- /dev/null +++ b/db/schema_migrations/20250822200745 @@ -0,0 +1 @@ +b3c81f066066792ced7c6abf85cd4e235643641aa811ff99533365993ed2d43c \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7c0fdf534f3f6932fe2972f6e235f282dcddfac6..b735d71437feea38699c2f7325c9a8876494377e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -24869,6 +24869,26 @@ CREATE SEQUENCE security_policies_id_seq ALTER SEQUENCE security_policies_id_seq OWNED BY security_policies.id; +CREATE TABLE security_policy_dismissals ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + project_id bigint NOT NULL, + merge_request_id bigint NOT NULL, + security_policy_id bigint NOT NULL, + user_id bigint, + security_findings_uuids text[] DEFAULT '{}'::text[] NOT NULL +); + +CREATE SEQUENCE security_policy_dismissals_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE security_policy_dismissals_id_seq OWNED BY security_policy_dismissals.id; + CREATE TABLE security_policy_project_links ( id bigint NOT NULL, project_id bigint NOT NULL, @@ -30415,6 +30435,8 @@ ALTER TABLE ONLY security_pipeline_execution_project_schedules ALTER COLUMN id S ALTER TABLE ONLY security_policies ALTER COLUMN id SET DEFAULT nextval('security_policies_id_seq'::regclass); +ALTER TABLE ONLY security_policy_dismissals ALTER COLUMN id SET DEFAULT nextval('security_policy_dismissals_id_seq'::regclass); + ALTER TABLE ONLY security_policy_project_links ALTER COLUMN id SET DEFAULT nextval('security_policy_project_links_id_seq'::regclass); ALTER TABLE ONLY security_policy_requirements ALTER COLUMN id SET DEFAULT nextval('security_policy_requirements_id_seq'::regclass); @@ -33817,6 +33839,9 @@ ALTER TABLE ONLY security_pipeline_execution_project_schedules ALTER TABLE ONLY security_policies ADD CONSTRAINT security_policies_pkey PRIMARY KEY (id); +ALTER TABLE ONLY security_policy_dismissals + ADD CONSTRAINT security_policy_dismissals_pkey PRIMARY KEY (id); + ALTER TABLE ONLY security_policy_project_links ADD CONSTRAINT security_policy_project_links_pkey PRIMARY KEY (id); @@ -36449,6 +36474,8 @@ CREATE UNIQUE INDEX i_pm_package_versions_on_package_id_and_version ON pm_packag CREATE UNIQUE INDEX i_pm_packages_purl_type_and_name ON pm_packages USING btree (purl_type, name); +CREATE UNIQUE INDEX i_policy_dismissals_on_merge_request_id_and_security_policy_id ON security_policy_dismissals USING btree (merge_request_id, security_policy_id); + CREATE INDEX i_project_compliance_violations_on_namespace_id_created_at_id ON project_compliance_violations USING btree (namespace_id, created_at DESC, id DESC); CREATE INDEX i_project_requirement_statuses_on_namespace_id_framework_id ON project_requirement_compliance_statuses USING btree (namespace_id, compliance_framework_id, id); @@ -40921,6 +40948,12 @@ CREATE INDEX index_security_policies_on_policy_management_project_id ON security CREATE UNIQUE INDEX index_security_policies_on_unique_config_type_policy_index ON security_policies USING btree (security_orchestration_policy_configuration_id, type, policy_index); +CREATE INDEX index_security_policy_dismissals_on_project_id ON security_policy_dismissals USING btree (project_id); + +CREATE INDEX index_security_policy_dismissals_on_security_policy_id ON security_policy_dismissals USING btree (security_policy_id); + +CREATE INDEX index_security_policy_dismissals_on_user_id ON security_policy_dismissals USING btree (user_id); + CREATE UNIQUE INDEX index_security_policy_project_links_on_project_and_policy ON security_policy_project_links USING btree (security_policy_id, project_id); CREATE INDEX index_security_policy_requirements_on_compliance_requirement_id ON security_policy_requirements USING btree (compliance_requirement_id); @@ -46562,6 +46595,9 @@ ALTER TABLE ONLY jira_tracker_data ALTER TABLE ONLY packages_composer_packages ADD CONSTRAINT fk_2f085bfc2a FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE ONLY security_policy_dismissals + ADD CONSTRAINT fk_2f3a252c44 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY required_code_owners_sections ADD CONSTRAINT fk_2f43f5cbbb FOREIGN KEY (protected_branch_project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -47843,6 +47879,9 @@ ALTER TABLE p_ci_runner_machine_builds ALTER TABLE ONLY ai_catalog_item_consumers ADD CONSTRAINT fk_bba1649fa5 FOREIGN KEY (ai_catalog_item_id) REFERENCES ai_catalog_items(id) ON DELETE RESTRICT; +ALTER TABLE ONLY security_policy_dismissals + ADD CONSTRAINT fk_bc10da1827 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE; + ALTER TABLE ONLY wiki_page_meta_user_mentions ADD CONSTRAINT fk_bc155eba89 FOREIGN KEY (wiki_page_meta_id) REFERENCES wiki_page_meta(id) ON DELETE CASCADE; @@ -47894,6 +47933,9 @@ ALTER TABLE ONLY design_management_versions ALTER TABLE ONLY packages_packages ADD CONSTRAINT fk_c188f0dba4 FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE ONLY security_policy_dismissals + ADD CONSTRAINT fk_c2379f1e97 FOREIGN KEY (security_policy_id) REFERENCES security_policies(id) ON DELETE CASCADE; + ALTER TABLE ONLY sbom_occurrences ADD CONSTRAINT fk_c2a5562923 FOREIGN KEY (source_id) REFERENCES sbom_sources(id) ON DELETE CASCADE; @@ -47930,6 +47972,9 @@ ALTER TABLE ONLY boards_epic_list_user_preferences ALTER TABLE ONLY user_broadcast_message_dismissals ADD CONSTRAINT fk_c7cbf5566d FOREIGN KEY (broadcast_message_id) REFERENCES broadcast_messages(id) ON DELETE CASCADE; +ALTER TABLE ONLY security_policy_dismissals + ADD CONSTRAINT fk_c7cfc32196 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + ALTER TABLE ONLY packages_debian_group_distribution_keys ADD CONSTRAINT fk_c802025a67 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; diff --git a/ee/app/models/ee/merge_request.rb b/ee/app/models/ee/merge_request.rb index a4d2c2d43b4a9baf989ee680daee9d3fef1d3d34..c4a2027f4e9ce87ffa1bce44712ba9b2367231f5 100644 --- a/ee/app/models/ee/merge_request.rb +++ b/ee/app/models/ee/merge_request.rb @@ -99,6 +99,8 @@ def set_applicable_when_copying_rules(applicable_ids) has_many :v2_approval_rules, through: :v2_approval_rules_merge_requests, class_name: 'MergeRequests::ApprovalRule', source: :approval_rule + has_many :policy_dismissals, class_name: 'Security::PolicyDismissal', inverse_of: :merge_request + delegate :sha, to: :head_pipeline, prefix: :head_pipeline, allow_nil: true delegate :sha, to: :base_pipeline, prefix: :base_pipeline, allow_nil: true delegate :wrapped_approval_rules, :invalid_approvers_rules, to: :approval_state diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 882fc4fcc38bc9779fc1504001b001a51e3a4cba..dc10b39869fc37640fe95217589b355e9f9f2349 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -247,6 +247,8 @@ def lock_for_confirmation!(id) -> { ready_with_active_connection }, class_name: 'Ai::ActiveContext::Code::Repository' + has_many :policy_dismissals, class_name: '::Security::PolicyDismissal', inverse_of: :project + elastic_index_dependant_association :issues, on_change: :visibility_level elastic_index_dependant_association :issues, on_change: :archived elastic_index_dependant_association :work_items, on_change: :visibility_level diff --git a/ee/app/models/security/policy.rb b/ee/app/models/security/policy.rb index 54677dcf0331e5c01ddc0384d0877fbb31e184ed..67406a33b63370544c56e94f3884d3a49c3f5d51 100644 --- a/ee/app/models/security/policy.rb +++ b/ee/app/models/security/policy.rb @@ -42,6 +42,9 @@ class Policy < ApplicationRecord has_many :approval_policy_merge_request_bypass_events, class_name: 'Security::ApprovalPolicyMergeRequestBypassEvent', foreign_key: :security_policy_id, inverse_of: :security_policy + has_many :policy_dismissals, + class_name: 'Security::PolicyDismissal', + foreign_key: :security_policy_id, inverse_of: :security_policy enum :type, { approval_policy: 0, diff --git a/ee/app/models/security/policy_dismissal.rb b/ee/app/models/security/policy_dismissal.rb new file mode 100644 index 0000000000000000000000000000000000000000..46b8bf23a1d4a97985b3366b17b0f8476e33a93a --- /dev/null +++ b/ee/app/models/security/policy_dismissal.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Security + class PolicyDismissal < ApplicationRecord + self.table_name = 'security_policy_dismissals' + + belongs_to :project, class_name: 'Project', optional: false + belongs_to :merge_request, class_name: 'MergeRequest', optional: false + belongs_to :security_policy, class_name: 'Security::Policy', optional: false + belongs_to :user, class_name: 'User', optional: true + + validates :merge_request_id, uniqueness: { scope: :security_policy_id } + validates :security_findings_uuids, presence: true + end +end diff --git a/ee/spec/factories/security/policy_dismissal.rb b/ee/spec/factories/security/policy_dismissal.rb new file mode 100644 index 0000000000000000000000000000000000000000..04b3e90ddf0af7db132639f199306589319722b8 --- /dev/null +++ b/ee/spec/factories/security/policy_dismissal.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :policy_dismissal, class: 'Security::PolicyDismissal' do + project + merge_request + security_policy + user + security_findings_uuids { [SecureRandom.uuid] } + end +end diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index c25d20e8618aab155672ef69feeffed955244714..29f4598c12280a91b786f3c8baf9f068056be1e5 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -118,6 +118,7 @@ it { is_expected.to have_many(:configured_ai_catalog_items).class_name('Ai::Catalog::ItemConsumer') } it { is_expected.to have_many(:ai_flow_triggers).class_name('Ai::FlowTrigger') } + it { is_expected.to have_many(:policy_dismissals).class_name('Security::PolicyDismissal') } include_examples 'ci_cd_settings delegation' do let(:attributes_with_prefix) do diff --git a/ee/spec/models/merge_request_spec.rb b/ee/spec/models/merge_request_spec.rb index 6a06feb18cbdf575a24af0e7515f1525877195e5..d3d9ee7cc5972b16c115df9d06c5a8659de1eded 100644 --- a/ee/spec/models/merge_request_spec.rb +++ b/ee/spec/models/merge_request_spec.rb @@ -33,6 +33,7 @@ it { is_expected.to have_many(:scan_result_policy_reads_through_approval_rules).through(:approval_rules).class_name('Security::ScanResultPolicyRead') } it { is_expected.to have_many(:security_policies_through_violations).through(:scan_result_policy_violations).class_name('Security::Policy') } it { is_expected.to have_many(:change_requesters).through(:requested_changes) } + it { is_expected.to have_many(:policy_dismissals) } describe 'policy violations' do let(:policy_1) { create(:scan_result_policy_read, project: project) } diff --git a/ee/spec/models/security/policy_dismissal_spec.rb b/ee/spec/models/security/policy_dismissal_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..51c6d90aa5077895efa909679c7ffe554f971800 --- /dev/null +++ b/ee/spec/models/security/policy_dismissal_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::PolicyDismissal, feature_category: :security_policy_management do + describe 'associations' do + it { is_expected.to belong_to(:project).required } + it { is_expected.to belong_to(:merge_request).required } + it { is_expected.to belong_to(:security_policy).required } + it { is_expected.to belong_to(:user).optional } + end + + describe 'validations' do + subject(:policy_dismissal) { create(:policy_dismissal) } + + it { is_expected.to validate_presence_of(:security_findings_uuids) } + it { is_expected.to(validate_uniqueness_of(:merge_request_id).scoped_to(%i[security_policy_id])) } + end +end diff --git a/ee/spec/models/security/policy_spec.rb b/ee/spec/models/security/policy_spec.rb index 12c8f64961e7a87a895e375668102a493ffb9112..d592b727c45fb31ad10fa812e065becf6b516b19 100644 --- a/ee/spec/models/security/policy_spec.rb +++ b/ee/spec/models/security/policy_spec.rb @@ -13,6 +13,7 @@ it { is_expected.to have_one(:security_pipeline_execution_policy_config_link) } it { is_expected.to have_many(:security_pipeline_execution_project_schedules) } it { is_expected.to have_many(:approval_policy_merge_request_bypass_events) } + it { is_expected.to have_many(:policy_dismissals) } it do is_expected.to validate_uniqueness_of(:security_orchestration_policy_configuration_id).scoped_to(%i[type diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 7b89079d0c403569bc80ed1419265c84aef2ce53..6b59b48441e477a7c8441f1dfd0126e7c8766d33 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -292,6 +292,7 @@ merge_requests: - approval_metrics - approval_policy_merge_request_bypass_events - generated_ref_commits +- policy_dismissals external_pull_requests: - project merge_request_diff: @@ -958,6 +959,7 @@ project: - analyzer_statuses - configured_ai_catalog_items - security_project_tracked_contexts +- policy_dismissals award_emoji: - awardable - user