Implement Secrets Manager enrollment persistence
What does this MR do and why?
Adds a user-controlled enrollment mechanism for Secrets Manager availability, for both SaaS (per root namespace) and self-managed (instance-wide). This is a long-term configuration that carries over to GA (context).
How it works
Secrets Manager availability now requires both the SM-gate flag and an enrollment record:
SM available =
License(:native_secrets_management)
AND SM-gate FF ON for the resource
AND enrollment record exists (root namespace on SaaS / instance on self-managed)This is enforced in three places:
- Policy gating (
GroupPolicy,ProjectPolicy) —secrets_manager_enabled/group_secrets_manager_enabledconditions delegate toAvailability.enabled_for_*?. Without all three, the policy preventsread/create/update/delete_*_secrets,provision_secrets_manager, etc. - View helpers —
secrets_manager_available_for_*?useAvailability.for_*?(license + AND). The SM section in group/project settings only renders when all three are satisfied. - CI runner read path —
Ci::BuildRunnerPresenter#project_secrets_manager_payload/#group_secrets_manager_payloadshort-circuit with{}whenAvailability.enabled_for_*?is false, so CI pipelines stop receiving secrets the moment a root namespace unenrolls.
Two distinct sets of feature flags
| Set | Flag names | What it gates |
|---|---|---|
| SM-gate flags | secrets_manager (project), group_secrets_manager (group) |
One of the two AND inputs for SM availability. |
| Enrollment UI flags (new) | secrets_manager_namespace_enrollment (SaaS), secrets_manager_instance_enrollment (self-managed) |
Whether the enrollment toggle UI renders + whether the enroll/unenroll mutations are reachable. Not an availability input themselves. |
SaaS vs self-managed
| SaaS | Self-managed | |
|---|---|---|
| Storage | secrets_manager_namespace_enrollments table (one row per root namespace) |
secrets_manager_instance_enrolled boolean on application_settings |
| Who enrolls | Group Owner of the top-level group | Instance admin |
| Guard | Rejects on self-managed | Rejects on .com |
Components
- Models:
NamespaceEnrollment(AR),InstanceEnrollment(PORO wrapping app setting). Both exposeenrolled?andenrollment_allowed?predicates. The AR uniqueness validator was dropped — the DB unique index + serviceRecordNotUniquerescue handle dedup correctly. - Services: Two separate services per use-case-oriented design:
SecretsManagement::NamespaceEnrollmentService.new(namespace, current_user:).enroll / .unenrollSecretsManagement::InstanceEnrollmentService.new(current_user:).enroll / .unenroll- Both are idempotent; reject user namespaces and non-root groups via the model's
enrollment_allowed?; emit audit events; instance service also expires the application settings cache so admins see immediate state changes.
- Availability (
ee/lib/secrets_management/availability.rb) — single source of truth forlicense + (SM-gate FF AND enrollment). Used byGroupPolicy,ProjectPolicy,EnrollmentHelper, andCi::BuildRunnerPresenter. - GraphQL: 2 queries + 4 mutations. Instance mutations gate via
EE::GlobalPolicy(authorize :create_/delete_/read_secrets_manager_enrollment+authorize!(:global)), which checks license + instance enrollment FF. - Permissions:
create_/delete_/read_secrets_manager_enrollment— split CRUD per conventions. Owner-only on Groups; admin-only on:global. - Helper:
EnrollmentHelper— view-side wrappers aroundAvailabilitypredicates. Explicitly included in both secrets controllers and both sidebar menus. - Audit events: 4 types (namespace enroll/unenroll, instance enroll/unenroll).
- Rake task:
gitlab:secrets_management:seed[NAMESPACE_ID]now also enables the enrollment FFs and enrolls (namespace on SaaS, instance on self-managed) before provisioning, so the seeded SMs satisfy the AND.
Rollout
Tracked in #599755 — one rollout issue covering both enrollment UI flags.
E2E coverage
gdk-instance-secrets-managerjob now setsQA_FEATURE_FLAGSto enable both SM-gate flags AND both enrollment FFs.- The shared context
'secrets manager base'and the twofeature_provision_specfiles now enroll the instance via the newenroll_instance_in_secrets_managerhelper before exercising SM, so the AND is satisfied in the test environment.
Database
-- New table
CREATE TABLE secrets_manager_namespace_enrollments (
id bigint NOT NULL,
namespace_id bigint NOT NULL, -- unique index + FK to namespaces ON DELETE CASCADE
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
-- New column
ALTER TABLE application_settings
ADD COLUMN secrets_manager_instance_enrolled boolean DEFAULT false NOT NULL;Both tables live on the gitlab_main_org schema, so the FK is a plain Postgres foreign key (not a loose FK). The enrollment row has no meaning without its namespace, so the on-delete is CASCADE.
The enrolled? query is a simple EXISTS on a unique index — sub-millisecond.
EXPLAIN (ANALYZE, BUFFERS)
All new queries are single-row lookups against an index or primary key. Generated locally against GDK after db:seed_fu (single enrollment row present).
NamespaceEnrollment.enrolled? — EXISTS check via unique index
SELECT 1 AS one FROM "secrets_manager_namespace_enrollments"
WHERE "secrets_manager_namespace_enrollments"."namespace_id" = 1 LIMIT 1Limit (cost=0.15..2.17 rows=1 width=4) (actual time=0.017..0.017 rows=1 loops=1)
Buffers: shared hit=2
-> Index Only Scan using uniq_idx_sm_namespace_enrollments_on_namespace_id on secrets_manager_namespace_enrollments (cost=0.15..2.17 rows=1 width=4) (actual time=0.017..0.017 rows=1 loops=1)
Index Cond: (namespace_id = 1)
Heap Fetches: 1
Buffers: shared hit=2
Planning Time: 0.012 ms
Execution Time: 0.020 msNamespaceEnrollment.find_by_namespace_id(id) — single-row find by unique index
SELECT "secrets_manager_namespace_enrollments".* FROM "secrets_manager_namespace_enrollments"
WHERE "secrets_manager_namespace_enrollments"."namespace_id" = 1 LIMIT 1Limit (cost=0.15..2.17 rows=1 width=32) (actual time=0.001..0.001 rows=1 loops=1)
Buffers: shared hit=2
-> Index Scan using uniq_idx_sm_namespace_enrollments_on_namespace_id on secrets_manager_namespace_enrollments (cost=0.15..2.17 rows=1 width=32) (actual time=0.001..0.001 rows=1 loops=1)
Index Cond: (namespace_id = 1)
Buffers: shared hit=2
Planning Time: 0.009 ms
Execution Time: 0.004 msNamespaceEnrollment.create! — single-row INSERT
INSERT INTO "secrets_manager_namespace_enrollments" ("namespace_id", "created_at", "updated_at")
VALUES ($1, NOW(), NOW()) RETURNING "id"Insert on secrets_manager_namespace_enrollments (cost=0.00..0.02 rows=1 width=32) (actual time=0.095..0.095 rows=1 loops=1)
Buffers: shared hit=14
-> Result (cost=0.00..0.02 rows=1 width=32) (actual time=0.047..0.047 rows=1 loops=1)
Buffers: shared hit=11
Planning Time: 0.014 ms
Trigger for constraint fk_rails_e8851cce67: time=0.170 calls=1
Execution Time: 0.275 msThe plain INSERT raises ActiveRecord::RecordNotUnique on the unique-index violation, which the service rescues and maps to a friendly "Namespace is already enrolled." response. The fk_rails_e8851cce67 constraint trigger validates the parent namespace exists.
NamespaceEnrollment#destroy! — DELETE by primary key
DELETE FROM "secrets_manager_namespace_enrollments"
WHERE "secrets_manager_namespace_enrollments"."id" = $1 RETURNING idDelete on secrets_manager_namespace_enrollments (cost=0.15..2.17 rows=1 width=6) (actual time=0.004..0.004 rows=1 loops=1)
Buffers: shared hit=4
-> Index Scan using secrets_manager_namespace_enrollments_pkey on secrets_manager_namespace_enrollments (cost=0.15..2.17 rows=1 width=6) (actual time=0.001..0.001 rows=1 loops=1)
Index Cond: (id = 1)
Buffers: shared hit=2
Planning Time: 0.009 ms
Execution Time: 0.007 msApplicationSetting UPDATE — instance enrollment toggle (PK lookup)
UPDATE "application_settings" SET "secrets_manager_instance_enrolled" = $1, "updated_at" = NOW()
WHERE "application_settings"."id" = $2 RETURNING idUpdate on application_settings (cost=0.14..2.16 rows=1 width=15) (actual time=0.266..0.266 rows=1 loops=1)
Buffers: shared hit=127 dirtied=1
-> Index Scan using application_settings_pkey on application_settings (cost=0.14..2.16 rows=1 width=15) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: (id = 1)
Buffers: shared hit=2
Planning Time: 0.041 ms
Execution Time: 0.279 msGraphQL examples
Namespace enrollment (SaaS)
# Enroll
mutation { namespaceSecretsManagerEnroll(input: { namespacePath: "my-group" }) {
enrollment { namespace { id fullPath } }
errors
}}
# Check (returns null if not enrolled)
query { namespaceSecretsManagerEnrollment(namespacePath: "my-group") {
namespace { id fullPath }
}}
# Unenroll
mutation { namespaceSecretsManagerUnenroll(input: { namespacePath: "my-group" }) { errors }}Instance enrollment (self-managed)
# Enroll
mutation { instanceSecretsManagerEnroll(input: {}) { errors }}
# Check (returns boolean)
query { instanceSecretsManagerEnrollment }
# Unenroll
mutation { instanceSecretsManagerUnenroll(input: {}) { errors }}References
- Resolves #598522
- Unblocks #598060 (closed) (frontend toggle — SaaS)
- Unblocks #599327 (closed) (gate SM UI based on enrollment state)
How to set up and validate locally
bin/rails db:migrate- Enable enrollment feature flags:
Feature.enable(:secrets_manager_namespace_enrollment) Feature.enable(:secrets_manager_instance_enrollment) - Test via rails console:
# SaaS (as group owner) SecretsManagement::EnrollmentService.new(user).enable(namespace: group) SecretsManagement::NamespaceEnrollment.enrolled?(group) # => true # Self-managed (as admin) SecretsManagement::EnrollmentService.new(admin).enable Gitlab::CurrentSettings.secrets_manager_instance_enrolled # => true
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.