diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 45f063a2048cdf06b7ed9e3c6e4d5082c683e8f7..4c4ad81fed73508c453e1ce8c8c202eacde50488 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -585,6 +585,36 @@ ] } }, + "id_tokens": { + "type": "object", + "markdownDescription": "Defines JWTs to be injected as environment variables.", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "aud": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "required": [ + "aud" + ], + "additionalProperties": false + } + } + }, "secrets": { "type": "object", "markdownDescription": "Defines secrets to be injected as environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secrets).", @@ -1209,6 +1239,9 @@ "cache": { "$ref": "#/definitions/cache" }, + "id_tokens": { + "$ref": "#/definitions/id_tokens" + }, "secrets": { "$ref": "#/definitions/secrets" }, diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index f44ba124fe206d58689974193ecfeb2ea30d428f..6441928835c6ceb9d4d574287882436e7d9d6031 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -1208,11 +1208,11 @@ def job_jwt_variables if project.ci_cd_settings.opt_in_jwt? id_tokens_variables else - legacy_jwt_variables.concat(id_tokens_variables) + predefined_jwt_variables.concat(id_tokens_variables) end end - def legacy_jwt_variables + def predefined_jwt_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| jwt = Gitlab::Ci::Jwt.for_build(self) jwt_v2 = Gitlab::Ci::JwtV2.for_build(self) @@ -1229,7 +1229,7 @@ def id_tokens_variables 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']) + token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['aud']) variables.append(key: var_name, value: token, public: false, masked: true) end diff --git a/app/validators/json_schemas/build_metadata_id_tokens.json b/app/validators/json_schemas/build_metadata_id_tokens.json index 7f39c7274f3942900af30c6b8ecd4c1777b23192..d97b2241ca34725545a095cb6944398943ac5be2 100644 --- a/app/validators/json_schemas/build_metadata_id_tokens.json +++ b/app/validators/json_schemas/build_metadata_id_tokens.json @@ -5,18 +5,27 @@ "patternProperties": { ".*": { "type": "object", - "patternProperties": { - "^id_token$": { - "type": "object", - "required": ["aud"], - "properties": { - "aud": { "type": "string" }, - "field": { "type": "string" } - }, - "additionalProperties": false + "required": [ + "aud" + ], + "properties": { + "aud": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] } }, "additionalProperties": false } } -} +} \ No newline at end of file diff --git a/lib/gitlab/ci/config/entry/id_token.rb b/lib/gitlab/ci/config/entry/id_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..12e0975d1b175a64033bafa67a5d4ba9cc757776 --- /dev/null +++ b/lib/gitlab/ci/config/entry/id_token.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a JWT definition. + # + class IdToken < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Attributable + include ::Gitlab::Config::Entry::Validatable + + attributes %i[aud] + + validations do + validates :config, required_keys: %i[aud], allowed_keys: %i[aud] + validates :aud, array_of_strings_or_string: true + end + + def value + { aud: aud } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 8e7f6ba4326fdf712a59589f8c33d47f4c559f37..ab17e1e3870b3f31b4a1b437435115759cc68a03 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -14,7 +14,7 @@ class Job < ::Gitlab::Config::Entry::Node ALLOWED_KEYS = %i[tags script image services start_in artifacts cache dependencies before_script after_script environment coverage retry parallel interruptible timeout - release].freeze + release id_tokens].freeze validations do validates :config, allowed_keys: Gitlab::Ci::Config::Entry::Job.allowed_keys + PROCESSABLE_ALLOWED_KEYS @@ -116,6 +116,11 @@ class Job < ::Gitlab::Config::Entry::Node description: 'Indicates whether this job is allowed to fail or not.', inherit: false + entry :id_tokens, ::Gitlab::Config::Entry::ComposableHash, + description: 'Configured JWTs for this job', + inherit: false, + metadata: { composable_class: ::Gitlab::Ci::Config::Entry::IdToken } + attributes :script, :tags, :when, :dependencies, :needs, :retry, :parallel, :start_in, :interruptible, :timeout, @@ -158,7 +163,8 @@ def value ignore: ignored?, allow_failure_criteria: allow_failure_criteria, needs: needs_defined? ? needs_value : nil, - scheduling_type: needs_defined? ? :dag : :stage + scheduling_type: needs_defined? ? :dag : :stage, + id_tokens: id_tokens_value ).compact end diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb index ff255543d3b2f275fadac26311f3223cac843587..31a4e10a53511dc9d74460c1bc317edd2adf9849 100644 --- a/lib/gitlab/ci/yaml_processor/result.rb +++ b/lib/gitlab/ci/yaml_processor/result.rb @@ -107,6 +107,7 @@ def build_attributes(name) cache: job[:cache], resource_group_key: job[:resource_group], scheduling_type: job[:scheduling_type], + id_tokens: job[:id_tokens], options: { image: job[:image], services: job[:services], diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index 32126a5fd9a9dee624977e55125235ce84799344..9c622d49db98a067f07f2297f86ce23f8b5e808c 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -30,6 +30,7 @@ import RulesYaml from './yaml_tests/positive_tests/rules.yml'; import ProjectPathYaml from './yaml_tests/positive_tests/project_path.yml'; import VariablesYaml from './yaml_tests/positive_tests/variables.yml'; import JobWhenYaml from './yaml_tests/positive_tests/job_when.yml'; +import IdTokensYaml from './yaml_tests/positive_tests/id_tokens.yml'; // YAML NEGATIVE TEST import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml'; @@ -45,6 +46,7 @@ import RulesNegativeYaml from './yaml_tests/negative_tests/rules.yml'; import TriggerNegative from './yaml_tests/negative_tests/trigger.yml'; import VariablesInvalidSyntaxDescYaml from './yaml_tests/negative_tests/variables/invalid_syntax_desc.yml'; import VariablesWrongSyntaxUsageExpand from './yaml_tests/negative_tests/variables/wrong_syntax_usage_expand.yml'; +import IdTokensNegativeYaml from './yaml_tests/negative_tests/id_tokens.yml'; const ajv = new Ajv({ strictTypes: false, @@ -80,6 +82,7 @@ describe('positive tests', () => { RulesYaml, VariablesYaml, ProjectPathYaml, + IdTokensYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a @@ -103,6 +106,7 @@ describe('negative tests', () => { // YAML ArtifactsNegativeYaml, CacheKeyNeative, + IdTokensNegativeYaml, IncludeNegativeYaml, JobWhenNegativeYaml, RulesNegativeYaml, diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml new file mode 100644 index 0000000000000000000000000000000000000000..aff2611f16c6256165fd74fed3e0233fceb8c361 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml @@ -0,0 +1,11 @@ +id_token_with_wrong_aud_type: + id_tokens: + INVALID_ID_TOKEN: + aud: + invalid_prop: invalid + +id_token_with_extra_properties: + id_tokens: + INVALID_ID_TOKEN: + aud: 'https://gitlab.com' + sub: 'not a valid property' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml new file mode 100644 index 0000000000000000000000000000000000000000..169b09ee56f3656cdd728c4ba2e9705df11efd40 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml @@ -0,0 +1,11 @@ +valid_id_tokens: + script: + - echo $ID_TOKEN_1 + - echo $ID_TOKEN_2 + id_tokens: + ID_TOKEN_1: + aud: 'https://gitlab.com' + ID_TOKEN_2: + aud: + - 'https://aws.com' + - 'https://google.com' diff --git a/spec/lib/gitlab/ci/config/entry/id_token_spec.rb b/spec/lib/gitlab/ci/config/entry/id_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..12585d662ec515cce7556a82d3c3730205757769 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/id_token_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Entry::IdToken do + context 'when given `aud` as a string' do + it 'is valid' do + config = { aud: 'https://gitlab.com' } + id_token = described_class.new(config) + + id_token.compose! + + expect(id_token).to be_valid + expect(id_token.value).to eq(aud: 'https://gitlab.com') + end + end + + context 'when given `aud` as an array' do + it 'is valid and concatenates the values' do + config = { aud: ['https://gitlab.com', 'https://aws.com'] } + id_token = described_class.new(config) + + id_token.compose! + + expect(id_token).to be_valid + expect(id_token.value).to eq(aud: ['https://gitlab.com', 'https://aws.com']) + end + end + + context 'when not given an `aud`' do + it 'is invalid' do + config = {} + id_token = described_class.new(config) + + id_token.compose! + + expect(id_token).not_to be_valid + expect(id_token.errors).to match_array([ + 'id token config missing required keys: aud', + 'id token aud should be an array of strings or a string' + ]) + end + end + + context 'when given an unknown keyword' do + it 'is invalid' do + config = { aud: 'https://gitlab.com', unknown: 'test' } + id_token = described_class.new(config) + + id_token.compose! + + expect(id_token).not_to be_valid + expect(id_token.errors).to match_array([ + 'id token config contains unknown keys: unknown' + ]) + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index acf60a6cddab6b5362d5ffa9fd0f37a5f718c5b5..22cb1f480c56da62fe48b1c9a45df8fc74138b40 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -716,7 +716,8 @@ let(:config) do { before_script: %w[ls pwd], script: 'rspec', - after_script: %w[cleanup] } + after_script: %w[cleanup], + id_tokens: { TEST_ID_TOKEN: { aud: 'https://gitlab.com' } } } end it 'returns correct value' do @@ -730,7 +731,8 @@ only: { refs: %w[branches tags] }, job_variables: {}, root_variables_inheritance: true, - scheduling_type: :stage) + scheduling_type: :stage, + id_tokens: { TEST_ID_TOKEN: { aud: 'https://gitlab.com' } }) end end end diff --git a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb index 7f2031687061bbfdcde1d13f0f092b1768470c18..5c9f156e05474369a07f8d427ceea878d28725bd 100644 --- a/spec/lib/gitlab/ci/yaml_processor/result_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor/result_spec.rb @@ -12,6 +12,20 @@ class YamlProcessor let(:ci_config) { Gitlab::Ci::Config.new(config_content, user: user) } let(:result) { described_class.new(ci_config: ci_config, warnings: ci_config&.warnings) } + describe '#builds' do + context 'when a job has ID tokens' do + let(:config_content) do + YAML.dump( + test: { stage: 'test', script: 'echo', id_tokens: { TEST_ID_TOKEN: { aud: 'https://gitlab.com' } } } + ) + end + + it 'includes `id_tokens`' do + expect(result.builds.first[:id_tokens]).to eq({ TEST_ID_TOKEN: { aud: 'https://gitlab.com' } }) + end + end + end + describe '#config_metadata' do subject(:config_metadata) { result.config_metadata } diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb index e728ce0f4740250fc74610b3ba0cdf6df6ff9683..3028f49a49c86086c84a37b4e4f8c17f2db0accf 100644 --- a/spec/models/ci/build_metadata_spec.rb +++ b/spec/models/ci/build_metadata_spec.rb @@ -107,9 +107,7 @@ } metadata.id_tokens = { TEST_JWT_TOKEN: { - id_token: { - aud: 'https://gitlab.test' - } + aud: 'https://gitlab.test' } } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 813b4b3faa65067843bf0bb3a23cc6578c0c8d5d..9269b341627081883e3e099014995c378f45982b 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -3539,8 +3539,8 @@ 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' } } + 'ID_TOKEN_1' => { aud: 'developers' }, + 'ID_TOKEN_2' => { aud: 'maintainers' } }) end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 67c13649c6f56087f0af47165dda838dc4c656ca..afa9ab81851bc624ed551bd34682b37462c82bc5 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -710,6 +710,29 @@ def previous_commit_sha_from_ref(ref) end end + context 'when the configuration includes ID tokens' do + it 'creates variables for the ID tokens' do + config = YAML.dump({ + job_with_id_tokens: { + script: 'ls', + id_tokens: { + 'TEST_ID_TOKEN' => { + aud: 'https://gitlab.com' + } + } + } + }) + stub_ci_pipeline_yaml_file(config) + + result = execute_service.payload + + expect(result).to be_persisted + expect(result.builds.first.id_tokens).to eq({ + 'TEST_ID_TOKEN' => { 'aud' => 'https://gitlab.com' } + }) + end + end + context 'with manual actions' do before do config = YAML.dump({ deploy: { script: 'ls', when: 'manual' } })