Appearance
plexctl identity
Synopsis
plexctl identity is the operator surface for the per-Domain identity collection and its invitation child collection. Identities are the User and ServiceIdentity principals visible inside a Domain; invitations stage the OIDC sign-in handshake that materialises a User aggregate on first accept (the server reads display_name and the upstream subject from the IdP's OIDC userinfo at Accept time, which is why the CLI does not accept either field on invite).
The four subcommands wrap the typed /v1/domains/{id}/identities and /v1/domains/{id}/invitations endpoints so an operator can list and inspect principals, stage an invitation, and revoke a pending invitation without hand-rolling curl calls. The invite subcommand emits an invitation URL whose token= query parameter is masked by default; passing --reveal-secrets (per-command flag or inherited from root) prints the URL verbatim and emits a one-line stderr warning recording that the invocation is audit-logged.
Invocation
text
plexctl identity <subcommand> [flags]The four subcommands are enumerated below. Every subcommand is a private cobra constructor; flags are validated at parse time so an invalid --type alias or a malformed --email never reaches the wire .
plexctl identity list
Lists identities inside a Domain. The --domain flag is required because the wire endpoint is scoped to a Domain (GET /v1/domains/{id}/identities). The optional --type filter accepts the operator-friendly aliases human and service, which the CLI maps onto the wire enum values user and service-identity at parse time. Supports keyset pagination via --cursor and an opt-in --all flag that walks every page until the next_cursor field is empty; the --all accumulator is capped at 100 000 items as defence-in-depth against a runaway server.
A --status filter is intentionally absent: the wire ListIdentitiesParams shape exposes only cursor, limit, and kind, and applying a status filter client-side would silently desynchronise the rendered row count from the operator-facing --limit value. The gap is tracked as a follow-up — when the server adds a status query parameter, the flag will appear here.
plexctl identity get
Returns a single identity by ID inside a Domain. Both --domain and the positional <principal-id> are required UUIDs; the server has no slug surface for principals. The IdentityDetail response carries the plaintext email field only for callers holding the Domain auditor ReBAC relation; non-auditor callers see EMAIL rendered as an empty string in the table view, which is a meaningful signal that the calling token does not carry the auditor relation.
plexctl identity invite
Stages an invitation for an external subject. Both --domain and --email are required; the --email value is validated client-side via mail.ParseAddress (RFC 5322) before any wire round-trip, so a malformed value surfaces as exit 2 with invalid email "<value>": <reason> rather than a 400 Bad Request from the server. The optional --ttl-seconds flag controls the invitation lifetime; the RunE forwards the value verbatim only when the flag is present (cmd.Flags().Changed("ttl-seconds")). Omit the flag to let the server pick its default; passing the flag (including --ttl-seconds 0) forwards the literal value to the server.
The --reveal-secrets per-command flag inherits from the root persistent flag when unset; setting either emits a one-line warning to stderr (warning: --reveal-secrets in effect; output may carry sensitive content (audit-logged)). The wire InvitationResponse shape never carries a plaintext token directly — the URL is a CLI-rendered envelope (sibling field on the rendered payload). The masking helpers handle the empty case gracefully: today the URL field is empty across every output mode, and the INVITE_URL table column renders blank while the invite_url key is omitted from JSON/YAML. When the server adds the URL envelope, the masking contract applies automatically.
The --display-name and --type flags are intentionally absent: InvitationCreateRequest carries neither field on the wire. The server materialises the resulting User aggregate's display_name from the IdP's OIDC userinfo at Accept time, and the principal kind defaults to user (operators issuing service-identity tokens hit a different surface). Accepting these flags client-side and dropping them silently was rejected because the operator would think the values were honoured.
plexctl identity revoke-invitation
Revokes a pending invitation. Requires --domain, the positional <invitation-id> UUID, and the root persistent --yes flag — a destructive op behind a single typo of the invitation id is unacceptable, so the RunE refuses to call the API without explicit confirmation. The server returns 204 No Content on success.
Flags
plexctl identity list
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
--domain | UUID | yes | — | Owning Domain UUID. |
--type | enum | no | — | Principal-kind filter: human (maps onto wire user) | service (maps onto wire service-identity). Validated at parse time. |
--limit | int | no | server default | Maximum items per page. 0 lets the server pick. |
--cursor | string | no | — | Continuation token from a previous call's next_cursor. Mutually exclusive with --all. |
--all | bool | no | false | Follow next_cursor until exhausted (capped at 100 000 items). |
plexctl identity get
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
<principal-id> (positional) | UUID | yes | — | Principal UUID. The server has no slug surface for principals. |
--domain | UUID | yes | — | Owning Domain UUID. |
plexctl identity invite
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
--domain | UUID | yes | — | Owning Domain UUID. |
--email | string (RFC 5322) | yes | — | External subject, validated client-side via mail.ParseAddress. A malformed value exits 2 before any wire round-trip. |
--ttl-seconds | int | no | server default | Invitation lifetime in seconds. Omit the flag to let the server pick its default; passing the flag forwards the value verbatim, including 0. |
--reveal-secrets | bool | no | false | Per-command override; inherits the root persistent flag when unset. Setting either flag emits the stderr warning and prints the invite URL verbatim. |
plexctl identity revoke-invitation
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
<invitation-id> (positional) | UUID | yes | — | Invitation UUID. |
--domain | UUID | yes | — | Owning Domain UUID. |
--yes (persistent) | bool | yes | false | Required confirmation for the destructive operation. |
Persistent flags inherited from root
--server, --profile, --token-file, --output, --yes, --reveal-secrets. The --yes flag is consumed by revoke-invitation; the --reveal-secrets flag is inherited by invite (the per-command flag overrides when set). The remaining persistent flags apply unchanged. See ../plexctl.md for the canonical list and the profile-resolution rules.
Output JSON schema reference
The wire payloads are defined in ../../../api/openapi/plexsphere-v1.yaml:
IdentitySummary— the per-row shape returned byListIdentities. Carriesid,external_subject_pseudonym,kind,domain_id,display_name,created_at. The listing surface never exposes plaintext email regardless of the caller's relation; only the per-Domain pseudonym is rendered.IdentityList— wrapsItems: [IdentitySummary]plus thenext_cursorkeyset pagination token.IdentityDetail— the shape returned byGetIdentity. Carries the same identifiers asIdentitySummaryplus an optionalemailfield; the server populatesemailonly for callers holding the DomainauditorReBAC relation.InvitationResponse— the shape returned byCreateInvitation. Carriesid,external_subject_pseudonym,expires_at,created_at,domain_id,status, and optionallyaccepted_at/revoked_at/expired_at/accepted_user_id. It does not include the invite URL; the URL is appended by the CLI as a sibling field on the rendered payload (see Secret rendering contract below).
Table-view columns
| Subcommand | Columns |
|---|---|
identity list (IdentitySummary rows) | ID, EXTERNAL_SUBJECT_PSEUDONYM, KIND, DOMAIN, DISPLAY_NAME, CREATED_AT |
identity get (IdentityDetail row) | ID, EMAIL, KIND, DOMAIN, DISPLAY_NAME, CREATED_AT |
identity invite (invitationPayload row) | ID, EXTERNAL_SUBJECT_PSEUDONYM, EXPIRES_AT, INVITE_URL |
The --output json and --output yaml modes round-trip the full wire shape verbatim so a downstream consumer can rely on the OpenAPI schema as the contract; the table view is a convenience projection for human operators.
Secret rendering contract
The invitationPayload is the only identity surface that carries a sensitive value: the invite URL's token= query parameter. Every other identity surface (list, get, revoke-invitation) emits the wire shape unchanged in every output mode.
- Default (masked). In every output mode (
text,json,yaml), the value of the URL'stokenquery parameter is replaced with the literal mask string***. The host, path, scheme, and any other query parameters round-trip unchanged so an operator can still see which server hosts the invitation even when the token is masked. The mask string is the spec-canonical value shared withdomain-idp, so audit log readers and dashboard renderers can match a single regex across both surfaces. - Reveal (
--reveal-secrets). When the per-command flag is set, or when the root persistent flag is inherited, the URL is printed verbatim and a one-line warning is written to stderr:warning: --reveal-secrets in effect; output may carry sensitive content (audit-logged). The warning shape matchesdomain-idpso the audit log post-processor can match a single regex across both surfaces. - Wire shape. The generated
InvitationResponsetype itself never carries a plaintext token; the token only appears as thetokenquery parameter of the invite URL. The CLI renders the URL as a siblinginvite_urlkey on the JSON/YAML output and as a column on the table view, never as a field on theInvitationResponseshape. Re-using the wire type verbatim keeps the--output jsonpayload identical to the server response. - Empty URL today. The current wire response does not yet include an invite URL envelope; the
INVITE_URLtable column renders blank and theinvite_urlkey is omitted from JSON/YAML output. When the server adds the URL envelope, populate the field server-side without changing the masking contract — the masking helpers cover the populated case automatically.
Exit codes
plexctl collapses every failure into the taxonomy below; the single source of truth is exitCodeFor in ../../../cmd/plexctl/app.go.
| Code | Reachable | Meaning |
|---|---|---|
0 | yes | The API returned the expected status (200 for list / get, 201 for invite, 204 for revoke-invitation). |
1 | yes | Runtime / API error: transport failure, unexpected status code, a malformed response body, or revoke-invitation invoked without --yes (the destructive-confirmation refusal is a plain errors.New which falls through exitCodeFor's default branch, mirroring group delete / audit erase-identity). |
2 | yes | Flag-parse / misconfiguration: missing --domain, malformed UUID (positional or flag), unknown --type alias, or invalid --email. |
3 | yes | Missing or insecure credentials, or 401 Unauthorized from the API. |
4 | yes | Permission denied or resource not addressable: 403 Forbidden where the body is not a typed ReBAC denial (e.g. wrong audience), or 404 Not Found from the API. |
77 | yes | Typed ReBAC denial: HTTP 403 with body reason == "rebac_denied". Distinct from 4 so CI scripts can branch on a ReBAC-specific authorization failure (e.g. "you lack the relation") without parsing the response body themselves. |
64 | no | Not reachable — identity is fully implemented and never returns *NotImplementedError. |
Examples
List identities in a Domain (basic)
shell
export PLEXSPHERE_URL="${PLEXSPHERE_URL:-https://localhost:8080}"
plexctl identity list \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0List humans only and walk every page
shell
plexctl identity list \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--type human \
--all \
--output jsonThe --type human alias maps onto the wire enum value user at flag parse time; --type service maps onto service-identity. Any other value exits 2 with --type: "<value>" is not a valid identity kind (want human|service).
Get an identity by ID (non-auditor caller, EMAIL is empty)
shell
plexctl identity get \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0c1text
ID EMAIL KIND DOMAIN DISPLAY_NAME CREATED_AT
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0c1 user 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 Ada Lovelace 2026-04-12T09:14:22ZThe empty EMAIL column is a meaningful signal: the calling token does not carry the Domain auditor ReBAC relation, so the server elided the plaintext email field on the response.
Stage an invitation (default masked output)
shell
plexctl identity invite \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--email ada@example.com \
--ttl-seconds 86400text
ID EXTERNAL_SUBJECT_PSEUDONYM EXPIRES_AT INVITE_URL
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0d2 pseu_ada_a0a0a0a0 2026-05-04T09:14:22ZThe INVITE_URL column is blank because the server does not yet return an invite URL envelope; when it does, the column will render the URL with the token= query parameter masked to *** while the host / path / scheme round-trip unchanged.
Stage an invitation with --reveal-secrets (audit-logged)
shell
plexctl identity invite \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--email ada@example.com \
--reveal-secrets
# stderr: warning: --reveal-secrets in effect; output may carry sensitive content (audit-logged)The warning literal matches domain-idp so a single regex can pick both surfaces out of the audit log. Setting --reveal-secrets on the root (e.g. plexctl --reveal-secrets identity invite …) has the same effect — the per-command flag is OR-ed with the inherited root flag.
Revoke an invitation with --yes
shell
plexctl identity revoke-invitation \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--yes \
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0d2
echo $? # 0Without --yes the RunE refuses with identity revoke-invitation: refusing without --yes (destructive operation) and exits 1 — the guard runs before the wire call.
Refusal: malformed --email (exit 2)
shell
plexctl identity invite \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
--email foo@bar
# stderr: plexctl: invalid email "foo@bar": mail: missing '@' or angle-addr
echo $? # 2The validation runs client-side via mail.ParseAddress before any wire round-trip, so a typo never reaches the server.
ReBAC denial (exit 77)
shell
plexctl identity get \
--server "${PLEXSPHERE_URL}" \
--domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0c1
# stderr: plexctl: 403 Forbidden: rebac_denied (need domain:auditor)
echo $? # 77The server returns 403 with body reason == "rebac_denied"; the CLI's exitCodeFor routes this to exit 77 instead of the generic 4 so a CI script can distinguish "you lack the relation" from "your token is for the wrong audience" without parsing the response body itself.
Cross-references
../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 source of truth for theListIdentities,GetIdentity,CreateInvitation, andRevokeInvitationoperations and theIdentitySummary,IdentityList,IdentityDetail, andInvitationResponseschemas.domain.md— sibling reference for the per-Domain parent surface that owns the identity collection.project.md— sibling reference for the per-Project surface inside a Domain.domain-idp.md— companion reference for the--reveal-secretsmasking pattern (the IdPclient_secret_reffield uses the same mask token, the same audit-logged warning, and the same root-persistent-flag inheritance contract).../../../cmd/plexctl/commands/identity.go— source of truth for the cobra command, theparseIdentityKindalias map, themail.ParseAddressclient-side validation, themaskInviteURLmasking helper, the--allcap, and the table projections forIdentitySummary,IdentityDetail, andinvitationPayload.