Permissions Catalog
Permissions are tuples of (resource_type, action, resource_pattern, authz_group). The type and action axes accept the literal '*' as a
wildcard meaning “any”; the two targeting axes select which resources of
the type the grant reaches — a SQL LIKE pattern over the resource’s name,
or (stack family only) an exact stack authz group — and both empty means
“any resource of that type”. A permission uses at most one targeting mode.
The catalog is enforced in pkg/domain/permissions.go:
ResourceTypeandActionenums list the legal concrete values plusResourceAny/ActionAny.IsValidPermission(rt, action)checks the validity matrix below. Wildcards on either axis always pass; concrete pairs must be in the matrix.RolePermission.Validate()runs at every persistence boundary (role creation, role-permission upsert) so junk pairs never reach the database. It rejects a non-empty pattern whenresource_type = '*'and on the org-level families that have no per-resource identity (member,role,identity_source) — such a grant could never match anything — and rejects anauthz_groupoutside the stack family or alongside a pattern.CompileResourcePattern(glob)turns a user-facing glob (*and?) into the canonical SQLLIKEpattern stored inrole_permissions.resource_pattern. Empty input and the literal*both canonicalise to''(the any-resource sentinel).
Validity matrix
| Resource family | Actions |
|---|---|
stack | read · read_logs · read_data · create · update · delete · deploy · confirm · cancel · prioritize · run_command · local_deploy · preview · unlock_state · overwrite_state · restore_state |
runner | delete |
runner_group | read · create · update · delete |
scm_integration | read · create · update · delete |
cloud_integration | read · update · delete |
notification_integration | read · create · update · delete |
member | read · create · update · delete |
role | read · create · update · delete · assign |
identity_source | read · create · update · delete |
service_account | read · create · update · delete |
context | read · create · update · delete |
label | read · create · update |
stack_authz_group | assign |
(*, *) is full administrator access. (*, X) and (X, *) are wildcards on
one axis. Adding a new action to a resource only requires updating the
matrix in permissions.go; existing wildcard grants pick it up
automatically.
Notes on the catalog shape:
-
Stack covers the whole deployment workflow. Triggering deployments (
deploy), confirming/skipping plans (confirm), cancelling in-flight runs (cancel), bumping a queued deployment ahead of FIFO order (prioritize— reorders waiting work only, never preempts running work; the bump outranks other work competing for the same runner groups, so a grant on one stack arbitrates shared capacity within those groups), and reading run logs (read_logs) are all stack actions. There is norunresource type — run IDs aren’t user-addressable and there’s nothing a user can do to a run that isn’t reachable via the parent stack. Splitting these out lets a role grant “trigger and watch deployments” without “edit the stack’s config” (which isupdate). -
read_datagates reading a stack’s Terraform outputs cross-stack. It backs thegantrycd_state_outputsdata source (aterraform_remote_statereplacement): a consumer stack — typically via its default service account — reads a producer’s outputs through the gantrycd API without S3 access or broadreadon the producer. Deliberately separate fromreadso a consumer can be granted just a producer’s outputs. It returns sensitive output values unmasked, so it is not bundled intostack-reader(which sees only the masked Outputs tab viaread) — grant it explicitly to the consuming identity or a custom role.adminandstack-adminhold it through their(stack, *)wildcard. -
run_commandis the dangerous escape hatch. It gates raw-command deployments — running an arbitrary bash script on a runner with the stack’s cloud and state-backend credentials. It is deliberately separate fromdeployso it must be granted explicitly (via thestack-command-runnerrole or a custom role);adminandstack-adminstill hold it through their(stack, *)wildcard. -
local_deploygates planning unreviewed local code. It allowsgantrycli local-plan— uploading a tarball of a local working tree and planning it on the stack’s runners. Even a plan executes arbitrary providers/data sources with the stack’s runtime credentials, and the code never went through SCM review, so it is deliberately separate fromdeploy(closer torun_commandin blast radius). Granted explicitly via thestack-local-deployerrole or a custom role;adminandstack-adminhold it through their(stack, *)wildcard. -
previewgates running an unreviewed PR’s plan. When a stack’spr_plan_modeismanual(the default), a pull request’s read-only preview plan is created parked on a confirmation stage instead of running, andpreviewis what confirms it. It is deliberately separate from bothdeployandconfirm, for the same reason aslocal_deploy:tofu initfetches the modules and provider binaries the PR’s HCL names, andtofu planthen executes those providers — and any data source they expose — with the stack’s runtime credentials. A pull request is the one deployment origin whose code has not been reviewed and merged, so this is neither “trigger the configured branch” (deploy) nor “approve an apply of reviewed code” (confirm), and an org may well want the two held by different people. It also gates settingpr_plan_modetoauto, which turns every future PR preview into a standing yes.Carried by
stack-approver, notstack-triggerer. Triggerer is commonly assigned to developers and CI identities; widening it during an upgrade would silently grant authority to execute unreviewed provider code. Organizations can put(stack, preview)in a narrower custom role when preview confirmation and apply approval belong to different people.adminandstack-adminhold it through their(stack, *)wildcard. -
unlock_state,overwrite_stateandrestore_stateare advanced state operations. They gate the backend-direct state-administration actions (managed backends only):unlock_stateforce-unlocks the stack’s real Terraform state lock (deleting the backend’s.tflockobject) after a crashed run leaves it stale;overwrite_statereplaces the stack’sterraform.tfstatewith an uploaded file (the previous state is backed up first);restore_staterolls the state back to a stored prior version of the state object (S3 object versioning; the current state is backed up first), and is narrower/lower-risk than uploading arbitrary state. Each is a separate action so it can be delegated on its own — via thestate-lock-manager/state-overwriter/state-restorerroles or a custom role — independently ofdeployandrun_command.adminandstack-adminhold all three through their(stack, *)wildcard. -
Runners are managed by runner-groups. The only direct user action on the
runnerfamily isdelete, used to force-remove a stuck or lost runner. Reads, creates and updates of runners go through the runner-group subsystem, not user roles. -
runner_grouppermissions are type-agnostic. Theself-hostedandgithub-actionsgroup types share one permission space —(runner_group, create/read/update/delete)gates both identically, scoped by the group name. Creating agithub-actionsgroup or editing itsgithub_configis anupdate/createonrunner_group; there is no separate permission for the GitHub App credentials. -
labelgates protected stack-label keys, and is scoped by the label KEY (not a stack/resource name). An org designates certain label keys protected; thereafter adding, removing, or re-valuing one of those keys on a stack requires(label, update, <keyGlob>)— the delegated, per-key grant a team is given (e.g.(label, update, env)), while free-form keys stay under(stack, update). The same grant gates both directions of auto-attach: setting a matching label on a stack (pull) and giving a context an auto-attach selector on a matching key (push) — so one permission is the single control point for a key’s auto-attach power.(label, read)views the org’s protected-key set and(label, create)manages it (a whole-set replace). Because(label, update, *)is admin-only until keys are protected, only admins can create auto-attaching contexts by default.adminholds all of these through its(*, *)wildcard;stack-adminadditionally holds(label, read). Seedocs/reference/authz.md. -
stack_authz_groupgates membership of stacks in authz groups, and is scoped by the group NAME. A stack carries free-form authz groups (stacks.authz_groups); a stack permission can target one exactly (see Targeting below). Adding a group to a stack or removing one requires(stack_authz_group, assign, <groupGlob>)for that group, on top of(stack, update)/(stack, create). A group has no row of its own — it exists while a stack carries it — so the assign grant is also the right to create it.adminholds it through(*, *). Seedocs/reference/authz.md.
Targeting
Roles fully describe what they grant — including which resources, via the targeting axes. Assignments and group bindings are pure user↔role / group↔role mappings; there is no per-assignment scope.
A permission is in exactly one of three targeting modes:
resource_pattern | authz_group | Meaning |
|---|---|---|
'' | '' | any resource of that type (the default) |
SQL LIKE pattern | '' | resources whose user-facing identifier matches |
'' | group name | stacks carrying that authz group (stack family only) |
Name patterns
Patterns are compiled from a user-facing glob: * matches any sequence,
? matches exactly one character. Literal %, _, and \ in the input
are escaped, so a stack named prod_backend matches the pattern
prod_backend exactly (the underscore is escaped, not used as a
single-char wildcard).
The match value is the resource’s user-facing identifier:
| Resource | Match value |
|---|---|
stack | stack name (proposed name on create; current name on read/update/delete/etc.) |
runner_group | runner group name |
runner | parent runner-group name — (runner, delete, "prod-k8s-*") scopes force-removal to those groups |
scm_integration | integration name (proposed name on create; current name on delete) |
cloud_integration | composite <stack-name>:<provider-type> — e.g. team-a-api:aws. List endpoint has no provider in the path and uses an empty match value (family-level), so patterned-only readers cannot list and must call the per-provider Get instead. |
notification_integration | integration name (proposed name on create; current name on read/update/delete). List uses an empty match value (family-level), so patterned-only readers must call the per-integration Get instead. |
stack_authz_group | the authz group name being added to or removed from a stack |
label | the protected label key being changed on a stack |
member, role, identity_source | none — these families have no per-resource identity, and Validate rejects a pattern on them |
Pattern matching applies at create time too: a permission
(stack, create, "team-a-*") constrains what names a user may create,
even if the stack does not exist yet.
Authz groups
authz_group is the scale path for scoping stack permissions. A name
pattern is evaluated as a filter over the org’s stacks, which costs
rows ÷ matches per list page and gets worse the narrower the grant (a grant
matching one stack scans the whole org on every page). A group is a leading
equality key in the list index (stack_authz_group_members), so a
group-scoped list can be served as one ordered range scan per granted
group. Measured on 1 vCPU / 1 GB with 1M stacks in one org for that query
shape: 0.04–0.55 ms per page at every selectivity, versus 55 ms and growing
for a one-stack name pattern.
Transitional: this change adds the index and the per-item check; the list repositories still filter by name pattern only, so a principal whose only stack grants are group-targeted lists nothing (fail closed) until the next change adds the group arms. See Authorization.
- A stack carries up to
domain.MaxAuthzGroupsPerStack(20) free-form group names (label-key charset). Groups are orthogonal dimensions —team-payments,env-prod,tier-1— so one stack is reachable by several teams’ roles without a combinatorial group per (team × env). (stack, deploy, authz_group = env-prod)matches every stack carryingenv-prod;(cloud_integration, update, authz_group = env-prod)matches those stacks’ cloud integrations. No other family accepts a group.- Exact match only: no wildcards, no key-only or negated selectors, and no AND of two groups in one permission — each of those would turn the range scan back into a filter. Two permissions are a union.
- Never on
create: a stack’s name is chosen at create under name-axis authority ((stack, create)untargeted or by pattern), and its groups are assigned in the same write under(stack_authz_group, assign, <name>). A group-targeted create grant would authorize any name at all, soValidateand a CHECK rejectauthz_grouponcreate. The same rule keeps a rename honest: the new name is re-checked without groups. - Setting or removing a group on a stack is gated by
(stack_authz_group, assign, <name>)— the analogue of the rename rule that re-checks(stack, update)against the new name. Without it,(stack, update, team-a-*)plus(stack, deploy, authz_group = env-prod)would let a user label their own stack intoenv-prodand deploy it.
Authz semantics
A check (target_resource, target_action, target_match_value) succeeds iff
some grant the user holds via direct assignment or SSO group binding
satisfies:
perm_resource = target_resourceORperm_resource = '*'perm_action = target_actionORperm_action = '*'- the grant’s targeting matches: (
perm_pattern = ''ANDperm_group = '') OR (perm_pattern <> ''ANDtarget_match_valueis non-NULL ANDtarget_match_value LIKE perm_pattern ESCAPE '\') OR (perm_group <> ''ANDperm_group = ANY(target_groups))
target_groups is the stack’s authz_groups (a cloud integration passes
its stack’s; a create passes the request’s), and NULL outside the stack
family. A targeted grant does NOT satisfy a NULL match value: asking “can I
do X anywhere?” (e.g. on a list endpoint) is only satisfied by an
untargeted grant. List endpoints typically issue a NULL-value check at the
family level and then filter the row set repository-side using the same
semantics.
Plus: super admin bypass, org existence, org not-deleted, active membership.
The full SQL is in internal/authz/authz.go.
Built-in roles
Defined and reconciled by migrations/00000000000001_initial.sql during
deploy. The catalog uses focused roles that admins can compose through
multi-role SSO bindings.
| Role key | Permissions |
|---|---|
admin | (*, *) |
iam-admin | (member, *), (role, *), (identity_source, *), (service_account, *) |
stack-admin | (stack, *), (context, *), (role, read), (label, read) |
stack-reader | (stack, read), (stack, read_logs), (context, read), (role, read) |
stack-triggerer | (stack, read), (stack, read_logs), (stack, deploy), (stack, cancel), (stack, prioritize), (context, read), (role, read) |
stack-approver | (stack, read), (stack, read_logs), (stack, confirm), (stack, preview), (context, read), (role, read) |
stack-command-runner | (stack, read), (stack, read_logs), (stack, run_command), (context, read), (role, read) — dangerous: arbitrary command execution |
stack-local-deployer | (stack, read), (stack, read_logs), (stack, local_deploy), (context, read), (role, read) — plan unreviewed local code (gantrycli local-plan) |
state-lock-manager | (stack, read), (stack, read_logs), (stack, unlock_state), (context, read), (role, read) — dangerous: force-unlock state lock |
state-overwriter | (stack, read), (stack, read_logs), (stack, overwrite_state), (context, read), (role, read) — dangerous: overwrite state |
state-restorer | (stack, read), (stack, read_logs), (stack, restore_state), (context, read), (role, read) — dangerous: restore a prior state version |
runner-admin | (runner_group, *), (runner, delete), (role, read) |
integrations-admin | (scm_integration, *), (cloud_integration, *), (notification_integration, *), (role, read) |
System roles ship untargeted (any resource of that type). Patterns and authz groups are where custom roles add value; the system-role seed carries no group and its convergence sweep removes one if it ever appears, since groups are per-org names.
Custom roles
Org admins create custom roles via the role-CRUD endpoints — see the
“Custom roles” section in Authorization. Each custom role
bundles between 1 and domain.MaxCustomRolePermissions (50) tuples, all
drawn from the same validity matrix that gates system roles. The role’s key
is auto-derived from name via domain.GenerateID and is immutable on
update — admins read keys in logs and policies, so a rename would
invalidate any external doc that quoted the key.
Custom roles do not auto-bundle (role, read): an admin who wants their
custom role to render in role pickers must include (role, read)
explicitly. This is intentional — the system catalog is curated to leak
only the picker-rendering bit, but a custom role is whatever the admin
chose, and we don’t second-guess it.
Deleting a custom role cascades through every direct assignment and SSO group binding role attachment that pointed at it. The cascade is by design: a role that no longer exists cannot grant anything, and orphaned grants would otherwise need a separate cleanup path.
Multi-role SSO bindings
A binding row identifies a (identity_source_id, group_key) pair and
attaches between 1 and domain.MaxRolesPerBinding (5) roles via
org_identity_group_role_binding_roles. When a user matches the binding’s
group, they receive the union of permissions from every attached role —
the same effect as if N separate single-role bindings existed, but easier
to manage and revoke as a unit.
Direct user-role assignments stay single-role for now; bindings are where role composition pays off because admins typically want to grant a group several focused capabilities at once.
Notes
- Personal organizations (
owner_user_id IS NOT NULL) reject all manual role assignments and group bindings (and custom-role creation); the owner gets theadminrole implicitly viaRoleService.GrantAdminRoleInTxon org creation. (role, read)is bundled into every non-wildcard system role so any active member can fetch the catalog the UI needs.- The validity matrix (
allowedActionsinpkg/domain/permissions.go) is exposed atGET /api/v1/permissions/catalogso the role-editor UI can disable invalid(resource, action)cells without duplicating the matrix. Each entry also carriessupports_pattern/supports_authz_group, the targeting modesValidateaccepts for that family, for the same reason.