diff --git a/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml b/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml new file mode 100644 index 0000000000000000000000000000000000000000..5664a699f45126c3e5cb4183c180d5eb2aa0f480 --- /dev/null +++ b/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml @@ -0,0 +1,9 @@ +--- +name: cloud_connector_jwt_replace +feature_issue_url: https://gitlab.com/gitlab-org/cloud-connector/gitlab-cloud-connector/-/issues/30 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172378 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/503739 +milestone: '17.7' +group: group::cloud connector +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/lib/cloud_connector/self_signed/available_service_data.rb b/ee/lib/cloud_connector/self_signed/available_service_data.rb index 61a0c4385cd9c1febac7d06f72d7d27661dc9dd7..a43776f8b336818151338c052698bed0f50f3b12 100644 --- a/ee/lib/cloud_connector/self_signed/available_service_data.rb +++ b/ee/lib/cloud_connector/self_signed/available_service_data.rb @@ -12,20 +12,45 @@ def initialize(name, cut_off_date, bundled_with, backend) @bundled_with = bundled_with @backend = backend + @key = load_signing_key end override :access_token def access_token(resource = nil, extra_claims: {}) - ::Gitlab::CloudConnector::SelfIssuedToken.new( - audience: backend, - subject: Gitlab::CurrentSettings.uuid, - scopes: scopes_for(resource), - extra_claims: extra_claims - ).encoded + if Feature.enabled?(:cloud_connector_jwt_replace, gitlab_org_group) + ::Gitlab::CloudConnector::JSONWebToken.new( + issuer: Doorkeeper::OpenidConnect.configuration.issuer, + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + realm: Gitlab::CloudConnector.gitlab_realm, + scopes: scopes_for(resource), + ttl: 1.hour, + extra_claims: extra_claims + ).encode(@key) + else + ::Gitlab::CloudConnector::SelfIssuedToken.new( + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + scopes: scopes_for(resource), + extra_claims: extra_claims + ).encoded + end end private + def gitlab_org_group + @gitlab_org_group ||= Group.find_by_path_or_name('gitlab-org') + end + + def load_signing_key + key_data = Rails.application.credentials.openid_connect_signing_key + + raise 'Cloud Connector: no key found' unless key_data + + ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data)) + end + def scopes_for(resource) free_access? ? allowed_scopes_during_free_access : allowed_scopes_from_purchased_bundles_for(resource) end diff --git a/ee/lib/gitlab/cloud_connector/json_web_token.rb b/ee/lib/gitlab/cloud_connector/json_web_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..13401ef8cc55392120c763eebf92cc954ef8c7b2 --- /dev/null +++ b/ee/lib/gitlab/cloud_connector/json_web_token.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gitlab + module CloudConnector + class JSONWebToken + SIGNING_ALGORITHM = 'RS256' + NOT_BEFORE_TIME = 5.seconds.to_i + + attr_reader :issued_at, :expires_at + + def initialize(issuer:, audience:, subject:, realm:, scopes:, ttl:, extra_claims: {}) + @id = SecureRandom.uuid + @audience = audience + @subject = subject + @issuer = issuer + @issued_at = Time.current.to_i + @not_before = @issued_at - NOT_BEFORE_TIME + @expires_at = (@issued_at + ttl).to_i + @realm = realm + @scopes = scopes + @extra_claims = extra_claims + end + + # jwk: + # The key (pair) as an instance of JWT::JWK. + # + # Returns a signed and Base64-encoded JSON Web Token string, to be + # written to the HTTP Authorization header field. + def encode(jwk) + header_fields = { typ: 'JWT', kid: jwk.kid } + + JWT.encode(payload, jwk.signing_key, SIGNING_ALGORITHM, header_fields) + end + + def payload + { + jti: @id, + aud: @audience, + sub: @subject, + iss: @issuer, + iat: @issued_at, + nbf: @not_before, + exp: @expires_at + }.merge(cloud_connector_claims) + end + + private + + def cloud_connector_claims + { + gitlab_realm: @realm, + scopes: @scopes + }.merge(@extra_claims) + end + end + end +end diff --git a/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb b/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb index 3a8d4341649614016e76d43739bd2146be1ee280..ac0e2c8821b5a1b58c9911a55edfa65593a0db9c 100644 --- a/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb +++ b/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb @@ -15,24 +15,71 @@ let(:duo_pro_scopes) { dc_unit_primitives + [:duo_chat_up3] } let(:duo_extra_scopes) { dc_unit_primitives + [:duo_chat_up4] } let(:bundled_with) { { "duo_pro" => duo_pro_scopes, "duo_extra" => duo_extra_scopes } } + + let(:issuer) { 'gitlab.com' } + let(:instance_id) { 'instance-uuid' } + let(:gitlab_realm) { 'saas' } + let(:ttl) { 1.hour } let(:extra_claims) { {} } - let(:expected_token) do - instance_double('Gitlab::CloudConnector::SelfIssuedToken', encoded: encoded_token_string) - end subject(:access_token) { available_service_data.access_token(resource) } shared_examples 'issue a token with scopes' do + let(:expected_token) do + instance_double('Gitlab::CloudConnector::JSONWebToken') + end + + before do + allow(Doorkeeper::OpenidConnect.configuration).to receive(:issuer).and_return(issuer) + allow(Gitlab::CurrentSettings).to receive(:uuid).and_return(instance_id) + allow(Gitlab::CloudConnector).to receive(:gitlab_realm).and_return(gitlab_realm) + end + it 'returns the constructed token' do - expect(Gitlab::CloudConnector::SelfIssuedToken).to receive(:new).with( + expect(Gitlab::CloudConnector::JSONWebToken).to receive(:new).with( + issuer: issuer, audience: backend, - subject: Gitlab::CurrentSettings.uuid, + subject: instance_id, + realm: gitlab_realm, scopes: scopes, + ttl: ttl, extra_claims: extra_claims ).and_return(expected_token) + expect(expected_token).to receive(:encode).with(instance_of(::JWT::JWK::RSA)).and_return(encoded_token_string) expect(access_token).to eq(encoded_token_string) end + + context 'when cloud_connector_jwt_replace is disabled' do + before do + stub_feature_flags(cloud_connector_jwt_replace: false) + end + + let(:expected_token) do + instance_double('Gitlab::CloudConnector::SelfIssuedToken', encoded: encoded_token_string) + end + + it 'returns the constructed token' do + expect(Gitlab::CloudConnector::SelfIssuedToken).to receive(:new).with( + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + scopes: scopes, + extra_claims: extra_claims + ).and_return(expected_token) + + expect(access_token).to eq(encoded_token_string) + end + end + end + + context 'when signing key is missing' do + before do + allow(Rails.application.credentials).to receive(:openid_connect_signing_key).and_return(nil) + end + + it 'raises NoSigningKeyError' do + expect { access_token }.to raise_error(StandardError, 'Cloud Connector: no key found') + end end context 'with free access' do diff --git a/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb b/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3ede2a75f5b66d75bf8b87db6320b06ff74c088 --- /dev/null +++ b/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::CloudConnector::JSONWebToken, feature_category: :cloud_connector do + let(:extra_claims) { {} } + + let(:expected_issuer) { 'gitlab.com' } + let(:expected_audience) { 'gitlab-ai-gateway' } + let(:expected_subject) { 'ABC-123' } + let(:expected_realm) { 'saas' } + let(:expected_scopes) { [:code_suggestions] } + let(:expected_ttl) { 10.minutes } + + subject(:token) do + described_class.new( + issuer: expected_issuer, + audience: expected_audience, + subject: expected_subject, + realm: expected_realm, + scopes: expected_scopes, + ttl: expected_ttl, + extra_claims: extra_claims + ) + end + + describe '#payload' do + subject(:payload) { token.payload } + + it 'has expected values', :freeze_time, :aggregate_failures do + now = Time.current.to_i + + # standard claims + expect(payload[:iss]).to eq(expected_issuer) + expect(payload[:aud]).to eq(expected_audience) + expect(payload[:sub]).to eq(expected_subject) + expect(payload[:iat]).to eq(now) + expect(payload[:nbf]).to eq(now - 5.seconds) + expect(payload[:exp]).to eq(now + 10.minutes) + + # cloud connector specific claims + expect(payload[:gitlab_realm]).to eq(expected_realm) + expect(payload[:scopes]).to eq(expected_scopes) + end + + context 'when passing extra claims' do + let(:extra_claims) { { custom: 123 } } + + it 'includes them in payload' do + expect(payload[:custom]).to eq(123) + end + end + end + + describe '#encode' do + let(:rsa_key) { ::JWT::JWK.new(OpenSSL::PKey::RSA.new(2048)) } + + subject(:encoded_token) { token.encode(rsa_key) } + + it 'encodes token instance to string' do + expect(encoded_token).to be_instance_of(String) + end + + it 'decodes successfully with public key', :aggregate_failures, :freeze_time do + now = Time.current.to_i + payload, header = JWT.decode(encoded_token, rsa_key.public_key, true, { algorithm: 'RS256' }) + + expect(header).to match( + "alg" => "RS256", + "typ" => "JWT", + "kid" => be_instance_of(String) + ) + expect(payload).to match( + "jti" => be_instance_of(String), + "aud" => expected_audience, + "sub" => expected_subject, + "iss" => expected_issuer, + "iat" => now.to_i, + "nbf" => (now - 5.seconds).to_i, + "exp" => (now + 10.minutes).to_i, + "gitlab_realm" => expected_realm, + "scopes" => ["code_suggestions"] + ) + end + end +end diff --git a/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb b/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb index cc2a21f9d15dca71f10fb90ab6a120184f27ecf2..52dd5601dcc81d1f9a345c3cce9e8f04a7cf649f 100644 --- a/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb +++ b/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb @@ -194,8 +194,8 @@ end before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(ai_gateway_token) + allow_next_instance_of(::Gitlab::CloudConnector::JSONWebToken) do |token| + allow(token).to receive(:encode).and_return(ai_gateway_token) end end @@ -422,8 +422,8 @@ def request end before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(ai_gateway_token) + allow_next_instance_of(::Gitlab::CloudConnector::JSONWebToken) do |token| + allow(token).to receive(:encode).and_return(ai_gateway_token) end end diff --git a/ee/spec/requests/api/internal/observability_spec.rb b/ee/spec/requests/api/internal/observability_spec.rb index 16272e449a445fc3fbe2c75f41bb0050ba08ccf9..3bfc35d2ac22cee195219cd4afcaf291119146d3 100644 --- a/ee/spec/requests/api/internal/observability_spec.rb +++ b/ee/spec/requests/api/internal/observability_spec.rb @@ -98,21 +98,7 @@ def full_path { extra_claims: { gitlab_namespace_id: namespace.id.to_s } }).and_return(gob_token) end - context 'when instance is saas', :saas do - let(:gitlab_realm) { "saas" } - - before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(gob_token) - end - end - - it_behaves_like 'success' - end - - context 'when instance is self-managed' do - it_behaves_like 'success' - end + it_behaves_like 'success' context 'without workhorse internal header' do let(:headers) { {} }