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