Skip to content

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

FlagTypeRequiredDefaultDescription
--domainUUIDyesOwning Domain UUID.
--typeenumnoPrincipal-kind filter: human (maps onto wire user) | service (maps onto wire service-identity). Validated at parse time.
--limitintnoserver defaultMaximum items per page. 0 lets the server pick.
--cursorstringnoContinuation token from a previous call's next_cursor. Mutually exclusive with --all.
--allboolnofalseFollow next_cursor until exhausted (capped at 100 000 items).

plexctl identity get

FlagTypeRequiredDefaultDescription
<principal-id> (positional)UUIDyesPrincipal UUID. The server has no slug surface for principals.
--domainUUIDyesOwning Domain UUID.

plexctl identity invite

FlagTypeRequiredDefaultDescription
--domainUUIDyesOwning Domain UUID.
--emailstring (RFC 5322)yesExternal subject, validated client-side via mail.ParseAddress. A malformed value exits 2 before any wire round-trip.
--ttl-secondsintnoserver defaultInvitation lifetime in seconds. Omit the flag to let the server pick its default; passing the flag forwards the value verbatim, including 0.
--reveal-secretsboolnofalsePer-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

FlagTypeRequiredDefaultDescription
<invitation-id> (positional)UUIDyesInvitation UUID.
--domainUUIDyesOwning Domain UUID.
--yes (persistent)boolyesfalseRequired 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 by ListIdentities. Carries id, 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 — wraps Items: [IdentitySummary] plus the next_cursor keyset pagination token.
  • IdentityDetail — the shape returned by GetIdentity. Carries the same identifiers as IdentitySummary plus an optional email field; the server populates email only for callers holding the Domain auditor ReBAC relation.
  • InvitationResponse — the shape returned by CreateInvitation. Carries id, external_subject_pseudonym, expires_at, created_at, domain_id, status, and optionally accepted_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

SubcommandColumns
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's token query 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 with domain-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 matches domain-idp so the audit log post-processor can match a single regex across both surfaces.
  • Wire shape. The generated InvitationResponse type itself never carries a plaintext token; the token only appears as the token query parameter of the invite URL. The CLI renders the URL as a sibling invite_url key on the JSON/YAML output and as a column on the table view, never as a field on the InvitationResponse shape. Re-using the wire type verbatim keeps the --output json payload identical to the server response.
  • Empty URL today. The current wire response does not yet include an invite URL envelope; the INVITE_URL table column renders blank and the invite_url key 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.

CodeReachableMeaning
0yesThe API returned the expected status (200 for list / get, 201 for invite, 204 for revoke-invitation).
1yesRuntime / 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).
2yesFlag-parse / misconfiguration: missing --domain, malformed UUID (positional or flag), unknown --type alias, or invalid --email.
3yesMissing or insecure credentials, or 401 Unauthorized from the API.
4yesPermission 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.
77yesTyped 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.
64noNot 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-a0a0a0a0a0a0

List humans only and walk every page

shell
plexctl identity list \
  --server "${PLEXSPHERE_URL}" \
  --domain 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0 \
  --type   human \
  --all \
  --output json

The --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-a0a0a0a0a0c1
text
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:22Z

The 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 86400
text
ID                                    EXTERNAL_SUBJECT_PSEUDONYM  EXPIRES_AT            INVITE_URL
0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0d2  pseu_ada_a0a0a0a0           2026-05-04T09:14:22Z

The 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 $?  # 0

Without --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 $?  # 2

The 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 $?  # 77

The 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 the ListIdentities, GetIdentity, CreateInvitation, and RevokeInvitation operations and the IdentitySummary, IdentityList, IdentityDetail, and InvitationResponse schemas.
  • 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-secrets masking pattern (the IdP client_secret_ref field 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, the parseIdentityKind alias map, the mail.ParseAddress client-side validation, the maskInviteURL masking helper, the --all cap, and the table projections for IdentitySummary, IdentityDetail, and invitationPayload.