Appearance
Groups HTTP API
This is the reference for the /v1/admin/groups HTTP surface. It maps each operation to its OpenAPI schema, the per-call ReBAC permission gate, the audit-row taxonomy, and the closed Problem.code set the handlers emit. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded-context narrative (Group source manual vs idp, the per-Group claim mapping, the membership XOR-column model, the (domain_id, slug) uniqueness invariant) see ../../contexts/identity/groups.md. For the sibling admin surface that manages the IdP bindings the source=idp Groups consume, see ./idp.md.
Operations
The ReBAC gate column lists the relation/object pair every handler runs against the parent Domain BEFORE any persistence side effect. IdPBinding-style Groups do not have a per-Group SpiceDB definition; the parent Domain's manage/read permission is the canonical authorization target. The audit-relation column matches the closed taxonomy emitted on every refusal.
| Method | Path | Operation ID | ReBAC gate | Audit relation | Body cap |
|---|---|---|---|---|---|
| POST | /v1/admin/groups | PostAdminGroup | domain:<body.domain_id>#manage | group.create | 8 KiB |
| GET | /v1/admin/groups | GetAdminGroupList | domain:<query.domain_id>#read | group.list | n/a |
| GET | /v1/admin/groups/{id} | GetAdminGroupByID | domain:<group.DomainID>#read (pre-authz GetByID) | group.read | n/a |
| PATCH | /v1/admin/groups/{id} | PatchAdminGroup | domain:<group.DomainID>#manage (pre-authz GetByID) | group.update | 8 KiB |
| DELETE | /v1/admin/groups/{id} | DeleteAdminGroupByID | domain:<group.DomainID>#manage (pre-authz GetByID) | group.delete | n/a |
| POST | /v1/admin/groups/{id}/members | PostAdminGroupMember | domain:<parent.DomainID>#manage (pre-authz GetByID) | group.member.add | 8 KiB |
| GET | /v1/admin/groups/{id}/members | GetAdminGroupMembers | domain:<parent.DomainID>#read (pre-authz GetByID) | group.member.list | n/a |
| DELETE | /v1/admin/groups/{id}/members/{principal_id} | DeleteAdminGroupMember | domain:<parent.DomainID>#manage (pre-authz GetByID) | group.member.remove | n/a |
GetAdminGroupList.domain_idis REQUIRED — Group slugs are scoped per-Domain and listing across Domains would require cross-Domain cursor merging that the repo layer does not support and the admin UI does not need. (Contrast withGetAdminIdPList.domain_idwhich is optional; that surface lists bindings, not Groups.)GetAdminGroupList.limitis clamped to[1, 200]with default50.cursoris opaque, HMAC-signed; tampered →400 invalid_cursor.PatchAdminGroupaccepts ONLYdisplay_name. Theslugis immutable because it participates in the(domain_id, slug)uniqueness constraint and is referenced by IdP claim mappings. Bodies that carry aslugkey (even with the same value) are rejected at decode time with400 slug_immutable.DeleteAdminGroupByIDcascades the membership rows that reference the Group — there is no separate orphan-cleanup step.
Source-specific invariant
The Group aggregate enforces a source-specific invariant on create:
source | idp_binding_id | idp_claim_value | Membership add path |
|---|---|---|---|
manual | MUST be absent | MUST be absent | Manual (PostAdminGroupMember). |
idp | REQUIRED | REQUIRED | Synced from the IdP claims by the binding's claim mapper; manual PostAdminGroupMember is rejected for source=idp Groups (the membership row would drift on the next sync). |
Violations surface as 400 source_invariant_violated from the aggregate's validator — they are NOT enforced via SQL CHECK constraints, so the error path is the same whether the request arrives via the admin UI, plexctl, or a direct API call.
The membership-add request body does NOT carry idp_claim_value — the per-Group claim mapping lives on the parent Group aggregate, not on each Membership row. A Membership.source mismatch with the parent Group's source surfaces as 409 source_conflict.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetAdminGroupByID / PatchAdminGroup / DeleteAdminGroupByID / PostAdminGroupMember / GetAdminGroupMembers / DeleteAdminGroupMember | id (path) | string (uuid) | yes | Group identifier (UUIDv7). Malformed → 400 invalid_group_id. |
| DeleteAdminGroupMember | principal_id (path) | string (uuid) | yes | Principal identifier (UUIDv7). |
| DeleteAdminGroupMember | kind (query) | string enum | yes | user / service_identity / group. The query parameter is REQUIRED because the underlying group_memberships table uses XOR columns (principal_user_id / principal_service_identity_id / principal_group_id) and the repo needs the discriminator to hit the correct index. Missing → 400 invalid_kind. |
| GetAdminGroupList | domain_id (query) | string (uuid) | yes | Owning Domain. Malformed or zero → 400 invalid_domain_id. |
| GetAdminGroupList | cursor (query) | string | no | Opaque HMAC-signed continuation. Tampered → 400 invalid_cursor. |
| GetAdminGroupList | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Group:
GroupRequest,GroupUpdateRequest,GroupResponse,GroupListResponse. - Membership:
GroupMembershipRequest,GroupMembershipResponse,GroupMembershipListResponse.
For the field-level shapes refer to api/openapi/plexsphere-v1.yaml under components/schemas/. The Group response carries the resolved (domain_id, slug) pair, the source discriminator, and (for source=idp) the idp_binding_id and idp_claim_value mapping.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json); the 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason, the traversed relation_path, and the request correlation_id.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_group_id | 400 | path-id family | Malformed UUID. |
invalid_domain_id | 400 | List | Malformed domain_id. |
invalid_cursor | 400 | List | HMAC verification failed. |
invalid_limit | 400 | List | Out of [1, 200]. |
invalid_kind | 400 | DeleteAdminGroupMember | Missing or unknown principal kind. |
slug_immutable | 400 | Patch | Body carried a slug key. |
source_invariant_violated | 400 | Create / AddMember | source=idp without binding/claim, or source=manual with one. |
not_found | 404 | path-id family | Group or Membership not found. |
slug_conflict | 409 | Create | (domain_id, slug) already exists. |
membership_conflict | 409 | AddMember | (group_id, principal_kind, principal_id) already exists. |
source_conflict | 409 | AddMember | Membership source does not match parent Group source. |
internal | 500 | every operation | Server-side failure path. |
A 403 response carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ./authz.md.
Cross-references
../../contexts/identity/groups.md— bounded-context reference for the Group aggregate, the source discriminator, the(domain_id, slug)uniqueness invariant, the membership XOR-column model, and the per-Group claim mapping../idp.md— the admin surface that manages the IdP bindingssource=idpGroups consume.../../how-to/identity/manage-groups.md— operator how-to for creating Groups and adding members.../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; the*AdminGroup*operations and theGroupRequest/GroupUpdateRequest/GroupResponse/GroupListResponse/GroupMembershipRequest/GroupMembershipResponse/GroupMembershipListResponseschemas.../../../internal/identity/— the bounded-context implementation: theGroupaggregate,Membershipvalue object, and the(domain_id, slug)repository.