diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml index eb5bf8f775588b29ed3e902d25823746682ccbf7..16c64f5653593117c94d6ca38295d2eb87763b71 100644 --- a/config/gitlab_loose_foreign_keys.yml +++ b/config/gitlab_loose_foreign_keys.yml @@ -453,6 +453,10 @@ project_security_exclusions: - table: projects column: project_id on_delete: async_delete +project_security_statistics: + - table: projects + column: project_id + on_delete: async_delete projects: - table: organizations column: organization_id diff --git a/db/docs/project_security_statistics.yml b/db/docs/project_security_statistics.yml new file mode 100644 index 0000000000000000000000000000000000000000..1f86174a727a06df64416ab22ec09d33f161b55f --- /dev/null +++ b/db/docs/project_security_statistics.yml @@ -0,0 +1,12 @@ +--- +table_name: project_security_statistics +classes: +- Security::ProjectStatistics +feature_categories: +- vulnerability_management +description: Stores security-related statistics +introduced_by_url: +milestone: '17.5' +gitlab_schema: gitlab_sec +sharding_key: + project_id: projects diff --git a/db/migrate/20240926161243_create_project_security_statistics_table.rb b/db/migrate/20240926161243_create_project_security_statistics_table.rb new file mode 100644 index 0000000000000000000000000000000000000000..b9ffedf9d4a3546b04d6dacd261e8f1fc9ec12d5 --- /dev/null +++ b/db/migrate/20240926161243_create_project_security_statistics_table.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateProjectSecurityStatisticsTable < Gitlab::Database::Migration[2.2] + milestone '17.5' + + def change + create_table :project_security_statistics, id: false do |t| # rubocop:disable Migration/EnsureFactoryForTable -- False positive + t.bigint :project_id, primary_key: true, default: nil + t.integer :vulnerability_count, default: 0, null: false + end + end +end diff --git a/db/schema_migrations/20240926161243 b/db/schema_migrations/20240926161243 new file mode 100644 index 0000000000000000000000000000000000000000..eee279010169ddb6ff118cd4e7b009b2628b3a20 --- /dev/null +++ b/db/schema_migrations/20240926161243 @@ -0,0 +1 @@ +2baf9eab2929a27d4dfca9d7a811a44e39102e0ac642dc769a008a06c18940a9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 838adf0408a3c5cdfad898584baf6f22d0f4b5ad..a6add27da228539d7c6d055aeda8e4152ceee1cf 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -16955,6 +16955,11 @@ CREATE SEQUENCE project_security_settings_project_id_seq ALTER SEQUENCE project_security_settings_project_id_seq OWNED BY project_security_settings.project_id; +CREATE TABLE project_security_statistics ( + project_id bigint NOT NULL, + vulnerability_count integer DEFAULT 0 NOT NULL +); + CREATE TABLE project_settings ( project_id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -25057,6 +25062,9 @@ ALTER TABLE ONLY project_security_exclusions ALTER TABLE ONLY project_security_settings ADD CONSTRAINT project_security_settings_pkey PRIMARY KEY (project_id); +ALTER TABLE ONLY project_security_statistics + ADD CONSTRAINT project_security_statistics_pkey PRIMARY KEY (project_id); + ALTER TABLE ONLY project_settings ADD CONSTRAINT project_settings_pkey PRIMARY KEY (project_id); diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 600cdb746f19b99d3d62c7d1632789e8c95875fb..6089305b97ae25df6a42fe9ed046badbf8dae112 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -77,6 +77,7 @@ def preload_protected_branches has_many :compliance_management_frameworks, through: :compliance_framework_settings, source: 'compliance_management_framework' has_one :security_setting, class_name: 'ProjectSecuritySetting' has_one :vulnerability_statistic, class_name: 'Vulnerabilities::Statistic' + has_one :security_statistics, class_name: 'Security::ProjectStatistics' has_one :dependency_proxy_packages_setting, class_name: '::DependencyProxy::Packages::Setting', inverse_of: :project has_one :zoekt_repository, class_name: '::Search::Zoekt::Repository', inverse_of: :project @@ -463,6 +464,10 @@ def lock_for_confirmation!(id) with_replicator Geo::ProjectRepositoryReplicator + def security_statistics + super || (self.security_statistics = Security::ProjectStatistics.create_for(self)) + end + def pipeline_configuration_full_path compliance_framework_settings .order(:id) diff --git a/ee/app/models/security/project_statistics.rb b/ee/app/models/security/project_statistics.rb new file mode 100644 index 0000000000000000000000000000000000000000..3660edacb3b510f73261fb8f05c1efe9f92c7577 --- /dev/null +++ b/ee/app/models/security/project_statistics.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Security + class ProjectStatistics < Gitlab::Database::SecApplicationRecord + self.primary_key = :project_id + self.table_name = 'project_security_statistics' + + belongs_to :project, optional: false + + scope :by_projects, ->(project_ids) { where(project_id: project_ids) } + + class << self + def create_for(project) + upsert({ project_id: project.id }) + + find_by_project_id(project.id) + end + end + + def increase_vulnerability_counter!(increment) + self.class.by_projects(project_id).update_all("vulnerability_count = vulnerability_count + #{increment}") + end + + def decrease_vulnerability_counter!(decrement) + self.class.by_projects(project_id).update_all("vulnerability_count = vulnerability_count - #{decrement}") + end + end +end diff --git a/ee/spec/factories/security/project_statistics.rb b/ee/spec/factories/security/project_statistics.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b300c2b8a4ab4dcb90e74393551fb3bf5643bac --- /dev/null +++ b/ee/spec/factories/security/project_statistics.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_security_statistics, class: 'Security::ProjectStatistics' do + project + end +end diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index 6e61142bc937252ed24005952df737d8a6aa9645..ed13cd88af0f2d59c555a06eff5ff50b1f37da10 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -32,6 +32,7 @@ it { is_expected.to have_many(:compliance_standards_adherence).class_name('Projects::ComplianceStandards::Adherence') } it { is_expected.to have_one(:security_setting).class_name('ProjectSecuritySetting') } it { is_expected.to have_one(:vulnerability_statistic).class_name('Vulnerabilities::Statistic') } + it { is_expected.to have_one(:security_statistics).class_name('Security::ProjectStatistics') } it { is_expected.to have_one(:security_orchestration_policy_configuration).class_name('Security::OrchestrationPolicyConfiguration').inverse_of(:project) } it { is_expected.to have_one(:dependency_proxy_packages_setting).class_name('DependencyProxy::Packages::Setting').inverse_of(:project) } @@ -4918,4 +4919,33 @@ def stub_default_url_options(host) end end end + + describe '#security_statistics' do + let_it_be(:project) { create(:project) } + + subject(:security_statistics) { project.security_statistics } + + context 'when there is no `project_security_statistics` for the project' do + it 'returns a new persisted `Security::ProjectStatistics` instance' do + expect(security_statistics).to be_an_instance_of(Security::ProjectStatistics) + .and have_attributes(project_id: project.id) + end + + it 'does not fire additional queries after the first access' do + security_statistics # warmup + + queries = ActiveRecord::QueryRecorder.new { security_statistics } + + expect(queries.count).to be_zero + end + end + + context 'when there is already a `project_security_statistics` for the project' do + let_it_be(:statistics) { create(:project_security_statistics, project: project) } + + it 'returns the existing record' do + expect(security_statistics).to eq(statistics) + end + end + end end diff --git a/ee/spec/models/security/project_statistics_spec.rb b/ee/spec/models/security/project_statistics_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c256e1306695a7c187e10869e08752165dc1d2c4 --- /dev/null +++ b/ee/spec/models/security/project_statistics_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::ProjectStatistics, feature_category: :vulnerability_management do + let_it_be(:statistics) { create(:project_security_statistics) } + + it { is_expected.to belong_to(:project).required } + + describe 'scopes' do + describe '.by_projects' do + subject { described_class.by_projects(statistics.project_id) } + + before do + create(:project_security_statistics) + end + + it { is_expected.to contain_exactly(statistics) } + end + end + + describe '.create_for' do + let_it_be_with_refind(:project) { create(:project) } + + subject(:create_statistics) { described_class.create_for(project) } + + context 'when there is already a record for the given project' do + let_it_be(:existing_record) { create(:project_security_statistics, project: project) } + + it { is_expected.to eq(existing_record) } + + it 'does not try to create a new record' do + expect { create_statistics }.not_to change { described_class.count } + end + end + + context 'when there is no record for the given project' do + it 'creates a new record' do + expect { create_statistics }.to change { described_class.count }.by(1) + end + end + end + + describe '#increase_vulnerability_counter!' do + subject(:decrease_vulnerability_counter) { statistics.increase_vulnerability_counter!(1) } + + it 'decreases the `vulnerability_count` attribute by given number' do + expect { decrease_vulnerability_counter }.to change { statistics.reload.vulnerability_count }.by(1) + end + end + + describe '#decrease_vulnerability_counter!' do + subject(:decrease_vulnerability_counter) { statistics.decrease_vulnerability_counter!(1) } + + it 'decreases the `vulnerability_count` attribute by given number' do + expect { decrease_vulnerability_counter }.to change { statistics.reload.vulnerability_count }.by(-1) + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index bb11e63a783a532b886257de70487d41e0672a66..22c1c0a8db10fd1fffae7a509816168606be7772 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -895,6 +895,7 @@ project: - observability_traces - observability_logs - security_exclusions +- security_statistics award_emoji: - awardable - user