diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 1da224c2174ffef4df485f44d414832bd557e511..a2a02d2fc90ca1f835d8c714b84ac5621ee42b42 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -61,6 +61,9 @@ "id_tokens": { "$ref": "#/definitions/id_tokens" }, + "identity_provider": { + "$ref": "#/definitions/identity_provider" + }, "retry": { "$ref": "#/definitions/retry" }, @@ -703,6 +706,13 @@ } } }, + "identity_provider": { + "type": "string", + "markdownDescription": "Sets an identity provider (experimental), allowing automatic authentication with the external provider. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#identity_provider).", + "enum": [ + "google_cloud" + ] + }, "secrets": { "type": "object", "markdownDescription": "Defines secrets to be injected as environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secrets).", @@ -1632,6 +1642,9 @@ "id_tokens": { "$ref": "#/definitions/id_tokens" }, + "identity_provider": { + "$ref": "#/definitions/identity_provider" + }, "secrets": { "$ref": "#/definitions/secrets" }, diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index b95397bbc735c4aeec0e6e5a80f6b3d3509cff61..bf050a761e10d58a1cd8c53a08648f159a9ec305 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -1,7 +1,10 @@ --- stage: Verify group: Pipeline Authoring -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +info: >- + To determine the technical writer assigned to the Stage/Group associated with + this page, see + https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments --- # CI/CD YAML syntax reference @@ -59,6 +62,7 @@ A GitLab CI/CD pipeline configuration includes: | [`dependencies`](#dependencies) | Restrict which artifacts are passed to a specific job by providing a list of jobs to fetch artifacts from. | | [`environment`](#environment) | Name of an environment to which the job deploys. | | [`extends`](#extends) | Configuration entries that this job inherits from. | + | [`identity_provider`](#identity_provider) | Authenticate with third party services. | | [`image`](#image) | Use Docker images. | | [`inherit`](#inherit) | Select which global defaults all jobs inherit. | | [`interruptible`](#interruptible) | Defines if a job can be canceled when made redundant by a newer run. | @@ -2437,6 +2441,33 @@ job1: - [GitLab Runner configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) +### `identity_provider` **(EXPERIMENT)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142054) in GitLab 16.9. This feature is an [Experiment](../../policy/experiment-beta-support.md). + +FLAG: +On GitLab.com, this feature is not available. +The feature is not ready for production use. + +Use `identity_provider` to authenticate with third party services. + +**Keyword type**: Job keyword. You can use it only as part of a job or in the [`default:` section](#default). + +**Possible inputs**: A provider identifier. Supported providers: `google_cloud` (Google Cloud). The Google Cloud + +**Example of `identity_provider`**: + +```yaml +job_with_identity_provider: + identity_provider: google_cloud + script: + - gcloud compute instances list +``` + +**Related topics**: + +- [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation). + ### `id_tokens` > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/356986) in GitLab 15.7. diff --git a/ee/app/models/ee/ci/build.rb b/ee/app/models/ee/ci/build.rb index 9a6aec29e2b24e89becc2b22be4da409ece4e8b0..efca5da43de18255df63f742a45dff9743ff5946 100644 --- a/ee/app/models/ee/ci/build.rb +++ b/ee/app/models/ee/ci/build.rb @@ -83,6 +83,11 @@ def variables end end + override :job_jwt_variables + def job_jwt_variables + super.concat(identity_provider_variables) + end + def cost_factor_enabled? runner&.cost_factor_enabled?(project) end @@ -313,6 +318,17 @@ def azure_key_vault_provider? def hashicorp_vault_provider? variable_value('VAULT_SERVER_URL').present? end + + def identity_provider_variables + return [] if options[:identity_provider].blank? + + case options[:identity_provider] + when 'google_cloud' + ::Gitlab::Ci::GoogleCloud::GenerateBuildEnvironmentVariablesService.new(self).execute + else + raise ArgumentError, "Unknown identity_provider value: #{options[:identity_provider]}" + end + end end end end diff --git a/ee/config/feature_flags/beta/ci_yaml_support_for_identity_provider.yml b/ee/config/feature_flags/beta/ci_yaml_support_for_identity_provider.yml new file mode 100644 index 0000000000000000000000000000000000000000..4034e91ac5b48f4b82d41d037e0dd4f831f653c0 --- /dev/null +++ b/ee/config/feature_flags/beta/ci_yaml_support_for_identity_provider.yml @@ -0,0 +1,9 @@ +--- +name: ci_yaml_support_for_identity_provider +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/438546 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142054 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/438420 +milestone: '16.9' +group: group::pipeline security +type: beta +default_enabled: false diff --git a/ee/lib/ee/gitlab/ci/config/entry/job.rb b/ee/lib/ee/gitlab/ci/config/entry/job.rb index 372cb00f3f73c4429c93f1ad94fdc0cabfb0c844..b7bc743ecab20a05db631b345a54c8fa82efc853 100644 --- a/ee/lib/ee/gitlab/ci/config/entry/job.rb +++ b/ee/lib/ee/gitlab/ci/config/entry/job.rb @@ -9,7 +9,7 @@ module Job extend ActiveSupport::Concern extend ::Gitlab::Utils::Override - EE_ALLOWED_KEYS = %i[dast_configuration secrets].freeze + EE_ALLOWED_KEYS = %i[dast_configuration identity_provider secrets].freeze prepended do attributes :dast_configuration, :secrets @@ -18,6 +18,10 @@ module Job description: 'DAST configuration for this job', inherit: false + entry :identity_provider, ::Gitlab::Ci::Config::Entry::IdentityProvider, + description: 'Configured identity provider for this job.', + inherit: false + entry :secrets, ::Gitlab::Config::Entry::ComposableHash, description: 'Configured secrets for this job', inherit: false, @@ -37,9 +41,17 @@ def allowed_keys def value super.merge({ dast_configuration: dast_configuration_value, + identity_provider: identity_provider_available? ? identity_provider_value : nil, secrets: secrets_value }.compact) end + + private + + def identity_provider_available? + ::Gitlab::Ci::YamlProcessor::FeatureFlags.enabled?(:ci_yaml_support_for_identity_provider, type: :beta) && + ::Gitlab::Saas.feature_available?(:google_artifact_registry) + end end end end diff --git a/ee/lib/ee/gitlab/ci/yaml_processor/result.rb b/ee/lib/ee/gitlab/ci/yaml_processor/result.rb index 2924ef2bfaf075cb72ac452afb3dccc95a9462b2..fd354c612c9fd14232ee87d62113d17d021ab18e 100644 --- a/ee/lib/ee/gitlab/ci/yaml_processor/result.rb +++ b/ee/lib/ee/gitlab/ci/yaml_processor/result.rb @@ -16,7 +16,8 @@ def build_attributes(name) super.deep_merge( { options: { - dast_configuration: job[:dast_configuration] + dast_configuration: job[:dast_configuration], + identity_provider: job[:identity_provider] }.compact, secrets: job[:secrets] }.compact diff --git a/ee/lib/gitlab/ci/config/entry/identity_provider.rb b/ee/lib/gitlab/ci/config/entry/identity_provider.rb new file mode 100644 index 0000000000000000000000000000000000000000..a6eb4bfc57a2a2d4329e5001ebc16bd0417277f2 --- /dev/null +++ b/ee/lib/gitlab/ci/config/entry/identity_provider.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents identity provider settings. + # + class IdentityProvider < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + ALLOWED_IDENTITY_PROVIDERS = %w[google_cloud].freeze + + validations do + validates :config, type: String, inclusion: { + in: ALLOWED_IDENTITY_PROVIDERS, + message: "should be one of: #{ALLOWED_IDENTITY_PROVIDERS.join(', ')}" + } + end + end + end + end + end +end diff --git a/ee/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service.rb b/ee/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..67ed067a84e39cc7a2eee87852a42987571e9f92 --- /dev/null +++ b/ee/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module GoogleCloud + class GenerateBuildEnvironmentVariablesService + def initialize(build) + @build = build + + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/439200 + @integration = build.project.google_cloud_platform_artifact_registry_integration + end + + def execute + return [] unless @integration&.active + + config_json = ::GoogleCloudPlatform::BaseClient.credentials( + audience: @integration.wlif, + encoded_jwt: encoded_jwt + ).to_json + + var_attributes = { value: config_json, public: false, masked: true, file: true } + + [ + { key: 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE', **var_attributes }, + { key: 'GOOGLE_APPLICATION_CREDENTIALS', **var_attributes } + ] + end + + private + + def encoded_jwt + JwtV2.for_build(@build, aud: ::GoogleCloudPlatform::BaseClient::GLGO_BASE_URL, wlif: @integration.wlif) + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb b/ee/spec/lib/ee/gitlab/ci/config/entry/job_spec.rb similarity index 64% rename from ee/spec/lib/gitlab/ci/config/entry/job_spec.rb rename to ee/spec/lib/ee/gitlab/ci/config/entry/job_spec.rb index d0e1d4939ba52dad2f9abace2bb2f16444c3f174..f791dcabd6c59269d78797f426d5726cb22e318c 100644 --- a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/config/entry/job_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Config::Entry::Job do +RSpec.describe Gitlab::Ci::Config::Entry::Job, feature_category: :pipeline_composition do let(:entry) { described_class.new(config, name: :rspec) } describe '.nodes' do context 'when filtering all the entry/node names' do subject { described_class.nodes.keys } - let(:result) { %i[dast_configuration secrets] } + let(:result) { %i[dast_configuration identity_provider secrets] } it { is_expected.to include(*result) } end @@ -47,7 +47,9 @@ end context 'when has dast_configuration' do - let(:config) { { script: 'echo', dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' } } } + let(:config) do + { script: 'echo', dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' } } + end it_behaves_like 'a valid entry' end @@ -73,11 +75,19 @@ it_behaves_like 'an invalid entry', 'secrets config should be a hash' end + + context 'when entry has unknown identity provider' do + let(:config) { { script: 'rspec', identity_provider: 'unknown' } } + + it_behaves_like 'an invalid entry', 'identity_provider config should be one of: google_cloud' + end end end describe 'dast_configuration' do - let(:config) { { script: 'echo', dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' } } } + let(:config) do + { script: 'echo', dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' } } + end before do entry.compose! @@ -136,4 +146,42 @@ }) end end + + describe 'identity_provider', :aggregate_failures, feature_category: :secrets_management do + let(:feature_flag_enabled) { true } + let(:saas_feature_enabled) { true } + let(:config) do + { + script: 'rspec', + identity_provider: 'google_cloud' + } + end + + before do + stub_feature_flags(ci_yaml_support_for_identity_provider: feature_flag_enabled) + stub_saas_features(google_artifact_registry: saas_feature_enabled) + + entry.compose! + end + + it 'includes identity provider-related values' do + expect(entry.value).to include(identity_provider: 'google_cloud') + end + + context 'when ci_yaml_support_for_identity_provider FF is disabled' do + let(:feature_flag_enabled) { false } + + it 'does not include identity provider-related values' do + expect(entry.value).not_to match(a_hash_including(identity_provider: anything)) + end + end + + context 'when feature is disabled' do + let(:saas_feature_enabled) { false } + + it 'does not include identity provider-related values' do + expect(entry.value).not_to match(a_hash_including(identity_provider: anything)) + end + end + end end diff --git a/ee/spec/lib/ee/gitlab/ci/yaml_processor/result_spec.rb b/ee/spec/lib/ee/gitlab/ci/yaml_processor/result_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd93559e5416d77cba9695aacaf297ae2f5e838b --- /dev/null +++ b/ee/spec/lib/ee/gitlab/ci/yaml_processor/result_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::YamlProcessor::Result, feature_category: :pipeline_composition do + include StubRequests + + let_it_be(:user) { create(:user) } + + let(:ci_config) { Gitlab::Ci::Config.new(config_content, user: user) } + let(:result) { described_class.new(ci_config: ci_config, warnings: ci_config&.warnings) } + + subject(:build) { result.builds.first } + + describe '#builds' do + context 'when a job has identity_provider', feature_category: :secrets_management do + let(:config_content) do + YAML.dump( + test: { stage: 'test', script: 'echo', identity_provider: 'google_cloud' } + ) + end + + before do + stub_saas_features(google_artifact_registry: true) + end + + it 'includes :identity_provider in :options' do + expect(build.dig(:options, :identity_provider)).to eq('google_cloud') + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service_spec.rb b/ee/spec/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b620ceddaf8b2c127a10844216378ab7190704d0 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/google_cloud/generate_build_environment_variables_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Ci::GoogleCloud::GenerateBuildEnvironmentVariablesService, '#execute', feature_category: :secrets_management do + let_it_be_with_refind(:project) { create(:project) } + let_it_be_with_refind(:integration) { create(:google_cloud_platform_artifact_registry_integration, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:rsa_key) { OpenSSL::PKey::RSA.generate(3072) } + let_it_be(:rsa_key_data) { rsa_key.to_s } + + let(:build) { build_stubbed(:ci_build, project: project, user: user) } + let(:service) { described_class.new(build) } + + subject(:execute) { service.execute } + + before do + stub_application_setting(ci_jwt_signing_key: rsa_key_data) + end + + it 'returns variables containing valid config.json', :aggregate_failures do + expect(execute).to contain_exactly( + a_hash_including(key: 'GOOGLE_APPLICATION_CREDENTIALS', file: true, masked: true), + a_hash_including(key: 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE', file: true, masked: true) + ) + + expect(Gitlab::Json.parse(execute.first[:value])).to match( + 'type' => 'external_account', + 'audience' => integration.wlif, + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:jwt', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => { + 'url' => 'https://auth.gcp.gitlab.com/token', + 'headers' => { 'Authorization' => a_string_matching(/Bearer [0-9a-zA-Z_\-.]+/) }, + 'format' => { 'type' => 'json', 'subject_token_field_name' => 'token' } + }) + expect(execute.pluck(:value)).to all(eq(execute.first[:value])) + end + + it 'creates a config with expected JWT token' do + config = Gitlab::Json.parse(execute.first[:value]) + authorization = config.dig(*%w[credential_source headers Authorization]) + encoded_token = authorization.split(' ').last + payload, _ = ::JWT.decode(encoded_token, rsa_key, true, { algorithm: 'RS256' }) + + expect(payload).to match(a_hash_including( + 'namespace_id' => project.namespace_id.to_s, + 'project_id' => project.id.to_s, + 'user_id' => user.id.to_s, + 'aud' => 'https://auth.gcp.gitlab.com', + 'wlif' => integration.wlif + )) + end + + context 'when integration is not present' do + before do + integration.destroy! + end + + it { is_expected.to eq([]) } + end + + context 'when integration is inactive' do + before do + integration.update_column(:active, false) + end + + it { is_expected.to eq([]) } + end +end diff --git a/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb b/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb index 5d09aaf45c7257b9bc026af2851d9de37757d32f..35e8d8843c88452e8b1e88f8b5c6be7497acb7b5 100644 --- a/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -2,7 +2,9 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::YamlProcessor do +RSpec.describe Gitlab::Ci::YamlProcessor, feature_category: :pipeline_composition do + subject(:result) { described_class.new(YAML.dump(config)).execute } + describe 'Bridge Needs' do let(:config) do { @@ -11,14 +13,12 @@ } end - subject { described_class.new(YAML.dump(config)).execute } - - context 'needs upstream pipeline' do + context 'when needs upstream pipeline' do let(:needs) { { pipeline: 'some/project' } } it 'creates jobs with valid specification' do - expect(subject.builds.size).to eq(2) - expect(subject.builds[0]).to eq( + expect(result.builds.size).to eq(2) + expect(result.builds[0]).to eq( stage: "build", stage_idx: 1, name: "build", @@ -32,7 +32,7 @@ root_variables_inheritance: true, scheduling_type: :stage ) - expect(subject.builds[1]).to eq( + expect(result.builds[1]).to eq( stage: "test", stage_idx: 2, name: "bridge", @@ -49,12 +49,12 @@ end end - context 'needs both job and pipeline' do + context 'when needs both job and pipeline' do let(:needs) { ['build', { pipeline: 'some/project' }] } it 'creates jobs with valid specification' do - expect(subject.builds.size).to eq(2) - expect(subject.builds[0]).to eq( + expect(result.builds.size).to eq(2) + expect(result.builds[0]).to eq( stage: "build", stage_idx: 1, name: "build", @@ -68,7 +68,7 @@ root_variables_inheritance: true, scheduling_type: :stage ) - expect(subject.builds[1]).to eq( + expect(result.builds[1]).to eq( stage: "test", stage_idx: 2, name: "bridge", @@ -88,7 +88,7 @@ end end - context 'needs cross projects artifacts' do + context 'when needs cross projects artifacts' do let(:config) do { build: { stage: 'build', script: 'test' }, @@ -121,9 +121,9 @@ end it 'creates jobs with valid specification' do - expect(subject.builds.size).to eq(3) + expect(result.builds.size).to eq(3) - expect(subject.builds[1]).to eq( + expect(result.builds[1]).to eq( stage: 'test', stage_idx: 2, name: 'test1', @@ -163,7 +163,7 @@ end end - context 'needs cross projects artifacts and pipelines' do + context 'when needs cross projects artifacts and pipelines' do let(:needs) do [ { @@ -179,7 +179,7 @@ end it 'returns errors' do - expect(subject.errors).to include( + expect(result.errors).to include( 'jobs:bridge config should contain either a trigger or a needs:pipeline') end end @@ -202,12 +202,12 @@ end it 'returns errors' do - expect(subject.errors).to contain_exactly( + expect(result.errors).to contain_exactly( 'jobs:test:needs:need ref should be a string') end end - describe 'cross pipeline needs' do + describe 'with cross pipeline needs' do context 'when job is not present' do let(:config) do { @@ -222,10 +222,10 @@ end it 'returns an error' do - expect(subject).not_to be_valid + expect(result).not_to be_valid # This currently shows a confusing error message because a conflict of syntax # with upstream pipeline status mirroring: https://gitlab.com/gitlab-org/gitlab/-/issues/280853 - expect(subject.errors).to include(/:needs config uses invalid types: bridge/) + expect(result.errors).to include(/:needs config uses invalid types: bridge/) end end end @@ -245,9 +245,9 @@ end it 'returns a valid specification' do - expect(subject).to be_valid + expect(result).to be_valid - rspec = subject.builds.last + rspec = result.builds.last expect(rspec.dig(:options, :cross_dependencies)).to eq( [ { pipeline: '$UPSTREAM_PIPELINE_ID', job: 'test', artifacts: true }, @@ -258,17 +258,25 @@ describe 'dast configuration' do let(:config) do - { build: { stage: 'build', dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' }, script: 'test' } } + { + build: { + stage: 'build', + dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' }, + script: 'test' + } + } end it 'creates a job with a valid specification' do - expect(subject.builds[0][:options]).to include(dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' }) + expect(result.builds[0][:options]).to include( + dast_configuration: { site_profile: 'Site profile', scanner_profile: 'Scanner profile' } + ) end end end describe 'secrets' do - context 'hashicorp vault' do + context 'on hashicorp vault' do let(:secrets) do { DATABASE_PASSWORD: { @@ -279,8 +287,6 @@ let(:config) { { deploy_to_production: { stage: 'deploy', script: ['echo'], secrets: secrets } } } - subject(:result) { described_class.new(YAML.dump(config)).execute } - it "returns secrets info" do secrets = result.builds.first.fetch(:secrets) @@ -296,7 +302,7 @@ end end - context 'azure key vault' do + context 'on azure key vault' do let(:secrets) do { DATABASE_PASSWORD: { @@ -310,8 +316,6 @@ let(:config) { { deploy_to_production: { stage: 'deploy', script: ['echo'], secrets: secrets } } } - subject(:result) { described_class.new(YAML.dump(config)).execute } - it "returns secrets info" do secrets = result.builds.first.fetch(:secrets) @@ -326,4 +330,25 @@ end end end + + describe 'identity_provider', feature_category: :secrets_management do + let(:config) do + { + build: { + stage: 'build', script: 'test', + identity_provider: 'google_cloud' + } + } + end + + before do + stub_saas_features(google_artifact_registry: true) + end + + it 'includes identity provider-related values' do + identity_provider = result.builds.first.dig(:options, :identity_provider) + + expect(identity_provider).to eq('google_cloud') + end + end end diff --git a/ee/spec/models/ci/build_spec.rb b/ee/spec/models/ci/build_spec.rb index 05d8eb2ba7fe8d7127fb18c8069d32e08b7e41fb..1a3be73c3a7f72e9dc12997f4aec279aaeb8885c 100644 --- a/ee/spec/models/ci/build_spec.rb +++ b/ee/spec/models/ci/build_spec.rb @@ -1005,6 +1005,67 @@ end end + describe 'build identity_provider' do + let_it_be(:user) { create(:user) } + + let(:identity_provider) { 'google_cloud' } + let(:build) do + create(:ci_build, pipeline: pipeline, user: user, options: { identity_provider: identity_provider }) + end + + subject(:variables) { build.variables } + + before do + rsa_key = OpenSSL::PKey::RSA.generate(3072) + stub_application_setting(ci_jwt_signing_key: rsa_key.to_s) + end + + it 'does not include the gcloud file variables' do + runner_vars = variables.to_runner_variables.index_by { |v| v[:key] } + runner_var_names = runner_vars.keys + + expect(runner_var_names).not_to include('CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE') + expect(runner_var_names).not_to include('GOOGLE_APPLICATION_CREDENTIALS') + end + + context 'with integration active' do + let!(:project) { create(:project) } + let!(:integration) { create(:google_cloud_platform_artifact_registry_integration, project: project) } + let!(:pipeline) { create(:ci_pipeline, project: project, status: 'success') } + + it 'includes the gcloud file variables' do + runner_vars = variables.to_runner_variables.index_by { |v| v[:key] } + runner_var_names = runner_vars.keys + + expect(runner_var_names).to include('CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE') + expect(runner_var_names).to include('GOOGLE_APPLICATION_CREDENTIALS') + expect(runner_vars['GOOGLE_APPLICATION_CREDENTIALS']).to include(file: true) + expect(runner_vars['GOOGLE_APPLICATION_CREDENTIALS'].except(:key)).to eq( + runner_vars['CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE'].except(:key)) + + json = Gitlab::Json.parse(runner_vars['CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE'][:value]) + expect(json).to match( + 'type' => 'external_account', + 'audience' => an_instance_of(String), + 'subject_token_type' => 'urn:ietf:params:oauth:token-type:jwt', + 'token_url' => 'https://sts.googleapis.com/v1/token', + 'credential_source' => { + 'url' => 'https://auth.gcp.gitlab.com/token', + 'headers' => { 'Authorization' => an_instance_of(String) }, + 'format' => { 'type' => 'json', 'subject_token_field_name' => 'token' } + }) + end + + context 'when identity_provider is unknown' do + let(:identity_provider) { 'unknown' } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, "Unknown identity_provider value: #{identity_provider}" + end + end + end + end + context 'with loose foreign keys for partitioned tables' do before do create(:security_scan, build: job) diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 7ea4b460640e18e664b17a58719478e6bec69a9d..74edf81480353762bcc0d315145b13fee79a4aa5 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -121,7 +121,7 @@ class Job < ::Gitlab::Config::Entry::Node inherit: false entry :id_tokens, ::Gitlab::Config::Entry::ComposableHash, - description: 'Configured JWTs for this job', + description: 'Configured JWTs for this job.', inherit: true, metadata: { composable_class: ::Gitlab::Ci::Config::Entry::IdToken } diff --git a/lib/gitlab/ci/yaml_processor/feature_flags.rb b/lib/gitlab/ci/yaml_processor/feature_flags.rb index 50d37f6e4a009a40f466e22f59de38b6203da2df..5a2fa7c8bc4edd01930a8e16d83b9200c5396ef6 100644 --- a/lib/gitlab/ci/yaml_processor/feature_flags.rb +++ b/lib/gitlab/ci/yaml_processor/feature_flags.rb @@ -27,8 +27,8 @@ def with_actor(actor) end # Use this to check if a feature flag is enabled - def enabled?(feature_flag) - ::Feature.enabled?(feature_flag, current_actor) + def enabled?(feature_flag, type: :development) + ::Feature.enabled?(feature_flag, current_actor, type: type) end def ensure_correct_usage diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/identity_provider.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/identity_provider.yml new file mode 100644 index 0000000000000000000000000000000000000000..76836cc1cea8a4ce0660937e1dc0ef47877fc2df --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/identity_provider.yml @@ -0,0 +1,5 @@ +id_token_with_missing_identity_provider_value: + identity_provider: "" + +id_token_with_unknown_identity_provider_value: + identity_provider: unknown diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/identity_provider.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/identity_provider.yml new file mode 100644 index 0000000000000000000000000000000000000000..6a54422bc314807c03804f4eb84620906da33afa --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/identity_provider.yml @@ -0,0 +1,2 @@ +valid_identity_provider: + identity_provider: google_cloud diff --git a/spec/lib/gitlab/ci/yaml_processor/feature_flags_spec.rb b/spec/lib/gitlab/ci/yaml_processor/feature_flags_spec.rb index 77346f328ca8521a8e599d05d7a4567b86d07ac0..72b87ec07e2ac80d5add21ff5df2e52571a33e7e 100644 --- a/spec/lib/gitlab/ci/yaml_processor/feature_flags_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor/feature_flags_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::YamlProcessor::FeatureFlags do +RSpec.describe Gitlab::Ci::YamlProcessor::FeatureFlags, feature_category: :pipeline_composition do let(:feature_flag) { :my_feature_flag } context 'when the actor is set' do @@ -11,12 +11,20 @@ it 'checks the feature flag using the given actor' do described_class.with_actor(actor) do - expect(Feature).to receive(:enabled?).with(feature_flag, actor) + expect(Feature).to receive(:enabled?).with(feature_flag, actor, type: :development) described_class.enabled?(feature_flag) end end + it 'checks the feature flag using the given type' do + described_class.with_actor(actor) do + expect(Feature).to receive(:enabled?).with(feature_flag, actor, type: :beta) + + described_class.enabled?(feature_flag, type: :beta) + end + end + it 'returns the value of the block' do result = described_class.with_actor(actor) do :test @@ -28,12 +36,12 @@ it 'restores the existing actor if any' do described_class.with_actor(actor) do described_class.with_actor(another_actor) do - expect(Feature).to receive(:enabled?).with(feature_flag, another_actor) + expect(Feature).to receive(:enabled?).with(feature_flag, another_actor, type: anything) described_class.enabled?(feature_flag) end - expect(Feature).to receive(:enabled?).with(feature_flag, actor) + expect(Feature).to receive(:enabled?).with(feature_flag, actor, type: anything) described_class.enabled?(feature_flag) end end @@ -59,7 +67,7 @@ end it 'checks the feature flag without actor' do - expect(Feature).to receive(:enabled?).with(feature_flag, nil) + expect(Feature).to receive(:enabled?).with(feature_flag, nil, type: anything) expect(Gitlab::ErrorTracking) .to receive(:track_and_raise_for_dev_exception) .and_call_original @@ -71,7 +79,7 @@ context 'when yaml_processor_feature_flag_corectness is not used' do it 'checks the feature flag without actor' do - expect(Feature).to receive(:enabled?).with(feature_flag, nil) + expect(Feature).to receive(:enabled?).with(feature_flag, nil, type: anything) expect(Gitlab::ErrorTracking) .to receive(:track_exception) @@ -83,7 +91,7 @@ context 'when actor is explicitly nil' do it 'checks the feature flag without actor' do described_class.with_actor(nil) do - expect(Feature).to receive(:enabled?).with(feature_flag, nil) + expect(Feature).to receive(:enabled?).with(feature_flag, nil, type: anything) described_class.enabled?(feature_flag) end