diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index bdf72c67419d82c37f0deec472f40e7cbd695b21..af3ec56cbfe85435623a5ccd3da952f542d31812 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -32938,6 +32938,7 @@ Project-level settings for product analytics provider. | `userPermissions` | [`ProjectPermissions!`](#projectpermissions) | Permissions for the current user on the resource. | | `valueStreamAnalytics` | [`ValueStreamAnalytics`](#valuestreamanalytics) | Information about Value Stream Analytics within the project. | | `visibility` | [`String`](#string) | Visibility of the project. | +| `vulnerabilityArchives` **{warning-solid}** | [`[VulnerabilityArchive!]`](#vulnerabilityarchive) | **Introduced** in GitLab 17.9. **Status**: Experiment. All vulnerability archives of the project. | | `vulnerabilityImages` | [`VulnerabilityContainerImageConnection`](#vulnerabilitycontainerimageconnection) | Container images reported on the project vulnerabilities. (see [Connections](#connections)) | | `vulnerabilityScanners` | [`VulnerabilityScannerConnection`](#vulnerabilityscannerconnection) | Vulnerability scanners reported on the project vulnerabilities. (see [Connections](#connections)) | | `webUrl` | [`String`](#string) | Web URL of the project. | @@ -38115,6 +38116,18 @@ four standard [pagination arguments](#pagination-arguments): | ---- | ---- | ----------- | | `filter` | [`NotesFilterType`](#notesfiltertype) | Type of notes collection: ALL_NOTES, ONLY_COMMENTS, ONLY_ACTIVITY. | +### `VulnerabilityArchive` + +Represents a vulnerability archive. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `archivedRecordsCount` | [`Int!`](#int) | Number of records the archive contains. | +| `month` | [`Int!`](#int) | Month of the archive, represented as a number from 1 (January) to 12 (December). | +| `year` | [`Int!`](#int) | Year of the archive. | + ### `VulnerabilityContainerImage` Represents a container image reported on the related vulnerability. diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index 634c8de3eb285f221687b400c75739c6159d0e37..1dc0ada052ceeba9107f13c557b70731ad8d1424 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -16,6 +16,12 @@ module ProjectType description: 'List of security training providers for the project', resolver: ::Resolvers::SecurityTrainingProvidersResolver + field :vulnerability_archives, [::Types::Vulnerabilities::ArchiveType], + null: true, + description: 'All vulnerability archives of the project.', + experiment: { milestone: '17.9' }, + resolver: ::Resolvers::Vulnerabilities::ArchivesResolver + field :vulnerabilities, ::Types::VulnerabilityType.connection_type, null: true, extras: [:lookahead], diff --git a/ee/app/graphql/resolvers/vulnerabilities/archives_resolver.rb b/ee/app/graphql/resolvers/vulnerabilities/archives_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..d8304d735acf377a7292504848cc1dea24e2b85a --- /dev/null +++ b/ee/app/graphql/resolvers/vulnerabilities/archives_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Vulnerabilities + class ArchivesResolver < VulnerabilitiesBaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type [::Types::Vulnerabilities::ArchiveType], null: true + + authorize :read_security_resource + authorizes_object! + + def resolve + ensure_feature_available! + + object.vulnerability_archives + end + + def ensure_feature_available! + raise_resource_not_available_error! unless Feature.enabled?(:vulnerability_archival, object) + end + end + end +end diff --git a/ee/app/graphql/types/vulnerabilities/archive_type.rb b/ee/app/graphql/types/vulnerabilities/archive_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..16352584ea794e40e9c070de55d21cecac196644 --- /dev/null +++ b/ee/app/graphql/types/vulnerabilities/archive_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Vulnerabilities + class ArchiveType < BaseObject # rubocop: disable Graphql/AuthorizeTypes -- Authorization is done in resolver layer + graphql_name 'VulnerabilityArchive' + description 'Represents a vulnerability archive' + + field :year, GraphQL::Types::Int, + null: false, description: 'Year of the archive.' + + field :month, GraphQL::Types::Int, + null: false, description: 'Month of the archive, represented as a number from 1 (January) to 12 (December).' + + field :archived_records_count, GraphQL::Types::Int, + null: false, description: 'Number of records the archive contains.' + end + end +end diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index caebfed440fdf94b00bd817dbdc477ad8772ecd6..becce3ba27295c5d06247de3bd58832e68c4e5a9 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -115,6 +115,7 @@ def lock_for_confirmation!(id) has_many :vulnerability_scanners, class_name: 'Vulnerabilities::Scanner' has_many :vulnerability_exports, class_name: 'Vulnerabilities::Export' has_many :vulnerability_remediations, class_name: 'Vulnerabilities::Remediation', inverse_of: :project + has_many :vulnerability_archives, class_name: 'Vulnerabilities::Archive' has_many :workspaces, class_name: 'RemoteDevelopment::Workspace', inverse_of: :project diff --git a/ee/app/models/vulnerabilities/archive.rb b/ee/app/models/vulnerabilities/archive.rb index 48758d2a23da9a62539fd0db67421744889e9021..64540d92fe32e847ce33aeb734339f084811fdbb 100644 --- a/ee/app/models/vulnerabilities/archive.rb +++ b/ee/app/models/vulnerabilities/archive.rb @@ -10,6 +10,8 @@ class Archive < Gitlab::Database::SecApplicationRecord validates :date, presence: true, uniqueness: { scope: :project_id } validates :archived_records_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + delegate :year, :month, to: :date, allow_nil: true + def date=(value) value = value.beginning_of_month if value diff --git a/ee/config/feature_flags/wip/vulnerability_archival.yml b/ee/config/feature_flags/wip/vulnerability_archival.yml new file mode 100644 index 0000000000000000000000000000000000000000..768ae1a9443934f93bcf85267f5cb0ec196e166d --- /dev/null +++ b/ee/config/feature_flags/wip/vulnerability_archival.yml @@ -0,0 +1,9 @@ +--- +name: vulnerability_archival +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/515509 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/181102 +rollout_issue_url: +milestone: '17.10' +group: group::security infrastructure +type: wip +default_enabled: false diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb index 1730444e1a6b2f66223d9c088049289253f15c63..46d078ff774957b3da7d77b0c3ac753abd10687d 100644 --- a/ee/spec/graphql/types/project_type_spec.rb +++ b/ee/spec/graphql/types/project_type_spec.rb @@ -37,6 +37,7 @@ merge_trains pending_member_approvals observability_logs_links observability_metrics_links observability_traces_links dependencies security_exclusions security_exclusion compliance_standards_adherence target_branch_rules duo_workflow_status_check component_usages + vulnerability_archives ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/ee/spec/graphql/types/vulnerabilities/archive_type_spec.rb b/ee/spec/graphql/types/vulnerabilities/archive_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8a68c68b9675ee593c9fbf45f2b5de729ffcc072 --- /dev/null +++ b/ee/spec/graphql/types/vulnerabilities/archive_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['VulnerabilityArchive'], feature_category: :vulnerability_management do + let(:expected_fields) { %i[year month archived_records_count] } + + subject { described_class } + + it { is_expected.to have_graphql_fields(expected_fields) } +end diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index b7edcee968fcd290b46130cb68b424de64179fab..16c26c48ba2cb00b8d5672966580d40bf6e661e7 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -37,6 +37,7 @@ it { is_expected.to have_one(:dependency_proxy_packages_setting).class_name('DependencyProxy::Packages::Setting').inverse_of(:project) } it { is_expected.to have_many(:path_locks) } + it { is_expected.to have_many(:vulnerability_archives).class_name('Vulnerabilities::Archive') } it { is_expected.to have_many(:vulnerability_feedback) } it { is_expected.to have_many(:vulnerability_exports) } it { is_expected.to have_many(:vulnerability_scanners) } diff --git a/ee/spec/models/vulnerabilities/archive_spec.rb b/ee/spec/models/vulnerabilities/archive_spec.rb index d4ff4536653f5c35ead080f2113dbd4389bd1437..cd2be03cda2d3cfd6787baaffcac66afb2222583 100644 --- a/ee/spec/models/vulnerabilities/archive_spec.rb +++ b/ee/spec/models/vulnerabilities/archive_spec.rb @@ -13,6 +13,9 @@ it { is_expected.to belong_to(:project).required } it { is_expected.to have_many(:archived_records) } + it { is_expected.to delegate_method(:year).to(:date).allow_nil } + it { is_expected.to delegate_method(:month).to(:date).allow_nil } + describe 'validations' do it { is_expected.to validate_uniqueness_of(:date).scoped_to(:project_id) } it { is_expected.to validate_numericality_of(:archived_records_count).only_integer.is_greater_than_or_equal_to(0) } diff --git a/ee/spec/requests/api/graphql/project/vulnerability_archives_spec.rb b/ee/spec/requests/api/graphql/project/vulnerability_archives_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..efa66d4b28fd692dde7d8e27b66475d2800c8bfe --- /dev/null +++ b/ee/spec/requests/api/graphql/project/vulnerability_archives_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.project(fullPath).vulnerabilityArchives', feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:archive) { create(:vulnerability_archive, project: project, archived_records_count: 5) } + + let(:make_graphql_request) { post_graphql(query, current_user: user) } + let(:query) do + <<~GQL + query { + project(fullPath: "#{project.full_path}") { + vulnerabilityArchives { + year + month + archivedRecordsCount + } + } + } + GQL + end + + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when the user can not read security resource' do + before do + make_graphql_request + end + + it_behaves_like 'a working graphql query that returns no data' + end + + context 'when the user can read security resource' do + let(:archives) { graphql_data.dig('project', 'vulnerabilityArchives') } + + before_all do + project.add_developer(user) + end + + it 'returns the archive data', :aggregate_failures do + make_graphql_request + + expect(archives.length).to be(1) + expect(archives.first['year']).to eq(archive.date.year) + expect(archives.first['month']).to eq(archive.date.month) + expect(archives.first['archivedRecordsCount']).to be(5) + end + + context 'when the `vulnerability_archival` feature flag is disabled' do + before do + stub_feature_flags(vulnerability_archival: false) + end + + it 'raises exception' do + make_graphql_request + + expect(archives).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index edb441d57764b99477be775cc7f85bec584e514a..8a6213142eef2eb87214cae681733aa2158dace9 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -787,6 +787,7 @@ project: - source_pipelines - sourced_pipelines - vulnerabilities +- vulnerability_archives - vulnerability_exports - vulnerability_findings - vulnerability_feedback