arizuko

arizukoconcepts › grants

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

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, action set, and tier defaults 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 funnels through one auth.Authorize(principal, action, scope, claims, params) call — the same for an agent and a human. A message wanting into a folder hits it once; a tool call leaving that folder hits it again. The MCP path and the REST path share the one function, so an agent and an operator see the same answer for the same request. The full call signature and the spawn-time tier composition live in reference / grants.

don't do this

where to go next

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.