Appearance
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
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| GET | /v1/domains/{id}/identities | ListIdentities | domain:<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} | GetIdentity | domain:<id>#read (BEFORE persistence read) + optional domain:<id>#auditor reveal gate | identity.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.limitis clamped at the handler to[1, 200]with default50.ListIdentities.cursoris opaque, HMAC-signed by the server; a tampered cursor surfaces as400 invalid_cursor.ListIdentities.kindfilters the page to a single principal kind; omit to page across both User and ServiceIdentity cohorts in the same Domain.GetIdentityruns theauditorreveal probe AFTER thereadgate and BEFORE the projection step. A deniedauditorcheck is NOT a request failure — the caller still receives theIdentityDetail, just with the plaintextexternal_subjectandemailfields elided.
Path & query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| ListIdentities / GetIdentity | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed → 400 invalid_domain_id. |
| GetIdentity | principalId (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed → 400 invalid_principal_id. |
| ListIdentities | kind (query) | string | no | Closed set {user, service-identity}. Out-of-set → 400 invalid_kind. Omit to page across both cohorts. |
| ListIdentities | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
| ListIdentities | cursor (query) | string | no | Opaque 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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Stable principal identifier (UUIDv7). For users this is the User aggregate id; for service identities the ServiceIdentity aggregate id. |
kind | IdentityKind | yes | Closed-set discriminator {user, service-identity}. |
domain_id | string (uuid) | yes | Owning Domain identifier (UUIDv7). |
display_name | string | yes | Operator-facing display string. For users the IdP-sourced full name; for service identities the operator-supplied label. |
external_subject_pseudonym | string (^[0-9a-f]{64}$) | yes | Per-Domain pseudonym of the IdP-side external_subject (32 bytes, lowercase hex). |
last_sign_in_at | string (date-time) (nullable) | no | Timestamp of the most recent successful sign-in, or null for principals that have never signed in. |
created_at | string (date-time) | yes | Aggregate 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.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid) | yes | Stable principal identifier (UUIDv7), matching IdentitySummary.id. |
kind | IdentityKind | yes | Closed-set discriminator {user, service-identity}. |
domain_id | string (uuid) | yes | Owning Domain identifier (UUIDv7). |
display_name | string | yes | Operator-facing display string, matching IdentitySummary.display_name. |
external_subject_pseudonym | string (^[0-9a-f]{64}$) | yes | Per-Domain pseudonym (32 bytes, lowercase hex). Always present. |
external_subject | string | no | Plaintext IdP-side subject (the OIDC sub claim or service identity token name). Populated ONLY when the caller holds domain:<id>#auditor; elided otherwise. |
email | string | no | Plaintext 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_at | string (date-time) (nullable) | no | Timestamp of the most recent successful sign-in, matching IdentitySummary.last_sign_in_at semantics. |
created_at | string (date-time) | yes | Aggregate creation timestamp (UTC). |
updated_at | string (date-time) | yes | Last-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.
| Field | Type | Required | Notes |
|---|---|---|---|
items | array<IdentitySummary> | yes | Identities in the current page (post per-row visibility filter). |
next_cursor | string (nullable) | no | Opaque HMAC-signed continuation. Absent or null at end-of-stream. |
ReBAC contract
| Operation | Relation evaluated | Subject | Object | On denial |
|---|---|---|---|---|
| ListIdentities (Domain gate) | read | resolved principal | domain:<id> | 403 PermissionDenied + audit row relation=identity.list, outcome=permission_denied. Existence side-channel closed. |
| ListIdentities (per-row filter) | read | resolved principal | user:<uuid> / serviceaccount:<uuid> for each candidate row | row filtered out; per-row denial NOT audited (page-level audit row carries item_count + authz_errors) |
| GetIdentity (Domain gate) | read (BEFORE persistence read) | resolved principal | domain:<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 principal | domain:<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 status | Problem.code | Origin | Trigger |
|---|---|---|---|
| 400 | invalid_domain_id | handler | Path {id} was not a non-zero UUID. |
| 400 | invalid_principal_id | handler | Path {principalId} was not a non-zero UUID. |
| 400 | invalid_kind | handler | List kind query parameter outside {user, service-identity}. |
| 400 | invalid_limit | handler | List limit query parameter out of [1, 200]. |
| 400 | invalid_cursor | handler | List cursor was tampered or malformed. |
| 401 | unauthenticated | handler | No resolved principal. |
| 403 | (PermissionDenied) | transport | ReBAC denied the Domain-scope gate (separate schema, not Problem). |
| 404 | identity_not_found | repo | Aggregate 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). |
| 500 | internal | handler | Unexpected 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.
| Operation | Outcome | Audit relation | Audit outcome | Outbox event |
|---|---|---|---|---|
| ListIdentities | success | identity.list (page-level row carrying item_count, kind, optional authz_errors) | granted | (none) |
| ListIdentities | 403 | identity.list | permission_denied | (none) |
| ListIdentities | 4xx invariant (invalid_kind, invalid_limit, invalid_cursor) | identity.list (CaveatContext.fields names the offending field) | invariant_violation | (none) |
| ListIdentities | 500 | identity.list | internal_error | (none) |
| GetIdentity | success | identity.read (CaveatContext.pseudonym_revealed: bool) | granted | (none) |
| GetIdentity | 403 | identity.read | permission_denied | (none) |
| GetIdentity | 404 | identity.read (CaveatContext.principal_id) | not_found | (none) |
| GetIdentity | 500 | identity.read | internal_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
./invitations.md— sibling reference for the/v1/domains/{id}/invitationsHTTP surface (Create / List / Get / Revoke) introduced by the same feature.../../contexts/identity/invitations.md— bounded-context narrative for the per-Domain identity + invitation surfaces (pseudonym contract, auditor reveal gate, JIT acceptance fork).../../contexts/identity/idp.md— OIDC sign-in flow that bumpslast_sign_in_atand consumes pending invitations during the JIT acceptance fork.../../contexts/identity/rebac.md— relation graph behinddomain#readanddomain#auditor.../../how-to/identity/invite-an-operator.md— operator how-to that exercisesGET .../identitiesand the by-id auditor reveal alongside the invitation flow.../api/index.md— platform-wide/v1HTTP surface map.../../../api/openapi/plexsphere-v1.yaml— authoritative OpenAPI 3.1 contract; this doc is a map, not a duplicate.../../../internal/transport/http/v1/identities/— handler package; closedProblem.codetaxonomy lives inhelpers.go; reader-port + Pseudonymiser bindings inwiring.go.