Goal of this module
Create an editor policy and a viewer policy, attach them to two users, and prove the difference by watching a viewer's write get denied at the gateway.
Permissions come only from ACL
Every app request is evaluated against ACL policies. There's no special admin API — policies, groups, roles, and the links between them are ordinary tenant system entities you manage with the same CRUD endpoints you already used for Project and Task.
A role is a label, not a permission
A tenant userrole (and the JWT role claim) grants nothing — it's a free-form label for your own UI logic. To change what a user can do, attach an aclpolicy, directly or through a group.
The IAM system entities
- aclpolicy —
name+statements[]. The actual grant. - group —
name+acl_policies[]. A bundle attached to many users at once. - useraclpolicy — a link:
user_id+acl_policy_id. One policy → one user. - usergroup — a link:
user_id+group_id. Puts a user in a group. - userrole — a
nameonly. A label; grants nothing.
A statement
Actions map from the route: POST → create, PUT → update, DELETE → delete, GET /list/* → list, GET /item/* → get. Forms include createtask (verb + entity), a wildcard like get*, or * for everything. Evaluation gathers all of a user's statements — direct and via groups — and applies: any matching Deny wins; else any matching Allow allows; else implicit deny.
{
"Sid": "EditTasks",
"Effect": "Allow",
"Action": ["createtask", "updatetask", "deletetask"],
"Resource": ["*"]
} 1 · The editor policy
POST /app/{orgCode}/create/aclpolicy
{ "name": "task-editor",
"statements": [
{ "Sid": "Read", "Effect": "Allow", "Action": ["get*","list*","count*"], "Resource": ["*"] },
{ "Sid": "Write", "Effect": "Allow",
"Action": ["createtask","updatetask","deletetask"], "Resource": ["*"] }
] }
# -> { "id": "<editorAclId>" } 2 · The viewer policy
POST /app/{orgCode}/create/aclpolicy
{ "name": "task-viewer",
"statements": [
{ "Sid": "ReadOnly", "Effect": "Allow", "Action": ["get*","list*","count*"], "Resource": ["*"] }
] }
# -> { "id": "<viewerAclId>" } 3 · Attach to users
Attach a policy directly to a user, or bundle it in a group and add the user to the group. Either works; groups scale better when many users share access.
# direct attach — make Bob an editor
POST /app/{orgCode}/create/useraclpolicy
{ "user_id": "<bobId>", "acl_policy_id": "<editorAclId>" }
# via a reusable group — make Vera a viewer
POST /app/{orgCode}/create/group
{ "name": "viewers", "acl_policies": ["<viewerAclId>"] } # -> { "id": "<groupId>" }
POST /app/{orgCode}/create/usergroup
{ "user_id": "<veraId>", "group_id": "<groupId>" } 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.
4 · Watch the deny
POST /app/{orgCode}/create/task — succeeds.GET /app/{orgCode}/list/task — succeeds (read is allowed).POST /app/{orgCode}/create/task — denied by the authorizer. No matching Allow, implicit deny.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.
Try it
Create both policies, make one user an editor and one a viewer, then trigger the viewer's denied write. Seeing that denial — without writing a single permission check yourself — is the whole point.