arizuko

arizukoreference › Grants

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
columnmeaning
principalWho is asking. Globbed segment-wise on / and :.
actioninteract, admin, mcp:<tool>, hold:mcp:<tool>, or *.
scopeFolder path or glob. ** crosses /, * doesn't.
paramsOptional predicates over the call's arguments (jid=telegram:*).
predicateOptional condition on the caller's JWT claims (discord:guild=G123). Empty = none.
effectallow or deny. Deny wins.
grant_option0 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.

Why a separate action: a hold is never an 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

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 kindexamplenotes
OAuth subgoogle:114019…The account's canonical provider sub, resolved at mint by authd.
Folder agentfolder:atlas/engThe container spawned at this folder. A first-class principal.
Platform identitytelegram:user/123456Channel-side identity, not yet OAuth-claimed.
Room JIDdiscord:837…/1504…Channel / room itself — carries the route's baseline grants.
Rolerole:operatorIndirection. 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 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