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:

  1. Policy gating (GroupPolicy, ProjectPolicy) — secrets_manager_enabled / group_secrets_manager_enabled conditions delegate to Availability.enabled_for_*?. Without all three, the policy prevents read/create/update/delete_*_secrets, provision_secrets_manager, etc.
  2. View helperssecrets_manager_available_for_*? use Availability.for_*? (license + AND). The SM section in group/project settings only renders when all three are satisfied.
  3. CI runner read pathCi::BuildRunnerPresenter#project_secrets_manager_payload / #group_secrets_manager_payload short-circuit with {} when Availability.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 expose enrolled? and enrollment_allowed? predicates. The AR uniqueness validator was dropped — the DB unique index + service RecordNotUnique rescue handle dedup correctly.
  • Services: Two separate services per use-case-oriented design:
    • SecretsManagement::NamespaceEnrollmentService.new(namespace, current_user:).enroll / .unenroll
    • SecretsManagement::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 for license + (SM-gate FF AND enrollment). Used by GroupPolicy, ProjectPolicy, EnrollmentHelper, and Ci::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 around Availability predicates. 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-manager job now sets QA_FEATURE_FLAGS to enable both SM-gate flags AND both enrollment FFs.
  • The shared context 'secrets manager base' and the two feature_provision_spec files now enroll the instance via the new enroll_instance_in_secrets_manager helper 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 1
Limit  (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 ms
NamespaceEnrollment.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 1
Limit  (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 ms
NamespaceEnrollment.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 ms

The 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 id
Delete 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 ms
ApplicationSetting UPDATE — instance enrollment toggle (PK lookup)
UPDATE "application_settings" SET "secrets_manager_instance_enrolled" = $1, "updated_at" = NOW()
WHERE "application_settings"."id" = $2 RETURNING id
Update 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 ms

GraphQL 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

How to set up and validate locally

  1. bin/rails db:migrate
  2. Enable enrollment feature flags:
    Feature.enable(:secrets_manager_namespace_enrollment)
    Feature.enable(:secrets_manager_instance_enrollment)
  3. 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.

Edited by Erick Bajao

Merge request reports

Loading