diff --git a/app/models/supply_chain/attestation.rb b/app/models/supply_chain/attestation.rb index e5d8c0ed2100e652a3a39bb5a7977a8cf435a73a..f8189a13db759b94be2851285339f6882cf40107 100644 --- a/app/models/supply_chain/attestation.rb +++ b/app/models/supply_chain/attestation.rb @@ -26,6 +26,7 @@ class Attestation < ::ApplicationRecord scope :for_project, ->(project_id) { where(project_id: project_id) } scope :with_digest, ->(subject_digest) { where(subject_digest: subject_digest) } scope :with_predicate_kind, ->(predicate_kind) { where(predicate_kind: predicate_kind) } + scope :with_iid, ->(iid) { where(iid: iid) } attribute :file_store, default: -> { AttestationUploader.default_store } diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 809ba50946c2ea78c02e970981faf1cb5277d9f2..cb6713982decdb4d63bbf2cc98ae70d2b1c26283 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -71,6 +71,8 @@ tags: description: Operations about protected_tags - name: remote_mirrors description: Operations about remote_mirrors +- name: attestations + description: Operations about attestations - name: tags description: Operations about tags - name: batched_background_migrations @@ -38326,7 +38328,7 @@ paths: get: summary: Fetch the list of all attestations for a specific project and artifact hash - description: This feature was introduced in GitLab 18.5 + description: This feature was introduced in GitLab 18.7 produces: - application/json parameters: @@ -38344,15 +38346,38 @@ paths: '200': description: Fetch the list of all attestations for a specific project and artifact hash - '401': - description: Unauthorized - '403': - description: Forbidden + schema: + "$ref": "#/definitions/API_Entities_SupplyChain_Attestation" '404': - description: Artifact not found + description: Artifact SHA-256 not found tags: - - projects + - attestations operationId: getApiV4ProjectsIdAttestationsSubjectDigest + "/api/v4/projects/{id}/attestations/{attestation_iid}/download": + get: + summary: Fetch a specific bundle by iid + description: This feature was introduced in GitLab 18.7 + 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: attestation_iid + description: The iid of the attestation + type: string + required: true + responses: + '200': + description: Fetch a specific bundle by iid + '404': + description: Artifact SHA-256 not found + tags: + - attestations + operationId: getApiV4ProjectsIdAttestationsAttestationIidDownload "/api/v4/projects/{id}/repository/tags": get: description: Get a project repository tags @@ -66795,6 +66820,50 @@ definitions: - commit_sha - branch description: Update existing submodule reference in repository + API_Entities_SupplyChain_Attestation: + type: object + properties: + id: + type: integer + format: int32 + example: 1 + iid: + type: integer + format: int32 + example: 14 + created_at: + type: string + format: date-time + example: '2025-09-17T02:26:10.898Z' + updated_at: + type: string + format: date-time + example: '2025-09-17T02:26:10.898Z' + expire_at: + type: string + format: date-time + example: '2025-09-17T02:26:10.898Z' + project_id: + type: integer + format: int32 + build_id: + type: integer + format: int32 + status: + type: string + example: success + predicate_kind: + type: string + example: provenance + predicate_type: + type: string + example: https://slsa.dev/provenance/v1 + subject_digest: + type: string + example: 5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa + download_url: + type: string + description: API_Entities_SupplyChain_Attestation model API_Entities_Tag: type: object properties: diff --git a/lib/api/entities/supply_chain/attestation.rb b/lib/api/entities/supply_chain/attestation.rb index c415ac479a6c6e8e51c14ffaf07e4af10af45d84..a7cb00ee9b73db1841cf181f4c5a8bf9b7c45328 100644 --- a/lib/api/entities/supply_chain/attestation.rb +++ b/lib/api/entities/supply_chain/attestation.rb @@ -4,8 +4,10 @@ module API module Entities module SupplyChain class Attestation < Grape::Entity - expose :id, documentation: { type: 'integer', example: 1 } + include ::API::Helpers::RelatedResourcesHelpers + expose :id, documentation: { type: 'integer', example: 1 } + expose :iid, documentation: { type: 'integer', example: 14 } 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' } @@ -17,6 +19,11 @@ class Attestation < Grape::Entity expose :predicate_type, documentation: { type: 'string', example: 'https://slsa.dev/provenance/v1' } expose :subject_digest, documentation: { type: 'string', example: '5db1fee4b5703808c48078a76768b155b421b210c0761cd6a5d223f4d99f1eaa' } + + expose :download_url do |attestation| + expose_url(api_v4_projects_attestations_download_path(id: attestation.project_id, attestation_iid: + attestation.iid)) + end end end end diff --git a/lib/api/supply_chain/attestations.rb b/lib/api/supply_chain/attestations.rb index 5194f69b5d014bc60358c3e1e6e8ba2bdc353d93..647fad58ce7de11f4c019ffbe859e3b0a194b191 100644 --- a/lib/api/supply_chain/attestations.rb +++ b/lib/api/supply_chain/attestations.rb @@ -9,9 +9,7 @@ class Attestations < ::API::Base urgency :low before do - project = find_project!(params[:id]) - - not_found! unless Feature.enabled?(:slsa_provenance_statement, project) + not_found! unless Feature.enabled?(:slsa_provenance_statement, user_project) authorize_read_attestations! end @@ -20,26 +18,44 @@ class Attestations < ::API::Base end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS 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 + detail 'This feature was introduced in GitLab 18.7' # TODO: update when FF is removed + success ::API::Entities::SupplyChain::Attestation failure [ - { code: 401, message: 'Unauthorized' }, - { code: 403, message: 'Forbidden' }, - { code: 404, message: 'Artifact not found' } + { code: 404, message: 'Artifact SHA-256 not found' } ] + tags ['attestations'] 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 - subject_digest = params[:subject_digest] - project = find_project!(params[:id]) + get ':id/attestations/:subject_digest', urgency: :low, format: false, + requirements: { subject_digest: /[A-Fa-f0-9]{64}/ } do + subject_digest = params[:subject_digest] + attestations = ::SupplyChain::Attestation.for_project(user_project.id).with_digest(subject_digest) + + present paginate(attestations), with: ::API::Entities::SupplyChain::Attestation + end + + desc 'Fetch a specific bundle by iid' do + success code: 200 + failure [ + { code: 404, message: 'Artifact SHA-256 not found' } + ] + detail 'This feature was introduced in GitLab 18.7' # TODO: update when FF is removed + tags ['attestations'] + end + params do + requires :attestation_iid, types: [String, Integer], desc: 'The iid of the attestation' + end + get ':id/attestations/:attestation_iid/download', urgency: :low, format: false do + attestation = ::SupplyChain::Attestation.for_project(user_project.id).with_iid(params[:attestation_iid]).sole - attestations = ::SupplyChain::Attestation.for_project(project.id).with_digest(subject_digest) + content_type 'text/json' + env['api.format'] = :txt - present paginate(attestations), with: ::API::Entities::SupplyChain::Attestation - end + present attestation.file.read + end end end end diff --git a/spec/models/supply_chain/attestation_spec.rb b/spec/models/supply_chain/attestation_spec.rb index 764035490b1c11f91804d1e6491e6d168f4ed545..3c80b573a736a74681ff6b72e01bcdecdd94a07a 100644 --- a/spec/models/supply_chain/attestation_spec.rb +++ b/spec/models/supply_chain/attestation_spec.rb @@ -66,6 +66,14 @@ end end + describe '#with_iid' do + subject(:attestation) { create(:supply_chain_attestation) } + + it 'returns the appropriate attestation' do + expect(described_class.for_project(attestation.project).with_iid(attestation.iid).take).to eq(attestation) + end + end + describe 'modules' do let_it_be(:project) { create(:project) } diff --git a/spec/requests/api/supply_chain/attestations_spec.rb b/spec/requests/api/supply_chain/attestations_spec.rb index 67a7a9dfab8c09006fd359ed5b44b21bb113ec89..9ba1c9bf7ca09e47bd3374f7b4bd9fe17331d321 100644 --- a/spec/requests/api/supply_chain/attestations_spec.rb +++ b/spec/requests/api/supply_chain/attestations_spec.rb @@ -34,10 +34,88 @@ project.add_developer(developer) end + shared_examples 'authorization checks' do + context 'when user is anonymous' do + let(:api_user) { nil } + + context 'when project is public' 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_endpoint + + 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 + get_endpoint + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when user is guest' do + let(:api_user) { guest } + + it 'allows to access attestations if has access' do + get_endpoint + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'disallows access if does not have access' do + get_endpoint_other_project + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is developer' do + let(:api_user) { developer } + + it 'allows to access attestations if has access' do + get_endpoint + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'disallows access if does not have access' do + get_endpoint_other_project + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is reporter' do + let(:api_user) { reporter } + + it 'allows to access attestations if has access' do + get_endpoint + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'disallows access if does not have access' do + get_endpoint_other_project + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + describe 'GET /projects/:id/attestations/:subject_digest' do 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(:get_endpoint) { get_attestations } + let(:get_endpoint_other_project) { get_attestations_other_project } + let_it_be(:other_attestation) do create(:supply_chain_attestation, project: other_project, subject_digest: subject_digest) end @@ -65,7 +143,10 @@ "status" => "success", "predicate_kind" => "provenance", "predicate_type" => "https://slsa.dev/provenance/v1", - "subject_digest" => subject_digest + "subject_digest" => subject_digest, + "iid" => attestation.iid, + "download_url" => end_with(api_v4_projects_attestations_download_path(id: attestation.project_id, + attestation_iid: attestation.iid)) }) end @@ -81,80 +162,7 @@ end end - describe 'authorization checks' do - context 'when user is anonymous' do - let(:api_user) { nil } - - context 'when project is public' 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_attestations - - 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 - get_attestations - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end - - context 'when user is guest' do - let(:api_user) { guest } - - it 'allows to access attestations if has access' do - get_attestations - - expect(response).to have_gitlab_http_status(:ok) - end - - it 'disallows access if does not have access' do - get_attestations_other_project - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user is developer' do - let(:api_user) { developer } - - it 'allows to access attestations if has access' do - get_attestations - - expect(response).to have_gitlab_http_status(:ok) - end - - it 'disallows access if does not have access' do - get_attestations_other_project - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user is reporter' do - let(:api_user) { reporter } - - it 'allows to access attestations if has access' do - get_attestations - - expect(response).to have_gitlab_http_status(:ok) - end - - it 'disallows access if does not have access' do - get_attestations_other_project - - expect(response).to have_gitlab_http_status(:not_found) - end - end - end + include_examples "authorization checks" end context 'when accessing via the project id' do @@ -181,4 +189,55 @@ end end end + + describe 'GET /projects/:id/attestations/:iid/download' do + let!(:attestation) { create(:supply_chain_attestation, project: project) } + let!(:other_attestation) do + create(:supply_chain_attestation, project: other_project) + end + + let(:target_url) { "/projects/#{project.id}/attestations/#{other_attestation.iid}/download" } + let(:get_endpoint) { download_attestation } + let(:get_endpoint_other_project) { download_attestation_other_project } + let(:download_attestation_other_project) do + url = "/projects/#{other_project.id}/attestations/#{attestation.iid}/download" + get api(url, api_user) + end + + subject(:download_attestation) do + url = target_url + get api(url, api_user) + end + + it "returns the contents of the attestation" do + status_code = download_attestation + + expect(status_code).to eq(200) + expect(response.body).to eq(attestation.file.read) + end + + context 'when slsa_provenance_statement is disabled' do + before do + stub_feature_flags(slsa_provenance_statement: false) + end + + it 'returns 404' do + download_attestation + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when an invalid iid is passed' do + let(:target_url) { "/projects/#{project.id}/attestations/1337/download" } + + it 'returns 404' do + download_attestation + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + include_examples "authorization checks" + end end