arizukoconcepts › grants

grants

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

The whole system is built on four nouns. Get these right and the rest is bookkeeping.

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

principal shapes

KindExampleMeaning
OAuth subgoogle:114aliceA canonical human, minted by auth (see auth).
Folder agentfolder:atlas/engThe agent container running at this folder.
Platform identitydiscord:user/811…A user known on a channel, not yet OAuth-claimed.
Room identitydiscord:837…/1504…A channel room itself — lets everyone in the room share a grant.
Rolerole:operatorA named bag of members. Filled by acl_membership.
Wildcards**, google:*, folder:**Globs split on : and /.

four rows, four real patterns

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

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 row rewriting, no migration — the channel-side id keeps working as before.

where the check runs

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"},
)

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.