From 55d46574a39e225865688390aab0ca42f787420e Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Tue, 23 Sep 2025 09:59:30 +1200 Subject: [PATCH 01/16] Add draft attestation API endpoint In this commit, I've added a draft of the `::API::SupplyChain::Attestations` endpoint. --- lib/api/api.rb | 1 + lib/api/supply_chain/attestations.rb | 50 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 lib/api/supply_chain/attestations.rb diff --git a/lib/api/api.rb b/lib/api/api.rb index 81f8daf25f4b62..270754a2f75e64 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -346,6 +346,7 @@ def initialize(location_url) mount ::API::Statistics mount ::API::Submodules mount ::API::Suggestions + mount ::API::SupplyChain::Attestations mount ::API::SystemHooks mount ::API::Tags mount ::API::Terraform::Modules::V1::NamespacePackages diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb new file mode 100644 index 00000000000000..f3f7f7e6f0e2f7 --- /dev/null +++ b/lib/api/supply_chain/attestations.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module API + module SupplyChain + class Attestations < ::API::Base + include PaginationParams + + # before { authenticate! } + + feature_category :artifact_security + urgency :low + + helpers do + # def authorize_download_artifacts! + # authorize_read_builds! + # end + end + + params do + requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Download the attestation Sigstore bundle for a specific artifact' do + detail 'This feature was introduced in GitLab 18.5' + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 403, message: 'Forbidden' }, + { code: 404, message: 'Not found' } + ] + end + params do + requires :subject_digest, type: String, + desc: 'The SHA-256 hash of the artifact' + end + get ':id/attestations/:subject_digest', + urgency: :low, + format: false, + requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/408576') + + attestations = [params[:subject_digest]] + + present attestations + end + end + end + end +end + +API::SupplyChain::Attestations.prepend_mod_with('API::SupplyChain::Attestations') -- GitLab From 0499fef21b0404863b9ae7630b5862d940f12a8b Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Tue, 23 Sep 2025 11:33:47 +1200 Subject: [PATCH 02/16] Add new API spec This commit adds a new spec for the new attestations API. Tests are mostly failing at this stage. --- lib/api/supply_chain/attestations.rb | 2 +- .../api/supply_chain/attestations_spec.rb | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 spec/requests/api/supply_chain/attestations_spec.rb diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index f3f7f7e6f0e2f7..2a0c862f313474 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -21,7 +21,7 @@ class Attestations < ::API::Base end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Download the attestation Sigstore bundle for a specific artifact' do - detail 'This feature was introduced in GitLab 18.5' + detail 'This feature was introduced in GitLab 18.5' # TODO: update when FF is removed failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb new file mode 100644 index 00000000000000..0d48e88effa28f --- /dev/null +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::SupplyChain::Attestations, feature_category: :artifact_security do + include HttpBasicAuthHelpers + include DependencyProxyHelpers + + include HttpIOHelpers + + let_it_be(:project, reload: true) do + create(:project, :repository, public_builds: false) + end + + let_it_be(:other_project, reload: true) do + create(:project, :repository) + end + + let_it_be(:pipeline, reload: true) do + create(:ci_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) + end + + let(:developer) { create(:user) } + let(:user) { developer } + let(:api_user) { user } + let(:reporter) { create(:project_member, :reporter, project: project).user } + let(:guest) { create(:project_member, :guest, project: project).user } + + let!(:job) do + create(:ci_build, :success, :tags, pipeline: pipeline, artifacts_expire_at: 1.day.since) + end + + before do + project.add_developer(developer) + end + + shared_examples 'returns unauthorized' do + it 'returns unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + describe 'GET /projects/:id/attestations/:subject_digest' do + context 'when job has attestations' do + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + let(:subject_digest) { "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } + + before do + get api("/projects/#{project.id}/attestations/#{subject_digest}") + end + + context 'when user is anonymous' do + let(:api_user) { nil } + + context 'when project is public' do + it 'allows to access attestations' do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + project.update_column(:public_builds, true) + + expect(response).to be_ok + end + end + + context 'when project is private' do + it 'rejects access and hides existence of attestations' do + expect(response).to be_false + end + end + end + + context 'when user is authorized' do + it 'returns a specific artifact file for a valid path' do + expect(response).to be_false + end + end + + context 'when user is unauthorized' do + it 'returns a specific artifact file for a valid path' do + expect(response).to be_false + end + end + end + + context 'when job does not have atestations' do + it 'does not return job artifact file' do + expect(response).to be_false + end + end + end +end -- GitLab From fac7c64796b78b9f06abca7d7ef4957c24cb6dfa Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Tue, 23 Sep 2025 14:41:01 +1200 Subject: [PATCH 03/16] Implement access controls In this commit, I've restricted access to the `Attestations` API endpoint only to users that have read access to a specific project. I've also added tests that verify this. --- app/policies/project_policy.rb | 2 + lib/api/helpers.rb | 4 + lib/api/supply_chain/attestations.rb | 10 +-- .../api/supply_chain/attestations_spec.rb | 79 +++++++++++++------ 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index a185d4a185c623..0df47dfb93db77 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -1259,6 +1259,8 @@ class ProjectPolicy < BasePolicy enable :invite_project_members end + rule { can?(:read_project) }.enable :read_attestation + private def project_archived_or_ancestors_archived? diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index ce454bb8b89b08..8865c2290d970a 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -420,6 +420,10 @@ def authorize_admin_member_role_on_instance! authorize! :admin_member_role end + def authorize_read_attestations! + authorize! :read_attestation, user_project + end + def authorize_read_builds! authorize! :read_build, user_project end diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index 2a0c862f313474..b6f56271eadac1 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -33,14 +33,10 @@ class Attestations < ::API::Base desc: 'The SHA-256 hash of the artifact' end get ':id/attestations/:subject_digest', - urgency: :low, - format: false, - requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do - Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/408576') + urgency: :low, format: false, requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do + authorize_read_attestations! - attestations = [params[:subject_digest]] - - present attestations + present params[:id] end end end diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index 0d48e88effa28f..76477d3271c5ad 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -9,7 +9,7 @@ include HttpIOHelpers let_it_be(:project, reload: true) do - create(:project, :repository, public_builds: false) + create(:project, :repository) end let_it_be(:other_project, reload: true) do @@ -27,63 +27,98 @@ let(:guest) { create(:project_member, :guest, project: project).user } let!(:job) do - create(:ci_build, :success, :tags, pipeline: pipeline, artifacts_expire_at: 1.day.since) + create(:ci_build, :success, :tags, pipeline: pipeline) end before do project.add_developer(developer) end - shared_examples 'returns unauthorized' do - it 'returns unauthorized' do - expect(response).to have_gitlab_http_status(:unauthorized) - end - end - describe 'GET /projects/:id/attestations/:subject_digest' do context 'when job has attestations' do let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } let(:subject_digest) { "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } + let(:get_attestation_other_project) do + url = "/projects/#{other_project.id}/attestations/#{subject_digest}" + get api(url, api_user) + end - before do - get api("/projects/#{project.id}/attestations/#{subject_digest}") + subject(:get_attestation) do + url = "/projects/#{project.id}/attestations/#{subject_digest}" + get api(url, api_user) end context 'when user is anonymous' do let(:api_user) { nil } context 'when project is public' do - it 'allows to access attestations' do + before do project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) project.update_column(:public_builds, true) + end + + it 'allows to access attestations' do + get_attestation - expect(response).to be_ok + expect(response).to have_gitlab_http_status(:ok) end end context 'when project is private' do it 'rejects access and hides existence of attestations' do - expect(response).to be_false + get_attestation + + expect(response).to have_gitlab_http_status(:not_found) end end end - context 'when user is authorized' do - it 'returns a specific artifact file for a valid path' do - expect(response).to be_false + context 'when user is guest' do + let(:api_user) { guest } + + it 'allows to access attestations if has access' do + get_attestation + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'disallows access if does not have access' do + get_attestation_other_project + + expect(response).to have_gitlab_http_status(:not_found) end end - context 'when user is unauthorized' do + context 'when user is developer' do + let(:api_user) { developer } + + it 'returns a specific artifact file for a valid path' do + get_attestation + + expect(response).to have_gitlab_http_status(:ok) + end + it 'returns a specific artifact file for a valid path' do - expect(response).to be_false + get_attestation_other_project + + expect(response).to have_gitlab_http_status(:not_found) end end - end - context 'when job does not have atestations' do - it 'does not return job artifact file' do - expect(response).to be_false + context 'when user is reporter' do + let(:api_user) { reporter } + + it 'returns a specific artifact file for a valid path' do + get_attestation + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns a specific artifact file for a valid path' do + get_attestation_other_project + + expect(response).to have_gitlab_http_status(:not_found) + end end end end -- GitLab From 8584b32028c1f0ddfc3ea0be5334f3c7fd1480f8 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Wed, 24 Sep 2025 09:48:02 +1200 Subject: [PATCH 04/16] Update spec descriptions --- spec/requests/api/supply_chain/attestations_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index 76477d3271c5ad..dd8e8f61b41ab7 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -92,13 +92,13 @@ context 'when user is developer' do let(:api_user) { developer } - it 'returns a specific artifact file for a valid path' do + it 'allows to access attestations if has access' do get_attestation expect(response).to have_gitlab_http_status(:ok) end - it 'returns a specific artifact file for a valid path' do + it 'disallows access if does not have access' do get_attestation_other_project expect(response).to have_gitlab_http_status(:not_found) @@ -108,13 +108,13 @@ context 'when user is reporter' do let(:api_user) { reporter } - it 'returns a specific artifact file for a valid path' do + it 'allows to access attestations if has access' do get_attestation expect(response).to have_gitlab_http_status(:ok) end - it 'returns a specific artifact file for a valid path' do + it 'disallows access if does not have access' do get_attestation_other_project expect(response).to have_gitlab_http_status(:not_found) -- GitLab From a347809748eeeab7438d59270be086a394e0ebf2 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Wed, 24 Sep 2025 11:23:19 +1200 Subject: [PATCH 05/16] Add new entity for attestations In this commit, I've deleted some boilerplate code, added a failing test and created an entity for returning attestations in the API. --- lib/api/entities/supply_chain/attestation.rb | 22 ++++ lib/api/supply_chain/attestations.rb | 12 +-- .../api/supply_chain/attestations_spec.rb | 102 ++++++++++-------- 3 files changed, 80 insertions(+), 56 deletions(-) create mode 100644 lib/api/entities/supply_chain/attestation.rb diff --git a/lib/api/entities/supply_chain/attestation.rb b/lib/api/entities/supply_chain/attestation.rb new file mode 100644 index 00000000000000..cd78f5c4e18136 --- /dev/null +++ b/lib/api/entities/supply_chain/attestation.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module API + module Entities + module SupplyChain + class Attestation < Grape::Entity + expose :id + + expose :created_at + expose :updated_at + expose :expire_at + + expose :project_id + expose :build_id + expose :status + expose :predicate_kind + expose :predicate_type + expose :subject_digest + end + end + end +end diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index b6f56271eadac1..ebfa37ec13a684 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -5,17 +5,9 @@ module SupplyChain class Attestations < ::API::Base include PaginationParams - # before { authenticate! } - feature_category :artifact_security urgency :low - helpers do - # def authorize_download_artifacts! - # authorize_read_builds! - # end - end - params do requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' end @@ -36,7 +28,9 @@ class Attestations < ::API::Base urgency: :low, format: false, requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do authorize_read_attestations! - present params[:id] + attestations = ::SupplyChain::Attestation.all + + present paginate(attestations), with: ::API::Entities::SupplyChain::Attestation end end end diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index dd8e8f61b41ab7..70d6aa05221026 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -35,9 +35,10 @@ end describe 'GET /projects/:id/attestations/:subject_digest' do - context 'when job has attestations' do - let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + context 'when attestations exist' do let(:subject_digest) { "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } + let_it_be(:attestation) { create(:supply_chain_attestation, project: project) } + let(:get_attestation_other_project) do url = "/projects/#{other_project.id}/attestations/#{subject_digest}" get api(url, api_user) @@ -48,76 +49,83 @@ get api(url, api_user) end - context 'when user is anonymous' do - let(:api_user) { nil } + it 'returns the right attestations in the response' do + get_attestation + expect(response.body).to be(false) + end - context 'when project is public' do - before do - project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) - project.update_column(:public_builds, true) - end + describe 'authorization checks' do + context 'when user is anonymous' do + let(:api_user) { nil } - it 'allows to access attestations' do - get_attestation + context 'when project is public' do + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + project.update_column(:public_builds, true) + end - expect(response).to have_gitlab_http_status(:ok) + it 'allows to access attestations' do + get_attestation + + expect(response).to have_gitlab_http_status(:ok) + end end - end - context 'when project is private' do - it 'rejects access and hides existence of attestations' do - get_attestation + context 'when project is private' do + it 'rejects access and hides existence of attestations' do + get_attestation - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end end - end - context 'when user is guest' do - let(:api_user) { guest } + context 'when user is guest' do + let(:api_user) { guest } - it 'allows to access attestations if has access' do - get_attestation + it 'allows to access attestations if has access' do + get_attestation - expect(response).to have_gitlab_http_status(:ok) - end + expect(response).to have_gitlab_http_status(:ok) + end - it 'disallows access if does not have access' do - get_attestation_other_project + it 'disallows access if does not have access' do + get_attestation_other_project - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end - end - context 'when user is developer' do - let(:api_user) { developer } + context 'when user is developer' do + let(:api_user) { developer } - it 'allows to access attestations if has access' do - get_attestation + it 'allows to access attestations if has access' do + get_attestation - expect(response).to have_gitlab_http_status(:ok) - end + expect(response).to have_gitlab_http_status(:ok) + end - it 'disallows access if does not have access' do - get_attestation_other_project + it 'disallows access if does not have access' do + get_attestation_other_project - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end - end - context 'when user is reporter' do - let(:api_user) { reporter } + context 'when user is reporter' do + let(:api_user) { reporter } - it 'allows to access attestations if has access' do - get_attestation + it 'allows to access attestations if has access' do + get_attestation - expect(response).to have_gitlab_http_status(:ok) - end + expect(response).to have_gitlab_http_status(:ok) + end - it 'disallows access if does not have access' do - get_attestation_other_project + it 'disallows access if does not have access' do + get_attestation_other_project - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end end end -- GitLab From 1c4147b4e34ee4a385aa15009e6b3c86c1505e72 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Wed, 24 Sep 2025 14:06:21 +1200 Subject: [PATCH 06/16] Implement proper filtering In this commit, I've modified the API endpoint to filter by project and subject_digest --- app/models/supply_chain/attestation.rb | 3 +++ lib/api/supply_chain/attestations.rb | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/supply_chain/attestation.rb b/app/models/supply_chain/attestation.rb index b0d7bebe66173f..9b8b68114a3c7f 100644 --- a/app/models/supply_chain/attestation.rb +++ b/app/models/supply_chain/attestation.rb @@ -14,6 +14,9 @@ class Attestation < ::ApplicationRecord validates :subject_digest, uniqueness: { scope: [:project_id, :predicate_kind] } + scope :for_project, ->(project_id) { where(project_id: project_id) } + scope :with_digest, ->(subject_digest) { where(subject_digest: subject_digest) } + enum :status, { success: 0, error: 1 diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index ebfa37ec13a684..f9a16b8e278d26 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -28,7 +28,10 @@ class Attestations < ::API::Base urgency: :low, format: false, requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do authorize_read_attestations! - attestations = ::SupplyChain::Attestation.all + project_id = params[:id] + subject_digest = params[:subject_digest] + + attestations = ::SupplyChain::Attestation.for_project(project_id).with_digest(subject_digest) present paginate(attestations), with: ::API::Entities::SupplyChain::Attestation end -- GitLab From 6c8c047ff2f665907946cae83841308c1c2ee41e Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Thu, 25 Sep 2025 10:58:36 +1200 Subject: [PATCH 07/16] Update documentation, add tests In this commit, I've added tests that confirm that the right attestation is being retrieved from the database. I've also updated a 'desc' for the documentation. --- lib/api/supply_chain/attestations.rb | 2 +- .../api/supply_chain/attestations_spec.rb | 75 +++++++++++++------ 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index f9a16b8e278d26..c2c696c590a3b0 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -12,7 +12,7 @@ class Attestations < ::API::Base requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc 'Download the attestation Sigstore bundle for a specific artifact' do + desc 'Download a list of all attestations for a specific project and artifact hash' do detail 'This feature was introduced in GitLab 18.5' # TODO: update when FF is removed failure [ { code: 401, message: 'Unauthorized' }, diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index 70d6aa05221026..b826b7cc9b02e9 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -35,23 +35,44 @@ end describe 'GET /projects/:id/attestations/:subject_digest' do - context 'when attestations exist' do - let(:subject_digest) { "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } - let_it_be(:attestation) { create(:supply_chain_attestation, project: project) } + let_it_be(:subject_digest) { "5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } + let_it_be(:other_subject_digest) { "fffffee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa" } + let_it_be(:attestation) { create(:supply_chain_attestation, project: project, subject_digest: subject_digest) } + let_it_be(:other_attestation) do + create(:supply_chain_attestation, project: other_project, subject_digest: subject_digest) + end - let(:get_attestation_other_project) do - url = "/projects/#{other_project.id}/attestations/#{subject_digest}" - get api(url, api_user) - end + let_it_be(:other_attestation_2) do + create(:supply_chain_attestation, project: other_project, subject_digest: other_subject_digest) + end - subject(:get_attestation) do - url = "/projects/#{project.id}/attestations/#{subject_digest}" - get api(url, api_user) - end + let(:get_attestations_other_project) do + url = "/projects/#{other_project.id}/attestations/#{subject_digest}" + get api(url, api_user) + end + + let(:target_url) { "/projects/#{project.id}/attestations/#{subject_digest}" } + + subject(:get_attestations) do + url = target_url + get api(url, api_user) + end + context 'when an attestation exists' do it 'returns the right attestations in the response' do - get_attestation - expect(response.body).to be(false) + get_attestations + + expect(json_response.length).to eq(1) + expect(json_response[0]).to include({ + "id" => attestation.id, + "project_id" => attestation.project_id, + "expire_at" => attestation.expire_at, + "build_id" => attestation.build_id, + "status" => "success", + "predicate_kind" => "provenance", + "predicate_type" => "https://slsa.dev/provenance/v1", + "subject_digest" => subject_digest + }) end describe 'authorization checks' do @@ -65,7 +86,7 @@ end it 'allows to access attestations' do - get_attestation + get_attestations expect(response).to have_gitlab_http_status(:ok) end @@ -73,7 +94,7 @@ context 'when project is private' do it 'rejects access and hides existence of attestations' do - get_attestation + get_attestations expect(response).to have_gitlab_http_status(:not_found) end @@ -84,13 +105,13 @@ let(:api_user) { guest } it 'allows to access attestations if has access' do - get_attestation + get_attestations expect(response).to have_gitlab_http_status(:ok) end it 'disallows access if does not have access' do - get_attestation_other_project + get_attestations_other_project expect(response).to have_gitlab_http_status(:not_found) end @@ -100,13 +121,13 @@ let(:api_user) { developer } it 'allows to access attestations if has access' do - get_attestation + get_attestations expect(response).to have_gitlab_http_status(:ok) end it 'disallows access if does not have access' do - get_attestation_other_project + get_attestations_other_project expect(response).to have_gitlab_http_status(:not_found) end @@ -116,18 +137,30 @@ let(:api_user) { reporter } it 'allows to access attestations if has access' do - get_attestation + get_attestations expect(response).to have_gitlab_http_status(:ok) end it 'disallows access if does not have access' do - get_attestation_other_project + get_attestations_other_project expect(response).to have_gitlab_http_status(:not_found) end end end end + + context 'when an attestation does not exist' do + let(:target_url) do + "/projects/#{project.id}/attestations/cbe6fc257a1cd3032c3c4bff38653cd14502f213000745006337dee7dcfc4de4" + end + + it 'returns an empty response' do + get_attestations + + expect(json_response.length).to eq(0) + end + end end end -- GitLab From 6d90ea553249f50f8fe414e5bbffb128f6356dec Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Thu, 25 Sep 2025 11:08:16 +1200 Subject: [PATCH 08/16] Document API parameters --- lib/api/entities/supply_chain/attestation.rb | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/api/entities/supply_chain/attestation.rb b/lib/api/entities/supply_chain/attestation.rb index cd78f5c4e18136..2ea0a117d27d25 100644 --- a/lib/api/entities/supply_chain/attestation.rb +++ b/lib/api/entities/supply_chain/attestation.rb @@ -6,16 +6,17 @@ module SupplyChain class Attestation < Grape::Entity expose :id - expose :created_at - expose :updated_at - expose :expire_at + expose :created_at, documentation: { type: 'dateTime', example: '2025-09-17T02:26:10.898Z' } + expose :updated_at, documentation: { type: 'dateTime', example: '2025-09-17T02:26:10.898Z' } + expose :expire_at, documentation: { type: 'dateTime', example: '2025-09-17T02:26:10.898Z' } - expose :project_id - expose :build_id - expose :status - expose :predicate_kind - expose :predicate_type - expose :subject_digest + expose :project_id, documentation: { type: 'integer' } + expose :build_id, documentation: { type: 'integer' } + expose :status, documentation: { type: 'string', example: 'success' } + expose :predicate_kind, documentation: { type: 'string', example: 'provenance' } + expose :predicate_type, documentation: { type: 'string', example: 'https://slsa.dev/provenance/v1' } + expose :subject_digest, + documentation: { type: 'string', example: '5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa' } end end end -- GitLab From ae057f51ec8fc7f7357a96b2f96333fc58a8bfa0 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Thu, 25 Sep 2025 11:10:27 +1200 Subject: [PATCH 09/16] Update OpenAPI documentation --- doc/api/openapi/openapi_v2.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 58a50270b33be6..012198e65975f6 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -38159,6 +38159,37 @@ paths: tags: - projects operationId: putApiV4ProjectsIdRepositorySubmodulesSubmodule + "/api/v4/projects/{id}/attestations/{subject_digest}": + get: + summary: Download a list of all attestations for a specific project and artifact + hash + description: This feature was introduced in GitLab 18.5 + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: subject_digest + description: The SHA-256 hash of the artifact + type: string + required: true + responses: + '200': + description: Download a list of all attestations for a specific project + and artifact hash + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: Not found + tags: + - projects + operationId: getApiV4ProjectsIdAttestationsSubjectDigest "/api/v4/projects/{id}/repository/tags": get: description: Get a project repository tags -- GitLab From d431846d6ae2ac1c2e4217c4749374ebfbdbe892 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Thu, 25 Sep 2025 11:23:44 +1200 Subject: [PATCH 10/16] Add FF check --- lib/api/supply_chain/attestations.rb | 6 +++++- spec/requests/api/supply_chain/attestations_spec.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index c2c696c590a3b0..90b8441dcf602f 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -26,9 +26,13 @@ class Attestations < ::API::Base end get ':id/attestations/:subject_digest', urgency: :low, format: false, requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do + project_id = params[:id] + project = find_project!(project_id) + + not_found! unless Feature.enabled?(:slsa_provenance_statement, project) + authorize_read_attestations! - project_id = params[:id] subject_digest = params[:subject_digest] attestations = ::SupplyChain::Attestation.for_project(project_id).with_digest(subject_digest) diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index b826b7cc9b02e9..ab8b5d052532da 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -75,6 +75,18 @@ }) end + context 'when slsa_provenance_statement is disabled' do + before do + stub_feature_flags(slsa_provenance_statement: false) + end + + it 'returns 404' do + get_attestations + + expect(response).to have_gitlab_http_status(:not_found) + end + end + describe 'authorization checks' do context 'when user is anonymous' do let(:api_user) { nil } -- GitLab From 351182a71f17c3b71b59b4bba7090d554aba5c50 Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Mon, 29 Sep 2025 11:03:23 +1300 Subject: [PATCH 11/16] Add documentation for :id parameter --- lib/api/entities/supply_chain/attestation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/entities/supply_chain/attestation.rb b/lib/api/entities/supply_chain/attestation.rb index 2ea0a117d27d25..c415ac479a6c6e 100644 --- a/lib/api/entities/supply_chain/attestation.rb +++ b/lib/api/entities/supply_chain/attestation.rb @@ -4,7 +4,7 @@ module API module Entities module SupplyChain class Attestation < Grape::Entity - expose :id + expose :id, documentation: { type: 'integer', example: 1 } expose :created_at, documentation: { type: 'dateTime', example: '2025-09-17T02:26:10.898Z' } expose :updated_at, documentation: { type: 'dateTime', example: '2025-09-17T02:26:10.898Z' } -- GitLab From d240e37d2c3b09820790ee4d1dccd285f549ea8d Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Mon, 29 Sep 2025 11:13:04 +1300 Subject: [PATCH 12/16] Move FF and authorisation checks to before block --- lib/api/supply_chain/attestations.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index 90b8441dcf602f..01b89ea296e40e 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -8,6 +8,13 @@ class Attestations < ::API::Base feature_category :artifact_security urgency :low + before do + project = find_project!(params[:id]) + + not_found! unless Feature.enabled?(:slsa_provenance_statement, project) + authorize_read_attestations! + end + params do requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' end @@ -26,16 +33,9 @@ class Attestations < ::API::Base end get ':id/attestations/:subject_digest', urgency: :low, format: false, requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do - project_id = params[:id] - project = find_project!(project_id) - - not_found! unless Feature.enabled?(:slsa_provenance_statement, project) - - authorize_read_attestations! - subject_digest = params[:subject_digest] - attestations = ::SupplyChain::Attestation.for_project(project_id).with_digest(subject_digest) + attestations = ::SupplyChain::Attestation.for_project(params[:id]).with_digest(subject_digest) present paginate(attestations), with: ::API::Entities::SupplyChain::Attestation end -- GitLab From a013cab51b5f93c2868f1f17f3913230f049eece Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Mon, 29 Sep 2025 11:22:15 +1300 Subject: [PATCH 13/16] Remove unused variable --- spec/requests/api/supply_chain/attestations_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index ab8b5d052532da..df90c5d0b0a988 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -42,10 +42,6 @@ create(:supply_chain_attestation, project: other_project, subject_digest: subject_digest) end - let_it_be(:other_attestation_2) do - create(:supply_chain_attestation, project: other_project, subject_digest: other_subject_digest) - end - let(:get_attestations_other_project) do url = "/projects/#{other_project.id}/attestations/#{subject_digest}" get api(url, api_user) -- GitLab From 0f0651c79ff50235840e74c6e88d85abddc4f63e Mon Sep 17 00:00:00 2001 From: Sam Roque-Worcel Date: Sun, 28 Sep 2025 22:23:22 +0000 Subject: [PATCH 14/16] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Marcel Amirault --- lib/api/supply_chain/attestations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index 01b89ea296e40e..5f22963e348679 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -19,7 +19,7 @@ class Attestations < ::API::Base requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc 'Download a list of all attestations for a specific project and artifact hash' do + desc 'Fetch the list of all attestations for a specific project and artifact hash' do detail 'This feature was introduced in GitLab 18.5' # TODO: update when FF is removed failure [ { code: 401, message: 'Unauthorized' }, -- GitLab From 4f6d92143422be3863bb05b4d899b3aa44cb7e9c Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Mon, 29 Sep 2025 11:26:49 +1300 Subject: [PATCH 15/16] Update error message --- lib/api/supply_chain/attestations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index 5f22963e348679..bbeb603d8b8835 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -24,7 +24,7 @@ class Attestations < ::API::Base failure [ { code: 401, message: 'Unauthorized' }, { code: 403, message: 'Forbidden' }, - { code: 404, message: 'Not found' } + { code: 404, message: 'Artifact not found' } ] end params do -- GitLab From 8905b7f682d03ba17637a16c024f10ada3acec6a Mon Sep 17 00:00:00 2001 From: Sam Joan Roque-Worcel Date: Mon, 29 Sep 2025 11:28:23 +1300 Subject: [PATCH 16/16] Update OpenAPI documentation --- doc/api/openapi/openapi_v2.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 6ca539fd0c4cb0..1f61133cdf9935 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -38163,7 +38163,7 @@ paths: operationId: putApiV4ProjectsIdRepositorySubmodulesSubmodule "/api/v4/projects/{id}/attestations/{subject_digest}": get: - summary: Download a list of all attestations for a specific project and artifact + summary: Fetch the list of all attestations for a specific project and artifact hash description: This feature was introduced in GitLab 18.5 produces: @@ -38181,14 +38181,14 @@ paths: required: true responses: '200': - description: Download a list of all attestations for a specific project - and artifact hash + description: Fetch the list of all attestations for a specific project and + artifact hash '401': description: Unauthorized '403': description: Forbidden '404': - description: Not found + description: Artifact not found tags: - projects operationId: getApiV4ProjectsIdAttestationsSubjectDigest -- GitLab