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 name only. A label stored on the user; surfaced in the JWT; grants nothing.

A statement

json
{
  "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.

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