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
betatype 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, notrial_eligiblenamespace keeps direct reads, beta-enrolled or not (a non-betatrial_eligiblenamespace cannot hold secrets anyway, since writes are denied until a trial starts). - Adds a
beta_program_endedfield to theSecretsManagement::Entitlementvalue object.permits_direct_read?now returns false fortrial_eligiblewhenbeta_program_endedis set. All other states are unchanged. Entitlement::Resolversetsbeta_program_endedfrom the feature flag (with the namespace as actor) only when constructing the:trial_eligibleentitlement, 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 thecreate_secrets_manager_api_jwtability 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_groupin the group policy,secrets_manager_entitlement_trial_eligible_on_projectin the project policy) hide subgroup and project secrets from alltrial_eligiblenamespaces. That was designed for new prospects, but the free beta cohort also lands intrial_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 abeta: trueenrollment 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-betatrial_eligiblenamespaces are unchanged (still hidden). - The GraphQL
betafield onSecretsManagerEnrollmentthat 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 flagon the:trial_eligiblerow. - 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.rbandee/spec/lib/secrets_management/entitlement/resolver_spec.rb: 131 examples, 0 failures.ee/spec/presenters/ci/build_runner_presenter_spec.rbentitlement contexts, new cases: post-cutofftrial_eligiblepayload suppressed with telemetry, pre-cutoff still served (and no denial telemetry emitted).ee/spec/policies/ee/group_policy_spec.rbandee/spec/policies/project_policy_spec.rb, new cases: post-cutofftrial_eligibledeniescreate_secrets_manager_api_jwt; groupread_secretstill allowed; beta-cohort contexts covering reads allowed for the beta cohort, writes still denied, and non-betatrial_eligiblenamespaces 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 thebetafield 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:
- With
end_secrets_manager_beta_programdisabled, CI secret reads and the REST access-token endpoint work as before. - 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). - With a beta-enrolled top-level group (a
SecretsManagement::NamespaceEnrollmentrecord withbeta: true), subgroup and project secrets remain visible (metadata only) in the Web UI even whiletrial_eligible; without the beta enrollment they stay hidden. - 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 1Query 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_experienceis on for the root ancestor, and the entitlement state istrial_eligible. - Request-cached via
Gitlab::SafeRequestStorekeyed 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 existingAvailability.enrolled?check, which already runs per node). - The table holds one row per enrolled top-level group, so it stays small.
References
- Work item: https://gitlab.com/gitlab-org/gitlab/-/work_items/612862 (confidential)
- Beta cohort tracking MR: !249180 (merged) (merged)
- GraphQL
betafield onSecretsManagerEnrollment: !249772 (merged) (merged)
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.