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 → aDecisionofGrantedorDenied(with a reason). users.is_super_admin = truebypasses everything. Otherwise an activeuser_membershipsrow 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:
| File | What it holds |
|---|---|
authz.go | Allowed (per-resource check) and CanAct (precondition-only check), plus the shared SQL fragments. |
scope.go | AccessibleScope (the bulk row-filter used by list endpoints). |
authz_integration_test.go, authz_reasons_test.go | tests. |
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_typeandactionaccept 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_patternis a SQLLIKEpattern over the resource’s name, compiled from a user-facing glob (*/?).authz_groupis an exact stack authz group name and applies to the stack family only (stack, andcloud_integrationthrough 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 indexstack_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 reservedgantrycd: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 roles —
roles.is_system = true,roles.org_id IS NULL. Reconciled frommigrations/00000000000001_initial.sqlduring deploy. Permissions are stored asrole_permissionsrows, so a seed change affects every existing assignment. - Custom org roles —
roles.org_idset,is_system = false. Admin-managed per org viaPOST/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 — seerole_service.go::DeleteCustomRole.
Two ways a user gets a role
- Direct assignment — a row in
user_role_assignments(seepkg/domain/user_role_assignment.go). - SSO group binding — a row in
org_identity_group_role_bindings, activated by the user’s non-expired rows inuser_external_entitlementsanswering 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): returnsdomain.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_idon 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.ReplaceForQuerydeletes 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 anerror, not aDecision.- Wildcards are legal only in grants (role permissions), never in a check.
ResourceIDis 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 createteam-a-*stacks.Allowednever 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 a404separately. This is what lets one query serve every resource type. On single-resource routes a denial is also mapped to404so 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
RequireAuthininternal/backend/middleware/auth.go. CSRF is enforced for mutating methods. - Personal access token (
Authorization: Bearer gantrycd_pat_…) — validated byPersonalAccessTokenService.Validate. Each PAT binds a token to one(user_id, org_id)pair; theuser_membershipsgate 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: handlerat 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 → Allowed → 403 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:
- A membership pre-gate (
Authz.CanAct) denies a non-member before the resource lookup runs. - Every denial — non-member, missing resource, or insufficient permission —
returns the same
NotFoundError→404, 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:
| Grant | What 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):
enforceProtectedLabelsloads the org’s protected keys, diffs the requested labels against the stack’s current ones (domain.ChangedProtectedLabelKeys), and runs one patternedAllowedcheck 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, andReplaceEnvironmentVariables(a context’s variables are the payload) —enforceSelectorLabelAuthzruns 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,enforceAutoAttachAuthzadditionally 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 inpkg/domain/permissions.go. - Never store a glob; store the canonical SQL
LIKEpattern produced bydomain.CompileResourcePattern, and decompile withdomain.DecompileResourcePatternwhen 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.