Skip to content

Identities HTTP API

This is the reference for the /v1/domains/{id}/identities HTTP surface. It maps each operation to its OpenAPI schema, ReBAC gate, audit emission, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract — for the bounded- context narrative covering the per-Domain pseudonym contract, the auditor-only plaintext reveal, and the parallel invitation lifecycle (Create / List / Get / Revoke + JIT acceptance during OIDC sign-in) see ../../contexts/identity/invitations.md; for the operator how-to that walks through inviting a peer operator and exercises the by-id auditor reveal see ../../how-to/identity/invite-an-operator.md.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET/v1/domains/{id}/identitiesListIdentitiesdomain:<id>#read (Domain-scope gate) + per-row read filter against user:<uuid> / serviceaccount:<uuid>identity.list (page-level row carrying item_count + authz_errors)(none)n/a
GET/v1/domains/{id}/identities/{principalId}GetIdentitydomain:<id>#read (BEFORE persistence read) + optional domain:<id>#auditor reveal gateidentity.read (carries pseudonym_revealed)(none)n/a
  • The Domain-scope gate runs BEFORE any persistence read so an unauthorised caller never observes the existence side-channel of the addressed Domain (an unauthorised request renders identically to one against a missing Domain).
  • ListIdentities.limit is clamped at the handler to [1, 200] with default 50.
  • ListIdentities.cursor is opaque, HMAC-signed by the server; a tampered cursor surfaces as 400 invalid_cursor.
  • ListIdentities.kind filters the page to a single principal kind; omit to page across both User and ServiceIdentity cohorts in the same Domain.
  • GetIdentity runs the auditor reveal probe AFTER the read gate and BEFORE the projection step. A denied auditor check is NOT a request failure — the caller still receives the IdentityDetail, just with the plaintext external_subject and email fields elided.

Path & query parameters

OperationParameterTypeRequiredNotes
ListIdentities / GetIdentityid (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_domain_id.
GetIdentityprincipalId (path)string (uuid)yesUUIDv7. Non-zero. Malformed → 400 invalid_principal_id.
ListIdentitieskind (query)stringnoClosed set {user, service-identity}. Out-of-set → 400 invalid_kind. Omit to page across both cohorts.
ListIdentitieslimit (query)integerno[1, 200], default 50. Out-of-range → 400 invalid_limit.
ListIdentitiescursor (query)stringnoOpaque HMAC-signed continuation. Tampered or malformed → 400 invalid_cursor.

Schemas

IdentitySummary

Read-side projection of a Domain principal — User OR ServiceIdentity — exposed on the listing surface. The summary intentionally omits plaintext external_subject and email and instead carries external_subject_pseudonym so a domain:<id>#read caller without the auditor relation never observes raw IdP-side PII.

FieldTypeRequiredNotes
idstring (uuid)yesStable principal identifier (UUIDv7). For users this is the User aggregate id; for service identities the ServiceIdentity aggregate id.
kindIdentityKindyesClosed-set discriminator {user, service-identity}.
domain_idstring (uuid)yesOwning Domain identifier (UUIDv7).
display_namestringyesOperator-facing display string. For users the IdP-sourced full name; for service identities the operator-supplied label.
external_subject_pseudonymstring (^[0-9a-f]{64}$)yesPer-Domain pseudonym of the IdP-side external_subject (32 bytes, lowercase hex).
last_sign_in_atstring (date-time) (nullable)noTimestamp of the most recent successful sign-in, or null for principals that have never signed in.
created_atstring (date-time)yesAggregate creation timestamp (UTC).

IdentityDetail

Auditor-facing projection of a Domain principal returned by GET /v1/domains/{id}/identities/{principalId}. Extends the IdentitySummary shape with optional external_subject and email plaintext fields. The plaintext fields are populated ONLY when the calling principal carries the auditor relation on the addressed Domain (domain:<id>#auditor); a read-only caller receives the same shape with the plaintext fields elided so a client cannot escalate by reading the wire bytes.

FieldTypeRequiredNotes
idstring (uuid)yesStable principal identifier (UUIDv7), matching IdentitySummary.id.
kindIdentityKindyesClosed-set discriminator {user, service-identity}.
domain_idstring (uuid)yesOwning Domain identifier (UUIDv7).
display_namestringyesOperator-facing display string, matching IdentitySummary.display_name.
external_subject_pseudonymstring (^[0-9a-f]{64}$)yesPer-Domain pseudonym (32 bytes, lowercase hex). Always present.
external_subjectstringnoPlaintext IdP-side subject (the OIDC sub claim or service identity token name). Populated ONLY when the caller holds domain:<id>#auditor; elided otherwise.
emailstringnoPlaintext IdP-side email address. Populated ONLY when the caller holds domain:<id>#auditor. Service identities never carry an email and the field is omitted regardless of the caller's relation.
last_sign_in_atstring (date-time) (nullable)noTimestamp of the most recent successful sign-in, matching IdentitySummary.last_sign_in_at semantics.
created_atstring (date-time)yesAggregate creation timestamp (UTC).
updated_atstring (date-time)yesLast-modified timestamp (UTC). Bumped by every aggregate mutator, including last_sign_in_at updates from the OIDC sign-in callback.

IdentityList

Page of identities returned by GET /v1/domains/{id}/identities. The window is computed by the persistence layer in (created_at DESC, id DESC) order; per-row visibility is layered on top so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead.

FieldTypeRequiredNotes
itemsarray<IdentitySummary>yesIdentities in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream.

ReBAC contract

OperationRelation evaluatedSubjectObjectOn denial
ListIdentities (Domain gate)readresolved principaldomain:<id>403 PermissionDenied + audit row relation=identity.list, outcome=permission_denied. Existence side-channel closed.
ListIdentities (per-row filter)readresolved principaluser:<uuid> / serviceaccount:<uuid> for each candidate rowrow filtered out; per-row denial NOT audited (page-level audit row carries item_count + authz_errors)
GetIdentity (Domain gate)read (BEFORE persistence read)resolved principaldomain:<id>403 PermissionDenied + audit row relation=identity.read, outcome=permission_denied. Existence side-channel closed.
GetIdentity (auditor reveal)auditor (AFTER read gate, BEFORE projection)resolved principaldomain:<id>NOT a request failure — the response shape is unchanged but external_subject and email are elided. The audit row's pseudonym_revealed=false records the outcome.

The 403 body on this surface is a PermissionDenied schema (NOT a Problem with code: permission_denied) — established by the ReBAC platform contract and reused here verbatim. The richer body carries reason, relation_path (via the correlation_id), and the missing relation. A non-ErrPermissionDenied error class on either gate is treated as a transport-flake observability incident and FAILS CLOSED — the auditor reveal is suppressed and the listing per-row is skipped (counted into authz_errors).

Error taxonomy

The closed Problem.code set this surface emits, exactly as defined in internal/transport/http/v1/identities/helpers.go. The Origin column names the layer (handler / repo / transport) so a future maintainer knows where to grep when the code changes.

HTTP statusProblem.codeOriginTrigger
400invalid_domain_idhandlerPath {id} was not a non-zero UUID.
400invalid_principal_idhandlerPath {principalId} was not a non-zero UUID.
400invalid_kindhandlerList kind query parameter outside {user, service-identity}.
400invalid_limithandlerList limit query parameter out of [1, 200].
400invalid_cursorhandlerList cursor was tampered or malformed.
401unauthenticatedhandlerNo resolved principal.
403(PermissionDenied)transportReBAC denied the Domain-scope gate (separate schema, not Problem).
404identity_not_foundrepoAggregate not present, or row exists in a different Domain than the path Domain (cross-Domain match collapses into the same response so the endpoint cannot be used as a cross-Domain enumeration oracle).
500internalhandlerUnexpected error (detail is generic; the underlying error text NEVER leaks to the wire).

Every Problem detail on this surface carries the (REQ-xxx, PX-0029) trailer so reviewers can grep production logs back to the originating requirement.

Audit & outbox contract

This surface is read-only — no mutator runs and no outbox event is ever appended. Every served byte is paired with exactly one transport-layer audit row through Deps.Sink. List denials and the per-row visibility filter share a single page-level row carrying item_count + authz_errors; the by-id surface emits one row per request, with pseudonym_revealed recording whether the auditor reveal gate granted the plaintext fields.

OperationOutcomeAudit relationAudit outcomeOutbox event
ListIdentitiessuccessidentity.list (page-level row carrying item_count, kind, optional authz_errors)granted(none)
ListIdentities403identity.listpermission_denied(none)
ListIdentities4xx invariant (invalid_kind, invalid_limit, invalid_cursor)identity.list (CaveatContext.fields names the offending field)invariant_violation(none)
ListIdentities500identity.listinternal_error(none)
GetIdentitysuccessidentity.read (CaveatContext.pseudonym_revealed: bool)granted(none)
GetIdentity403identity.readpermission_denied(none)
GetIdentity404identity.read (CaveatContext.principal_id)not_found(none)
GetIdentity500identity.readinternal_error(none)

The pseudonym_revealed flag is the auditor-query pivot: dashboards can branch on pseudonym_revealed=true vs pseudonym_revealed=false without re-deriving the relation from the subject. The authz_errors counter on the list row records both per-row authz transport flakes AND per-row pseudonymise failures so a single panel surfaces both failure modes.

Auditor reveal example

The same User aggregate, returned to a read-only caller and to a caller carrying domain:<id>#auditor. Note that the auditor body adds external_subject and email while keeping every other field byte-for-byte identical to the reader projection.

Reader (domain:<id>#read only):

json
{
  "id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1",
  "kind": "user",
  "domain_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "display_name": "Ada Lovelace",
  "external_subject_pseudonym": "9f2c7c0d2a3a4b8d1e6c8a7f5b3d2e1c0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d",
  "last_sign_in_at": "2026-05-02T09:30:00Z",
  "created_at": "2026-05-01T10:00:00Z",
  "updated_at": "2026-05-02T09:30:00Z"
}

Auditor (domain:<id>#auditor):

json
{
  "id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1",
  "kind": "user",
  "domain_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1",
  "display_name": "Ada Lovelace",
  "external_subject_pseudonym": "9f2c7c0d2a3a4b8d1e6c8a7f5b3d2e1c0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d",
  "external_subject": "ada@idp.example.com",
  "email": "ada@example.com",
  "last_sign_in_at": "2026-05-02T09:30:00Z",
  "created_at": "2026-05-01T10:00:00Z",
  "updated_at": "2026-05-02T09:30:00Z"
}

The reader response carries pseudonym_revealed=false on the matching identity.read audit row; the auditor response carries pseudonym_revealed=true.

Cross-references