Appearance
Access Orchestrator — short-lived signed session credentials for ssh, k8s, and tcp
This document is the authoritative bounded-context reference for the Access Orchestrator — the context that issues short-lived, session-scoped JWTs granting one of three mediated-access kinds (ssh, k8s, tcp) against a target Resource's Nodes. A single Session aggregate is the consistency boundary: it carries the kind, the discriminated SessionTarget, the issuing Domain / Project, the target Resource, the requesting Identity, and the TTL / idle / expiry envelope, plus the per-Session jti that doubles as the token identifier. Issuance runs a resource#act ReBAC check, enforces the per-Domain SessionPolicy (TTL clamp, the three concurrency caps, the issuance rate limit, and the step-up matrix), mints and signs the session JWT through the Signing Service, and persists the aggregate alongside an outbox event in one transaction. Revocation marks the Session revoked, appends a revocation-list row, and emits a revoke event the Signed Event Bus fans out so every target plexd drops the session instantly. A background sweeper revokes expired and idle Sessions through the same revoke path. The domain root that pins the ubiquitous language is ../../internal/access/doc.go.
The orchestrator deliberately does not custody the Ed25519 signing key — that lives in the Signing Service custodian (../../internal/signing/); this context only holds the typed gRPC client seam (../../internal/access/signer_client.go) that signs the canonical claim bytes. It does not terminate or proxy the mediated stream itself — the target plexd binds the listener and reports activity back through the callback surface; this context issues the credential and tracks its lifecycle. And it never stores the assembled JWT at rest: the persisted projection carries only the session metadata, the jti, and the expiry, never the bearer token bytes.
Ubiquitous language
The terms below travel verbatim across the domain root, the persistence layer, the JWT vocabulary, the application services, the two domain events, and the transport surface. Internal code never paraphrases them; documentation, JSON fields, and database columns adopt the exact spelling.
| Term | Definition | Code anchor |
|---|---|---|
| Session | The aggregate root: one issued, session-scoped grant of mediated access. It carries the Kind, the discriminated SessionTarget, the issuing Domain / Project, the target Resource, the requesting Identity, the TTL / idle / expiry envelope, the per-Session jti, and the revocation fields. The aggregate boundary is the single Session; its invariants are established only through New (a fresh issuance) or Hydrate (a persisted one). | ../../internal/access/session.go (Session) |
| Kind | Value object discriminating the three mediated-access kinds — ssh, k8s, tcp. A closed set; ParseKind rejects every other input so an invalid kind surfaces at construction time. | ../../internal/access/types.go (Kind, ParseKind) |
| SessionTarget | The discriminated value object describing what a Session grants access to. Exactly one of the three variants — ssh (user + optional command allow-list), k8s (user + optional impersonation groups), tcp (host + port) — is populated, matching the parent Kind. The discriminator is unexported so a target can only be built through the per-variant constructor. | ../../internal/access/target.go (SessionTarget) |
| SessionID / JTI | SessionID is the binary form of the Session's UUIDv7; it doubles as the token's jti. JTIFromSessionID pins the two to one byte form on purpose: a token's jti is its Session's id, so revoking the Session revokes the token. | ../../internal/access/types.go (SessionID, JTI) |
| DomainID / ProjectID / ResourceID / IdentityID | Context-local [16]byte identifier value objects — opaque byte arrays rather than wrappers around an internal/identity type, because the depguard no-cross-context-imports rule denies the access context that import. The composition root adapts between the identity ids and these via the underlying [16]byte. | ../../internal/access/types.go |
| TTL / IdleTimeout | Duration value objects with a strictly-positive invariant. TTL is the lifetime from issuance to hard expiry (the per-Domain MaxTTL clamp is applied by the Session aggregate, not the value object); IdleTimeout is the inactivity window after which the sweeper revokes a Session. | ../../internal/access/types.go (TTL, IdleTimeout) |
| RevokeReason | Bounded textual rationale carried on a revocation: non-empty, capped at MaxRevokeReasonBytes, surfacing the invariants at construction time. | ../../internal/access/types.go (RevokeReason) |
| SessionPolicy | The access context's in-context snapshot of the per-Domain session-issuance policy: the default and maximum TTL, the idle timeout, the three concurrency caps, the issuance rate limit, and the step-up matrix. A plain value snapshot distinct from the authoritative tenancy.SessionPolicy — see the DECISION below. | ../../internal/access/policy.go (SessionPolicy) |
| Claims | The session-JWT claim set: the prefixed iss / aud / sub, the jti pinned to the SessionID, the kind and discriminated target, and the iat / nbf / exp Unix-seconds timestamps. | ../../internal/access/jwt/claims.go (Claims) |
| SessionIssued / SessionRevoked | The two domain events. SessionIssued (discriminator access.SessionIssued) carries the session_setup payload; SessionRevoked (discriminator access.SessionRevoked) carries the session_revoked payload. The closed two-member set is pinned by an AST gate. | ../../internal/access/events/events.go |
| SessionView | The transport-local, metadata-only projection of a Session the read surface returns. It carries the identity, scope, status, timestamps, the jti, and the signing-key id — but no token, JWT, or secret field. | ../../internal/transport/http/v1/access/wiring.go (SessionView) |
The Session aggregate and its invariants
Session (../../internal/access/session.go) records one issued grant. Fields are unexported so the aggregate's invariants can only be established through New (a fresh issuance) or Hydrate (a persisted one). New enforces, at construction time, every invariant below; each failure is a distinct boundary error:
domainID,projectID,resourceID, andidentityIDare each non-zero.kindis a validKind(it round-trips throughParseKind).targetis set and itsKind()matches the Session'skind— an ssh kind with a k8s target is refused.ttl,maxTTL, andidleTimeoutare each set.issuedAtis set.
Having validated the inputs, New settles the derived state: it mints a fresh UUIDv7 SessionID, pins the jti to it via JTIFromSessionID, sets notBefore equal to issuedAt (coerced to UTC), and computes expiresAt as issuedAt plus the clamped TTL.
DECISION: an over-ceiling TTL is clamped to maxTTL, not rejected. Issuance clamps the requested TTL down to the per-Domain MaxTTL, so a caller requesting a longer lifetime gets the longest the policy allows rather than a failed issuance; rejecting an over-ceiling TTL would turn a benign over-ask into an error the caller must special-case, where the policy's intent is simply to bound the lifetime. A TTL at or under the ceiling is admitted unchanged.
The signing-key id and the idempotency key are not supplied to New: the signing-key id is known only after the Signer reports the key it used, and the idempotency key is request-scoped. Both are stamped onto a freshly-built Session through the copy-returning value-receiver mutators WithSigningKeyID and WithIdempotencyKey, which keep the aggregate immutable — each returns a modified copy rather than mutating the receiver in place.
Hydrate is the repository's reconstruction entry point. It rebuilds a Session from persisted state without re-running New's business validation: it still rejects structurally-impossible state (zero ids, zero timestamps) so a corrupt row never produces a half-formed aggregate, but it trusts the kind, target, and timing envelope the persistence layer already validated on write. Crucially it does not re-clamp the TTL or recompute expiresAt — those were settled at issuance and are reproduced as stored, so a later policy change can never retroactively shorten a live session.
IsExpiredAt(now) reports whether the hard expiry falls at or before now; the callback and sweeper paths read it to decide liveness.
Per-Kind SessionTarget value objects
SessionTarget (../../internal/access/target.go) is a single discriminated union — one tagged-union struct with an unexported kind discriminator and per-variant fields — rather than three types behind an interface.
DECISION: one discriminated union type rather than three types behind an interface. The persistence layer stores the target in one
jsonbcolumn and the aggregate carries exactly one target value, so a single type with a kind discriminator round-trips through oneMarshalJSON/UnmarshalJSONpair; an interface with three implementors would force the repository to type-switch on read and would not map cleanly onto the single column.
The three constructors enforce per-variant invariants and copy their slices defensively so a target never aliases the caller's input:
NewSSHTarget(user, allowedCommands)—usernon-empty; the optional command allow-list is capped atMaxAllowedCommands(64) entries and each command atMaxCommandBytes(1024) bytes; an empty command in the list is refused.NewK8sTarget(user, impersonationGroups)—usernon-empty; the optional impersonation-group set is capped atMaxImpersonationGroups(32) entries; an empty group is refused.NewTCPTarget(host, port)—hostnon-empty andportin the valid range1..65535. The tcp shape is kept protocol-opaque: it carries only the host and port the stream is mediated to, never an L7 interpretation.
The wire shape is the targetJSON struct with the exact lowercase keys kind, user, allowed_commands, impersonation_groups, host, port — each optional per-kind field carries omitempty so a variant serialises without the fields it does not use. UnmarshalJSON re-validates each variant through its constructor, so a corrupt or hand-edited target row never produces a half-formed value, and an unknown kind discriminator is rejected.
The element caps above are complemented by a whole-document byte ceiling, MaxSessionTargetBytes (96 KiB) (../../internal/access/constants.go): the per-element caps bound count and per-element length but not the combined jsonb size, so both are enforced to keep the control-plane target column index- and replication-friendly.
The JWT claim set and canonical-bytes encoder
The jwt sub-package (../../internal/access/jwt/claims.go) owns the session-JWT vocabulary: the claim set, the deterministic canonical-JSON encoder the Signing Service signs over, the protected header, and the verifier that rejects the alg-confusion and missing-claim foot-guns.
Claims
NewClaims composes the claim set and validates a token that can never be valid is never built — every id is non-zero, the kind is valid, the target matches the kind, and the timing envelope is well-ordered (iat <= nbf < exp). The claim names and forms are the wire contract:
| Claim | Form | Notes |
|---|---|---|
iss | plexsphere://domain/<domain-uuid> | The issuing Domain. |
aud | resource://<resource-uuid> | The target Resource the verifier guards. |
sub | identity://<identity-uuid> | The requesting principal. |
jti | <session-uuid> | Equals the SessionID, so a revoked Session and its token share one identity on the revocation list. |
kind | ssh / k8s / tcp | The mediated-access kind. |
target | the discriminated SessionTarget object | The per-kind target. |
iat / nbf / exp | Unix seconds | The timing envelope; nbf equals iat. |
Canonical-bytes determinism
CanonicalClaims (../../internal/access/jwt/canonical.go) encodes the claims as deterministic canonical JSON: object keys are emitted in sorted (lexicographic) order at every nesting level and HTML escaping is disabled so the bytes are stable across encoders. The Signing Service signs over exactly these bytes and the verifier recomputes them, so any drift in the byte sequence would invalidate every previously-issued token.
DECISION: the canonical form is produced by decoding to a generic value and re-encoding with sorted keys, rather than trusting
encoding/json's struct-field emission order. The claim set embeds theSessionTargetvalue object, whose ownMarshalJSONcontrols its key order, and a future field addition or reorder on either struct must not silently change the signed bytes; only an explicit sort guarantees a stable, cross-version canonical form.
Protected header
The JOSE protected header (../../internal/access/jwt/header.go) pins alg to EdDSA, typ to at+jwt (an RFC 9068 access token, so a relying party distinguishes an access token from an id token by the media type), and kid to the signing key the verifier selects the public key with. NewHeader refuses an empty kid so a malformed token is never signed.
The verifier and its rejection sentinels
Verify (../../internal/access/jwt/verify.go) runs checks in a fixed order — structural parse, header (alg then kid), signature, then the claim checks (issuer, audience, expiry) — so a token failing several checks fails on the earliest, most fundamental one. Each failure surfaces a distinct sentinel so a relying party branches on exactly which foot-gun a token tripped, never on a stringly-typed message:
ErrMalformedToken— the compact serialisation is not three non-empty base64url segments, or a segment fails to decode or JSON-parse.ErrUnsupportedAlg—algis anything other thanEdDSA, most importantly thenonealg-confusion foot-gun, refused up front so an unsigned token is never trusted.ErrMissingKid— an emptykid, so no public key can be selected.ErrSignatureInvalid— the EdDSA signature does not verify against the supplied public key over the signing input.ErrMissingIssuer— an emptyissclaim, so the token names no issuing Domain.ErrAudienceMismatch— theaudclaim does not equal the audience the verifier expects for the Resource it guards.ErrTokenExpired— theexpclaim is at or before the verification time.
The signing input the signature is computed over is base64url(header).base64url(canonical-claims) — SigningInput builds it so the issuer and verifier agree byte-for-byte, and Assemble joins the input and the raw signature into the compact serialisation.
Per-Domain SessionPolicy
SessionPolicy (../../internal/access/policy.go) is the access context's in-context snapshot of the per-Domain session-issuance policy the application services enforce. The pinned defaults DefaultSessionPolicy returns are the single source of the numbers:
| Field | Default | Meaning |
|---|---|---|
DefaultTTL | 30 minutes | TTL applied when an issuance omits one. |
MaxTTL | 4 hours | The per-Domain ceiling the Session aggregate clamps a requested TTL down to. |
IdleTimeout | 15 minutes | The inactivity window after which the sweeper revokes a Session. |
MaxConcurrentPerIdentityPerResource | 3 | Live Sessions one Identity may hold against one Resource. |
MaxConcurrentPerIdentityPerDomain | 20 | Live Sessions one Identity may hold across a whole Domain. |
MaxConcurrentPerResource | 10 | Live Sessions all Identities together may hold against one Resource. |
IssuanceRateLimit | 1 issuance/second sustained, burst 5 | The per-Domain token-bucket rate limit on issuance. |
StepUpRequiredKinds | empty | Kinds that always require step-up regardless of the per-Resource flag; empty means no kind forces step-up by policy alone. |
StepUpRequiredACRValues | empty | The closed set of acr value classes the Domain accepts as a satisfied step-up. When step-up is required the principal must present at least one member (set-membership, not mere non-emptiness); empty means any non-empty acr satisfies the floor and the acr_values challenge names no required value. |
StepUpFreshnessSeconds | 600 (ten minutes) | The maximum age of the step-up auth_time the issuance gate accepts (inclusive: an auth_time exactly 600 s old is still fresh). |
DECISION: the default issuance rate limit is 1 issuance/second sustained with a burst of 5. Session issuance is an interactive, human-driven operation (an operator opening an ssh / k8s / tcp session), not a machine firehose, so a sustained rate above ~1/s would only widen a credential-stuffing or token-minting abuse window without serving a real workflow; the burst of 5 absorbs a legitimate operator opening one ssh, one k8s, and one tcp session back-to-back without throttling while still capping a runaway loop.
A non-positive cap is treated as unbounded so a Domain that leaves a cap unset is not accidentally locked at zero. RequiresStepUpForKind(k) reports whether the policy alone forces step-up for a kind regardless of the per-Resource flag.
DECISION: the access context owns a
SessionPolicysnapshot type distinct fromtenancy.SessionPolicyrather than importing the tenancy type. The depguard no-cross-context-imports rule denies the access context aninternal/identityimport, so the canonicaltenancy.SessionPolicyvalue object cannot cross into this package; a thin composition-root adapter copies the tenancy snapshot into this type field-for-field instead. This mirrors how the actions and bridge contexts keep local mirrors of cross-context value shapes.
The snapshot is sourced through the PolicyReader port (../../internal/access/ports.go). The production adapter (../../cmd/plexsphere/access_factory_prod.go) runs the sqlc GetDomainByID query, decodes the per-Domain session_policy jsonb column into the tenancy snapshot, applies the zero-injects-default resolver, and folds it onto the access-local SessionPolicy. An empty object ('{}') — the column's default — resolves to the pinned defaults so an untuned Domain still issues sessions under the safe defaults.
Issuance state machine
A Session moves through a small, explicit lifecycle: pending-check → issued → { revoked | expired | idle_timed_out }. Each transition has exactly one emitter.
pending-check → issued
The IssueService (../../internal/access/services/issue.go) runs the issuance pipeline in order — the order is the contract:
- Boundary validation — the ids, subject, kind, and target are present and well-formed.
- Authorization hard gate — the subject must hold the
actrelation on the target Resource (resource:<uuid>), else a denied audit row is emitted andErrPermissionDeniedis returned before any read or write. - Scope + policy resolution — the Resource resolves to its (Domain, Project) scope and step-up flag; the per-Domain
SessionPolicyis read. - Step-up gate — when the Resource flags step-up or the policy forces step-up for the kind, the principal must present an
acrthe Domain accepts (set-membership againstStepUpRequiredACRValues) and a freshauth_time, elseErrStepUpRequired(see Step-up enforcement). - Idempotency lookup — a hit on the
(Identity, key)pair issued within the fixed 5-minute dedupe window (IdempotencyDedupeWindow) short-circuits to the existing Session, re-minting the identical token with no new persist. The window — not the session lifetime — is the dedupe contract: a replay of the same key after the window mints a fresh Session even while the original is still live. - Concurrency caps + rate limit — each of the three live counts must stay under its policy cap and the per-Domain token bucket must admit the issuance, else
ErrSessionLimitExceeded. - Aggregate construction — the TTL is clamped to MaxTTL inside
New; a zero requested TTL defaults to the policy'sDefaultTTL. - Mint, sign, assemble — the claims are built, canonicalised, signed, and assembled into the compact JWT; the aggregate is stamped with the signing-key id and the idempotency key.
- Atomic persist —
CreateSession+AppendOutboxEvent(thesession_setupevent) commit in oneRunInTx. - Post-commit — the live-sessions gauge and issuance counter are incremented, the granted audit row is emitted, and the optional wire push fires.
DECISION: an idempotent replay re-derives the token by re-signing the same canonical claims rather than persisting and returning the original token bytes. The
access_sessionrow deliberately does not store the assembled JWT (the projection is metadata-only; the token never rests at rest in the control plane), so the only way to return the same token on a replay is to rebuild the claims from the stored aggregate and re-sign; the signer is deterministic over the canonical bytes for a given key, so a re-sign reproduces the identical token. Persisting the token bytes was rejected because storing the bearer token at rest is the very exposure the metadata-only projection avoids.
The signer is consulted in two phases against the same Signer port: a first Sign over the canonical claims discovers the active kid (its signature is discarded), then the header is built with that kid and a second Sign over the real compact signing input produces the signature embedded in the token. This is necessary because the protected header — which carries the kid — is part of the signed bytes, but the kid is only known after the Signer reports the active key; the narrow Signer port exposes only Sign, not an active-key probe. A follow-up tracked in the source collapses the two phases once the port gains a probe.
issued → revoked
The RevokeService (../../internal/access/services/revoke.go) revokes a live Session: in one RunInTx, SetRevoked stamps revoked_at and the reason and appends the revocation-list row, and — only when the guarded UPDATE actually matched a live row — appends exactly one session_revoked outbox row in the same transaction. Revocation is idempotent: a second revoke against an already-revoked Session is a no-op that emits nothing (the deny-set entry and the event were written by the first revoke). Post-commit, and only when the revoke landed, it emits one access.revoke audit row, decrements the live-sessions gauge, and fires the optional wire push.
DECISION:
RevokeServicecarries no Authorizer. The authorization for a revoke is owned by the transport boundary (the operator's relation on the Session's Resource is checked beforeRevokeis invoked) and by the sweeper's system-initiated path (which has no human subject to gate); the service trusts its caller, exactly as the callback service trusts the authenticated-node boundary. The audit row still records the acting subject so the trail names who revoked.
issued → expired | idle_timed_out
The Sweeper (../../internal/access/services/sweeper.go) is the background revocation sweeper. On each pass it reads the live Sessions whose hard expiry has passed or whose last activity is older than their idle timeout, and routes each one through the revoke path — so a swept Session emits the session_revoked event, the audit row, and the revocation-list entry exactly as an operator-initiated revoke does. The reason recorded is ttl_expired when the Session's hard expiry has passed at now, else idle_timeout; the acting subject is the system principal system.
DECISION: the sweeper depends on a narrow
sessionRevokerinterface (theRevokeService.RevokeSessionsurface) rather than the concrete*RevokeService. The interface makes the sweep's per-Session disposition testable in isolation and keeps the dependency direction pointing at a behaviour, not a struct; embedding the revoke logic directly in the sweeper was rejected because it would duplicate the outbox + audit + revocation-list emission theRevokeServicealready owns, drifting the two paths.
A per-Session failure inside a pass is logged and skipped so one poisoned row never aborts the whole sweep. Reconcile runs one pass and records a monotonically-advancing last-tick timestamp the boot probe reads to assert progress; SweepStart runs the tick loop until the context is cancelled. The default batch limit is 100 past-due Sessions per pass and the default cadence is 30 seconds.
Audit contract
Every mutation and every denial emits one audit row through the nil-tolerated AuditSink port. The row's Relation field names the operation (distinct from the ReBAC relation act), using the dotted-snake audit relations the services stamp:
| Relation | Emitted by | When |
|---|---|---|
access.issue | IssueService | A granted issuance, and an issuance refused at the authorization hard gate (outcome denied). |
access.revoke | RevokeService | A performed revocation (outcome granted); an idempotent no-op emits nothing. |
access.callback | SessionCallbackService | An accepted per-kind activity callback (outcome granted). |
The transport layer (../../internal/transport/http/v1/access/errors.go) emits its own audit-first rows for the transport-tier decisions — the access.issue / access.list / access.get / access.revoke gates, the denials, and the body-shape rejections — in addition to the service's lifecycle emission. The audit row is written before the response is flushed so a flaky audit backend cannot land a silent denial; a sink error is made loud via a structured slog breadcrumb but never propagated, so a flaky backend cannot turn a successful operation into a user-visible 5xx. The production adapter folds the access-local outcome strings onto the canonical four-value audit.Reason enum (../../cmd/plexsphere/access_factory_prod.go), and the chained sink writes each row into the per-Domain audit hash chain — a production rollout that emits only into slog is refused at boot.
DECISION: an unrecognised outcome maps onto the insufficient-relation reason (fail-closed) rather than the granted reason. A future service outcome string the mapping does not yet know about must not be recorded as a grant; classifying an unknown outcome as the weaker denial-shaped reason keeps a forgotten enum extension auditable rather than silently laundering it into a grant.
The per-kind activity callback
The append-only plexsphere.access_session_event table (../../internal/platform/db/migrations/0044_access_sessions.sql) is the transition / activity log of every session lifecycle change, keyed on (session_id, seq) with an app-supplied seq so the per-session order is a structural fact the timeline reads back without a sort. It FKs access_session with ON DELETE CASCADE — an event belongs to its Session and has no life outside the aggregate root.
The SessionCallbackService (../../internal/access/services/callback.go) is the application service for the activity-callback path. A target plexd reports the per-kind activity it observed on a mediated session; Apply loads the Session first (so the kind it validates against is the persisted Session kind, never a caller-supplied one), refuses a callback for a dead (revoked or hard-expired) Session with ErrSessionNotLive, validates the per-kind shape against the closed set and the per-kind Detail bound, and on acceptance appends the activity event and stamps last_active so the idle sweeper measures inactivity from the last touch. Because the kind is read from the Session, a cross-kind event (e.g. a k8s api_request reported on an ssh session) is structurally impossible and the per-kind Detail bound cannot be bypassed by declaring a different kind. The closed per-kind activity sets and Detail bounds are:
| Kind | Activity types | Detail |
|---|---|---|
ssh | command_executed, command_exited | The ssh command bytes, capped at MaxCommandBytes (1 KiB); an over-cap detail is ErrInvalidActivity. |
k8s | api_request | The api-request attributes, capped at MaxSessionEventDetailBytes (4 KiB); an over-cap detail is ErrInvalidActivity. |
tcp | session_started, session_ended | No L7 payload — a non-empty Detail is ErrInvalidActivity. |
The activity types in this table are the internal per-kind discriminators the callback service validates and persists into
access_session_event. The externalPOST /v1/nodes/{id}/sessions/{session_id}OpenAPI body is a richer wire shape (it carries a verb-style activity field); the composition-root callback adapter maps the wire shape onto these internal types. Likewise the k8s impersonation set is theimpersonation_groupskey in the JWT /target_paramsjsonb, while the HTTP issue body usesimpersonate_groups; the transport handler maps between the two. Neither is drift — they are the wire ↔ domain projections.
DECISION: the tcp shape admits only
session_started/session_endedand carries no L7 payload. The tcpSessionTargetitself is protocol-opaque (host + port only), so the control plane records that a raw stream opened and closed but never inspects or stores its bytes; admitting acommand_executed/api_requestshape for tcp would imply an L7 interpretation the tcp mediation deliberately does not perform.
DECISION:
SessionCallbackServicecarries no Authorizer. The calling target's identity is authenticated at the transport / callback-token layer beforeApplyis invoked (the target presents the per-session callback credential the transport validates), so re-checking a ReBAC relation here would gate the activity path on a graph the target never participates in.
The transport surface for the callback is the NSK-authenticated POST /v1/nodes/{id}/sessions/{session_id} operation, wired onto the callback service at the composition root.
Closed wire-literal extension
The two domain events route onto a closed two-member set of wire literals on the Signed Event Bus. The publisher (../../internal/mesh/sse/publisher.go) maps the outbox event_type through its wireTypeFor dispatch table: access.SessionIssued → session_setup and access.SessionRevoked → session_revoked. A plexd consumer branches only on the wire Type, never on the outbox event_type column — it receives the per-Node session_setup payload that provisions an ssh/k8s/tcp session locally, or the session_revoked payload that tears one down.
DECISION: adding a wire type is a two-line change — a new
EventType…constant declaration and a new arm in thewireTypeForswitch — and thePublishhot path body never changes. The wire literal set is closed: a per-deployment plugin point would invite drift the verifier cannot detect, and the workspace dispatch-table gate refuses any drift between the constant set and the switch.
The closed access-event set itself is pinned by an AST gate over the events sub-package, so the two discriminators cannot silently grow a third. The events carry no key material and no bearer token: the session_setup payload carries the session and token ids, the kind, the discriminated target, the hard expiry, the idle timeout, and the callback URL; session_revoked carries the session and token ids, the revocation time, and the rationale.
The WirePublisher port the services may call for an immediate post-commit push is nil-tolerated and currently left unwired at the composition root — the access events ride the outbox relay alone, the same deferred posture the actions factory takes, so no event is dropped. A non-nil port turns immediate wire emission on as a strict superset of the outbox flow.
ReBAC reuse of resource#act
Authorization reuses the act relation the actions and bridge contexts already gate on, so the authz object model stays uniform. The gate is a hard gate run before any body decode, persistence read, or write. The object the gate pivots on differs by operation:
- IssueSession gates on
resource:<resource_id>— the targeted Resource named in the request body, because a Session always targets a concrete Resource. - ListSessions / GetSession / RevokeSession gate on
project:<project_id>— at the transport boundary those routes carry only the Project (List) or a Session id whose owning Resource is not known until a persistence read, so the transport gate enforces the Project-residency boundary first and the application service runs the fine-grained per-Session / per-Resource check inside its transaction.
DECISION: the List / Get / Revoke gate pivots on the Project, not a synthetic
resource:<project_id>. The schema backs the split:resource.actderives from the parent Project'sact, so a principal authorised on a Resource is authorised on its parent Project, andproject.actis a real permission the gate resolves against. Routing those gates at a syntheticresource:<project_id>object would address a non-existent Resource object and surface as a relation-not-found fault rather than a clean denial.
An authz denial is rendered as a PermissionDenied body (the wire shape every other ReBAC denial uses) with the audit-first row emitted before the response is flushed. The issuance service collapses an authz denial onto ErrPermissionDenied; a scope-isolation breach distinct from a ReBAC denial surfaces as ErrOutOfScope, rendered as a PermissionDenied body with the out_of_scope reason. The closed Problem-code taxonomy this surface emits is pinned as constants in the transport errors.go so the OpenAPI drift gate can pivot the wire contract on the byte-for-byte string.
The metadata-only contract is structural, not a reviewer obligation:
DECISION: the
SessionViewprojection is metadata-only — it carries no token, JWT, or secret field. The signed session-scoped JWT is delivered exactly once, inline in the IssueSession 201 response, and never persisted server-side beyond itsjtiand expiry. Modelling the read projection without a token field makes the non-leakage of the JWT through List / Get structural — the compiler refuses any code that tries to re-expose it.
Step-up enforcement contract
The step-up gate fires during issuance when the Resource flags step-up or the per-Domain policy forces step-up for the kind (RequiresStepUpForKind). It is resolved through the IdPClaims port (../../internal/access/ports.go), which exposes the SET of acr value classes the principal presented (PresentedACR), the authentication-methods (amr), and the auth_time as a read seam rather than having the issuance service parse the raw IdP token. The gate (../../internal/access/services/issue.go) runs two arms, both of which must pass:
- ACR arm (set-membership) — when the Domain names specific
StepUpRequiredACRValues, the presented set must contain at least one of them; when the Domain names none, any non-empty presentedacrsatisfies the floor. A non-empty-but-wrongacris refused, not only an empty one — the gate enforces membership, not mere non-emptiness. - Freshness arm — the
auth_timemust be present and no older than the policy'sStepUpFreshnessSecondswindow measured against the service clock (the boundary is inclusive: anauth_timeexactly the window's age is still fresh). A zeroauth_timefails closed.
On failure the gate returns a typed *access.StepUpError carrying the Domain's RequiredACRValues and a discriminated Reason (insufficient_acr vs stale); it unwraps to ErrStepUpRequired so errors.Is branches still match. An API-token principal — which presents no acr and a zero auth_time — fails closed. The transport layer renders the refusal as a 401 with an RFC 9470 §3 WWW-Authenticate: Bearer error="insufficient_user_authentication" challenge; when the Domain names required ACR values they are appended as the acr_values parameter (RFC 9470 defines only acr_values, so a Domain that names none emits the bare challenge). The body is a Problem with code step_up_required so a client can branch without parsing the header; the stale-vs-insufficient_acr distinction is carried on the typed StepUpError.Reason for the audit trail rather than the wire code (RFC 9470 has a single code for both).
The production IdPClaims adapter (../../cmd/plexsphere/access_factory_prod.go) is a thin Principal-backed adapter: it reads the authenticated caller from the request context (authn.FromContext) and surfaces the concrete acr / amr / auth_time that principal actually presented, so the freshness and ACR arms are evaluated against the live credential. The freshness arm and the policy-driven ACR arm are therefore fully functional in production; an operator configures step-up on the per-Domain SessionPolicy (StepUpRequiredKinds + StepUpRequiredACRValues + StepUpFreshnessSeconds).
Deferred sub-arm. The per-Resource step-up flag (
resources.step_up_required) is the one step-up sub-arm not yet wired end-to-end: theResourceaggregate carriesStepUpRequired()but the column is not persisted and no write path sets it, so the productionResourceReaderreportsStepUpRequired=falseand defers to the per-Domain policy matrix. Tracked byTODO(security, PX-0077)in theaccessResourceReaderadapter. Adding the column without a write path would ship a read that is always false.
Persistence
The schema lives in two migrations. 0044_access_sessions.sql (../../internal/platform/db/migrations/0044_access_sessions.sql) adds three tables:
plexsphere.access_session— the Session aggregate root, one row per minted session. The app-minted UUIDv7session_idis the primary key and is the tokenjti(no database DEFAULT, so the row key is thejtiembedded in the signed token written in the same transaction).domain_id/project_id/resource_idFK the tenancy parentsON DELETE RESTRICTso the scope cannot be deleted while a session references it;identity_idcarries no FK so a token-initiated (service-identity) session is not rejected.kindis enum-via-CHECK (ssh/k8s/tcp);target_paramsisjsonbwhose discriminator (target_params->>'kind') must equalkind.idle_timeout_secondsandttl_secondssnapshot the per-Domain policy at issuance so a later policy change never retroactively shortens a live session.revoked_at/revoke_reasonstay NULL while the session is live.plexsphere.access_session_event— the append-only transition / activity log keyed on(session_id, seq), FKaccess_sessionON DELETE CASCADE.plexsphere.access_revocation_list— thejtideny set a verifier consults before honouring a session token.jtiis the primary key (it equals the revoked session'ssession_id); the row is deliberately not a child ofaccess_sessionso a revocation outlives the session row it tombstones.expires_atlets a reaper drop entries once the deny row's retention has elapsed, keeping the deny set bounded. The retention is anchored at the revocation instant plus a fixed floor —revoked_at + max(MaxTTL, 4h)(access.DefaultRevocationTTLFloor, operator-overridable viaPLEXSPHERE_ACCESS_REVOCATION_TTL_FLOOR) — not the revoked session's own (possibly short) expiry. Anchoring at the floor means a short-lived session's deny row still outlives the longest token any Domain can mint, so a late-presented sibling token cannot slip past a prematurely-reaped deny set.
The keyset-pagination index access_session_resource_keyset_idx matches the List query's (resource_id, issued_at DESC, session_id DESC) order; the partial index access_session_live_by_identity_resource_idx (WHERE revoked_at IS NULL) backs the concurrency caps so a live-count never scans revoked history; access_revocation_list_expiry_idx backs the reaper.
0045_domain_session_policy.sql (../../internal/platform/db/migrations/0045_domain_session_policy.sql) adds the per-Domain session_policy jsonb column to plexsphere.domains, additive with a NOT NULL DEFAULT '{}'::jsonb so every existing Domain gains the column without a backfill — the empty object round-trips through the resolver as "use the pinned defaults".
DECISION: both migrations refuse the downgrade with SQLSTATE
0A000. The three access tables hold the operator-authored record of which identities opened which sessions against which Resources, the append-only transition log that is the evidence trail of how each session settled, and the revocation deny set a verifier consults to reject a compromised token — material not reconstructible from anywhere else, and dropping it would re-honour every revoked token. Thesession_policycolumn likewise holds every operator-authored per-Domain access posture, which a downgrade-then-upgrade cycle would silently reset to the defaults, weakening the access controls without a trace. Operators performing a legitimate wipe-and-reinstall drop the Postgres database itself; the down path is not a teardown tool.
The Postgres adapter (../../internal/access/repo/) is the only package in the context that imports pgx; the driver is confined there so the domain, ports, services, and events layers stay framework-free. It exposes the aggregate-shaped access.Repository method signatures and runs each operation inside a short ReadCommitted transaction so the services' RunInTx composition shares one MVCC snapshot across the per-aggregate mutation and the outbox emission. A missing row surfaces as ErrSessionNotFound, which the production service adapter folds onto the transport read sentinel.
Observability and metrics
The application services emit three Prometheus collectors through a zero-value-tolerant bundle (../../internal/access/services/metrics.go):
plexsphere_access_live_sessions{domain_id}— a gauge of the live (not-yet-revoked, not-yet-expired) sessions per Domain. Issuance increments it after a session commits; revocation and the sweeper decrement it. The three live-touching services share one registerer so the increments and decrements settle the same gauge.plexsphere_access_sessions_issued_total{domain_id}— a counter of admitted issuances per Domain.plexsphere_access_issuance_duration_seconds— an unlabelled histogram of one issuance's duration, from request to signed token.
DECISION: the gauge and counter carry exactly the
{domain_id}label and the histogram carries none. The per-Domain concurrency caps and the issuance rate limit are themselves keyed on Domain, so a Domain dimension is the operationally-meaningful split; a subject / resource / session-id label would be unbounded (one new series per principal, target, or session) — that high-cardinality identity belongs in the structured slog line and the audit trail, never in a metric label. The latency histogram stays unlabelled because the distribution is a control-plane-wide health signal.
Composition root and operations
The composition root (../../cmd/plexsphere/access_factory_prod.go) assembles the repo + application-service + transport-adapter graph that flips the four /v1 session handlers (issue / list / get / revoke) off their 501 stub and wires the NSK-authenticated callback service. The surface is opt-in via PLEXSPHERE_DSN — an empty DSN keeps the handlers on the 501 stub. When DSN is set, two further pins are required and refused if empty after trimming: PLEXSPHERE_ACCESS_SIGNER_ENDPOINT (the issuance service signs every JWT through the signer) and PLEXSPHERE_ACCESS_CALLBACK_BASE_URL (a target plexd needs a reachable callback). The optional signer mTLS triple (…SIGNER_CLIENT_CERT / …SIGNER_CLIENT_KEY / …SIGNER_SERVER_CA), the cursor HMAC key (PLEXSPHERE_ACCESS_CURSOR_HMAC_KEY, hex, ≥ 32 bytes; missing falls back to the identity codec), the sweeper cadence (PLEXSPHERE_ACCESS_SWEEPER_TICK), and the revocation-TTL-floor knob round out the surface.
The browser-SSH attach gateway has its own composition root (../../cmd/plexsphere/access_attach_factory_prod.go), opt-in on the same PLEXSPHERE_DSN gate, with two optional knobs that fall back to package defaults so the surface activates with DSN alone: PLEXSPHERE_ACCESS_ATTACH_LISTENER_PORT (the plexd mediated-listener port the gateway dials, integer in 1..65535, default 7222) and PLEXSPHERE_ACCESS_ATTACH_DIAL_TIMEOUT (a Go duration bounding a single mesh dial so a wedged target cannot pin the WebSocket upgrade, must be positive, default 10s).
The factory registers an idle/expiry Reconciler (run once at boot and on every /readyz tick, delegating to Sweeper.Reconcile) and a steady-state Sweep goroutine. Both are typed as plain func(ctx) error so this composition root does not import internal/platform/bootstrap.
DECISION: a replica count above one is refused at build time. The idle/expiry Reconciler/Sweep run on every replica without leader election, so a multi-replica rollout would stage N concurrent sweeps against the same past-due-session partition; the
PLEXSPHERE_REPLICASguard keeps the unsafe topology loud, mirroring the single-replica posture the actions timeout sweep guards.
Deferred-work boundaries
This context is the session-credential producer and lifecycle owner. The following are deliberately out of scope for this slice and integrate through named seams a later story wires:
- Secret material custody. The per-kind target carries only the operator-supplied coordinates (the login user, the host:port) — never credential material; the auth secrets a mediated session ultimately consumes are held by the Secret Store seam, not this context.
- Hook / binary integrity correlation. This context tracks session lifecycle and activity; correlating the reported per-kind activity (or a target's advertised hooks) against a known-good integrity baseline belongs to the Hook Integrity ingest arm, not this context's callback path.
- Dual-control approval of an issuance. This context runs the
resource#actReBAC gate and the step-up gate before minting; gating a high-risk issuance behind a propose → approve dual-control workflow is the generic Approval gate's concern, layered on top of issuance rather than baked into it. - Immediate wire fan-out. The
WirePublisherport is declared and nil-tolerated but left unwired; the access events ride the outbox relay until the access wire payload/subject mapping lands. - Per-Identity IdP claim store. The step-up gate reads
acr/amr/auth_timethrough theIdPClaimsport, which the production adapter currently backs with fail-closed defaults until the per-Identity claim store is persisted.
Cross-references
../../internal/access/doc.go— the ubiquitous-language boundary and the in-scope / out-of-scope statement.../../internal/access/session.go— theSessionaggregate, itsNew/Hydrateinvariants, the TTL-clamp DECISION, and the post-New stamp mutators.../../internal/access/target.go— the discriminatedSessionTarget, the per-variant constructors and caps, and the single-union DECISION.../../internal/access/types.go— theKindclosed set, the[16]byteid value objects,JTI,TTL,IdleTimeout, andRevokeReason.../../internal/access/policy.go— theSessionPolicysnapshot, the pinned defaults, the rate-limit DECISION, and the access-owns-a-snapshot DECISION.../../internal/access/constants.go— the element caps, the whole-document byte ceiling, and the revocation-TTL floor helper.../../internal/access/errors.go— the domain sentinels and the no-HTTP-status-in-the-domain DECISION.../../internal/access/events/events.go— theSessionIssued/SessionRevokedevents, their discriminators, and thesession_setup/session_revokedpayload shapes.../../internal/access/jwt/claims.go,canonical.go,header.go, andverify.go— the claim set, the canonical encoder, theat+jwt/EdDSA/kidheader, and the verifier's rejection sentinels.../../internal/access/ports.go— theRepository/Signer/Authorizer/AuditSink/WirePublisher/Clock/IdPClaims/ResourceReader/PolicyReaderseams and the required-vs-nil-tolerated split.../../internal/access/services/issue.go,revoke.go,sweeper.go, andcallback.go— the issuance pipeline, the idempotent revoke, the expiry/idle sweep, and the per-kind callback.../../internal/access/services/metrics.go— the live-sessions gauge, the issuance counter, and the issuance-duration histogram.../../internal/access/repo/— the Postgres adapter, the SQLSTATE-to-sentinel dispatch, and the short-transactionRunInTxcomposition.../../internal/access/signer_client.go— the pooled, retrying gRPC client onto the Signing Service theSignerport is satisfied by.../../internal/transport/http/v1/access/doc.go,wiring.go, anderrors.go— the metadata-onlySessionViewprojection, the closed Problem-code taxonomy, the step-up challenge writer, and theresource:/project:authz split.../../cmd/plexsphere/access_factory_prod.go— the composition root, the env contract, the policy fold, the fail-closed IdP adapter, the sweeper probe, and the replica-safety validation.../../internal/mesh/sse/publisher.go— thesession_setup/session_revokedwire literals and thewireTypeFormapping.../../internal/platform/db/migrations/0044_access_sessions.sqland0045_domain_session_policy.sql— the three access tables, the per-Domain policy column, and the downgrade-refusal posture.../../api/openapi/plexsphere-v1.yaml— theIssueSession/ListSessions/GetSession/RevokeSessionoperations and their request / response schemas.../contributing/layout.md— the package-to-subsystem map and the depguard rules that enforce the context's isolation.../../README.md#access-orchestrator— the system-level Access Orchestrator subsystem description.