A grant answers one question: may principal P do action A on scope S? Two SQL tables answer every check — acl for the rules and acl_membership for "this id belongs to that group." One auth.Authorize() call walks both.
The whole system is built on four nouns. Get these right and the rest is bookkeeping.
google:114alice), a folder's agent container (folder:atlas/eng), a channel-side identity (discord:user/811…), or a named role (role:operator).interact (send a message), admin (manage the folder), mcp:<tool> (call one MCP tool), or * (everything).atlas/eng, or a glob like atlas/**.allow (default) or deny. Deny wins.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 that folder — you do not list them one by one. The implication is:
* ⊇ admin ⊇ interact
* ⊇ admin ⊇ mcp:<tool>
Deny short-circuits the whole thing. If any matching row says deny, the request is rejected even when other rows would allow it. The check lives in auth.Authorize (auth/authorize.go); it tries explicit rules first, then falls back to tier defaults derived in grants.DeriveRules for mcp:* actions only.
| Kind | Example | Meaning |
|---|---|---|
| OAuth sub | google:114alice | A canonical human, minted by auth (see auth). |
| Folder agent | folder:atlas/eng | The agent container running at this folder. |
| Platform identity | discord:user/811… | A user known on a channel, not yet OAuth-claimed. |
| Room identity | discord:837…/1504… | A channel room itself — lets everyone in the room share a grant. |
| Role | role:operator | A named bag of members. Filled by acl_membership. |
| Wildcards | **, google:*, folder:** | Globs split on : and /. |
Each row below is something you would actually insert. All four together describe a working multi-channel instance.
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());
4. Everyone in a Discord room may chat with one folder's agent. The principal is the room, not the users in it. New people who join the room pick up the grant for free.
INSERT INTO acl (principal, action, scope, granted_at) VALUES
('discord:837.../channel/1504...', 'interact', 'main/lab', now());
The same rows from the operator CLI:
arizuko grants add google:114alice interact alice
arizuko grants add google:114alice admin eng/**
arizuko grants add discord:user/badguy '*' '**' --deny
arizuko grants add discord:837.../channel/1504... interact main/lab
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.
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 row rewriting, no migration — the channel-side id keeps working as before.
Every gate funnels through auth.Authorize(principal, action, scope, claims, params). The MCP server calls it when the agent invokes a tool; gated calls it when a message wants to enter a folder. One call, one answer.
Authorize(
principal: "folder:atlas/eng",
action: "mcp:send",
scope: "atlas/eng",
claims: {tier: "2", world: "atlas"},
params: {jid: "telegram:group/-1234"},
)
admin with a params predicate. Admin means full write at scope. Conditional admin is surprising. Use mcp:<tool> with params when you want a narrow capability.routes table answers "where does this message land." acl answers "may this principal act here." They share the principal namespace for room ids but never merge.acl_membership(sub, role:operator) edge at arizuko create. Adding more operators is another membership row — not a fresh wildcard grant each time.Canonical spec with the full schema and evaluation algorithm: specs/4/9-acl-unified.md. Rule-syntax reference: reference / grants. For where principals come from in the first place, see auth.