grants
One auth.Authorize call answers every authorization question: may principal P perform action A on scope S? It reads two tables, acl for permission rows and acl_membership for roles and identity links. A folder path carries no authority of its own. This page is the syntax and semantics reference for the row, the evaluator, and delegation.
See concepts › authorization for the mental model, GRANTS.md for the maintainer walkthrough, and auth/README.md for the figure and every rule behind the evaluator. Code: auth/authorize.go (the evaluator), auth/delegate.go (delegation), auth/acl.go (scope matcher), store/acl.go (reader / writer).
One acl row is one rule
One row answers one question:
(principal, action, scope, params, predicate, effect, grant_option)
→ allow | deny
| column | meaning |
|---|---|
principal | Who is asking. Globbed segment-wise on / and :. |
action | *, admin, interact, egress, web:publish, gh:write, mcp:*, mcp:<tool>, shell:env:<KEY>, ext:<provider>:<area>:<verb>, hold:mcp:<tool> or <resource>.<verb>. |
scope | Folder path or glob. ** crosses /, * does not. |
params | Optional predicates over the call's arguments (jid=telegram:*). |
predicate | Optional condition on the caller's JWT claims (discord:guild=G123). Empty = none. |
effect | allow or deny. Deny wins. |
grant_option | 0 or 1. 1 means the holder may re-delegate this row or a subset of it. |
Holding a call for a human
A hold:mcp:<tool> row does not grant or deny anything. It says the call must wait for a person. When the agent makes it, the call does not run: a pending_actions row is written, the chat gets a notice, and an operator answers /approve <id> or /reject <id>.
# hold every delete this folder makes
principal = folder:atlas action = hold:mcp:delete scope = atlas
# hold only when the argument matches — reads and staging stay inline
principal = folder:atlas action = hold:mcp:network_allow scope = atlas
params = host=*.prod.example.com
Approval buttons and /approve, /reject and /always reach the held call while the agent waits. A green or red reaction on the notice works too; 👍 and 👎 deliberately do not, because they are ordinary social reactions. The reviewer needs an admin grant covering the held row’s folder. The agent’s socket has no face of pending_actions at all: an agent that could approve would approve its own call.
- The call waits. The gate suspends the tool call and polls for a verdict for up to
HITL_WAIT, 10 minutes by default; it must stay underRUNED_RUN_TIMEOUT, or the guest is killed first. Approval runs the same call, with the arguments the notice showed, in the same turn. A reject or a timeout answers the agent with a tool result,NOT RUN (id …), telling it not to retry. - The hold runs before the authorization check, and never replaces it. An approved call still meets
Authorize. An elevated/rootturn has no hold gate. - A hold that cannot be recorded still holds. If the
pending_actionsrow cannot be written, the call is blocked and the agent gets an error, because there is no id anybody could approve. /alwaysis/approveplus one skip-approval row. The row iseffect = denyon thehold:action — deny on a hold means “do not hold” — and it is written in the same batch as the verdict. There is no third verdict onpending_actions. To revoke, remove that row withremove_aclorarizuko group … ungrant./alwayswrites exactly the matcher the notice showed. Every scalar argument becomes an exact value, never a glob, so one sample cannot widen into a pattern. A value carrying,,*or surrounding spaces cannot be stated exactly, so/alwaysis refused for it. Non-scalar arguments are skipped. One renderer feeds the notice, the REST row and the writer, so no surface shows one rule and stores another.
effect on an ordinary mcp:<tool> row. The evaluator treats every non-deny effect as allow, so such a row would silently grant the tool it meant to gate. The hold: namespace keeps hold rules invisible to the allow/deny evaluator, and holds are matched on the exact action — otherwise an operator's (*, **) row would hold every tool they touch.Nothing in the row is derived. A path is a routing target, a JID prefix, a home shared into the guest, and a web vhost — it is never an authority level, and how deep it sits means nothing here.
Two tables hold the rules
acl—(principal, action, scope, effect, params, predicate, granted_by, granted_at, grant_option). The whole permission graph, one entity in routd’s store.acl_membership—(child, parent, added_by, added_at)edges. Walked transitively at authorization time to expand a principal into every role it carries and every identity it has been linked to. A write that would make a principal its own ancestor is refused.
Row counts stay small — hundreds, not millions. Adjacent tables in play but orthogonal: groups (what folders exist), routes (which JIDs land in which folder).
A row names a principal, an action and a scope
| principal kind | example | notes |
|---|---|---|
| OAuth sub | google:114019583462810573829 | The account's canonical provider sub, resolved at mint by authd. |
| Folder agent | folder:atlas/eng | The guest booted at this folder. A first-class principal. |
| Platform identity | telegram:user/123456 | Channel-side identity, not yet OAuth-claimed. |
| Room JID | discord:5678/9012 | Channel / room itself — carries the route's baseline grants. |
| Role | role:operator | Indirection. Members via acl_membership. |
| Wildcards | **, google:*, folder:** | Globs anchor on : and /, so google:* does not match google:114/sub. |
Actions form a lattice, evaluated rather than denormalized (actionCovers in auth/authorize.go):
* ⊃ admin ⊃ interact
* ⊃ mcp:<tool> admin ⊃ mcp:<tool> mcp:* ⊃ mcp:<tool>
Granting admin is not the same as inserting one row per mcp:<tool>. The lattice is the contract — a tool added tomorrow is covered by yesterday's admin row.
There is no policy language. OPA and Rego are not used, and the lattice is never flattened into one row per tool: the common case is gating like reply(jid=slack:*), which one row and a params glob already say.
Scope is the folder path globbed segment-wise (matchPattern in auth/acl.go): * does not cross /, ** matches zero or more segments. It is a pure function — no DB query.
Params and predicates narrow a row
Two optional columns narrow a row further. Both use the same grammar:
spec := clause ("," clause)*
clause := name "=" glob # value must glob-match
| name # must simply be present
params is checked against the call's arguments, predicate against the caller's JWT claims. A value glob runs over the whole value, so * crosses : and /: jid=telegram:* covers telegram:group/123. Every clause must hold; a missing key fails the row.
Arguments reach a row only where the gate passes them. The messaging tools pass jid, so a jid= row works. Most other tools check the grant with no arguments at all, so a row with params matches nothing there: a deny on such a tool is tool-wide only. A hold receives the arguments, so a hold can be shaped. A predicate on a folder: principal never matches, because the agent’s socket carries no login claims.
params: jid=telegram:group/* # only sends into telegram groups
params: readonly=true # only the read-only form of the call
predicate: discord:guild=G123 # only when the caller's token carries that guild
A row that fails on params or predicate simply does not match. It neither allows nor denies — denial comes from an explicit effect='deny' row, or from nothing matching at all.
auth.Authorize evaluates every request
auth.Authorize is the sole runtime evaluator. Operator REST calls and the agent's MCP socket both land here, over the same rows — one mechanism, two callers, not two systems.
1. Expand the caller's principals through acl_membership.
2. Load exact and wildcard rows for those principals.
3. Check scope, predicate and parameters. Matching denies apply on either axis.
4. Route grants use their full scope and action coverage.
5. Sender-only grants allow targets inside the turn folder's subtree, with an
exact action or that action namespace's wildcard.
6. Any matching deny wins; otherwise a matching allow wins; else deny.
There is no fallback. An action with no matching allow row is refused, loudly. Nothing is computed from the folder's shape when the table comes up empty.
Rows are read on every call — no cache. Revoking a grant takes effect on the caller's next tool call, even mid-turn.
Tool visibility is a separate view over the same rows: auth.EffectiveActions asks whether the caller holds an action at any scope, and that answer drives tools/list. Because the list and the per-call gate read one table, they agree by construction. Deny rows are scope-specific, so they do not hide a tool from the list — Authorize still refuses the call.
Two roles exist at the start
role:member is the floor. Every folder is bound to it the moment it is created (store.SeedMemberRole, store/membership.go), and it carries the twelve messaging verbs plus disengage:
reply, send, send_file, send_voice,
post, forward, quote, repost,
like, dislike, edit, delete, disengage
Read tools and set_work are always on and need no grant. Everything else — register_group, the route tools, network_*, schedule_*, observe_*, invite_*, token minting, writing acl itself — is an explicit row somebody delegated. The floor is seeded with grant_option = 0: it is not re-delegable, because every new folder is born a member directly. routd writes the role’s rows into an empty store at first boot (store.SeedRoutdFloor).
role:operator is the root of every delegation chain: one row, * on **, allow, WITH GRANT OPTION, seeded by the same first boot. Root is a grant somebody holds, not a place in the tree. The operator invokes it with /root; there is no root folder, and a top-level folder is just a world — an ordinary tenant with ordinary grants.
Making someone an operator is one membership edge. The permissions row is shared, so a second operator costs one row:
# Make alice an operator: one acl_membership edge to role:operator.
arizuko group -i krons grant google:114alice '**'
Any other pattern writes an ordinary acl row instead — see arizuko group … grant.
A holder delegates only what it holds
Authority moves by delegation, and the bound is subset-of-held: a principal may grant onward only rows it already holds, and only those it holds with grant_option = 1. This is Postgres's GRANT … WITH GRANT OPTION, and it means authority strictly decreases down every chain without anyone counting path segments.
auth.Delegate is a pure precondition — it writes nothing, and the caller writes the rows only after it returns clean. It refuses:
- a row the granter does not hold with grant option;
- a
denyrow — a restriction is not a subset of what you hold; - a wildcard principal as the target, which would hand the grant to every matching sub;
- anything the granter's own
denyrows cover — you cannot re-grant your way past a block on yourself.
A new group starts with whatever its creating group or a lineage ancestor delegates to it, on top of the role:member floor. A child can never exceed its granter.
The scope glob contains the folder
The grant's scope glob bounds its target. For an agent turn, sender-only allows also require the target to be inside the turn folder's subtree. Route grants retain their full scope. Authorize(caller, action, ACTUAL-target, params) checks these rules in one call.
acl(folder:acme/ops, mcp:register_group, 'acme/**', allow)
register_group("acme/ops/oncall") → allowed
register_group("acme/billing") → allowed
register_group("other/team") → denied
So a management call passes the real target as the scope, and the row's glob decides. Widening reach is editing one glob, not moving a folder.
Turn capabilities work the same way. At dispatch, routd resolves four booleans from acl and sends them typed on the run request (routd/dispatch.go): share_mount(readonly=true) makes the shared mount read-only, egress appends * to the folder's allowlist so the turn's proxy runs in allow-all, web:publish opens the web surface, and gh:write lets the agent make the GitHub writes that the gh gate of the agent refuses by default, such as a merge or a release. A plain folder holds none of them and runs with a writable share, an allowlist of exactly what its own rows name, and no web surface until an operator delegates. The proxy itself is never optional — every turn gets one, and the guest's tap drops anything that tries to go around it.
One grant carries a value rather than a boolean. A capability credential normally reaches a tool and nothing else, through the broker, per call. A CLI the agent drives itself — gh, buildkite — is a Bash call, so there is no tool name to resolve a secret for; it can only authenticate from the shell environment. A shell:env:<KEY> row names one secret that may cross, and with no row nothing crosses. The action is matched exactly, so an operator's (*, **) row does not confer it: widening the shell is always an explicit act. shell:env:* is the explicit all-keys row. The five model keys never cross this way, even under shell:env:* — they already reach the guest by their own door, and the store keeps them off folder scope.
That grant cannot bound what the token buys. Once the credential is in the shell the agent spends it with curl as easily as with gh, and no acl row sees the difference. Scope the token at the issuing service — a PAT minted without pull_requests: write cannot approve a pull request however it is called.
Four example rows
# Let a support agent send only into telegram groups.
principal: folder:atlas/support
action: mcp:send
scope: atlas/support
params: jid=telegram:group/*
# Ban one user from an otherwise-open channel. Deny wins.
principal: telegram:user/123456
action: interact
scope: atlas/support
effect: deny
# Let a world agent create its own subgroups, and only its own.
principal: folder:acme
action: mcp:register_group
scope: acme/**
grant_option: 1
# Hand a team lead the route table for one subtree, without
# letting them pass it on further.
principal: google:114lead@example.com
action: admin
scope: acme/eng/**
grant_option: 0
Prefer deleting an allow row or a membership edge over inserting a deny. Denies are for true exceptions — one banned user in an open room — and because deny wins everywhere, a forgotten one is hard to find later.
Ten pages and files give the details
- concepts: authorization — mental model + worked patterns
- concepts: scopes — how a scope glob reads
auth/README.md— the row, the three readers, the two axesGRANTS.md— maintainer referenceauth/authorize.go—Authorize,EffectiveActionsauth/delegate.go—Delegate(subset-of-held)auth/acl.go—MatchGroups(pure scope matcher)store/acl.go— row reader / writer- schema: acl, acl_membership
- jid format — what the params in
send(jid=…)look like