Secrets Manager: Enforce granular permissions on the backend
Blocked by
- !243263 (merged): a short OpenBao token TTL on the
user_jwtCEL role. TheuserPermissionsresolver mints a persisted token forcapabilities-selfand revokes it best-effort. Without a role TTL, a failed revoke leaves the token alive for OpenBao's ~32-day default, so the TTL must land first. Backfill of existing roles is tracked in #604545 (closed).
What this MR does
This is the backend part of enforcing granular Secrets Manager permissions. It does three things:
- Exposes the current user's permissions over GraphQL. New
userPermissionsfield onProjectSecretsManagerandGroupSecretsManagerwith four booleans:readMetadata,createSecrets,updateSecrets,deleteSecrets. They are resolved from the user's real OpenBao effective capabilities, not their GitLab role. - Blocks the page when the user has no read access.
Projects::SecretsControllerandGroups::SecretsControllerreturn 404 when the user lacks the effective OpenBao read capability. - Enforces granular-token (GPAT) authorization on the secrets
manager query surface, via the
read_secrets_managerassignable scope.
Implements #601817.
Why
Any Reporter or above can open the Secrets Manager page and see the action buttons even without an OpenBao grant, and creating a secret then fails on submit. The role check is too coarse. The real access rules live in OpenBao, so this makes the backend the single source of truth.
How it works
Resolving effective capabilities
EffectiveCapabilitiesService resolves the booleans from the user's
real OpenBao capabilities:
- Log in as the user with a CEL login (
ProjectUserJwt/GroupUserJwt) to mint a real token. - Call
sys/capabilities-selfon the secrets data and metadata paths. - Revoke the minted token (best-effort cleanup).
An explicit login is used rather than the usual inline auth, because
inline auth mints a request-scoped token that OpenBao never writes to
the token store, so sys/capabilities-self rejects it with "invalid
token". OpenBao computes the union of the user's principal policies
(user, member role, groups, role), deny precedence, and expiry, so no
permission logic is duplicated in Ruby.
Fails closed: if OpenBao is unreachable, all permissions return
false and the controller returns 404.
Path mapping: readMetadata from read/list on the metadata path,
and createSecrets/updateSecrets/deleteSecrets from
create/update/delete on the data path.
Granular-token authorization
The query surface is authorized for granular PATs via the
read_secrets_manager assignable scope (shared with !240364 (merged), extended
to the project and group boundaries), with directives on the
ProjectSecretsManager/GroupSecretsManager types and the root
projectSecretsManager/groupSecretsManager query fields.
Field naming
The read field is named readMetadata (not readSecrets) to align
with the read_metadata vs read_value split being introduced for
direct API access (!240364 (merged)). readValue is intentionally not exposed
yet because it lives on a separate api_jwt mount. It is tracked in
#602726 (closed).
Backend / frontend split
The frontend changes live in a separate MR: !240850. They are split
because the Secrets Manager is already in production (Beta), so during
a rolling deploy the backend must merge and deploy first. The
GraphQL fields are additive, so this is safe to deploy on its own. The
frontend MR must rename readSecrets to readMetadata.
Databases
None. No migrations, no schema or query changes.
Follow-ups (tracked separately)
readValueGraphQL exposure (#602726 (closed)).- Cache
EffectiveCapabilitiesServiceresults across requests to avoid the duplicate OpenBao round-trips between the controller'scheck_read_capability!and the GraphQLuserPermissionsresolver (#605467). - Consolidate the Secrets Manager authorization ability zoo (#605081).
How to validate locally
- Enable the Secrets Manager for a project or group.
- Sign in as a Reporter with no granular secrets permission.
- Open the Secrets Manager page. You should get a 404.
- Grant that user read access via the Secrets Manager permissions UI.
- Reload. The page now loads (200).
Tests
- GraphQL request specs (project + group): each permission combination, grants via user/role/group principals, expired grants, guest, OpenBao unreachable, and the granular-token shared example.
- Client spec:
capabilities_selfagainst real OpenBao, plus minted-token cleanup (revoke invoked + revoke failure tolerated). - Service unit specs: capability-to-boolean mapping, the
dataenvelope, deny and partial combinations, fail-closed on every OpenBao error. - Controller specs: read present -> 200, read missing -> 404, OpenBao unreachable -> 404.
References
- Issue: #601817
- Frontend MR: !240850
- Direct API access (read_metadata/read_value split): !240364 (merged)
- read_value GraphQL exposure: #602726 (closed)