Deny direct secret reads for trial_eligible after beta cutoff

What does this MR do and why?

GitLab Secrets Manager is moving from a free beta to a paid add-on. When the beta program ends, beta namespaces that did not convert to the paid add-on remain in the trial_eligible entitlement state. SecretsManagement::Entitlement#permits_direct_read? gates the paths where Rails hands the client a JWT to read secrets directly from OpenBao: CI pipeline secret reads and the non-CI/CD REST access-token endpoint. It currently returns true for trial_eligible, which means that after the beta ends, a non-converted namespace's CI pipelines could keep reading secrets for free, indefinitely. This usage is unmetered: each read emits a billable event, but there is no billable source behind it.

This MR closes that gap:

  • Adds a new beta type feature flag, end_secrets_manager_beta_program, disabled by default. Enabled means the beta cutoff is in effect. The flag is deliberately phrased in the negation (per the design in !249180 (merged) (merged)) so that removing the flag later makes the enabled branch the permanent behavior. The flag takes the namespace as actor, so the cutoff can be staged per namespace (for example, beta cohort first), but the intended end state is enabling it globally and then removing the flag: once the beta program is over, no trial_eligible namespace keeps direct reads, beta-enrolled or not (a non-beta trial_eligible namespace cannot hold secrets anyway, since writes are denied until a trial starts).
  • Adds a beta_program_ended field to the SecretsManagement::Entitlement value object. permits_direct_read? now returns false for trial_eligible when beta_program_ended is set. All other states are unchanged.
  • Entitlement::Resolver sets beta_program_ended from the feature flag (with the namespace as actor) only when constructing the :trial_eligible entitlement, so the value object itself stays free of feature-flag knowledge.
  • No caller changes were needed: both enforcement surfaces already funnel through permits_direct_read?, namely the CI build runner presenter (ee/app/presenters/ee/ci/build_runner_presenter.rb) and the create_secrets_manager_api_jwt ability in the group and project policies.

It also adds a visibility carve-out for the free beta cohort:

  • Problem: the existing policy conditions (secrets_manager_entitlement_trial_eligible_on_non_root_group in the group policy, secrets_manager_entitlement_trial_eligible_on_project in the project policy) hide subgroup and project secrets from all trial_eligible namespaces. That was designed for new prospects, but the free beta cohort also lands in trial_eligible, so beta users would lose UI/GraphQL visibility of secrets they created everywhere except the top-level group.
  • Fix: those conditions now skip namespaces whose top-level group has a beta enrollment record, via a new class method SecretsManagement::NamespaceEnrollment.beta_enrolled?, which checks for a beta: true enrollment on the root ancestor. Beta users keep metadata-only read visibility of subgroup and project secrets. Writes remain gated by the existing entitlement rules. Non-beta trial_eligible namespaces are unchanged (still hidden).
  • The GraphQL beta field on SecretsManagerEnrollment that the frontend needs for its display logic shipped separately in !249772 (merged) (merged). This MR only adds a small GraphQL type spec for that type (ee/spec/graphql/types/secrets_management/enrollment_type_spec.rb), since !249772 (merged) shipped without one. The branch was rebased onto master to build on top of it.

Resulting behavior:

Scenario Behavior
trial_eligible, post-cutoff (flag enabled) CI pipeline secret reads denied; REST access-token JWT denied; Web UI/GraphQL remain accessible (Rails-brokered, gated by the broader permits_read?) but are metadata-only (these surfaces never expose secret values), so namespaces keep visibility of which secrets exist; for the beta cohort this visibility now covers subgroup and project secrets too, not just the top-level group; values stay stored in OpenBao and become readable again as soon as the namespace starts a trial or subscribes
trial_eligible, pre-cutoff (flag disabled) Unchanged; direct reads still permitted
blocked with grace reason Unchanged; still permits direct reads (existing behavior for lapsed paid customers)

CI denial still emits the existing denial telemetry: the secrets_manager_access_denied internal event with reason trial_required.

No changelog entry: the change is entirely behind a feature flag that is disabled by default.

Review feedback addressed:

  • Request spec coverage added in ee/spec/requests/api/secrets_management/access_tokens_spec.rb: the REST access-token endpoint now has explicit 403 assertions post-cutoff and 201 pre-cutoff, for both project and group routes.
  • The resolver's mapping-table comment now documents beta_program_ended: from flag on the :trial_eligible row.
  • The pre-cutoff presenter spec context now also asserts no denial telemetry is emitted.

Testing done (all passing locally):

  • ee/spec/lib/secrets_management/entitlement_spec.rb and ee/spec/lib/secrets_management/entitlement/resolver_spec.rb: 131 examples, 0 failures.
  • ee/spec/presenters/ci/build_runner_presenter_spec.rb entitlement contexts, new cases: post-cutoff trial_eligible payload suppressed with telemetry, pre-cutoff still served (and no denial telemetry emitted).
  • ee/spec/policies/ee/group_policy_spec.rb and ee/spec/policies/project_policy_spec.rb, new cases: post-cutoff trial_eligible denies create_secrets_manager_api_jwt; group read_secret still allowed; beta-cohort contexts covering reads allowed for the beta cohort, writes still denied, and non-beta trial_eligible namespaces still hidden.
  • ee/spec/models/secrets_management/namespace_enrollment_spec.rb: covers .beta_enrolled?.
  • ee/spec/requests/api/secrets_management/access_tokens_spec.rb: REST access-token endpoint denial and success cases described above.
  • ee/spec/graphql/types/secrets_management/enrollment_type_spec.rb: type spec for the beta field shipped in !249772 (merged) (merged).

How to set up and validate locally

In a SaaS-simulated GDK (GITLAB_SIMULATE_SAAS=1) with secrets_manager_paid_experience enabled and an entitlement that resolves to trial_eligible:

  1. With end_secrets_manager_beta_program disabled, CI secret reads and the REST access-token endpoint work as before.
  2. Run Feature.enable(:end_secrets_manager_beta_program). CI pipeline secret reads and the REST access-token endpoint now deny, while the Web UI still lists the secrets (metadata only).
  3. With a beta-enrolled top-level group (a SecretsManagement::NamespaceEnrollment record with beta: true), subgroup and project secrets remain visible (metadata only) in the Web UI even while trial_eligible; without the beta enrollment they stay hidden.
  4. Disable the flag again and behavior returns to the pre-cutoff state.

Database review

No migrations. This MR adds one new query, in SecretsManagement::NamespaceEnrollment.beta_enrolled?, called from the group and project policy conditions:

SELECT 1 AS one
FROM "secrets_manager_namespace_enrollments"
WHERE "secrets_manager_namespace_enrollments"."namespace_id" = 9970
  AND "secrets_manager_namespace_enrollments"."beta" = TRUE
LIMIT 1

Query plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54713/commands/157702 -- Index Scan using uniq_idx_sm_namespace_enrollments_on_namespace_id, 1 row, 2.4 ms execution with a cold cache (6 buffers).

Index coverage: namespace_id is covered by the existing unique index, so the lookup matches at most one row; the beta filter applies to that single row. No new index needed.

Frequency and caching:

  • Only reachable when Secrets Manager is enabled, secrets_manager_paid_experience is on for the root ancestor, and the entitlement state is trial_eligible.
  • Request-cached via Gitlab::SafeRequestStore keyed on the root namespace id, so a request touching N subgroups/projects under one top-level group issues at most one query (same shape and table as the existing Availability.enrolled? check, which already runs per node).
  • The table holds one row per enrolled top-level group, so it stays small.

References

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 Dmytro Biryukov

Merge request reports

Loading