Permissions for your app users are enforced only through ACL policies. The app authorizer evaluates them on every request. There is no special admin API — ACL policies, groups, roles, and the links between them are ordinary tenant system entities you manage with the same CRUDL endpoints you use for any entity.
A role is a label, not a permission
A tenant userrole (and the role claim in the JWT) grants nothing. It's a free-form label for your own in-app logic. To change what a user can access, attach an aclpolicy — directly or through a group.
The IAM system entities
- aclpolicy —
name+statements[]. The actual grant. - group —
name+acl_policies[]. A bundle of policies you attach to many users at once. - useraclpolicy — a link:
user_id+acl_policy_id. Attaches one policy directly to one user. - usergroup — a link:
user_id+group_id. Puts a user in a group (they inherit its policies). - userrole — a
nameonly. A label stored on the user; surfaced in the JWT; grants nothing.
A statement
{
"Sid": "EditOrders",
"Effect": "Allow", // Allow | Deny (an explicit Deny always wins)
"Action": ["createorder", "updateorder"], // "<verb><entity>" or a wildcard
"Resource": ["*"], // "*" or a path glob like "/content/*"
"Condition": {} // not applied: a non-empty Condition fails closed (see below)
} Actions map from the route: GET /list/* → list, GET /item/* → get, POST → create, PUT → update, DELETE → delete. Forms include createorder (verb + entity), a verb wildcard like get*, or * for everything. Evaluation gathers all of a user's statements — direct and via groups — and applies: any matching Deny denies; else any matching Allow allows; else implicit deny.
Approving a paused crew run is its own action, createdecision (POST /app/{orgcode}/agents/decision). The Admin and Member policies allow it (they allow every create…); the Viewer policy does not, and new joiners start as Viewers. Each organization decides who may approve: give a role createdecision (or create*) to allow it, or add createdecision to a policy's Deny list to take it away.
Conditions are not applied
The answer for a route is cached for 300 seconds by token, method and path, so a Condition that reads anything else cannot be honored. An Allow with a Condition grants nothing and a Deny with a Condition always applies. Do not rely on a Condition for access that must work on every request.
Build one — worked example
Give a user create/update/delete on order, plus read on everything.
# 1. The policy
POST /app/{orgcode}/create/aclpolicy
{ "name": "order-editor",
"statements": [
{ "Sid": "Read", "Effect": "Allow", "Action": ["get*","list*","count*"], "Resource": ["*"] },
{ "Sid": "Orders", "Effect": "Allow",
"Action": ["createorder","updateorder","deleteorder"], "Resource": ["*"] }
] }
# -> { "id": "<aclId>" }
# 2a. Attach directly to a user ...
POST /app/{orgcode}/create/useraclpolicy
{ "user_id": "<userId>", "acl_policy_id": "<aclId>" }
# 2b. ... or via a reusable group
POST /app/{orgcode}/create/group
{ "name": "editors", "acl_policies": ["<aclId>"] } # -> { "id": "<groupId>" }
POST /app/{orgcode}/create/usergroup
{ "user_id": "<userId>", "group_id": "<groupId>" } Changes take effect on the next token
The authorizer caches a user's resolved ACL per token. After changing policies, have the user re-login (or wait out the cache) to pick them up. Membership is the exception: whether the user is still an approved member of the workspace is checked on every run, so removing someone takes effect within the 300-second route cache.
Send only the ids on links
For useraclpolicy and usergroup, send just the id fields — the platform populates the lookup keys the authorizer needs. Never set those by hand.
Defaults that ship with a workspace
When a workspace is created the founder gets an adminacl policy (* on *), the admin role label, and a default-admins group. New members get whatever the workspace's default_member_access names — by default a read-and-self-service vieweracl (it cannot upload content, so a viewer never owns content they could not delete). Every approved member can also load the Playground's entity, Logic Block and agent lists without a policy granting it; only a Deny on getcatalog refuses it. Change that default to give joiners a different starting policy: when you build with an AI assistant over MCP, it creates the least-privileged policy your business needs (a shopper for a store, a student for a course) and sets it as the default with set_default_member_access, then tells you what it chose. Only an organization or workspace admin gives anyone more than that default.