grants
Every authorization question — "may principal P perform action A on scope S?" — is answered by one auth.Authorize call against two tables: acl (permission rows) and acl_membership (role + identity indirection). 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, specs/5/32-acl-unified.md for the row and evaluator, and specs/5/33-paths-roles.md for the model. Code: auth/authorize.go (the evaluator), auth/delegate.go (delegation), auth/acl.go (scope matcher), store/acl.go (reader / writer).
1. The row
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 | interact, admin, mcp:<tool>, hold:mcp:<tool>, or *. |
scope | Folder path or glob. ** crosses /, * doesn't. |
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
On approval the original agent re-issues the call in its own next turn, so it runs with that agent's container, session, grants and secrets. Nothing executes out of turn on someone else's behalf. The release is one-shot, and re-issuing with different arguments is held again — you approve a call, not a permission.
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 container home, and a web vhost — it is never an authority level, and how deep it sits means nothing here.
2. The two tables
acl—(principal, action, scope, effect, params, predicate, granted_by, granted_at, grant_option). The whole permission graph. Created by migration 0007;grant_optionadded by 0021.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.
Row counts stay small — hundreds, not millions. Adjacent tables in play but orthogonal: groups (what folders exist), routes (which JIDs land in which folder).
3. Principals, actions, scopes
| principal kind | example | notes |
|---|---|---|
| OAuth sub | google:114019… | The account's canonical provider sub, resolved at mint by authd. |
| Folder agent | folder:atlas/eng | The container spawned at this folder. A first-class principal. |
| Platform identity | telegram:user/123456 | Channel-side identity, not yet OAuth-claimed. |
| Room JID | discord:837…/1504… | 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.
Scope is the folder path globbed segment-wise (matchPattern in auth/acl.go): * doesn't cross /, ** matches zero or more segments. It is a pure function — no DB query.
4. Params and predicates
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 glob here is single-segment: * stops at : and /. Every clause must hold; a missing key fails the row.
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 doesn't match. It neither allows nor denies — denial comes from an explicit effect='deny' row, or from nothing matching at all.
5. Evaluation
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 transitively through acl_membership.
2. Load exact-match rows, plus wildcard rows matching the expanded set.
3. Keep rows where action covers, scope matches, predicate holds,
params hold.
4. Any surviving deny row ⇒ denied.
5. Otherwise any surviving allow row ⇒ allowed.
6. No surviving row ⇒ denied.
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 don't hide a tool from the list — Authorize still refuses the call.
6. Two seeded roles
role:member is the floor. Every folder is bound to it the moment it is created (assignDefaultRole, routd/seed_grants.go), and it carries the twelve messaging verbs and nothing else:
reply, send, send_file, send_voice,
post, forward, quote, repost,
like, dislike, edit, delete
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. Seed: migration 0023.
role:operator is the root of every delegation chain: one row, * on **, allow, WITH GRANT OPTION (migration 0022). 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.
arizuko group krons grant google:114alice@example.com '**'
# Equivalent SQL:
INSERT INTO acl_membership (child, parent, added_at) VALUES
('google:114alice@example.com', 'role:operator', now());
Any other pattern writes an ordinary acl row instead — see arizuko group … grant.
7. Delegation
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 doesn't 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.
8. Containment
Containment is the grant's scope glob. There is no separate hierarchy walk and no persisted containment row — Authorize(caller, action, ACTUAL-target, params) checks magnitude and containment in the same 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.
Container capabilities work the same way. At dispatch, routd resolves three booleans from acl and ships them typed on the run request (routd/dispatch.go): share_mount(readonly=true) makes the shared mount read-only, egress opens outbound network, web:publish opens the web surface. A plain folder holds none of them and runs with a writable share, constrained egress, and no web surface until an operator delegates.
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.
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.
9. Worked examples
# 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.
10. Go deeper
- concepts: authorization — mental model + worked patterns
- concepts: scopes — how a scope glob reads
- specs/5/32-acl-unified.md — the row and the evaluator
- specs/5/33-paths-roles.md — paths, roles, grant option
GRANTS.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