Skip to main content

GET /subjects/:id/groups and PUT /subjects/:id/groups

The read requires view-users; the write requires manage-users. Both answer the groups the subject directly belongs to — the same set the groups claim of its next token carries (ADR 0022) — and the write replaces that set wholesale: a group left out is one the subject leaves. An unknown group id, or one from another tenant, answers 400 naming it.

Joining a group is granting its roles, and its ancestors' too, since role resolution walks up the tree. So the write carries the same capability ceiling PUT /subjects/:id/roles does: every role mapped to a requested group or any of its ancestors, expanded through role_composites, is compared with the caller's own capabilities, and a set reaching past them is refused with 403, leaves the membership unchanged, and writes a refused row to the audit trail. The ceiling is measured over the whole resulting set, as it is for roles — a caller cannot resubmit a membership it could not itself have granted. And the target ceiling (POST /subjects/:id/password) comes first: a subject holding a capability the caller does not cannot have any membership changed by that caller, removals included.

If-Match is mandatory here, not optional, for the reason it is on PUT /subjects/:id/roles: absent, 428; stale, 412. The tag is over the list exactly as the GET answers it, so reparenting a member group, which rewrites its path, changes the tag even though the membership did not; an empty membership answers the same "eef46741…" every empty list here does.

In a tenant groups-demo created through POST /admin/tenants for it. There, platform-admins is mapped to tenant-admin through PUT /groups/:id/roles, oncall is its child with no role of its own, support has none either, and mei2 is a subject created through POST /subjects. $HELPDESK_TOKEN belongs to helpdesk, a user of that tenant created through POST /subjects, granted manage-users alone through PUT /subjects/:id/roles, and signed in with the one-time password POST /subjects/:id/password issued it; $ADMIN_TOKEN is ada's. The refusal below turns on two facts, shown first — what helpdesk holds, and that oncall carries nothing itself while its parent carries tenant-admin:

curl -sS -H "Authorization: Bearer $HELPDESK_TOKEN" \
http://localhost:3082/admin/tenants/groups-demo/whoami

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/groups-demo/groups/01a109b9-c2d9-7265-9cfe-1cc931aa4a87/roles

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/groups-demo/groups/01a109b9-c2c0-7af8-baf6-399f4128272f/roles
{"subjectId":"01a109b9-c365-7216-9064-207c3931f9e0","issuerTenantId":"01a109b9-c288-7ef5-a623-21ab2f266513","capabilities":["manage-users","view-users"],"crossTenant":false}
{"items":[]}
{"items":[{"id":"01a109b9-c296-7612-84cf-fd8e6a727672","name":"tenant-admin","client_id":"01a109b9-c28f-78c9-bac7-b8be0801cfd6","client_key":"odudu-admin"}]}

The read, then a write with no If-Match, then helpdesk putting mei2 in oncall:

curl -sS -D - -H "Authorization: Bearer $HELPDESK_TOKEN" \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups

curl -sS -D - -X PUT \
-H "Authorization: Bearer $HELPDESK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"group_ids": ["01a109b9-c2f2-795b-9df3-36c34e89980e"]}' \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups

curl -sS -D - -X PUT \
-H "Authorization: Bearer $HELPDESK_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
-d '{"group_ids": ["01a109b9-c2d9-7265-9cfe-1cc931aa4a87"]}' \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups
HTTP/1.1 200 OK
x-request-id: 01a109b9-f21c-7f4a-b3f7-d25034ae0c60
cache-control: no-store
etag: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"
content-type: application/json; charset=utf-8
content-length: 12
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"items":[]}

HTTP/1.1 428 Precondition Required
x-request-id: 01a109b9-f22d-71b1-b8e2-f82b7f95e7e8
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 181
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"type":"about:blank","title":"Precondition Required","status":428,"detail":"If-Match is required to replace a subject’s groups","instance":"01a109b9-f22d-71b1-b8e2-f82b7f95e7e8"}

HTTP/1.1 403 Forbidden
x-request-id: 01a109b9-f243-73fa-8f46-668be780b58a
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 228
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"type":"about:blank","title":"Forbidden","status":403,"detail":"the caller does not hold: tenant-admin, manage-clients, manage-tenant, manage-keys, manage-sessions, view-audit","instance":"01a109b9-f243-73fa-8f46-668be780b58a"}

oncall names no role, and is refused for everything tenant-admin composites that helpdesk does not hold — reached through its parent. The membership is still empty, under the same tag, so the same If-Match then puts mei2 in support, and replaying it once that has landed is stale:

curl -sS -D - -H "Authorization: Bearer $HELPDESK_TOKEN" \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups

curl -sS -D - -X PUT \
-H "Authorization: Bearer $HELPDESK_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
-d '{"group_ids": ["01a109b9-c2f2-795b-9df3-36c34e89980e"]}' \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups

curl -sS -D - -X PUT \
-H "Authorization: Bearer $HELPDESK_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"' \
-d '{"group_ids": []}' \
http://localhost:3082/admin/tenants/groups-demo/subjects/01a109b9-c34e-7692-b5a4-67af33cf85e0/groups
HTTP/1.1 200 OK
x-request-id: 01a109b9-f25d-7357-a2c8-4c86697f32da
cache-control: no-store
etag: "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1"
content-type: application/json; charset=utf-8
content-length: 12
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"items":[]}

HTTP/1.1 200 OK
x-request-id: 01a109b9-f26f-7e40-9063-700663679c1b
cache-control: no-store
etag: "66a62a8dcf68d9df0b7e058b8cd86c42a83d9452d2fabf3f17091b29552fecf4"
content-type: application/json; charset=utf-8
content-length: 201
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"items":[{"id":"01a109b9-c2f2-795b-9df3-36c34e89980e","name":"support","description":null,"parent_id":null,"default_for_new_subjects":false,"path":"/support","created_at":"2026-10-05T01:42:11.441Z"}]}

HTTP/1.1 412 Precondition Failed
x-request-id: 01a109b9-f288-78e9-ade7-7dfb8ef68ccd
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 153
Date: Mon, 05 Oct 2026 01:42:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"If-Match no longer matches","instance":"01a109b9-f288-78e9-ade7-7dfb8ef68ccd"}

Both writes that reached the ceiling are in the trail, scoped here to mei2 — the refusal naming what was denied, the replacement the ids before and after:

In the same tenant:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
'http://localhost:3082/admin/tenants/groups-demo/audit?action=subject.groups_set&resource_type=subject&resource_id=01a109b9-c34e-7692-b5a4-67af33cf85e0'; echo
{"items":[{"id":"01a109b9-f27e-7d11-a166-1263b8d4466f","occurred_at":"2026-10-05T01:42:23.608Z","event_type":"admin_mutation","action":"subject.groups_set","outcome":"allowed","actor_tenant_id":"01a109b9-c288-7ef5-a623-21ab2f266513","actor_subject_id":"01a109b9-c365-7216-9064-207c3931f9e0","actor_client_id":"01a109b9-c28f-78c9-bac7-b8be0801cfd6","actor_name":"helpdesk","actor_origin":"tenant","resource_type":"subject","resource_id":"01a109b9-c34e-7692-b5a4-67af33cf85e0","request_id":"01a109b9-f26f-7e40-9063-700663679c1b","ip":"172.22.0.1","detail":{"group_ids":{"after":["01a109b9-c2f2-795b-9df3-36c34e89980e"],"before":[]}}},{"id":"01a109b9-f252-7573-967d-079323ae2a71","occurred_at":"2026-10-05T01:42:23.565Z","event_type":"admin_mutation","action":"subject.groups_set","outcome":"refused","actor_tenant_id":"01a109b9-c288-7ef5-a623-21ab2f266513","actor_subject_id":"01a109b9-c365-7216-9064-207c3931f9e0","actor_client_id":"01a109b9-c28f-78c9-bac7-b8be0801cfd6","actor_name":"helpdesk","actor_origin":"tenant","resource_type":"subject","resource_id":"01a109b9-c34e-7692-b5a4-67af33cf85e0","request_id":"01a109b9-f243-73fa-8f46-668be780b58a","ip":"172.22.0.1","detail":{"denied":["tenant-admin","manage-clients","manage-tenant","manage-keys","manage-sessions","view-audit"]}}]}