GET /roles/:id/composites and DELETE…
GET /roles/:id/composites and DELETE /roles/:id/composites/:childId
Both require manage-tenant. The read answers the role's direct
children only, each in the same shape GET /roles/:id answers, ordered by
name — not what those children in turn include, which is what a subject
holding the role actually receives. It is not paged, the same as
GET /groups/:id/roles: the list is one role's own edges, edited one edge
at a time, not a tenant-wide collection. DELETE removes one edge and
answers 204, then 404 once there is no such edge; it is audited as
role.composite_remove, with the child's id in detail. A subject holding
the parent loses the child on its next token.
A role belonging to the tenant's built-in admin client keeps its
composites: removing one answers 409, naming the role and the client.
Taking manage-users out of tenant-admin, or view-users out of
manage-users, strips that capability from every administrator holding the
parent — the same lockout DELETE /roles/:id refuses for the role itself,
and read from the same builtin_admin column. POST /roles/:id/composites
refuses to add one there in the first place, so a capability role holds
exactly the edges provisioning gave it.
An edge between ordinary roles is removed whatever it nests, unless what
the child reaches includes an admin capability the caller does not hold
(a removal is judged by what it removes).
In composites-demo, created through POST /admin/tenants for it.
billing-admin nests billing-viewer and invoice-editor, each nested there
through POST /roles/:id/composites:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-586f-7d37-8635-d3d478ec27b0/composites
{"items":[{"id":"01a10bd5-5894-7574-967a-b9d73aca3cc2","name":"billing-viewer","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.651Z","admin_reach":[]},{"id":"01a10bd5-58b1-7f83-933d-edbb5b0d9d23","name":"invoice-editor","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.680Z","admin_reach":[]}]}
invoice-editor removed, the same removal repeated, then the read again:
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-586f-7d37-8635-d3d478ec27b0/composites/01a10bd5-58b1-7f83-933d-edbb5b0d9d23
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-586f-7d37-8635-d3d478ec27b0/composites/01a10bd5-58b1-7f83-933d-edbb5b0d9d23
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-586f-7d37-8635-d3d478ec27b0/composites
HTTP/1.1 204 No Content
x-request-id: 01a10bd5-5a21-7f9e-9358-a6ddba04a460
cache-control: no-store
etag: "a90d843a406386c7b64d76101ec4d961177ac1f3e87c073b79619a8193246bb5"
Date: Mon, 05 Oct 2026 11:31:34 GMT
Connection: keep-alive
Keep-Alive: timeout=72
HTTP/1.1 404 Not Found
x-request-id: 01a10bd5-5a48-7b0f-bec0-64eb7de7a546
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 214
Date: Mon, 05 Oct 2026 11:31:34 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Not Found","status":404,"detail":"no composite 01a10bd5-58b1-7f83-933d-edbb5b0d9d23 under role 01a10bd5-586f-7d37-8635-d3d478ec27b0","instance":"01a10bd5-5a48-7b0f-bec0-64eb7de7a546"}
{"items":[{"id":"01a10bd5-5894-7574-967a-b9d73aca3cc2","name":"billing-viewer","description":null,"client_id":null,"client_key":null,"default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.651Z","admin_reach":[]}]}
The guard. tenant-admin does nest manage-users — so a refusal is not a
404 for a missing edge — and every child's client_id is the tenant's
built-in admin client, odudu-admin by its client_key; removing that
edge, as ada, who holds tenant-admin itself:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-583e-7b19-a308-08d826338992/composites
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/composites-demo/roles/01a10bd5-583e-7b19-a308-08d826338992/composites/01a10bd5-5841-7874-aa25-572fc2a878e5
{"items":[{"id":"01a10bd5-5842-7490-b382-85389129dc00","name":"manage-clients","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["manage-clients"]},{"id":"01a10bd5-5844-71e3-bed0-0949a7ea38f5","name":"manage-keys","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["manage-keys"]},{"id":"01a10bd5-5846-7dc3-b7ac-1ec2d0a29eaf","name":"manage-sessions","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["manage-sessions"]},{"id":"01a10bd5-5843-76ad-acd9-600944633961","name":"manage-tenant","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["manage-tenant"]},{"id":"01a10bd5-5841-7874-aa25-572fc2a878e5","name":"manage-users","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["view-users","manage-users"]},{"id":"01a10bd5-5847-76e9-a6c2-5035c8b1c8e0","name":"view-audit","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["view-audit"]},{"id":"01a10bd5-5840-7456-ae39-b63101320c03","name":"view-users","description":null,"client_id":"01a10bd5-5836-749c-aa8a-4b597ca19dde","client_key":"odudu-admin","default_for_new_subjects":false,"created_at":"2026-10-05T11:31:33.533Z","admin_reach":["view-users"]}]}
HTTP/1.1 409 Conflict
x-request-id: 01a10bd5-5aa9-7257-9841-8d55f52532fc
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 283
Date: Mon, 05 Oct 2026 11:31:34 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Conflict","status":409,"detail":"tenant-admin is a capability of odudu-admin, this tenant's built-in admin client, and removing a composite from it would strip that from every administrator holding it","instance":"01a10bd5-5aa9-7257-9841-8d55f52532fc"}
The trail holds the removal that landed; the guarded one wrote its
refused row on tenant-admin, the role it was attempted on. Scoped to
the parent role's resource_id, since the action alone would also match
every other role's removals in this tenant:
With actor_name and actor_origin on each row, after the same three roles are
nested in a tenant of the same name and the same edge removed twice, the ids are
a fresh run's, not those above. The guarded removal there came later, and is
outside its scope either way:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:3082/admin/tenants/composites-demo/audit?action=role.composite_remove&resource_type=role&resource_id=01a0ee92-a1c5-70f4-b754-e2b9d40c22c7'; echo
{"items":[{"id":"01a0ee92-a3d6-72f5-8f9e-9b31dd3f3923","occurred_at":"2026-09-29T19:09:42.712Z","event_type":"admin_mutation","action":"role.composite_remove","outcome":"allowed","actor_tenant_id":"0199aa00-0000-7000-8000-000000000001","actor_subject_id":"01a0ee8a-bfb8-763c-ac22-0c8d97b0fada","actor_client_id":"01a0ee8a-bf72-77c9-a423-f61084924d9f","actor_name":null,"actor_origin":"system","resource_type":"role","resource_id":"01a0ee92-a1c5-70f4-b754-e2b9d40c22c7","request_id":"01a0ee92-a38e-7adc-ab75-39fad347ea6c","ip":"172.22.0.1","detail":{"child_role_id":"01a0ee92-a205-756a-8ca0-a2fbca9167e0"}}]}
Composites answer an ETag
GET …/composites answers an ETag over the list it returns, and both
composite writes answer the list's new one — POST beside its 204, and
DELETE beside its. If-Match is optional on both, as it has always been:
honoured when sent, and a stale one is refused with 412 before anything
changes. In etags-demo, on its billing-viewer and a second role
billing-reader (01a10bd4-90c5-717d-b188-344280ce610d), starting from no
composites:
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/etags-demo/roles/01a10bd4-90e5-79e8-92bd-8f9024ae8829/composites
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'If-Match: "0000"' -d '{"child_role_id":"01a10bd4-90c5-717d-b188-344280ce610d"}' \
http://localhost:3082/admin/tenants/etags-demo/roles/01a10bd4-90e5-79e8-92bd-8f9024ae8829/composites
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
-d '{"child_role_id":"01a10bd4-90c5-717d-b188-344280ce610d"}' \
http://localhost:3082/admin/tenants/etags-demo/roles/01a10bd4-90e5-79e8-92bd-8f9024ae8829/composites
curl -sS -D - -o /dev/null -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/etags-demo/roles/01a10bd4-90e5-79e8-92bd-8f9024ae8829/composites
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
http://localhost:3082/admin/tenants/etags-demo/roles/01a10bd4-90e5-79e8-92bd-8f9024ae8829/composites/01a10bd4-90c5-717d-b188-344280ce610d
Each one's status line, etag header and body, in that order. The last is
the empty list's ETag again, which no longer matches once the edge
exists:
HTTP/1.1 200 OK
etag: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"
{"items":[]}
HTTP/1.1 412 Precondition Failed
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"If-Match no longer matches","instance":"01a10bd4-9189-73aa-8144-d537d3d723dc"}
HTTP/1.1 204 No Content
etag: "d3a2f8e19b84fd992be23207e179afe9b80112fabeb7a204f9d2eec6ae178147"
HTTP/1.1 200 OK
etag: "d3a2f8e19b84fd992be23207e179afe9b80112fabeb7a204f9d2eec6ae178147"
HTTP/1.1 412 Precondition Failed
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"If-Match no longer matches","instance":"01a10bd4-91db-7ba1-a35e-922a461fed09"}