Since v0.38.0 every authorization question — "may principal P perform action A on scope S?" — is answered by one auth.Authorize call against two tables: acl (permission rows) and acl_membership (role + identity indirection). This page is the syntax + semantics reference for both the rule grammar (still used for MCP params) and the tier defaults derived in code at spawn time.
See concepts › authorization for the mental model, GRANTS.md for the maintainer walkthrough, and specs/6/9-acl-unified.md for the migration story. Code: auth/authorize.go, auth/policy.go (structural / hierarchy checks), grants/grants.go (rule engine, tier defaults).
Tier is derived from folder depth, not stored. From auth/identity.go:16:
Tier = min(strings.Count(folder, "/"), 3)
World = first path segment
| tier | folder shape | example | role |
|---|---|---|---|
| 0 | 0 slashes | root, main | instance root — unrestricted, CLI-only surface |
| 1 | 1 slash | atlas/support | "world" group — basic send + per-platform actions for every platform routed under this world + management tools + read-write share mount |
| 2 | 2 slashes | atlas/support/oncall | depth-2 group — basic send + per-platform actions narrowed to routes under this folder's subtree + read-only share mount |
| 3 | 3+ slashes | atlas/support/oncall/launch-q3 | deep group — reply-only (reply, send_file, like, edit) |
acl — (principal, action, scope, effect, params, predicate, granted_by, granted_at). The whole permission graph. Created by migration 0052; populated by migration 0053 from the legacy user_groups + grant_rules tables.acl_membership — (child, parent, added_by, added_at) edges. Walked transitively at authorization time to expand a sub into every role it carries and every canonical identity it has been linked to.Adjacent tables still in play but orthogonal: groups (folder hierarchy — what exists), routes (which JIDs land in which folder — what reaches the agent).
| principal kind | example | notes |
|---|---|---|
| OAuth sub | google:114019… | Canonical human, minted by auth/oauth.go. |
| Folder agent | folder:atlas/eng | The container spawned at this folder. |
| Platform identity | telegram:user/123456 | Channel-side identity, not yet OAuth-claimed. |
| Room JID | discord:837…/1504… | Channel / room itself — carries route grants. |
| Role | role:operator | Indirection. Members via acl_membership. |
| Wildcards | **, google:*, folder:** | Globs anchor on : and /. |
Actions: interact, admin, mcp:<tool>, or *. The structural / hierarchy gate on outbound verbs, route management, task ownership, etc. lives separately in auth.AuthorizeStructural (formerly auth.Authorize — renamed in v0.38.0).
Scope is the folder path globbed segment-wise — * doesn't cross /, ** matches zero or more segments. Same matcher as before, now exposed as auth.MatchGroups (pure function, no DB query).
The grant-rule grammar still parses params predicates on mcp:<tool> entries. Strings parsed by grants.ParseRule:
rule := ["!"] action ["(" params ")"]
params := param ("," param)*
param := ["!"] name ["=" glob]
! prefix on the action = deny rule. Last match wins (grants.CheckAction).action is the MCP tool name. * alone matches every action.param=glob — param's value must match. * stops at , or ).!param — deny when this param is present at all.Action == "", matching nothing — a typo cannot silently widen access.* # tier-0 instance root
send # any send call
send(jid=telegram:group/*) # send only to telegram groups
!post # forbid posting
send_file(jid=*) # file send to any allowed target
share_mount(readonly=false) # writable workspace mount
share_mount(readonly=true) # read-only workspace mount
From grants.DeriveRules. Computed at every spawn, used as the fall-back for mcp:* actions with no matching acl row.
| tier | default rules |
|---|---|
| 0 | ["*"] — unrestricted |
| 1 | basicSendActions + platformRules(RouteSourceJIDsInWorld(world)) + tier1FixedActions + share_mount(readonly=false) |
| 2 | basicSendActions + platformRules(RouteSourceJIDsInWorld(folder)) + share_mount(readonly=true) — narrower platform scope: only JIDs routed to this folder or its descendants |
| 3+ | ["reply", "send_file", "like", "edit"] |
send, send_file, reply
platformRules)For each platform appearing in the route table's scope, each verb is registered as <verb>(jid=<platform>:*).
send, send_file, reply,
forward,
post, quote, repost, like, dislike, delete, edit
schedule_task, register_group, escalate_group, delegate_group,
get_routes, set_routes, add_route, delete_route,
list_tasks, pause_task, resume_task, cancel_task
AuthorizeStructural)A second authorization layer in auth/policy.go — renamed from auth.Authorize to auth.AuthorizeStructural in v0.38.0 — enforces hierarchy and tier invariants the rule list can't express. Even if the agent's rules permit the action, this gate can reject.
| tool | tier rule | extra constraint |
|---|---|---|
list_tasks | any | none |
send, send_file, reply, post, like, dislike, delete, edit, forward, quote, repost | any | target JID's owning folder must equal id.Folder or be a descendant |
reset_session | any | target folder must equal own or descendant |
inject_message | tier ≤ 1 | — |
register_group | tier < 2 | tier 0 ⇒ target must contain "/" (worlds are CLI-only); tier 1 ⇒ child must be direct child of own folder |
escalate_group | tier ≥ 2 | — |
delegate_group | tier ≠ 3 | target must be strictly inside id.Folder |
get_routes, set_routes, add_route, delete_route | tier < 2 | tier 1 ⇒ route target inside own folder |
schedule_task, pause_task, resume_task, cancel_task | tier ≠ 3 | tier 2 ⇒ task owner == own folder; tier 1 ⇒ task owner in same world |
list_acl | tier ≤ 2 | tier 2 ⇒ own subtree only; tier > 2 ⇒ unauthorized |
invite_create | tier < 2 | tier 1 ⇒ target in same world |
Pre-v0.38.0 the operator was a literal user_groups(sub, '**') row. Now it's a role: acl_membership(sub, role:operator), plus one wildcard acl row granting role:operator the action of choice on scope **. arizuko grant <sub> ** writes the membership edge; the role's permissions are seeded once by arizuko create.
# Make alice an operator.
arizuko grant google:114alice@example.com '**'
# Equivalent SQL:
INSERT INTO acl_membership (child, parent, added_at) VALUES
('google:114alice@example.com', 'role:operator', now());
Adding more operators is one membership row each — the permissions row is shared.
Canonical site: Gateway.runAgentWithOpts in gateway/gateway.go.
1. Identity = auth.Resolve(F)
→ {Folder: F, Tier: min(depth, 3), World: firstSeg}
2. Rules = grants.DeriveRules(store, F, id.Tier, id.World)
→ tier-default list including routed-platform rules
3. Env = base ∪ FolderSecrets(F) (deepest-wins hierarchy)
∪ UserSecrets(UserSubByJID(J)) (single-user chats only)
Per-folder MCP overrides live in acl as (principal=folder:<F>, action=mcp:<tool>, scope=<F>, params, effect) rows. The in-container MCP server consults them at registration (grants.MatchingRules — tools with no match are dropped from tools/list) and at invocation (grants.CheckAction — synthesized calls still get refused).
acl answers who. Tier answers what default tools the agent sees. A user with role:operator on a tier-3 folder still gets only deep-group rules.effect='deny' row rejects, even if matching allow rows exist. Prefer deleting allow rows or membership edges over inserting denies; denies are for true exceptions (banning one user from an otherwise-open channel).escalate_group / delegate_group, each gated by AuthorizeStructural.chats.is_group=0. Values are AES-256-GCM encrypted at rest.GRANTS.md — maintainer referenceauth/authorize.go — Authorize (the unified entry point)auth/policy.go — AuthorizeStructural (hierarchy / tier)auth/acl.go — MatchGroups (pure scope matcher)grants/grants.go — ParseRule, CheckAction, MatchingRules, DeriveRulessend(jid=…) look like