Skip to content
GantryCD

Authorization

Authorization decides whether a user may perform an action. It lives entirely in internal/authz/ and answers every question with one SQL query — no data is pulled into Go to branch on.

TL;DR

  • A user’s permissions come from roles. A role is a bundle of (resource_type, action, resource_pattern, authz_group) tuples.
  • A user gets a role two ways: a direct assignment, or an SSO group binding that matches one of their groups.
  • Every check is (user, org, resource_type, action, resource_id) → a single query → a Decision of Granted or Denied (with a reason).
  • users.is_super_admin = true bypasses everything. Otherwise an active user_memberships row in the org is required before any permission counts.

The query mechanics — the three queries and a worked walkthrough of every outcome — are on their own page: Authorization queries.

Where the code lives

The package is small and deliberately flat:

FileWhat it holds
authz.goAllowed (per-resource check) and CanAct (precondition-only check), plus the shared SQL fragments.
scope.goAccessibleScope (the bulk row-filter used by list endpoints).
authz_integration_test.go, authz_reasons_test.gotests.

There are no per-resource files — one query serves every resource type.

Why authz is not in the services layer

Authz is on the hot path of nearly every user request, so it is built for that:

  • one SQL query per check, straight against the database — no repository orchestration;
  • five bind parameters cover every check, and no value is ever concatenated into the SQL text (permission strings included);
  • handlers own the access decision — services assume authz already ran.

The model — what grants a permission

Roles and permissions

A role bundles a set of (resource_type, action, resource_pattern, authz_group) permissions:

  • resource_type and action accept the literal '*' as a wildcard meaning “any”.
  • The two targeting fields select which resources of the type the grant reaches, and at most one is set. resource_pattern is a SQL LIKE pattern over the resource’s name, compiled from a user-facing glob (*/?). authz_group is an exact stack authz group name and applies to the stack family only (stack, and cloud_integration through its stack). Both empty means “any resource of that type”.
  • A stack’s groups live in stacks.authz_groups. They are the scale path: a pattern is a filter over the org’s stacks (cost grows with org size ÷ matches), a group is a leading equality key in the list index stack_authz_group_members (written by this change; read by the list arms of the next one). Changing a stack’s groups needs (stack_authz_group, assign, <name>) for every group added or removed — the same guard the rename rule applies to names, so a scoped updater cannot move a stack into a group whose grants they hold.
  • Why a dedicated field and not labels: a label that grants access must be guarded on every edit, and gantrycd:group:<view> labels are the topology partition — visible, filterable, and free to change under (stack, update). Reusing either would make ordinary label edits an authorization change or force every topology key under the protected-key guard. Authz groups carry no topology meaning and topology groups carry no authz meaning; the reserved gantrycd: prefix is rejected as a group name so the two cannot be confused.

The valid (resource_type, action) matrix lives in pkg/domain/permissions.go; the catalog detail is in permissions_catalog.md.

A role fully describes what it grants, resource targeting included. To restrict a role to a subset of resources, you give it patterned permissions — you do not scope the assignment. (See “Assignments carry no scope” below.)

Two kinds of role: system and custom

  • System rolesroles.is_system = true, roles.org_id IS NULL. Reconciled from migrations/00000000000001_initial.sql during deploy. Permissions are stored as role_permissions rows, so a seed change affects every existing assignment.
  • Custom org rolesroles.org_id set, is_system = false. Admin-managed per org via POST/PUT/DELETE /api/v1/orgs/{org_id}/roles. Non-admins cannot create them: the create handler is gated on (role, create), which only the system Admin role bundles (via wildcard). Deleting a custom role cascades through every assignment and binding-role attachment that referenced it — see role_service.go::DeleteCustomRole.

Two ways a user gets a role

  1. Direct assignment — a row in user_role_assignments (see pkg/domain/user_role_assignment.go).
  2. SSO group binding — a row in org_identity_group_role_bindings, activated by the user’s non-expired rows in user_external_entitlements answering the question that source asks (see Evidence is identified by the question asked).

Both are pure user↔role / group↔role mappings. Assignments and bindings carry no scope of their own — only the role’s resource_pattern controls reach.

Super-admin and membership

users.is_super_admin = true short-circuits every check (so operators can recover a broken or deleted org). For everyone else, an active user_memberships row in the org is the gate: no membership, no permission, regardless of role assignments.

Seat cap (max_users)

Each org has a purchased seat cap, organizations.max_users (default 1), set by super-admins in Backstage. Only a net-new member is gated (ensureSeatForMember): an already-active member is always allowed (a renewal occupies no new seat), and personal orgs are exempt — so an org that is over its cap (from a lowered cap or the default backfilling below current membership) never de-provisions or blocks the members it already has, only new ones. A net-new member is admitted only when CountActiveMembers < max_users (Organization.HasFreeSeat). Enforcement points:

  • SSO just-in-time provisioning (SSOService.refreshEntitlementsAndMemberships): when the org is at cap, the membership is withheld silently — the user is still provisioned globally (user row + session) but gets no seat in that org until one frees. The login does not fail.
  • Manual add (MembershipService.AddManualMember): returns domain.SeatLimitReachedError (HTTP 409).
  • Personal orgs are exempt (the single owner is never seat-capped).

Freeing a seat is just revoking a member ((member, delete), held by admin and iam-admin); the org-admin Members page surfaces “seats used / max”. Lowering max_users below the current count evicts no one — it only blocks new members until usage falls under the cap. Note an SSO-provisioned member who is revoked re-acquires a seat on their next login if room remains, so revoking to make space is first-come, not a permanent block.

The gate takes a per-org advisory lock (AcquireSeatProvisioningLock) before the count for a net-new member, so concurrent provisions (a burst of simultaneous first-time logins) can’t all pass the check and overshoot the cap under READ COMMITTED. The SSO provisioning loop iterates its matched orgs in sorted order so these locks are always acquired in the same order across concurrent logins — two users net-new to the same over-cap orgs would otherwise deadlock.

Rollout: the default cap is 1, but a backfill migration (grandfather_max_users) raises each existing org’s max_users to its current active-member count so the default never retroactively caps an org below the members it already has. This is essential: identity_source memberships expire and are deleted by the membership-expiry job, so a returning SSO member’s re-login is a net-new grant — on an over-cap org the gate would withhold it and silently de-provision existing members. After the backfill, existing members keep access; the org still cannot GROW past its (grandfathered) cap until an operator raises max_users. The migration re-runs on every deploy (the hook re-applies all data migrations), so it is written to only ever touch orgs still at the untouched default of 1 (WHERE max_users = 1): once bumped it is a no-op, and any operator-set cap — including a deliberately low one to shrink an org by attrition via SetOrgMaxUsers — is never reverted. (runs_per_hour_limit is deliberately neither grandfathered nor backfilled — its default applies as-is to every org created under it, is surfaced on the Queue, and is not a lockout.)

Two signals help operators find orgs needing a raise: denials emit a gantrycd_org_seat_denials{org,source} metric (source sso for silent just-in-time withholds, manual for the 409), and the daily seat-cap-audit logs a recurring WARN for each over-cap org (it changes nothing). It follows the same per-org sweep pattern as the resource-index / dependency-graph rebuilds: a cheap scheduler job lists orgs and enqueues one light async task per org (deduped so a slow drain never stacks up), and the task processor’s worker slots drain those across replicas, each org’s task held under an exclusive lease (see async_tasks.md). Each task is a single indexed check (the org’s active-member count vs its max_users) — so the per-org work is load-balanced across replicas and no task runs a heavy aggregation. A DB CHECK keeps max_users >= 1.

Granting a role — escalation prevention

Holding (role, assign) / (role, create) lets you manage roles; it does not let you manufacture authority you lack. Every write that confers permissions — assigning a role to a user or service account, creating or updating a custom role, binding roles to an SSO group — is gated by services.GrantGuard: a caller may confer a role only when every permission in it is covered by a permission the caller already holds (PermissionCovers in pkg/domain/permissions.go). This is the Kubernetes-RBAC escalation rule.

Without it, a bounded delegated-admin role is a full-takeover vector: IAM Admin holds (role, *) and (service_account, *) but no stack/cloud/state permissions, yet could create a service account, grant it the system Admin role (or a custom role carrying (*, *)), mint its token, and authenticate as it — a single-actor jump to (*, *), because the caller both creates the principal and mints its credential. The self-assignment guards in AssignRoleToUser and ServiceAccountService.assertNotSelf do not catch this: a fresh service account is a distinct principal, not “yourself”.

Coverage is conservative on the targeting axes — a holder permission covers a target only when it is untargeted or byte-identical on both resource_pattern and authz_group, never merely broader, and a pattern never covers a group or vice versa. This can only reject a grant, never widen one. Two callers bypass the check: super-admins (their bypass already covers everything) and principal-less internal callers (operator CLI, seeders).

SSO group bindings in depth

A binding identifies a (identity_source_id, group_key) pair. It references an org_identity_sources row directly, so two OIDC sources in the same org pointing at different IdPs do not share bindings — the staff IdP’s “eng” group is not the contractor IdP’s “eng” group. Only enabled sources participate in authorization and effective-role reads, so disabling a source immediately stops its group bindings from granting roles.

Multi-role bindings and the per-binding cap

Roles are attached to a binding via org_identity_group_role_binding_roles — between 1 and domain.MaxRolesPerBinding (5) per binding. A user who matches the group receives the union of every attached role’s permissions.

The cap is enforced at authz read time, not at the binding write path. The SQL trims each binding to its first MaxRolesPerBinding roles (lexicographic by role_id) with:

JOIN LATERAL (
    SELECT role_id FROM org_identity_group_role_binding_roles
    WHERE binding_id = gr.id
    ORDER BY role_id
    LIMIT 5            -- domain.MaxRolesPerBinding, baked at init()
) brr ON true

Any rows past the cap — from direct SQL, a repair script, or a future API path that forgets the service-layer dedup — simply don’t participate. The application layer (RoleService.dedupedRoleKeys) also rejects over-cap input up front for UX, but the SQL is the authoritative guard.

Entitlements are persisted only for bound groups

A user can belong to thousands of groups in their IdP, almost none of which GantryCD has an org_identity_group_role_bindings row for. The SSO entitlement refresh (SSOService.refreshEntitlementsAndMemberships) therefore persists a user_external_entitlements row only for the groups that actually have a binding under one of the sources the login answered — see OrgIdentityGroupRoleBindingRepository.ListBoundGroupKeys, which intersects the user’s groups against the bound ones in SQL so an org with thousands of bindings returns only the handful the user holds. An entitlement for an unbound group is never read: every consumer (authz, ListEffectiveRolesForUser) joins through org_identity_group_role_bindings, so storing it is pure write amplification.

Which sources a login may activate is decided in SQL too, by OrgIdentitySourceRepository.ListEnabledForQuery, using the same predicate the readers join on. That keeps the rule in one language instead of restating it in Go, and it gates org membership as well as evidence: a login answered by one OAuth client does not provision the user into an org whose source asks a different question.

Evidence is identified by the question asked

user_external_entitlements records the question a login asked the IdP(provider_key, issuer, client_id, groups_claim) — alongside the group it answered. Every reader joins on that whole tuple. An entitlement activates a binding only when the binding’s identity source asks that same question.

This is not bookkeeping. (provider_key, issuer) alone is not a question: an org can run several OIDC sources — the one-source-per-provider unique index excludes oidc — and two of them may share an issuer while asking different things. An IdP can release a different group set per OAuth client, and a different groups claim is a different list entirely. Matching on provider and issuer let a group answered to one client silently activate a same-named binding under a source configured for another, so adding a second source inherited the first’s grants.

client_id is the OAuth client and groups_claim the claim the list was read from. They are named for org_identity_sources.oidc_client_id / .oidc_groups_claim, which they are compared against — client_id is not the aud claim. The groups claim is normalised at write time by OrgIdentitySourceService, so neither side re-applies a default.

No query names a provider. A provider with one deployment-wide configuration (GitHub, Google) stores '' in all three OIDC columns and asks a question with '' in all three, so the same four equality tests match it on provider_key alone. That is why the question a provider reports is source-shaped rather than token-shaped: OrgAwareProvider.EntitlementQuery returns the empty issuer such a provider stores, not the issuer its tokens carry — the user-identity path still uses the real one. Adding a provider needs no change to any query.

Sharing is intentional, and one limitation comes with it

Sources that ask the same question share evidence on purpose. That is what makes “sign in once” work: a user who logs in through one org’s source arrives in every other org whose source asks the identical question already holding their group-derived roles, without a second login. One login writes one row per group however many orgs consume it.

groups_scope is deliberately not part of the question. It controls which OAuth scopes a login requests, not what a group means — and FetchEntitlements reads the groups claim from userinfo either way, so many IdPs return groups without a dedicated scope being asked for. Keeping it out means evidence stays valid when an admin adds or removes a scope, instead of being silently orphaned.

The trust limitation this creates: two sources that differ only by groups_scope ask the same question, so a source configured without the scope still consumes group evidence fetched by an equivalent source that has it. An admin who omits the scope hoping that source will not act on group data does not get that isolation — groups_scope is not an access-control boundary.

To genuinely isolate two sources, give them different clients, issuers, or groups claims. Those are what identify the question, and each is enforced by both readers (internal/authz and ListEffectiveRolesForUser) and covered by tests that fail if the match is loosened.

The alternative — making scope part of the identity — was considered and rejected: a source with no groups scope can barely produce evidence of its own, so its group bindings would silently never grant, which is a worse failure than the sharing above.

Three further consequences follow:

  • Evidence is shared, orgs are not. Two orgs trusting the same IdP with the same client and claim asked the identical question, so one login stores one row for a group both of them bind — not one per org. A user in N such orgs holds evidence proportional to their group count, not N × group count. Separation is enforced independently, by org_id on the binding.
  • There is no FK to org_identity_sources, and none is wanted. Evidence is not owned by a source row. Deleting or repointing a source changes the question it asks, so its bindings stop matching immediately and the grant ends without a cascade; expiry then reaps the row. The guarantee holds by construction.
  • A login revalidates only the question it re-asked. ExternalEntitlementRepository.ReplaceForQuery deletes and rewrites exactly those rows in one call, so the new answer can never land while the old rows keep granting. A login as one client neither grants nor revokes roles derived from a different client or claim.

Pre-upgrade rows carry '' for both new columns. For non-OIDC providers that is correct and they keep working untouched. OIDC rows are dead — every OIDC source requires a client id, so none of them can match — and migrations/20260728000000_retire_unanswerable_oidc_entitlements.sql deletes them rather than guessing which client produced them; affected users regain their group-derived roles on next login.

Org membership is unaffected — it is computed from the full in-memory group set against each source’s rules, not from the persisted rows.

Consequence: a binding created for a group that had no binding when an affected user last logged in does not grant that user anything until their next entitlement refresh (next login). The delay is bounded by the session TTL — expired entitlements force re-login — and is fail-closed (it under-grants, never over-grants). Once a group has any binding, every later login persists it, so subsequent bindings or role changes for that group take effect immediately.

Prefer group bindings over direct assignments

A GantryCD user_id is the deterministic triple <provider_prefix>:<idp_login>:<idp_subject> (see pkg/domain/userid.go). When the IdP renames a user, the next sign-in derives a new user_id. Group bindings survive the rename — entitlements are recomputed every login. Direct user_role_assignments reference the old user_id and become orphans. Reserve direct assignments for stable identities (e.g. an external auditor); use bindings everywhere else.

How a check is answered

Every check resolves to one of three parameterised SQL queries, evaluated entirely in the database — Allowed (per-resource), CanAct (precondition only), and AccessibleScope (list filtering). The full query text, the shared fragments, and a worked walkthrough of every outcome are in Authorization queries.

The Check API

type Check struct {
    UserID, OrgID string
    ResourceType  domain.ResourceType   // typed; "*" forbidden in checks
    Action        domain.Action         // typed; "*" forbidden in checks
    ResourceID    string                // the resource's match value;
                                         // "" for a family-level question
    ResourceGroups []string             // the stack's authz groups (stack family
                                         // only; on create, the request's); nil
                                         // means no group grant can match
}

decision, err := authz.Allowed(ctx, tx, authz.Check{...})
  • Check.Validate() runs first. A (resource_type, action) pair not in the validity matrix is a programming bug, not a denial — the caller gets an error, not a Decision.
  • Wildcards are legal only in grants (role permissions), never in a check.
  • ResourceID is the resource’s user-facing match value — the stack name, the runner-group name. It is "" for families without per-resource targeting (member, role, org, identity_source). On create-time checks, pass the proposed name, so a user with (stack, create, "team-a-*") can only create team-a-* stacks.
  • Allowed never fails a resource-existence check. The handler (or the middleware that resolves an ID to a name) fetches the resource first; a missing one becomes a 404 separately. This is what lets one query serve every resource type. On single-resource routes a denial is also mapped to 404 so it cannot be told apart from a miss — see “Call pattern” below.

Authentication vs. authorization

Allowed takes only (UserID, OrgID, …) — it does not know how the caller’s identity was established. Two paths populate the request context:

  • Session cookie — set at the SSO callback, validated by RequireAuth in internal/backend/middleware/auth.go. CSRF is enforced for mutating methods.
  • Personal access token (Authorization: Bearer gantrycd_pat_…) — validated by PersonalAccessTokenService.Validate. Each PAT binds a token to one (user_id, org_id) pair; the user_memberships gate is re-checked on every request, so revoking a member stops their PATs immediately. CSRF is skipped (Bearer headers are not browser-auto-attached).

PATs are deliberately org-scoped: the middleware extracts org_id from the URL path and rejects any request whose path org differs from the token’s bound org. User-scoped routes with no org_id segment (/api/v1/auth/me, /api/v1/me/...) pass through. Token creation goes through DenyPAT, so a leaked PAT cannot mint more PATs.

/api/v1/auth/me is the whoami endpoint. Per-resource Allowed is not run for it (no specific resource), so the credential validator must not let a stale credential reach the handler — that is what CanAct is for. PersonalAccessTokenService.Validate calls CanAct after the credential checks (format, hash, expiry, user-disabled); Create calls it before minting a token. For PAT callers the /auth/me response also carries pat_id, pat_name, pat_org_id, and pat_org_login_start_handle; sessions omit all four.

Disabling a user, force-logging-out, and revoking a member each delete the relevant PATs in the same write transaction as the session/role cascade — see AdminService.DisableUser, AdminService.ForceLogoutUser, and MembershipService.RevokeMember.

Service-account tokens (Authorization: Bearer gantrycd_sa_…) authenticate as the service account, not the human who minted them — deliberately, so a service account outlives whoever set it up. ServiceAccountTokenService.Validate re-checks the account’s is_disabled and the token’s expiry, never the minter. To stop a compromised or departing user from laundering revocable access into a user-independent credential, disabling / force-logging-out / deleting a user also deletes the SA tokens that user minted — tracked via service_account_tokens.created_by. The account itself, and tokens other users minted, are untouched; a still-trusted user re-mints if needed.

Listing resources — AccessibleScope

A per-resource Allowed call answers “is this resource visible?”. List endpoints need “which resources are visible?” — and a patterned-only reader would be wrongly 403’d by a family-level Allowed (a patterned grant does not satisfy a NULL resource id, see Authorization queries, Case 8).

So list endpoints for pattern-supporting families (stack, runner_group, and /runners, which inherits the runner-group axis) use AccessibleScope instead. It runs one query and returns a row-filter shape:

type AccessibleScope struct {
    SuperAdmin  bool      // sees every resource in the org
    Member      bool      // false → empty result (also covers org missing/deleted)
    HasUnscoped bool      // holds an untargeted grant → sees every resource
    Patterns    []string  // otherwise: the LIKE patterns to filter names by …
    Groups      []string  // … unioned with the stacks carrying any of these groups
}

The service then decides, and the repository just fetches — policy stays in internal/authz, repositories are pure data access:

scope, _ := authz.AccessibleScope(ctx, tx, userID, orgID, domain.ResourceStack, domain.ActionRead)
switch {
case !scope.Member:            stacks = nil                                  // empty
case scope.HasUnscoped:        stacks = repo.ListByOrg(...)                   // all
case len(scope.Patterns) > 0:  stacks = repo.ListByOrgFilteredByName(..., scope.Patterns)
default:                       stacks = nil                                  // empty
}

Transitional: the stack list repositories filter by name pattern only. A principal whose only stack grants are group-targeted therefore falls into the default arm and lists nothing — every stack-scoped list (stacks, facets, queue, Explore, topology) fails closed for them, while per-item checks already honour groups. The list arms over stack_authz_group_members are the next change; until it lands, group targeting is usable through per-item URLs and the API, not through the list pages.

A non-member observes 200 + empty body here, not 403. Non-pattern families (member, role, identity_source, scm_integration, cloud_integration) keep the ordinary middleware family check on their list endpoints — they have no pattern axis to interfere.

Call pattern

Session-authz middleware opens a read transaction, builds the Check, calls Allowed, and returns 403 on denial — or 404 on single-resource routes, see below. See code_boundaries.md for the full request flow. Two specifics:

  • Stack create-time check is handler-side (// authz: handler at the mount): (stack, create) is evaluated once the body is parsed, against the proposed name only — a patterned create grant constrains what names may be created, and the requested groups are gated separately by (stack_authz_group, assign, …). A rename re-checks (stack, update) against the new name the same way, without groups.
  • Runner / runner-group JWT flows skip session authz entirely — their middleware injects identity (org_id, runner_id) from JWT claims and the handler enforces cross-org guards directly.

Single-resource routes answer 404, not 403

A route whose path id identifies one resource — GET/PUT/DELETE /orgs/{org}/stacks/{stack_id} and the equivalent runner-group, cloud-integration, runner-delete and scm-integration-delete routes — resolves the id to the resource before the per-resource Allowed check, because authz patterns match the resource’s name, not its id. A naive ordering (look up → 404 if missing → Allowed403 if denied) leaks existence: any authenticated caller, including a non-member of the org, could tell “exists but forbidden” (403) from “absent” (404), and since ids are name slugs (domain.GenerateID) that is a practical name-enumeration oracle.

middleware.scopedDecision closes it:

  1. A membership pre-gate (Authz.CanAct) denies a non-member before the resource lookup runs.
  2. Every denial — non-member, missing resource, or insufficient permission — returns the same NotFoundError404, with a byte-identical body.

So on these routes a 403 is never observable; “you may not” and “no such thing” become one answer. The real reason is still written to the request log for audit. Org- and family-level routes (create, list, org-scoped checks) keep returning 403 — they carry no resource id, so no existence oracle.

Cross-tenant guards

Allowed answers “is the user permitted to do X on resource Y in org Z?” It does not verify that resource Y actually belongs to org Z. That guard belongs to the service or repo — e.g. GetByIDInOrg(orgID, id) returns NotFoundError when the row’s org doesn’t match. The runner-group service shows the pattern with assertRunnerInOrg.

Protected stack labels

Stack labels are ordinarily free-form metadata, editable by anyone with (stack, update). The label resource type lets an org gate specific label keys behind their own permission without widening stack-edit rights. It is the authz half of context auto-attach: because a context implicitly attaches to any stack whose labels match a selector, protecting the keys that selector matches controls who can pull that context — and its secrets — onto a stack.

An org designates a set of protected label keys. Three grants apply:

GrantWhat it allows
(label, read)Read the org’s protected-key set.
(label, create)Replace the protected-key set (a whole-set write).
(label, update, <keyGlob>)Add, remove, or re-value a matching protected key on a stack (pull side), and give a context an auto-attach selector on a matching key (push side).

Unlike other resources, label’s match value is the label key itself, not a stack or resource name — so (label, update, env) delegates the env key across every stack, and (label, update, team-*) a whole family of keys. The read and create grants are family-level: the protected set is org config, so they carry no per-resource pattern.

(label, read) and (label, create) gate GET/PUT /api/v1/orgs/{org_id}/config/protected-labels in middleware. The (label, update, …) check runs on both directions of auto-attach:

  • Stack write time (pull): enforceProtectedLabels loads the org’s protected keys, diffs the requested labels against the stack’s current ones (domain.ChangedProtectedLabelKeys), and runs one patterned Allowed check per changed protected key. A write touching no protected key — or an org with none configured — takes only the ordinary (stack, update) path. This side is not retroactive: only a change is gated, so pre-existing labels are grandfathered.
  • Context write time (push): on any write that keeps or sets a non-empty selector — CreateContext, UpdateContext, and ReplaceEnvironmentVariables (a context’s variables are the payload) — enforceSelectorLabelAuthz runs one patterned (label, update, <key>) check per selector key. The whole selector is authorized (not just changes), since the entire payload — variables and hooks — is what auto-attaches; so there is no grandfathering gap. On update/env-var writes to an existing context, enforceAutoAttachAuthz additionally requires (context, read) on it — mirroring the explicit-attach guard, so a principal who can (context, update) a secret context but not read it can’t repoint its selector to a stack they control and exfiltrate. An empty selector needs no check. Note (label, update, K) gates using K as a selector regardless of whether K is protected; but if K is not also in the protected set, the pull side stays open, so a key a secret context auto-attaches on should be protected (see access control).

Because (label, update, *) is admin-only until keys are protected and granted, only admins can create auto-attaching contexts by default — the push direction is closed out of the box and delegated per key on purpose. gantrycd: keys cannot be protected — they are already governed by their namespace rules.

Built-in coverage: admin’s (*, *) bundles all three grants; stack-admin additionally carries (label, read) so a stack write blocked by a protected key surfaces the policy instead of an opaque 403. The catalog entry is in permissions_catalog.md.

Rules

  • Do not fetch data into Go merely to branch on permissions.
  • Do not move authz into services for convenience.
  • Permission strings are validated against the matrix at the persistence boundary; never write a (resource_type, action) pair you haven’t declared in pkg/domain/permissions.go.
  • Never store a glob; store the canonical SQL LIKE pattern produced by domain.CompileResourcePattern, and decompile with domain.DecompileResourcePattern when round-tripping to the UI.
  • The whole authz package is three queries built from shared fragments. If a check needs more, you are probably solving the wrong problem at the wrong layer.