Allow secret deletion in read-only Secrets Manager states
What does this MR do and why?
This MR is backend only. There are no screenshots to add.
The parent gating issue (https://gitlab.com/gitlab-org/gitlab/-/work_items/623384) says the Secrets Manager page must go read-only when a trial has expired, the beta cohort window has closed, or the subscription is in its grace period. Right now read-only means no create, no update, and no delete.
The UX decision in that issue is that users must still be able to delete secrets one by one, even in read-only mode. If users can see data but cannot remove it, that is bad data management. Permission management stays disabled in all read-only states.
The frontend team also asked for a single backend source of truth for read-only checks, instead of re-deriving the logic in Vue.
This MR is the backend part of that work. The frontend counterpart is https://gitlab.com/gitlab-org/gitlab/-/work_items/623333.
What changed
- The billing entitlement no longer blocks deletes in read-only states. It blocks them only in
ineligible, the same state where it blocks reads, soEntitlement#permits_read?is now also the delete gate and no new predicate was added. Everyblockedreason and the beta cohort after the program ends allow deletes. This is not an authorization change. A user still needs thedelete_*ability for their role and the OpenBao delete capability, exactly as today. No extra CustomersDot call is made, reads already resolve the same memoized entitlement. - In the project and group policies,
delete_project_secretsanddelete_secretleave the "entitlement denies writes" rule. They stay covered by the existing "entitlement denies access" rule, and by the rules that hide subgroups and projects while the top-level group is only trial eligible. Create, update, admin, provision, deprovision, and permission abilities are not changed. - The two secret delete mutations no longer include
EnforcesWriteEntitlement. That concern exists to return a structuredreasonwhen the entitlement denies a write. For deletes the only denying state isineligible, where the policy already refuses the mutation, and the frontend never selectedreasonon the delete mutations. So thereasonfield is removed fromProjectSecretDeletePayloadandGroupSecretDeletePayload. It was an experiment field. The permission delete mutations keep the write gate. One side effect: a delete refused forineligibleno longer emits thesecrets_manager_access_deniedevent, it is a plain policy refusal. Sinceineligiblehides the whole page, no user can reach a delete button in that state, so the lost signal is theoretical. EffectiveCapabilitiesService, which backsuserPermissionson the secrets manager GraphQL types, reportsdeletefrom the same read check asreadMetadata, so it follows metadata reads. The OpenBaodeletecapability for the user is still required, the entitlement only narrows it. It also sets create, update, and delete to false when the instance is read-only.- A new
readOnly: Boolean!experiment field (milestone 19.4) is added to theProjectSecretsManagerandGroupSecretsManagerGraphQL types. It mirrorsGitlab::Database.read_only?so the frontend can show a maintenance mode banner. GraphQL reference docs and the introspection JSON are regenerated.
Important note on scope
Strict read-only (maintenance mode, Geo secondary) is not enforced or tested per mutation in this MR. Mutations::BaseMutation#ready? already refuses every GraphQL write when the instance is read-only, and that is the established pattern across the codebase. This MR only surfaces that state to the UI, through userPermissions and the new readOnly field. A policy-level condition and per-mutation request specs for this were tried during self-review and removed, because they were redundant with the existing ready? check.
Behaviour detail
For a non-beta trial_eligible project or subgroup, the whole resource is hidden, so the delete mutation returns "resource not available". A root group in the same state stays visible with its trial call to action, and the delete runs there. Both cases are covered by request specs.
Feature flag
The entitlement behaviour only applies when secrets_manager_paid_experience is enabled for the top-level group. This flag is off by default. The readOnly field itself is not behind this flag.
Out of scope
- Archived and pending-deletion namespaces. This is an open product decision in the parent issue. Today only the frontend hides the buttons, from
archivedandmarkedForDeletionon the entity. Theproject:archivedandgroup:archivedpermission groups list no secrets abilities, so the backend does not block secret writes or deletes there. Unchanged by this MR. - Showing the page as read-only after unenrollment. Unenrollment fails the availability check and returns a 404 today. This belongs to the page visibility topic, not this MR.
- Expired Premium or Ultimate subscriptions. UX and product confirmed the 404 behaviour stays as is.
- Removing the entitlement check from metadata reads and deletes altogether, so a CustomersDot outage no longer hides the page. Tracked in https://gitlab.com/gitlab-org/gitlab/-/work_items/628297.
Testing summary
- The entitlement class is unchanged apart from a comment, so its existing specs cover
permits_read?. - Policy specs cover every state and role for both policies, plus the beta cohort after cutoff.
- The capabilities service spec covers blocked, ineligible, paid, beta cutoff, non-beta trial_eligible, and read-only instance states.
- Delete mutation request specs, run against a real OpenBao, cover:
ineligibledenied, non-betatrial_eligible(project hidden, root group still deletes),blockedstill allows delete, and beta cohort after cutoff still allows delete. - Type specs cover the
readOnlyfield and the field lists. Request specs on bothuserPermissionsqueries turn on the real maintenance mode setting and assert the query still answers, withreadOnlytrue and onlyreadMetadatatrue. The GraphQL POST is allowlisted by the read-only middleware, so the field stays reachable while the instance is frozen.
References
- Backend issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/628267
- Related to parent gating issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/623384
- Related to frontend counterpart: https://gitlab.com/gitlab-org/gitlab/-/work_items/623333
- Related to parent epic: https://gitlab.com/groups/gitlab-org/-/work_items/21755
- Related to self-managed tracker: https://gitlab.com/gitlab-org/gitlab/-/work_items/628139
- Follow-up, stop consulting the entitlement for metadata reads and deletes: https://gitlab.com/gitlab-org/gitlab/-/work_items/628297
How to set up and validate locally
- In a Rails console, enable the feature flag:
Feature.enable(:secrets_manager_paid_experience) - Reproducing a
blockedentitlement locally needs a CustomersDot trial that has already expired, so these paths are best verified through the request specs. Run:This needs the GDK OpenBao service running.bundle exec rspec ee/spec/requests/api/graphql/secrets_management/project_secrets/delete_spec.rb ee/spec/requests/api/graphql/secrets_management/group_secrets/delete_spec.rb - To see the
readOnlyfield, turn on maintenance mode in Admin area > Settings > General > Maintenance mode. Then open GraphiQL at/-/graphql-explorerand run:Expect{ project(fullPath: "<project path>") { secretsManager { readOnly userPermissions { createSecrets updateSecrets deleteSecrets readMetadata } } } }readOnly: trueand all three write booleans false. Turn maintenance mode off and expectreadOnly: false.
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.