arizuko

arizuko › reference › Grants

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
columnmeaning
principalWho 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>.
scopeFolder path or glob. ** crosses /, * does not.
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

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.

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 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

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 kindexamplenotes
OAuth subgoogle:114019583462810573829The account's canonical provider sub, resolved at mint by authd.
Folder agentfolder:atlas/engThe guest booted at this folder. A first-class principal.
Platform identitytelegram:user/123456Channel-side identity, not yet OAuth-claimed.
Room JIDdiscord:5678/9012Channel / 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.

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 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