diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index cc2711e7009ec8e3a81745342fcc0c744b68cace..f67d1983356a92838cb0c6fc285d745765d639f1 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -227,7 +227,7 @@ def clone_accessors yaml_variables when environment coverage_regex description tag_list protected needs_attributes job_variables_attributes resource_group scheduling_type - ci_stage partition_id].freeze + ci_stage partition_id id_tokens].freeze end end @@ -1204,6 +1204,14 @@ def has_expiring_artifacts? end def job_jwt_variables + if project.ci_cd_settings.opt_in_jwt? + id_tokens_variables + else + legacy_jwt_variables.concat(id_tokens_variables) + end + end + + def legacy_jwt_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless Feature.enabled?(:ci_job_jwt, project) @@ -1217,6 +1225,20 @@ def job_jwt_variables end end + def id_tokens_variables + return [] unless id_tokens? + + Gitlab::Ci::Variables::Collection.new.tap do |variables| + id_tokens.each do |var_name, token_data| + token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['id_token']['aud']) + + variables.append(key: var_name, value: token, public: false, masked: true) + end + rescue OpenSSL::PKey::RSAError, Gitlab::Ci::Jwt::NoSigningKeyError => e + Gitlab::ErrorTracking.track_exception(e) + end + end + def cache_for_online_runners(&block) Rails.cache.fetch( ['has-online-runners', id], diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 71b26b70bbf088b45f9dca2967feba3e38d444bb..ff884984099c6e17b8af479159c49d015ee19946 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -80,7 +80,7 @@ def interruptible=(value) end def id_tokens? - !!metadata&.id_tokens? + metadata&.id_tokens.present? end def id_tokens=(value) diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json index 3c8035d0dcf41a16cea2fd8c18e23c44fd06f259..5dcd33a2cf009138c3583fd49239c63f7363817e 100644 --- a/app/validators/json_schemas/build_metadata_secrets.json +++ b/app/validators/json_schemas/build_metadata_secrets.json @@ -24,7 +24,8 @@ }, "additionalProperties": false }, - "^file$": { "type": "boolean" } + "^file$": { "type": "boolean" }, + "^token$": { "type": "string" } }, "additionalProperties": false } diff --git a/ee/app/presenters/ee/ci/build_runner_presenter.rb b/ee/app/presenters/ee/ci/build_runner_presenter.rb index ab03c6cc98d3a384cc5571782e7dbc7052f8fa83..254913e97be02142eccae74fd43e4a9294de227e 100644 --- a/ee/app/presenters/ee/ci/build_runner_presenter.rb +++ b/ee/app/presenters/ee/ci/build_runner_presenter.rb @@ -7,14 +7,14 @@ module BuildRunnerPresenter def secrets_configuration secrets.to_h.transform_values do |secret| - secret['vault']['server'] = vault_server if secret['vault'] + secret['vault']['server'] = vault_server(secret) if secret['vault'] secret end end private - def vault_server + def vault_server(secret) @vault_server ||= { 'url' => variable_value('VAULT_SERVER_URL'), 'namespace' => variable_value('VAULT_NAMESPACE'), @@ -22,12 +22,28 @@ def vault_server 'name' => 'jwt', 'path' => variable_value('VAULT_AUTH_PATH', 'jwt'), 'data' => { - 'jwt' => '${CI_JOB_JWT}', + 'jwt' => vault_jwt(secret), 'role' => variable_value('VAULT_AUTH_ROLE') }.compact } } end + + def vault_jwt(secret) + if project.ci_cd_settings.opt_in_jwt? + id_token_var(secret) + else + '${CI_JOB_JWT}' + end + end + + def id_token_var(secret) + return unless id_tokens? + + token = secret['token'] || id_tokens.each_key.first + + "${#{token}}" + end end end end diff --git a/ee/spec/presenters/ci/build_runner_presenter_spec.rb b/ee/spec/presenters/ci/build_runner_presenter_spec.rb index 73e2d582a4a88f285d8808139b946ef4d45ce687..a20ed9ab8e0af005fe88e99d80663138b0d204ca 100644 --- a/ee/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/ee/spec/presenters/ci/build_runner_presenter_spec.rb @@ -108,6 +108,71 @@ expect(subject.fetch('file')).to be_truthy end end + + context "when the job's project has `opt_in_jwt` set to true" do + before do + ci_build.project.ci_cd_settings.update!(opt_in_jwt: true) + end + + context 'when there are ID tokens available' do + before do + rsa_key = OpenSSL::PKey::RSA.generate(3072).to_s + stub_application_setting(ci_jwt_signing_key: rsa_key) + ci_build.id_tokens = { + 'VAULT_ID_TOKEN_1' => { id_token: { aud: 'https://gitlab.test' } }, + 'VAULT_ID_TOKEN_2' => { id_token: { aud: 'https://gitlab.link' } } + } + end + + it 'adds the first ID token to the Vault server payload' do + jwt = presenter.secrets_configuration.dig('DATABASE_PASSWORD', 'vault', 'server', 'auth', 'data', 'jwt') + + expect(jwt).to eq('${VAULT_ID_TOKEN_1}') + end + + context 'when the token variable is specified for the vault secret' do + let(:secrets) do + { + DATABASE_PASSWORD: { + file: true, + token: 'VAULT_ID_TOKEN_2', + vault: { + engine: { name: 'kv-v2', path: 'kv-v2' }, + path: 'production/db', + field: 'password' + } + } + } + end + + it 'uses the specified token variable' do + jwt = presenter.secrets_configuration.dig('DATABASE_PASSWORD', 'vault', 'server', 'auth', 'data', 'jwt') + + expect(jwt).to eq('${VAULT_ID_TOKEN_2}') + end + end + end + + context 'when there is no ID token available' do + it 'leaves the `jwt` field empty' do + jwt = presenter.secrets_configuration.dig('DATABASE_PASSWORD', 'vault', 'server', 'auth', 'data', 'jwt') + + expect(jwt).to be_blank + end + end + end + + context "when the job's project has `opt_in_jwt` set to false" do + before do + ci_build.project.ci_cd_settings.update!(opt_in_jwt: false) + end + + it 'adds CI_JOB_JWT to the Vault server payload' do + jwt = presenter.secrets_configuration.dig('DATABASE_PASSWORD', 'vault', 'server', 'auth', 'data', 'jwt') + + expect(jwt).to eq('${CI_JOB_JWT}') + end + end end end end diff --git a/lib/gitlab/ci/jwt.rb b/lib/gitlab/ci/jwt.rb index d3e7210b82074758997413f29d75580a5d8b9587..d82ca875e765ec40f163e4ec1a2fff5d2d510d53 100644 --- a/lib/gitlab/ci/jwt.rb +++ b/lib/gitlab/ci/jwt.rb @@ -12,7 +12,7 @@ def self.for_build(build) self.new(build, ttl: build.metadata_timeout).encoded end - def initialize(build, ttl: nil) + def initialize(build, ttl:) @build = build @ttl = ttl end diff --git a/lib/gitlab/ci/jwt_v2.rb b/lib/gitlab/ci/jwt_v2.rb index 4e01688a955b77696b61a786e32a3bdc81c39474..cfefa79d9e0b2bcc97c0fbf69082cb4e16c51140 100644 --- a/lib/gitlab/ci/jwt_v2.rb +++ b/lib/gitlab/ci/jwt_v2.rb @@ -3,13 +3,27 @@ module Gitlab module Ci class JwtV2 < Jwt + DEFAULT_AUD = Settings.gitlab.base_url + + def self.for_build(build, aud: DEFAULT_AUD) + new(build, ttl: build.metadata_timeout, aud: aud).encoded + end + + def initialize(build, ttl:, aud:) + super(build, ttl: ttl) + + @aud = aud + end + private + attr_reader :aud + def reserved_claims super.merge( iss: Settings.gitlab.base_url, sub: "project_path:#{project.full_path}:ref_type:#{ref_type}:ref:#{source_ref}", - aud: Settings.gitlab.base_url + aud: aud ) end end diff --git a/spec/lib/gitlab/ci/jwt_v2_spec.rb b/spec/lib/gitlab/ci/jwt_v2_spec.rb index 33aaa145a3935a0776390588721f4365608e9fe5..5eeab658a8e9f68443c807f78b1e22b2918ec8bb 100644 --- a/spec/lib/gitlab/ci/jwt_v2_spec.rb +++ b/spec/lib/gitlab/ci/jwt_v2_spec.rb @@ -7,6 +7,8 @@ let(:project) { build_stubbed(:project, namespace: namespace) } let(:user) { build_stubbed(:user) } let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19') } + let(:aud) { described_class::DEFAULT_AUD } + let(:build) do build_stubbed( :ci_build, @@ -16,7 +18,7 @@ ) end - subject(:ci_job_jwt_v2) { described_class.new(build, ttl: 30) } + subject(:ci_job_jwt_v2) { described_class.new(build, ttl: 30, aud: aud) } it { is_expected.to be_a Gitlab::Ci::Jwt } @@ -30,5 +32,13 @@ expect(payload[:sub]).to eq("project_path:#{project.full_path}:ref_type:branch:ref:#{pipeline.source_ref}") end end + + context 'when given an aud' do + let(:aud) { 'AWS' } + + it 'uses that aud in the payload' do + expect(payload[:aud]).to eq('AWS') + end + end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 41b817f231b8171820f369567bf9e5a7a00f1533..00f9a6279a55a876281da1cea286d20f99fff0f4 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -2817,6 +2817,14 @@ end end + context 'when the opt_in_jwt project setting is true' do + it 'does not include the JWT variables' do + project.ci_cd_settings.update!(opt_in_jwt: true) + + expect(subject.pluck(:key)).not_to include('CI_JOB_JWT', 'CI_JOB_JWT_V1', 'CI_JOB_JWT_V2') + end + end + describe 'variables ordering' do context 'when variables hierarchy is stubbed' do let(:build_pre_var) { { key: 'build', value: 'value', public: true, masked: false } } @@ -3527,6 +3535,49 @@ it { is_expected.to include(key: job_variable.key, value: job_variable.value, public: false, masked: false) } end + + context 'when ID tokens are defined on the build' do + before do + rsa_key = OpenSSL::PKey::RSA.generate(3072).to_s + stub_application_setting(ci_jwt_signing_key: rsa_key) + build.metadata.update!(id_tokens: { + 'ID_TOKEN_1' => { id_token: { aud: 'developers' } }, + 'ID_TOKEN_2' => { id_token: { aud: 'maintainers' } } + }) + end + + subject(:runner_vars) { build.variables.to_runner_variables } + + it 'includes the ID token variables' do + expect(runner_vars).to include( + a_hash_including(key: 'ID_TOKEN_1', public: false, masked: true), + a_hash_including(key: 'ID_TOKEN_2', public: false, masked: true) + ) + + id_token_var_1 = runner_vars.find { |var| var[:key] == 'ID_TOKEN_1' } + id_token_var_2 = runner_vars.find { |var| var[:key] == 'ID_TOKEN_2' } + id_token_1 = JWT.decode(id_token_var_1[:value], nil, false).first + id_token_2 = JWT.decode(id_token_var_2[:value], nil, false).first + expect(id_token_1['aud']).to eq('developers') + expect(id_token_2['aud']).to eq('maintainers') + end + + context 'when a NoSigningKeyError is raised' do + it 'does not include the ID token variables' do + allow(::Gitlab::Ci::JwtV2).to receive(:for_build).and_raise(::Gitlab::Ci::Jwt::NoSigningKeyError) + + expect(runner_vars.map { |var| var[:key] }).not_to include('ID_TOKEN_1', 'ID_TOKEN_2') + end + end + + context 'when a RSAError is raised' do + it 'does not include the ID token variables' do + allow(::Gitlab::Ci::JwtV2).to receive(:for_build).and_raise(::OpenSSL::PKey::RSAError) + + expect(runner_vars.map { |var| var[:key] }).not_to include('ID_TOKEN_1', 'ID_TOKEN_2') + end + end + end end describe '#scoped_variables' do diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index 61e2864a518efa33b4ac62e088d1291a1ed221f7..a199111b1e3dbd8c1e2415600f3b1351118c8e3c 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -177,7 +177,7 @@ Ci::Build.attribute_names.map(&:to_sym) + Ci::Build.attribute_aliases.keys.map(&:to_sym) + Ci::Build.reflect_on_all_associations.map(&:name) + - [:tag_list, :needs_attributes, :job_variables_attributes] - + [:tag_list, :needs_attributes, :job_variables_attributes, :id_tokens] - # ToDo: Move EE accessors to ee/ ::Ci::Build.extra_accessors - [:dast_site_profiles_build, :dast_scanner_profiles_build]