Skip to content

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.

MethodPathOperation IDReBAC gateAudit relationBody cap
POST/v1/admin/groupsPostAdminGroupdomain:<body.domain_id>#managegroup.create8 KiB
GET/v1/admin/groupsGetAdminGroupListdomain:<query.domain_id>#readgroup.listn/a
GET/v1/admin/groups/{id}GetAdminGroupByIDdomain:<group.DomainID>#read (pre-authz GetByID)group.readn/a
PATCH/v1/admin/groups/{id}PatchAdminGroupdomain:<group.DomainID>#manage (pre-authz GetByID)group.update8 KiB
DELETE/v1/admin/groups/{id}DeleteAdminGroupByIDdomain:<group.DomainID>#manage (pre-authz GetByID)group.deleten/a
POST/v1/admin/groups/{id}/membersPostAdminGroupMemberdomain:<parent.DomainID>#manage (pre-authz GetByID)group.member.add8 KiB
GET/v1/admin/groups/{id}/membersGetAdminGroupMembersdomain:<parent.DomainID>#read (pre-authz GetByID)group.member.listn/a
DELETE/v1/admin/groups/{id}/members/{principal_id}DeleteAdminGroupMemberdomain:<parent.DomainID>#manage (pre-authz GetByID)group.member.removen/a
  • GetAdminGroupList.domain_id is 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 with GetAdminIdPList.domain_id which is optional; that surface lists bindings, not Groups.)
  • GetAdminGroupList.limit is clamped to [1, 200] with default 50. cursor is opaque, HMAC-signed; tampered → 400 invalid_cursor.
  • PatchAdminGroup accepts ONLY display_name. The slug is immutable because it participates in the (domain_id, slug) uniqueness constraint and is referenced by IdP claim mappings. Bodies that carry a slug key (even with the same value) are rejected at decode time with 400 slug_immutable.
  • DeleteAdminGroupByID cascades 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:

sourceidp_binding_ididp_claim_valueMembership add path
manualMUST be absentMUST be absentManual (PostAdminGroupMember).
idpREQUIREDREQUIREDSynced 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

OperationParameterTypeRequiredNotes
GetAdminGroupByID / PatchAdminGroup / DeleteAdminGroupByID / PostAdminGroupMember / GetAdminGroupMembers / DeleteAdminGroupMemberid (path)string (uuid)yesGroup identifier (UUIDv7). Malformed → 400 invalid_group_id.
DeleteAdminGroupMemberprincipal_id (path)string (uuid)yesPrincipal identifier (UUIDv7).
DeleteAdminGroupMemberkind (query)string enumyesuser / 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.
GetAdminGroupListdomain_id (query)string (uuid)yesOwning Domain. Malformed or zero → 400 invalid_domain_id.
GetAdminGroupListcursor (query)stringnoOpaque HMAC-signed continuation. Tampered → 400 invalid_cursor.
GetAdminGroupListlimit (query)integerno[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.

CodeStatusWhereMeaning
invalid_group_id400path-id familyMalformed UUID.
invalid_domain_id400ListMalformed domain_id.
invalid_cursor400ListHMAC verification failed.
invalid_limit400ListOut of [1, 200].
invalid_kind400DeleteAdminGroupMemberMissing or unknown principal kind.
slug_immutable400PatchBody carried a slug key.
source_invariant_violated400Create / AddMembersource=idp without binding/claim, or source=manual with one.
not_found404path-id familyGroup or Membership not found.
slug_conflict409Create(domain_id, slug) already exists.
membership_conflict409AddMember(group_id, principal_kind, principal_id) already exists.
source_conflict409AddMemberMembership source does not match parent Group source.
internal500every operationServer-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 bindings source=idp Groups 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 the GroupRequest / GroupUpdateRequest / GroupResponse / GroupListResponse / GroupMembershipRequest / GroupMembershipResponse / GroupMembershipListResponse schemas.
  • ../../../internal/identity/ — the bounded-context implementation: the Group aggregate, Membership value object, and the (domain_id, slug) repository.