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

  1. The billing entitlement no longer blocks deletes in read-only states. It blocks them only in ineligible, the same state where it blocks reads, so Entitlement#permits_read? is now also the delete gate and no new predicate was added. Every blocked reason and the beta cohort after the program ends allow deletes. This is not an authorization change. A user still needs the delete_* 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.
  2. In the project and group policies, delete_project_secrets and delete_secret leave 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.
  3. The two secret delete mutations no longer include EnforcesWriteEntitlement. That concern exists to return a structured reason when the entitlement denies a write. For deletes the only denying state is ineligible, where the policy already refuses the mutation, and the frontend never selected reason on the delete mutations. So the reason field is removed from ProjectSecretDeletePayload and GroupSecretDeletePayload. It was an experiment field. The permission delete mutations keep the write gate. One side effect: a delete refused for ineligible no longer emits the secrets_manager_access_denied event, it is a plain policy refusal. Since ineligible hides the whole page, no user can reach a delete button in that state, so the lost signal is theoretical.
  4. EffectiveCapabilitiesService, which backs userPermissions on the secrets manager GraphQL types, reports delete from the same read check as readMetadata, so it follows metadata reads. The OpenBao delete capability 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.
  5. A new readOnly: Boolean! experiment field (milestone 19.4) is added to the ProjectSecretsManager and GroupSecretsManager GraphQL types. It mirrors Gitlab::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 archived and markedForDeletion on the entity. The project:archived and group:archived permission 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: ineligible denied, non-beta trial_eligible (project hidden, root group still deletes), blocked still allows delete, and beta cohort after cutoff still allows delete.
  • Type specs cover the readOnly field and the field lists. Request specs on both userPermissions queries turn on the real maintenance mode setting and assert the query still answers, with readOnly true and only readMetadata true. The GraphQL POST is allowlisted by the read-only middleware, so the field stays reachable while the instance is frozen.

References

How to set up and validate locally

  1. In a Rails console, enable the feature flag:
    Feature.enable(:secrets_manager_paid_experience)
  2. Reproducing a blocked entitlement locally needs a CustomersDot trial that has already expired, so these paths are best verified through the request specs. Run:
    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
    This needs the GDK OpenBao service running.
  3. To see the readOnly field, turn on maintenance mode in Admin area > Settings > General > Maintenance mode. Then open GraphiQL at /-/graphql-explorer and run:
    {
      project(fullPath: "<project path>") {
        secretsManager {
          readOnly
          userPermissions {
            createSecrets
            updateSecrets
            deleteSecrets
            readMetadata
          }
        }
      }
    }
    Expect readOnly: true and all three write booleans false. Turn maintenance mode off and expect readOnly: 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.

Edited by Erick Bajao

Merge request reports

Loading
Loading