Skip to content
GantryCD

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:

  • ResourceType and Action enums list the legal concrete values plus ResourceAny / 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 when resource_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 an authz_group outside the stack family or alongside a pattern.
  • CompileResourcePattern(glob) turns a user-facing glob (* and ?) into the canonical SQL LIKE pattern stored in role_permissions.resource_pattern. Empty input and the literal * both canonicalise to '' (the any-resource sentinel).

Validity matrix

Resource familyActions
stackread · read_logs · read_data · create · update · delete · deploy · confirm · cancel · prioritize · run_command · local_deploy · preview · unlock_state · overwrite_state · restore_state
runnerdelete
runner_groupread · create · update · delete
scm_integrationread · create · update · delete
cloud_integrationread · update · delete
notification_integrationread · create · update · delete
memberread · create · update · delete
roleread · create · update · delete · assign
identity_sourceread · create · update · delete
service_accountread · create · update · delete
contextread · create · update · delete
labelread · create · update
stack_authz_groupassign

(*, *) 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 no run resource 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 is update).

  • read_data gates reading a stack’s Terraform outputs cross-stack. It backs the gantrycd_state_outputs data source (a terraform_remote_state replacement): a consumer stack — typically via its default service account — reads a producer’s outputs through the gantrycd API without S3 access or broad read on the producer. Deliberately separate from read so a consumer can be granted just a producer’s outputs. It returns sensitive output values unmasked, so it is not bundled into stack-reader (which sees only the masked Outputs tab via read) — grant it explicitly to the consuming identity or a custom role. admin and stack-admin hold it through their (stack, *) wildcard.

  • run_command is 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 from deploy so it must be granted explicitly (via the stack-command-runner role or a custom role); admin and stack-admin still hold it through their (stack, *) wildcard.

  • local_deploy gates planning unreviewed local code. It allows gantrycli 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 from deploy (closer to run_command in blast radius). Granted explicitly via the stack-local-deployer role or a custom role; admin and stack-admin hold it through their (stack, *) wildcard.

  • preview gates running an unreviewed PR’s plan. When a stack’s pr_plan_mode is manual (the default), a pull request’s read-only preview plan is created parked on a confirmation stage instead of running, and preview is what confirms it. It is deliberately separate from both deploy and confirm, for the same reason as local_deploy: tofu init fetches the modules and provider binaries the PR’s HCL names, and tofu plan then 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 setting pr_plan_mode to auto, which turns every future PR preview into a standing yes.

    Carried by stack-approver, not stack-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. admin and stack-admin hold it through their (stack, *) wildcard.

  • unlock_state, overwrite_state and restore_state are advanced state operations. They gate the backend-direct state-administration actions (managed backends only): unlock_state force-unlocks the stack’s real Terraform state lock (deleting the backend’s .tflock object) after a crashed run leaves it stale; overwrite_state replaces the stack’s terraform.tfstate with an uploaded file (the previous state is backed up first); restore_state rolls 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 the state-lock-manager / state-overwriter / state-restorer roles or a custom role — independently of deploy and run_command. admin and stack-admin hold all three through their (stack, *) wildcard.

  • Runners are managed by runner-groups. The only direct user action on the runner family is delete, 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_group permissions are type-agnostic. The self-hosted and github-actions group types share one permission space — (runner_group, create/read/update/delete) gates both identically, scoped by the group name. Creating a github-actions group or editing its github_config is an update/create on runner_group; there is no separate permission for the GitHub App credentials.

  • label gates 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. admin holds all of these through its (*, *) wildcard; stack-admin additionally holds (label, read). See docs/reference/authz.md.

  • stack_authz_group gates 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. admin holds it through (*, *). See docs/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_patternauthz_groupMeaning
''''any resource of that type (the default)
SQL LIKE pattern''resources whose user-facing identifier matches
''group namestacks 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:

ResourceMatch value
stackstack name (proposed name on create; current name on read/update/delete/etc.)
runner_grouprunner group name
runnerparent runner-group name(runner, delete, "prod-k8s-*") scopes force-removal to those groups
scm_integrationintegration name (proposed name on create; current name on delete)
cloud_integrationcomposite <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_integrationintegration 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_groupthe authz group name being added to or removed from a stack
labelthe protected label key being changed on a stack
member, role, identity_sourcenone — 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 carrying env-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, so Validate and a CHECK reject authz_group on create. 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 into env-prod and 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:

  1. perm_resource = target_resource OR perm_resource = '*'
  2. perm_action = target_action OR perm_action = '*'
  3. the grant’s targeting matches: (perm_pattern = '' AND perm_group = '') OR (perm_pattern <> '' AND target_match_value is non-NULL AND target_match_value LIKE perm_pattern ESCAPE '\') OR (perm_group <> '' AND perm_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 keyPermissions
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 the admin role implicitly via RoleService.GrantAdminRoleInTx on 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 (allowedActions in pkg/domain/permissions.go) is exposed at GET /api/v1/permissions/catalog so the role-editor UI can disable invalid (resource, action) cells without duplicating the matrix. Each entry also carries supports_pattern / supports_authz_group, the targeting modes Validate accepts for that family, for the same reason.