grants
Auth minted the principal; a grant decides what that principal may do. Every permission check asks the same question: may principal P do action A on scope S? — for example, may google:114alice send a message in eng/oncall? Two tables hold the answer: acl — ACL, Access Control List, the permission rows — for the rules, and acl_membership for “this id belongs to that group.” One auth.Authorize() call walks both. Get those two tables right and there is nothing else to learn.
the four words
Four words name everything in that question. Get these right and the rest is rows.
- Principal — who is asking: a human (
google:114alice), a folder's agent (folder:atlas/eng), a channel-side id, or a named role. - Action — what they want:
interact,admin,mcp:<tool>, or*. - Scope — where: a folder path like
atlas/eng, or a glob likeatlas/**. - Effect —
allow(default) ordeny. Deny wins.
the action lattice
Actions imply each other. If you have *, you have admin; if you have admin, you have interact and every mcp:<tool>. So a single admin row on a folder covers every tool call inside it — you do not list them one by one. One matching deny stops the check, even when other rows would allow it. The full principal shapes, the action lattice, and the two seeded roles are tabulated in reference / grants; here we stay with the four words and some real rows.
three rows, three real patterns
Each row below is something you would actually insert.
1. Alice can talk in her own folder.
INSERT INTO acl (principal, action, scope, granted_at) VALUES
('google:114alice', 'interact', 'alice', now());
2. The eng folder admin manages the whole subtree. The glob eng/** matches eng, eng/sre, eng/sre/oncall, and so on.
INSERT INTO acl (principal, action, scope, granted_at) VALUES
('google:114alice', 'admin', 'eng/**', now());
3. Ban one user globally. A deny row at scope ** beats every allow.
INSERT INTO acl (principal, action, scope, effect, granted_at) VALUES
('discord:user/badguy', '*', '**', 'deny', now());
The same shape grants a whole channel at once: make the room the principal (discord:837…/channel/1504… with interact on main/lab) and everyone who joins the room picks up the grant for free. Each row has a one-line arizuko grants add … CLI equivalent — full syntax in reference / grants.
roles — one indirection, many members
When the same permission set should apply to several people, you do not copy the row. You make a role, grant the role, and add members. Two tables, two operations.
-- Define the role: editors may admin the docs subtree.
INSERT INTO acl (principal, action, scope, granted_at) VALUES
('role:editor', 'admin', 'docs/**', now());
-- Bind alice to the role.
INSERT INTO acl_membership (child, parent, added_at) VALUES
('google:114alice', 'role:editor', now());
Adding a second editor — one more acl_membership row. Granting editors a new scope — one more acl row. Roles can contain roles: acl_membership(role:senior-editor, role:editor) works the same way.
claiming a channel identity
Alice posts in Discord as discord:user/811…. Later she logs in via Google. The OAuth callback writes one membership edge:
INSERT INTO acl_membership (child, parent, added_at) VALUES
('discord:user/811...', 'google:114alice', now());
That edge is the claim. The next Discord message from her expands to include the canonical sub; any grants on google:114alice apply automatically. No acl row gets rewritten, no migration runs — the channel-side id keeps working as before.
where the check runs
Every gate asks the same question — may this principal do this action on this scope? A message wanting into a folder hits a gate once; a tool call leaving that folder hits one again. The two faces share the handler, the folder-containment rule, and the check itself. An operator’s REST call and an agent’s MCP call both land in auth.Authorize, over the same acl rows; what differs is only where the principal came from — a JWT subject on one side, the socket’s folder on the other. There is no second mechanism for agents and no default derived from where a folder sits. The full call signature lives in reference / grants.
A subagent holds exactly what its parent holds. When an agent spawns a Claude Code subagent inside its turn, the subagent talks over the same unix socket, so the gate sees the same principal and answers the same way. There is no narrower set for a narrower job: a subagent spawned to summarise one file can still call every tool the parent can, secrets and outbound requests included. That is deliberate — the socket is the boundary, and a second socket would be a second answer to the same question. Scope the parent folder to what you are willing to let a subagent do, not just what the agent itself needs. Decision and reasoning: specs/5/5 R4.
don't do this
- Don't insert deny rows to "take back" access. Remove the allow row or the membership edge instead. Denies are for true exceptions — banning one user from an otherwise-open channel — not for undoing yesterday's grant.
- Don't grant
adminwith a params predicate. Admin means full write at scope. Conditional admin is surprising. Usemcp:<tool>with params when you want a narrow capability. - Don't put routing into ACL. The
routestable answers "where does this message land."aclanswers "may this principal act here." They share the principal namespace for room ids but never merge. - Don't bypass roles for operators. The first operator is bound by one
acl_membership(sub, role:operator)edge atarizuko create. Adding more operators is another membership row — not a fresh wildcard grant each time.
where to go next
Canonical spec with the full schema and evaluation algorithm: specs/5/32-acl-unified.md. Rule-syntax reference: reference / grants. For where principals come from in the first place, see auth.