GET /roles, POST /roles, GET /roles/:id, PATCH /roles/:id…
GET /roles, POST /roles, GET /roles/:id, PATCH /roles/:id and DELETE /roles/:id
All five require manage-tenant; the list, GET /roles, is also readable with
view-users (below). A role is either a tenant role (client_id is null) or
scoped to one client, in which case a token's roles claim carries it qualified
by that client's own name rather than plain. PATCH amends only description —
at most 1000 characters, a CHECK holds it there, and null clears it; every
other field, name, client_id and default_for_new_subjects included, is
refused with a reason — the last naming PUT /roles/:id/default, which is where
it is set — the same shape PATCH /subjects/:id refuses id, type and
username. A duplicate name — per tenant for a tenant role, per client for a
client-scoped one — answers 409, and a client_id naming no client answers
400. DELETE cascades: every role_composites edge, subject_roles
assignment and client_scope_roles mapping naming the role goes with it.
That cascade is why a role belonging to the tenant's built-in admin
client cannot be deleted at all — 409, naming the role and the client.
The capability roles live on that client, and subject_roles_role_fk would
strip a deleted one from every administrator holding it: a caller with
manage-tenant and nothing else could delete tenant-admin, or
manage-tenant itself, and lock the tenant out of its own admin API. It is
the same guard PATCH /clients/{id} puts on that client's own lockout
fields, on the roles the client owns. The check reads the builtin_admin
column, so renaming the client in the database does not evade it. Any
other role is deleted unless it reaches an admin capability the caller does
not hold (a removal is judged by what it removes).
A create, in its showcase tenant:
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "billing-viewer", "description": "read-only access to invoices"}' \
http://localhost:3082/admin/tenants/showcase/roles
{"id":"01a10bd4-d376-748b-a43b-f40458d88100","name":"billing-viewer","description":"read-only access to invoices","client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:30:59.574Z","admin_reach":[]}
Search and filters follow GET /subjects: ?name= is a prefix, matched
case-insensitively as a range over the stored name_search column
(0075_list_indexes_roles_groups_scopes.sql), and a searched listing is ordered
by that folded name, then by id. ?client= is the one exact filter,
ANDed with it: tenant for the tenant roles alone, or a client's id for the
roles scoped to that client. A cursor is bound to every filter it was minted
under, and any other parameter is refused with 400 naming it. In a tenant
roles-demo created there for it, which held no roles of its own but the
built-in admin client's until these four were created, the last scoped to a
public client demo-spa (01a10bd4-d668-7670-afe0-844b224421c5) made there
with seed client first:
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "billing-viewer"}' \
http://localhost:3082/admin/tenants/roles-demo/roles
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Billing-Admin"}' \
http://localhost:3082/admin/tenants/roles-demo/roles
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "support"}' \
http://localhost:3082/admin/tenants/roles-demo/roles
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "billing-spa", "client_id": "01a10bd4-d668-7670-afe0-844b224421c5"}' \
http://localhost:3082/admin/tenants/roles-demo/roles
{"id":"01a10bd4-d6d8-73f1-b60b-11d57d6bb686","name":"billing-viewer","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.438Z","admin_reach":[]}
{"id":"01a10bd4-d6f3-7ca9-ab57-830ede1e570b","name":"Billing-Admin","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.467Z","admin_reach":[]}
{"id":"01a10bd4-d70c-70c1-9fc7-09b29cce97db","name":"support","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.492Z","admin_reach":[]}
{"id":"01a10bd4-d727-7bbc-b05f-a238bd2d00d2","name":"billing-spa","description":null,"client_id":"01a10bd4-d668-7670-afe0-844b224421c5","client_key":"demo-spa","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.518Z","admin_reach":[]}
BILLING finds all three billing roles in folded order, tenant and
client alike; ?client=tenant keeps the two tenant roles, and the client's
id keeps its one:
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?name=BILLING"
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?name=billing&client=tenant"
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?client=01a10bd4-d668-7670-afe0-844b224421c5"
{"items":[{"id":"01a10bd4-d6f3-7ca9-ab57-830ede1e570b","name":"Billing-Admin","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.467Z","admin_reach":[]},{"id":"01a10bd4-d727-7bbc-b05f-a238bd2d00d2","name":"billing-spa","description":null,"client_id":"01a10bd4-d668-7670-afe0-844b224421c5","client_key":"demo-spa","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.518Z","admin_reach":[]},{"id":"01a10bd4-d6d8-73f1-b60b-11d57d6bb686","name":"billing-viewer","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.438Z","admin_reach":[]}]}
{"items":[{"id":"01a10bd4-d6f3-7ca9-ab57-830ede1e570b","name":"Billing-Admin","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.467Z","admin_reach":[]},{"id":"01a10bd4-d6d8-73f1-b60b-11d57d6bb686","name":"billing-viewer","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.438Z","admin_reach":[]}]}
{"items":[{"id":"01a10bd4-d727-7bbc-b05f-a238bd2d00d2","name":"billing-spa","description":null,"client_id":"01a10bd4-d668-7670-afe0-844b224421c5","client_key":"demo-spa","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.518Z","admin_reach":[]}]}
One at a time, the Link header carries the search forward:
curl -sS -D - \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?name=billing&limit=1"
HTTP/1.1 200 OK
x-request-id: 01a10bd4-d783-7e2e-8d08-0e26bdc54a64
cache-control: no-store
link: </admin/tenants/roles-demo/roles?limit=1&name=billing&cursor=eyJhZnRlciI6IjAxYTEwYmQ0LWQ2ZjMtN2NhOS1hYjU3LTgzMGVkZTFlNTcwYiIsInNvcnQiOiJiaWxsaW5nLWFkbWluIiwiY29sbGVjdGlvbiI6InJvbGVzIiwidGVuYW50SWQiOiIwMWExMGJkNC1kNDg1LTc2NDItYmQxYS04YzY1MzBmYTk5NTgiLCJmaWx0ZXJzIjoiM0w4aEJkRGMyWVZnelVseHhtSlVYZGluWHJDMG10NHZMTXF1TlNTdEdOdyJ9.v_fTLZ_BVcFLmG1cpmnr-lJijG8KwzLcjOTkCAkdsM8>; rel="next"
content-type: application/json; charset=utf-8
content-length: 542
Date: Mon, 05 Oct 2026 11:31:00 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"items":[{"id":"01a10bd4-d6f3-7ca9-ab57-830ede1e570b","name":"Billing-Admin","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.467Z","admin_reach":[]}],"next":"eyJhZnRlciI6IjAxYTEwYmQ0LWQ2ZjMtN2NhOS1hYjU3LTgzMGVkZTFlNTcwYiIsInNvcnQiOiJiaWxsaW5nLWFkbWluIiwiY29sbGVjdGlvbiI6InJvbGVzIiwidGVuYW50SWQiOiIwMWExMGJkNC1kNDg1LTc2NDItYmQxYS04YzY1MzBmYTk5NTgiLCJmaWx0ZXJzIjoiM0w4aEJkRGMyWVZnelVseHhtSlVYZGluWHJDMG10NHZMTXF1TlNTdEdOdyJ9.v_fTLZ_BVcFLmG1cpmnr-lJijG8KwzLcjOTkCAkdsM8"}
Following that link, then replaying its cursor with ?client=tenant added:
CURSOR='eyJhZnRlciI6IjAxYTEwYmQ0LWQ2ZjMtN2NhOS1hYjU3LTgzMGVkZTFlNTcwYiIsInNvcnQiOiJiaWxsaW5nLWFkbWluIiwiY29sbGVjdGlvbiI6InJvbGVzIiwidGVuYW50SWQiOiIwMWExMGJkNC1kNDg1LTc2NDItYmQxYS04YzY1MzBmYTk5NTgiLCJmaWx0ZXJzIjoiM0w4aEJkRGMyWVZnelVseHhtSlVYZGluWHJDMG10NHZMTXF1TlNTdEdOdyJ9.v_fTLZ_BVcFLmG1cpmnr-lJijG8KwzLcjOTkCAkdsM8'
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?limit=1&name=billing&cursor=$CURSOR"
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?limit=1&name=billing&client=tenant&cursor=$CURSOR"
{"items":[{"id":"01a10bd4-d727-7bbc-b05f-a238bd2d00d2","name":"billing-spa","description":null,"client_id":"01a10bd4-d668-7670-afe0-844b224421c5","client_key":"demo-spa","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:00.518Z","admin_reach":[]}],"next":"eyJhZnRlciI6IjAxYTEwYmQ0LWQ3MjctN2JiYy1iMDVmLWEyMzhiZDJkMDBkMiIsInNvcnQiOiJiaWxsaW5nLXNwYSIsImNvbGxlY3Rpb24iOiJyb2xlcyIsInRlbmFudElkIjoiMDFhMTBiZDQtZDQ4NS03NjQyLWJkMWEtOGM2NTMwZmE5OTU4IiwiZmlsdGVycyI6IjNMOGhCZERjMllWZ3pVbHh4bUpVWGRpblhyQzBtdDR2TE1xdU5TU3RHTncifQ.ijOveSgDATnglqazyZg9Bg3YQ16FbkWdHCndAD36v6E"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"cursor is invalid or expired","errors":[{"path":"cursor","message":"is invalid or expired"}],"instance":"01a10bd4-d7b2-7276-af2a-0852ca741785"}
A client that is neither tenant nor an id, an unknown parameter, and
the stored name_search column named in a create body and in an amendment
— the first three refused by the generated schema, the last by the
amendment allowlist, which knows only the fields a role's wire shape
carries:
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?client=spa"
curl -sS \
-H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/roles-demo/roles?search=billing"
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "auditor", "name_search": "x"}' \
http://localhost:3082/admin/tenants/roles-demo/roles
curl -sS -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name_search": "x"}' \
http://localhost:3082/admin/tenants/roles-demo/roles/01a10bd4-d70c-70c1-9fc7-09b29cce97db
{"type":"about:blank","title":"Error","status":400,"detail":"querystring/client must be equal to constant, querystring/client must match pattern \"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$\", querystring/client must match a schema in anyOf","errors":[{"path":"client","message":"must be equal to constant"},{"path":"client","message":"must match pattern \"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$\""},{"path":"client","message":"must match a schema in anyOf"}],"instance":"01a10bd4-d7c8-74da-80b9-cf4ba4610516"}
{"type":"about:blank","title":"Error","status":400,"detail":"querystring must NOT have additional properties: search","errors":[{"path":"search","message":"must NOT have additional properties"}],"instance":"01a10bd4-d7d3-7526-973e-335c0fc0a1d3"}
{"type":"about:blank","title":"Error","status":400,"detail":"body must NOT have additional properties: name_search","errors":[{"path":"name_search","message":"must NOT have additional properties"}],"instance":"01a10bd4-d7e0-7c73-9077-69671a60f01e"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"name_search: name_search is not a role field","errors":[{"path":"name_search","message":"name_search is not a role field"}],"instance":"01a10bd4-d7ec-77d2-8e3f-858092080816"}
Every 409 that guards the built-in admin client's roles writes a refused row
with the refusal's text under detail.reason, the way a 403 does (ADR 0037's
amendment of 2026-09-28): deleting one of those roles here, and adding or
removing a composite of one below. On ceiling-removal, from a removal is
judged by what it
removes,
as the system administrator, whose subject the row names:
RUN_START=$(date -u +%FT%T.000Z)
T=http://localhost:3000/admin/tenants/ceiling-removal
curl -sS -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
"$T/roles/01a0e59a-b2d6-7148-a179-a3cff021a673"
echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"$T/audit?resource_type=role&resource_id=01a0e59a-b2d6-7148-a179-a3cff021a673&from=$RUN_START" \
| jq -c '.items[] | {action, outcome, actor_subject_id, detail}'
{"type":"about:blank","title":"Conflict","status":409,"detail":"tenant-admin is a capability of odudu-admin, this tenant's built-in admin client, and deleting it would strip it from every administrator holding it","instance":"01a0e5ae-3e45-778c-a2cd-cf0bc8367b4d"}
{"action":"role.delete","outcome":"refused","actor_subject_id":"01a0e0a7-0ead-703a-ab34-22bcf5167d46","detail":{"reason":"tenant-admin is a capability of odudu-admin, this tenant's built-in admin client, and deleting it would strip it from every administrator holding it"}}
The owning client, by name
A role names its owning client twice: client_id, the client's row id, and
client_key, that client's own client_id — the name a person tells it apart
by, since a tenant role and a client's may share a name. Both are null for a
tenant role. The same pair rides on every role list: a role's composites, and
the items a subject's, a group's and a scope's roles answer beside id and
name. client_key is refused by PATCH like client_id. In etags-demo,
whose public client etags-app was made with seed client and whose subject
ines with POST /subjects: a role reader created on the client, the
tenant's roles filtered to it, and ines given reader and the tenant role
billing-viewer from above under the ETag of her empty list:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"name":"reader","client_id":"01a10bd4-903e-716d-b038-29c8d3ee53ec"}' \
http://localhost:3082/admin/tenants/etags-demo/roles
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:3082/admin/tenants/etags-demo/roles?client=01a10bd4-903e-716d-b038-29c8d3ee53ec'
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
-d '{"role_ids":["01a10bd4-9120-7c99-a4b7-9b35a2219dc8","01a10bd4-90e5-79e8-92bd-8f9024ae8829"]}' \
http://localhost:3082/admin/tenants/etags-demo/subjects/01a10bd4-90a5-7793-971d-100cba5bc688/roles
{"id":"01a10bd4-9120-7c99-a4b7-9b35a2219dc8","name":"reader","description":null,"client_id":"01a10bd4-903e-716d-b038-29c8d3ee53ec","client_key":"etags-app","default_for_new_subjects":false,"created_at":"2026-10-05T11:30:42.591Z","admin_reach":[]}
{"items":[{"id":"01a10bd4-9120-7c99-a4b7-9b35a2219dc8","name":"reader","description":null,"client_id":"01a10bd4-903e-716d-b038-29c8d3ee53ec","client_key":"etags-app","default_for_new_subjects":false,"created_at":"2026-10-05T11:30:42.591Z","admin_reach":[]}]}
{"items":[{"id":"01a10bd4-90e5-79e8-92bd-8f9024ae8829","name":"billing-viewer","client_id":null,"client_key":null},{"id":"01a10bd4-9120-7c99-a4b7-9b35a2219dc8","name":"reader","client_id":"01a10bd4-903e-716d-b038-29c8d3ee53ec","client_key":"etags-app"}]}
What a role reaches: admin_reach
Every role answer — the list, a role's own read and every write that answers it,
and a role's composites — carries admin_reach: the admin capabilities the role
hands out, expanded through role_composites however deep it nests them, by
capabilitiesReachableFrom, the same computation every capability ceiling here
judges a write by, and read for a whole page in one query. It names capabilities
in the order whoami does, never tenant-admin itself, whose holding is the
capabilities it nests. It is derived on each read and never stored: the ETag
is taken over the stored fields alone, so a composite added somewhere below a
role does not stale a write to its description, and PATCH refuses it with a
reason.
A tenant role audit-lead nesting odudu-admin:view-audit, and audit-bundle
nesting audit-lead, each made there with POST /roles and POST /roles/:id/composites for it, $P the tenant's
http://localhost:3082/admin/tenants/reach-demo. audit-bundle names no
capability itself and reaches one two levels down — what its default is then
refused for:
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" $P/roles/01a10bd6-2cbc-706c-8d46-891aad99d5bb
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" $P/roles/01a10bd6-2cbc-706c-8d46-891aad99d5bb/composites; echo
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"default":true}' $P/roles/01a10bd6-2cbc-706c-8d46-891aad99d5bb/default; echo
Each response's status line and etag where it has one, then its body:
HTTP/1.1 200 OK
etag: "7ecd0c326f94377e60c254866472c0d0b033d159f60df4629dc6ffd41ef7ecde"
{"id":"01a10bd6-2cbc-706c-8d46-891aad99d5bb","name":"audit-bundle","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:32:27.964Z","admin_reach":["view-audit"]}
{"items":[{"id":"01a10bd6-2ca5-763e-b394-ce919be96bc2","name":"audit-lead","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:32:27.940Z","admin_reach":["view-audit"]}]}
{"type":"about:blank","title":"Forbidden","status":403,"detail":"a role handed to every new subject may reach no admin capability, and this one would reach: view-audit","instance":"01a10bd6-2e73-7649-a48f-c17b297dca44"}
The list a user manager picks from
GET /roles alone is also readable with view-users, and so with
manage-users, which composes it: a user manager assigns roles to a subject and
needs the list to pick from. GET /groups is widened the same way. Nothing else
under either path is — a role's own read, its composites and both counts still
require manage-tenant — and what a user manager may assign is still held to
its own capabilities by the ceiling (ADR 0040). A caller refused one of these
lists gets the usual 403, and its capability.refused row names view-users
under detail.also_admits beside the manage-tenant it names as capability,
since either would have admitted it. In a tenant admins-demo created there for
it, as hana, a subject seeded there with seed user, granted
odudu-admin:view-users alone with seed grant-role and signed in through the
tenant's own admin client, first whoami, then two lists and a count:
curl -sS -H "Authorization: Bearer $HANA_TOKEN" http://localhost:3082/admin/tenants/admins-demo/whoami
curl -sS -H "Authorization: Bearer $HANA_TOKEN" 'http://localhost:3082/admin/tenants/admins-demo/roles?name=manage&limit=2'
curl -sS -H "Authorization: Bearer $HANA_TOKEN" http://localhost:3082/admin/tenants/admins-demo/groups
curl -sS -H "Authorization: Bearer $HANA_TOKEN" http://localhost:3082/admin/tenants/admins-demo/roles/count
{"subjectId":"01a10bd4-f479-7550-825d-3c8550dce115","issuerTenantId":"01a10bd4-f16d-7850-b858-651e575d55f3","capabilities":["view-users"],"crossTenant":false}
{"items":[{"id":"01a10bd4-f17f-7cf9-bb84-4ae17ae3e1a0","name":"manage-clients","description":null,"client_id":"01a10bd4-f176-7236-8562-d17d73d5ca00","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:07.245Z","admin_reach":["manage-clients"]},{"id":"01a10bd4-f181-794a-a3be-a2e7aa703a1a","name":"manage-keys","description":null,"client_id":"01a10bd4-f176-7236-8562-d17d73d5ca00","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:07.245Z","admin_reach":["manage-keys"]}],"next":"eyJhZnRlciI6IjAxYTEwYmQ0LWYxODEtNzk0YS1hM2JlLWEyZTdhYTcwM2ExYSIsInNvcnQiOiJtYW5hZ2Uta2V5cyIsImNvbGxlY3Rpb24iOiJyb2xlcyIsInRlbmFudElkIjoiMDFhMTBiZDQtZjE2ZC03ODUwLWI4NTgtNjUxZTU3NWQ1NWYzIiwiZmlsdGVycyI6IlJmN1o1Njk1VkFrc05UeHBDNUhuYnZuN1dYME5hV0d0MGdmVk9OblNpTmcifQ.hbcmmrbCixpSou6fDWJKHn8iiap4gM_BtpdZl_wJA_M"}
{"items":[]}
{"type":"about:blank","title":"Forbidden","status":403,"instance":"01a10bd4-f7ef-7b52-b934-b29dfb03b749"}
Then kai, created there the same way holding manage-clients alone,
refused the list, and the row that refusal wrote:
The same again, now that each row answers actor_name and actor_origin; the
ids are a fresh run's, not those above:
curl -sS -H "Authorization: Bearer $KAI_TOKEN" http://localhost:3082/admin/tenants/admins-demo/roles; echo
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:3082/admin/tenants/admins-demo/audit?action=capability.refused&limit=1'; echo
{"type":"about:blank","title":"Forbidden","status":403,"instance":"01a0ee91-6bf2-708d-839a-f58481e120e8"}
{"items":[{"id":"01a0ee91-6c24-7df3-aff4-f3b3cb39e5c3","occurred_at":"2026-09-29T19:08:22.941Z","event_type":"admin_access","action":"capability.refused","outcome":"refused","actor_tenant_id":"01a0ee91-5500-7587-bec8-ccd5ea0cfb56","actor_subject_id":"01a0ee91-621f-7416-9ebf-80d7244ddda7","actor_client_id":"01a0ee91-551f-76e0-b7f0-ff2a01e786b4","actor_name":"kai","actor_origin":"tenant","resource_type":null,"resource_id":null,"request_id":"01a0ee91-6bf2-708d-839a-f58481e120e8","ip":"172.22.0.1","detail":{"reason":"missing_capability","capability":"manage-tenant","also_admits":["view-users"]}}]}