diff --git a/db/docs/secrets_management_recovery_keys.yml b/db/docs/secrets_management_recovery_keys.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4cc1a32563fce1f2947f475365c457a24ca2d9b --- /dev/null +++ b/db/docs/secrets_management_recovery_keys.yml @@ -0,0 +1,10 @@ +--- +table_name: secrets_management_recovery_keys +classes: +- SecretsManagement::RecoveryKey +feature_categories: +- secrets_management +description: Contains the OpenBao service recovery key +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/208680 +milestone: '18.6' +gitlab_schema: gitlab_main_cell_local diff --git a/db/migrate/20251020180958_create_secrets_management_recovery_keys.rb b/db/migrate/20251020180958_create_secrets_management_recovery_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..722379c80841514843153518d817d81ebd98373c --- /dev/null +++ b/db/migrate/20251020180958_create_secrets_management_recovery_keys.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateSecretsManagementRecoveryKeys < Gitlab::Database::Migration[2.3] + milestone '18.6' + + def change + create_table :secrets_management_recovery_keys do |t| + t.text :key, limit: 10240 + t.boolean :active, null: false, default: false + + t.timestamps_with_timezone null: false + end + end +end diff --git a/db/schema_migrations/20251020180958 b/db/schema_migrations/20251020180958 new file mode 100644 index 0000000000000000000000000000000000000000..6008959991d2ea23974a7ae48b683ee741bac544 --- /dev/null +++ b/db/schema_migrations/20251020180958 @@ -0,0 +1 @@ +08c4a45b87a54562b06f9cade5029a5fae11e45bd2228886a7bcedaa4c0d1092 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b5bdd311d8bfe26837cecb23a7ed051d3bf3e977..a6593e8f723b0d47cac2d4ff34c9311027b630c1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -25587,6 +25587,24 @@ CREATE SEQUENCE secret_rotation_infos_id_seq ALTER SEQUENCE secret_rotation_infos_id_seq OWNED BY secret_rotation_infos.id; +CREATE TABLE secrets_management_recovery_keys ( + id bigint NOT NULL, + key text, + active boolean DEFAULT false NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_e36c395dea CHECK ((char_length(key) <= 10240)) +); + +CREATE SEQUENCE secrets_management_recovery_keys_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE secrets_management_recovery_keys_id_seq OWNED BY secrets_management_recovery_keys.id; + CREATE TABLE security_attributes ( id bigint NOT NULL, namespace_id bigint NOT NULL, @@ -31566,6 +31584,8 @@ ALTER TABLE ONLY scim_oauth_access_tokens ALTER COLUMN id SET DEFAULT nextval('s ALTER TABLE ONLY secret_rotation_infos ALTER COLUMN id SET DEFAULT nextval('secret_rotation_infos_id_seq'::regclass); +ALTER TABLE ONLY secrets_management_recovery_keys ALTER COLUMN id SET DEFAULT nextval('secrets_management_recovery_keys_id_seq'::regclass); + ALTER TABLE ONLY security_attributes ALTER COLUMN id SET DEFAULT nextval('security_attributes_id_seq'::regclass); ALTER TABLE ONLY security_categories ALTER COLUMN id SET DEFAULT nextval('security_categories_id_seq'::regclass); @@ -35091,6 +35111,9 @@ ALTER TABLE ONLY secret_detection_token_statuses ALTER TABLE ONLY secret_rotation_infos ADD CONSTRAINT secret_rotation_infos_pkey PRIMARY KEY (id); +ALTER TABLE ONLY secrets_management_recovery_keys + ADD CONSTRAINT secrets_management_recovery_keys_pkey PRIMARY KEY (id); + ALTER TABLE ONLY security_attributes ADD CONSTRAINT security_attributes_pkey PRIMARY KEY (id); diff --git a/ee/app/models/secrets_management/recovery_key.rb b/ee/app/models/secrets_management/recovery_key.rb new file mode 100644 index 0000000000000000000000000000000000000000..ff4afa2c600e99fa9629eb247ab8441f9c3662d0 --- /dev/null +++ b/ee/app/models/secrets_management/recovery_key.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SecretsManagement + class RecoveryKey < ApplicationRecord + self.table_name = 'secrets_management_recovery_keys' + + encrypts :key + + validates :key, presence: true + validates :active, inclusion: [true, false] + validate :exactly_one_active + + scope :active, -> { where(active: true) } + + def exactly_one_active + return unless active? + + active_count = SecretsManagement::RecoveryKey.active.count + + return if active_count == 0 + + errors.add( + :base, + _("Only one active RecoveryKey can exist at a time") + ) + end + end +end diff --git a/ee/lib/secrets_management/secrets_manager_client.rb b/ee/lib/secrets_management/secrets_manager_client.rb index 66ac5a6d5860900faf42c2dee9a08e179e010417..171e5b5d55a28334f4a9a700218cf68abf0cd102 100644 --- a/ee/lib/secrets_management/secrets_manager_client.rb +++ b/ee/lib/secrets_management/secrets_manager_client.rb @@ -12,6 +12,7 @@ class SecretsManagerClient OPENBAO_TOKEN_MAX_TTL = '15m' OPENBAO_EXPIRATION_LEEWAY = 150 OPENBAO_NOT_BEFORE_LEEWAY = 150 + OPENBAO_RECOVERY_SHARES_THRESHOLD = 1 OPENBAO_CLOCK_SKEW_LEEWAY = 60 OPENBAO_INLINE_AUTH_FAILED_HEADER = "X-Vault-Inline-Auth-Failed" OPENBAO_INLINE_AUTH_FAILED_VALUE = "true" @@ -258,6 +259,21 @@ def delete_policy(name) ) end + def rotate_recovery_init + recovery_values = { + secret_shares: OPENBAO_RECOVERY_SHARES_THRESHOLD, + secret_threshold: OPENBAO_RECOVERY_SHARES_THRESHOLD + } + + rotate_recovery_url = "/v1/sys/rotate/recovery/init" + make_request(:post, rotate_recovery_url, recovery_values) + end + + def rotate_recovery_cancel + rotate_recovery_url = "/v1/sys/rotate/recovery/init" + make_request(:delete, rotate_recovery_url, recovery_values) + end + def cel_login_jwt(mount_path:, role:, jwt:) url = "auth/#{mount_path}/cel/login" body = { role: role, jwt: jwt } diff --git a/ee/lib/secrets_management/secrets_manager_jwt.rb b/ee/lib/secrets_management/secrets_manager_jwt.rb index 83931ccdaae5788aad83fff4c1a06fa84cc9edc1..9557934613c9c6442f142d9e468733edfc41ec5e 100644 --- a/ee/lib/secrets_management/secrets_manager_jwt.rb +++ b/ee/lib/secrets_management/secrets_manager_jwt.rb @@ -3,6 +3,7 @@ module SecretsManagement class SecretsManagerJwt < Gitlab::Ci::JwtBase DEFAULT_TTL = 30.seconds + SYSTEM_UID = 'gitlab_secrets_manager' attr_reader :current_user, :project, :old_aud @@ -16,17 +17,22 @@ def initialize(current_user: nil, project: nil, old_aud: nil) def payload now = Time.now.to_i - { + payload = { iss: Gitlab.config.gitlab.url, iat: now, nbf: now, exp: now + DEFAULT_TTL.to_i, jti: SecureRandom.uuid, aud: aud, - sub: 'gitlab_secrets_manager', + sub: SYSTEM_UID, secrets_manager_scope: 'privileged', correlation_id: Labkit::Correlation::CorrelationId.current_id - }.merge(project_claims) + } + + payload = payload.merge(project_claims) if project + payload[:user_id] = SYSTEM_UID unless payload.key? :user_id + + payload end def project_claims diff --git a/ee/lib/tasks/gitlab/secrets_management/openbao.rake b/ee/lib/tasks/gitlab/secrets_management/openbao.rake index 8eee2e50961653c8a19458ce0bae625be369e739..b75336397e612319e25613dbf5a15c2fc2401146 100644 --- a/ee/lib/tasks/gitlab/secrets_management/openbao.rake +++ b/ee/lib/tasks/gitlab/secrets_management/openbao.rake @@ -61,6 +61,41 @@ Usage: rake "gitlab:secrets_management:openbao:clone[/installation/dir]") ) end end + + desc 'GitLab | Secrets Management | Retrieve recovery keys from OpenBao' + task :recovery_key_generate, [] => :gitlab_environment do + privileged_jwt = SecretsManagement::SecretsManagerJwt.new.encoded + secrets_manager_client = SecretsManagement::SecretsManagerClient.new(jwt: privileged_jwt) + + result = secrets_manager_client.rotate_recovery_init + if result["data"].key? "keys" + key = result["data"]["keys"][0] + + old_key = SecretsManagement::RecoveryKey.active.take + if old_key + old_key.active = false + old_key.save! + puts "Marked old key as inactive." + end + + new_key = SecretsManagement::RecoveryKey.new do |nk| + nk.active = true + nk.key = key + end + + new_key.save! + + puts "Persisted key to the database" + else + puts "Cannot get key, key has already been retrieved." + + # Avoid leaving rotation in an inconsistent state. + secrets_manager_client.rotate_recovery_cancel + end + rescue SecretsManagement::SecretsManagerClient::ApiError => e + puts "Cannot get key, exception: #{e}" + Gitlab::ErrorTracking.track_and_raise_exception(e) + end end end end diff --git a/ee/spec/factories/secrets_management/recovery_keys.rb b/ee/spec/factories/secrets_management/recovery_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b2ae371b7579eeb7708b61ed3be0c1e0426dc9d --- /dev/null +++ b/ee/spec/factories/secrets_management/recovery_keys.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :secrets_management_recovery_keys, class: 'SecretsManagement::RecoveryKey' do + key { "secret_value" } + end +end diff --git a/ee/spec/lib/secrets_management/secrets_manager_client_spec.rb b/ee/spec/lib/secrets_management/secrets_manager_client_spec.rb index 3dd58fea899ace543cef01e5c9ecfaa36faea7f0..e518613f33aae7e1d062fc1c5ba508cd0ccc9b81 100644 --- a/ee/spec/lib/secrets_management/secrets_manager_client_spec.rb +++ b/ee/spec/lib/secrets_management/secrets_manager_client_spec.rb @@ -1006,5 +1006,21 @@ expect(result).to contain_exactly(new_data: "DBPASS") end end + + describe '#rotate_recovery_init' do + subject(:rotate_recovery_init) { client.rotate_recovery_init } + + let(:response) { rotate_recovery_init } + let(:mocked_response) { '{"request_id"=>"id", "data"=>{"keys"=>["key"]}}' } + + before do + stub_request(:post, "#{described_class.configuration.host}/v1/sys/rotate/recovery/init") + .to_return(body: mocked_response) + end + + it 'configures the JWT auth method' do + expect(response).to eq(mocked_response) + end + end end end diff --git a/ee/spec/lib/secrets_management/secrets_manager_jwt_spec.rb b/ee/spec/lib/secrets_management/secrets_manager_jwt_spec.rb index 5c25278303f8152863f357d68aed91fcd91d2cef..de6b88f0a95bc88e4038c3f39e16fffb5a9c4522 100644 --- a/ee/spec/lib/secrets_management/secrets_manager_jwt_spec.rb +++ b/ee/spec/lib/secrets_management/secrets_manager_jwt_spec.rb @@ -108,8 +108,20 @@ context 'when project is not present' do let(:current_project) { nil } - it 'raises an error due to the delegation to namespace' do - expect { payload }.to raise_error(NoMethodError) + it 'does not crash' do + expect { payload }.not_to raise_error + end + end + + context 'when both user and project are not present' do + let(:current_user) { nil } + let(:current_project) { nil } + + it 'includes project claims with nil user fields' do + expect(payload).to include( + user_id: described_class::SYSTEM_UID, + sub: described_class::SYSTEM_UID + ) end end end diff --git a/ee/spec/models/secrets_management/recovery_key_spec.rb b/ee/spec/models/secrets_management/recovery_key_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5edf5301756197518d1645615060e4ac6a5294ac --- /dev/null +++ b/ee/spec/models/secrets_management/recovery_key_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SecretsManagement::RecoveryKey, :gitlab_secrets_manager, feature_category: :secrets_management do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/ee/spec/support/helpers/secrets_management/test_jwt.rb b/ee/spec/support/helpers/secrets_management/test_jwt.rb index 5828baa8def8613f1bf8540dc615eceb726acca3..246ccc01786aa55b3045cfe4e296260bb728bedf 100644 --- a/ee/spec/support/helpers/secrets_management/test_jwt.rb +++ b/ee/spec/support/helpers/secrets_management/test_jwt.rb @@ -24,6 +24,7 @@ def project_claims def payload claims = super + claims = claims.merge(project_claims) # super may not always call this method claims[:sub] = sub claims[:secrets_manager_scope] = secrets_manager_scope claims diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3db3438acc1bdbd64e2e1caa4d8b806c24382454..ac9a5d3a74b2485a53641b9d12dcecf86933acf5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -45859,6 +45859,9 @@ msgstr "" msgid "Only members of this group can access the wiki." msgstr "" +msgid "Only one active RecoveryKey can exist at a time" +msgstr "" + msgid "Only one security policy bot is allowed per project" msgstr ""