From 6e4d99b273f35c7b52f4ba90c37bb96ff28edb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20C=CC=8Cavoj?= Date: Wed, 15 Oct 2025 20:08:09 +0200 Subject: [PATCH 1/4] Add policy source claims to id_tokens Add `job_metadata` with the job source to `id_tokens` claims. This can be used to verify whether a job comes from: - scan_execution_policy - pipeline_execution_policy - other source, based on the pipeline source Changelog: added --- lib/gitlab/ci/jwt_v2.rb | 3 ++- spec/lib/gitlab/ci/jwt_v2_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/ci/jwt_v2.rb b/lib/gitlab/ci/jwt_v2.rb index 228f904b6b044e..62aa990a9a2abb 100644 --- a/lib/gitlab/ci/jwt_v2.rb +++ b/lib/gitlab/ci/jwt_v2.rb @@ -64,7 +64,8 @@ def predefined_claims sha: pipeline.sha, project_visibility: Gitlab::VisibilityLevel.string_level(project.visibility_level), user_identities: user_identities, - target_audience: target_audience + target_audience: target_audience, + job_metadata: { source: build.source } }.compact mapper = ClaimMapper.new(project_config, pipeline) diff --git a/spec/lib/gitlab/ci/jwt_v2_spec.rb b/spec/lib/gitlab/ci/jwt_v2_spec.rb index cd1ac1c10f965e..43bf4af15b0fa9 100644 --- a/spec/lib/gitlab/ci/jwt_v2_spec.rb +++ b/spec/lib/gitlab/ci/jwt_v2_spec.rb @@ -59,6 +59,26 @@ end end + describe 'job_metadata' do + context 'without build_source' do + it 'includes the job source' do + allow(pipeline).to receive(:source).and_return('api') + + expect(payload[:job_metadata]).to eq(source: 'api') + end + end + + context 'with build_source' do + it 'includes the job source' do + allow(build).to receive(:build_source).and_return( + build_stubbed(:ci_build_source, build: build, source: 'web') + ) + + expect(payload[:job_metadata]).to eq(source: 'web') + end + end + end + describe 'when only project_path provided' do let(:sub_components) { [:project_path] } -- GitLab From c740c1f3073d13b6b4fb8bca33584dd068f796ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20C=CC=8Cavoj?= Date: Mon, 17 Nov 2025 10:33:12 +0100 Subject: [PATCH 2/4] Move job_metadata to ci_claims and update docs --- doc/ci/secrets/id_token_authentication.md | 4 ++++ lib/gitlab/ci/jwt_v2.rb | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/doc/ci/secrets/id_token_authentication.md b/doc/ci/secrets/id_token_authentication.md index 7a7d4e47c595ba..a946725bdf1e37 100644 --- a/doc/ci/secrets/id_token_authentication.md +++ b/doc/ci/secrets/id_token_authentication.md @@ -121,6 +121,7 @@ The token also includes custom claims provided by GitLab: | `ci_config_ref_uri` | Always | The ref path to the top-level pipeline definition, for example, `gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `ci_config_sha` | Always | Git commit SHA for the `ci_config_ref_uri`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `project_visibility` | Always | The [visibility](../../user/public_access.md) of the project where the pipeline is running. Can be `internal`, `private`, or `public`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/418810) in GitLab 16.3. | +| `job_metadata` | Always | Extended attributes that can be used to verify the job downstream. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.7. | ```json { @@ -147,6 +148,9 @@ The token also includes custom claims provided by GitLab: "environment_protected": "false", "deployment_tier": "testing", "environment_action": "start", + "job_metadata": { + "source": "push" + }, "runner_id": 1, "runner_environment": "self-hosted", "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", diff --git a/lib/gitlab/ci/jwt_v2.rb b/lib/gitlab/ci/jwt_v2.rb index 62aa990a9a2abb..c0ddc9a426277b 100644 --- a/lib/gitlab/ci/jwt_v2.rb +++ b/lib/gitlab/ci/jwt_v2.rb @@ -64,8 +64,7 @@ def predefined_claims sha: pipeline.sha, project_visibility: Gitlab::VisibilityLevel.string_level(project.visibility_level), user_identities: user_identities, - target_audience: target_audience, - job_metadata: { source: build.source } + target_audience: target_audience }.compact mapper = ClaimMapper.new(project_config, pipeline) @@ -73,6 +72,12 @@ def predefined_claims super.merge(additional_custom_claims).merge(mapper.to_h) end + def ci_claims + super.merge( + job_metadata: { source: build.source } + ) + end + def user_identities return unless user&.pass_user_identities_to_ci_jwt -- GitLab From eff8677d0a62fcd6983b87c7623ba7bf6fb09d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20C=CC=8Cavoj?= Date: Wed, 17 Dec 2025 15:48:06 +0100 Subject: [PATCH 3/4] Update the structure to match the latest proposal - Always include "job_source" - In EE, include "job_source_policy" for policy jobs - Update documentation --- doc/ci/secrets/id_token_authentication.md | 9 +- .../orchestration_policy_configuration.rb | 5 + ee/lib/ee/gitlab/ci/jwt_v2.rb | 33 ++++++ ee/spec/lib/ee/gitlab/ci/jwt_v2_spec.rb | 105 ++++++++++++++++++ ...orchestration_policy_configuration_spec.rb | 16 +++ lib/gitlab/ci/jwt_v2.rb | 4 +- spec/lib/gitlab/ci/jwt_v2_spec.rb | 8 +- 7 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 ee/lib/ee/gitlab/ci/jwt_v2.rb create mode 100644 ee/spec/lib/ee/gitlab/ci/jwt_v2_spec.rb diff --git a/doc/ci/secrets/id_token_authentication.md b/doc/ci/secrets/id_token_authentication.md index a946725bdf1e37..0d52b8a412b71c 100644 --- a/doc/ci/secrets/id_token_authentication.md +++ b/doc/ci/secrets/id_token_authentication.md @@ -121,7 +121,8 @@ The token also includes custom claims provided by GitLab: | `ci_config_ref_uri` | Always | The ref path to the top-level pipeline definition, for example, `gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `ci_config_sha` | Always | Git commit SHA for the `ci_config_ref_uri`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `project_visibility` | Always | The [visibility](../../user/public_access.md) of the project where the pipeline is running. Can be `internal`, `private`, or `public`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/418810) in GitLab 16.3. | -| `job_metadata` | Always | Extended attributes that can be used to verify the job downstream. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.7. | +| `job_source` | Always | Job source. It can be either a pipeline source, `pipeline_execution_policy` or `scan_execution_policy`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | +| `job_source_policy` | Always | The `sha` and `ref` for the policy configuration which triggered the job. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | ```json { @@ -148,8 +149,10 @@ The token also includes custom claims provided by GitLab: "environment_protected": "false", "deployment_tier": "testing", "environment_action": "start", - "job_metadata": { - "source": "push" + "job_source": "push", + "job_source_policy": { + "config_uri": "https://gitlab.example.com/my-group/my-policy-project/.gitlab/security-policies/policy.yml@refs/heads/main", + "config_sha": "ab035e64eca9a7a85bd62e485d3593f52a2804ac" }, "runner_id": 1, "runner_environment": "self-hosted", diff --git a/ee/app/models/security/orchestration_policy_configuration.rb b/ee/app/models/security/orchestration_policy_configuration.rb index d4ac0115988d82..ee4b311651f841 100644 --- a/ee/app/models/security/orchestration_policy_configuration.rb +++ b/ee/app/models/security/orchestration_policy_configuration.rb @@ -88,6 +88,11 @@ def self.policy_management_project?(project_id) self.exists?(security_policy_management_project_id: project_id) end + def self.policy_project_configuration_uri(project) + configuration_ref = Gitlab::Git::BRANCH_REF_PREFIX + project.default_branch_or_main + "#{Gitlab.config.gitlab.url}/#{project.full_path}/#{POLICY_PATH}@#{configuration_ref}" + end + def configuration_sha policy_last_commit&.id end diff --git a/ee/lib/ee/gitlab/ci/jwt_v2.rb b/ee/lib/ee/gitlab/ci/jwt_v2.rb new file mode 100644 index 00000000000000..ad9344466e7bba --- /dev/null +++ b/ee/lib/ee/gitlab/ci/jwt_v2.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module JwtV2 + extend ::Gitlab::Utils::Override + + private + + override :ci_claims + def ci_claims + super.merge(policy_claims) + end + + def policy_claims + policy_options = build.options[:policy] + return {} if policy_options&.dig(:sha).blank? || policy_options&.dig(:project_id).blank? + + policy_project = ::Project.find_by_id(policy_options[:project_id]) + return {} if policy_project.nil? + + { + job_source_policy: { + config_uri: ::Security::OrchestrationPolicyConfiguration.policy_project_configuration_uri(policy_project), + config_sha: policy_options[:sha] + }.compact + }.compact_blank + end + end + end + end +end diff --git a/ee/spec/lib/ee/gitlab/ci/jwt_v2_spec.rb b/ee/spec/lib/ee/gitlab/ci/jwt_v2_spec.rb new file mode 100644 index 00000000000000..32ca2bd9ed463e --- /dev/null +++ b/ee/spec/lib/ee/gitlab/ci/jwt_v2_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::JwtV2, feature_category: :secrets_management do + let(:namespace) { build_stubbed(:namespace) } + let(:project) { build_stubbed(:project, namespace: namespace) } + let(:user) do + build_stubbed( + :user, + identities: [build_stubbed(:identity, extern_uid: '1', provider: 'github')] + ) + end + + let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19') } + let(:runner) { build_stubbed(:ci_runner) } + let(:aud) { nil } + let(:sub_components) { [:project_path, :ref_type, :ref] } + let(:target_audience) { nil } + let(:options) { nil } + + let(:build) do + build_stubbed( + :ci_build, + project: project, + user: user, + pipeline: pipeline, + runner: runner, + options: options + ) + end + + subject(:ci_job_jwt_v2) do + described_class.new(build, ttl: 30, aud: aud, sub_components: sub_components, target_audience: target_audience) + end + + describe '#payload' do + subject(:payload) { ci_job_jwt_v2.payload } + + describe 'job_source_policy' do + shared_examples_for 'does not include job_source_policy in the payload' do + it 'does not include job_source_policy in the payload' do + expect(payload).not_to include(:job_source_policy) + end + end + + context 'without options' do + it_behaves_like 'does not include job_source_policy in the payload' + end + + context 'with options but without policy option' do + let(:options) { { job_timeout: 30 } } + + it_behaves_like 'does not include job_source_policy in the payload' + end + + context 'with policy options' do + let_it_be_with_refind(:policy_project) { create(:project, :repository) } + + before do + allow(::Security::OrchestrationPolicyConfiguration).to receive(:policy_project_configuration_uri) + .with(policy_project) + .and_return('config_uri') + end + + context 'with all necessary options' do + let(:options) { { policy: { project_id: policy_project.id, sha: 'config_sha' } } } + + it 'contains job_source_policy' do + expect(payload[:job_source_policy]).to eq({ + config_uri: 'config_uri', + config_sha: 'config_sha' + }) + end + + context 'when project does not exist' do + before do + policy_project.destroy! + end + + it_behaves_like 'does not include job_source_policy in the payload' + end + end + + context 'when sha option is empty' do + let(:options) { { policy: { project_id: policy_project.id } } } + + it_behaves_like 'does not include job_source_policy in the payload' + end + + context 'when project_id option is empty' do + let(:options) { { policy: { sha: 'config_sha' } } } + + it_behaves_like 'does not include job_source_policy in the payload' + end + + context 'when neither sha not project_id is provided' do + let(:options) { { policy: {} } } + + it_behaves_like 'does not include job_source_policy in the payload' + end + end + end + end +end diff --git a/ee/spec/models/security/orchestration_policy_configuration_spec.rb b/ee/spec/models/security/orchestration_policy_configuration_spec.rb index fc7433aa799f33..7abebdc0ab89d4 100644 --- a/ee/spec/models/security/orchestration_policy_configuration_spec.rb +++ b/ee/spec/models/security/orchestration_policy_configuration_spec.rb @@ -331,6 +331,22 @@ end end + describe '.policy_project_configuration_uri' do + subject(:configuration_uri) { described_class.policy_project_configuration_uri(project) } + + let_it_be(:project) { create(:project, :repository) } + + before do + allow(project).to receive(:default_branch_or_main).and_return('main') + allow(Gitlab.config.gitlab).to receive(:url).and_return('https://gitlab.example.com') + end + + it 'returns the URI to the policy file' do + expected_uri = "https://gitlab.example.com/#{project.full_path}/#{described_class::POLICY_PATH}@refs/heads/main" + expect(configuration_uri).to eq(expected_uri) + end + end + describe '#configuration_sha' do let(:last_commit) { instance_double(Commit, id: 'abc123') } diff --git a/lib/gitlab/ci/jwt_v2.rb b/lib/gitlab/ci/jwt_v2.rb index c0ddc9a426277b..7d8edcf2354705 100644 --- a/lib/gitlab/ci/jwt_v2.rb +++ b/lib/gitlab/ci/jwt_v2.rb @@ -74,7 +74,7 @@ def predefined_claims def ci_claims super.merge( - job_metadata: { source: build.source } + job_source: build.source ) end @@ -113,3 +113,5 @@ def issuer_url end end end + +Gitlab::Ci::JwtV2.prepend_mod diff --git a/spec/lib/gitlab/ci/jwt_v2_spec.rb b/spec/lib/gitlab/ci/jwt_v2_spec.rb index 43bf4af15b0fa9..f6cfbfa0c25bf2 100644 --- a/spec/lib/gitlab/ci/jwt_v2_spec.rb +++ b/spec/lib/gitlab/ci/jwt_v2_spec.rb @@ -59,22 +59,22 @@ end end - describe 'job_metadata' do + describe 'job_source' do context 'without build_source' do it 'includes the job source' do allow(pipeline).to receive(:source).and_return('api') - expect(payload[:job_metadata]).to eq(source: 'api') + expect(payload[:job_source]).to eq('api') end end context 'with build_source' do it 'includes the job source' do allow(build).to receive(:build_source).and_return( - build_stubbed(:ci_build_source, build: build, source: 'web') + build_stubbed(:ci_build_source, job: build, source: 'web') ) - expect(payload[:job_metadata]).to eq(source: 'web') + expect(payload[:job_source]).to eq('web') end end end -- GitLab From 8894127c18c8b56b216e7e15542d6443344a1f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20C=CC=8Cavoj?= Date: Thu, 18 Dec 2025 15:26:55 +0100 Subject: [PATCH 4/4] Fix documentation and link to the pipeline sources correctly --- doc/ci/secrets/id_token_authentication.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/ci/secrets/id_token_authentication.md b/doc/ci/secrets/id_token_authentication.md index 0d52b8a412b71c..162b602818e574 100644 --- a/doc/ci/secrets/id_token_authentication.md +++ b/doc/ci/secrets/id_token_authentication.md @@ -98,7 +98,7 @@ The token also includes custom claims provided by GitLab: | `user_login` | Always | Username of the user executing the job. | | `user_email` | Always | Email of the user executing the job. | | `user_access_level` | Always | Access level of the user executing the job. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/432052) in GitLab 16.9. | -| `job_project_id` | Always | ID of the project running the job. Use this to scope to the project by ID. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/563038) in GitLab 18.4. | +| `job_project_id` | Always | ID of the project running the job. Use this to scope to the project by ID. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues../jobs/_index.md#available-job-sources/563038) in GitLab 18.4. | | `job_project_path` | Always | Path of the project running the job. Use this to scope to the project by path. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/563038) in GitLab 18.4. | | `job_namespace_id` | Always | Namespace ID of the project running the job. Use this to scope to group or user level namespace by ID. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/563038) in GitLab 18.4. | | `job_namespace_path` | Always | Namespace path of the project running the job. Use this to scope to group or user level namespace by path. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/563038) in GitLab 18.4. | @@ -121,8 +121,8 @@ The token also includes custom claims provided by GitLab: | `ci_config_ref_uri` | Always | The ref path to the top-level pipeline definition, for example, `gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `ci_config_sha` | Always | Git commit SHA for the `ci_config_ref_uri`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/404722) in GitLab 16.2. This claim is `null` unless the pipeline definition is located in the same project. | | `project_visibility` | Always | The [visibility](../../user/public_access.md) of the project where the pipeline is running. Can be `internal`, `private`, or `public`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/418810) in GitLab 16.3. | -| `job_source` | Always | Job source. It can be either a pipeline source, `pipeline_execution_policy` or `scan_execution_policy`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | -| `job_source_policy` | Always | The `sha` and `ref` for the policy configuration which triggered the job. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | +| `job_source` | Always | [Job source](../jobs/_index.md#available-job-sources). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | +| `job_source_policy` | Job triggered by a policy | The `config_sha` and `config_uri` for the policy configuration which triggered the job. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/459001) in GitLab 18.8. | ```json { -- GitLab