Skip to content

Security model

This page is the consolidated security-control map for the platform. It names each core control, the seam that enforces it, and the regression tests that keep the control honest, then closes with the findings ledger from the most recent security review. It is a navigation surface, not a duplicate of the per-context references: the detailed mechanics live in the bounded-context docs cross-linked below, and this page points at them rather than re-deriving them.

For the per-context detail behind the controls inventoried here, see Retention and right to erasure, Access Orchestrator, Secret Store, Threat model and boundaries, and Signing security invariants.

Control inventory

The platform's security posture rests on a small set of controls, each owned by one seam. The Source column cites the seam as a repo-relative path so a reviewer can read the enforcement next to its claim.

ControlWhat it enforcesSource
Signed event publishEvery mesh event envelope is signed before it is written; an unsigned envelope is never published.internal/mesh/sse/publisher.go
Signed event bus dispatch gateThe node-events read surface fails closed to HTTP 501 unless all five dispatch ports are wired; the production verification path is intentionally not wired.internal/transport/http/v1/handlers/events_dispatch.go
Node-secret plaintext invariantNode secret keys are persisted wrapped-only; a compile-time column guard refuses any plaintext column.internal/identity/nodes/repo/nsk_no_plaintext_assertion.go
Audit hash chainThe append-only audit log is a tamper-evident hash chain; the verifier names any divergent sequence number.internal/audit/chain/verify.go
Step-up issuance gateElevated session issuance requires a fresh assurance class before a token is minted.internal/access/services/issue.go
Secret read fail-closed auditEvery secret read is rate limited and audited; a read whose audit row cannot be persisted returns no ciphertext.internal/secrets/services/fetch_service.go
OIDC id-token validationIdentity tokens are rejected on expired, not-yet-valid, wrong-audience, and wrong-issuer claims before a session is established.internal/identity/idp/oidc/adapter.go

Signed event bus signing coverage

The node-events read surface is gated behind five dispatch ports: the event stream, the nonce store, the signature verifier, the relation checker, and the node repository. If any one of these ports is nil the dispatch path returns HTTP 501 with the Problem code problemCodeSignedEventBusNotProvisioned. The gate is all-or-nothing: there is no half-on configuration where the surface answers without a complete dispatch graph.

In production the verification path is deliberately left unwired. SetSignatureVerifier, SetRelationChecker, and SetNodeRepo are never called in cmd/plexsphere, so at least one of the five ports is always nil and the endpoint fails closed to 501. This page makes no claim of a live verification path; the read surface fails closed until the follow-up signed-event-bus wiring story brings the production bus online.

On the publish side the property is the inverse and is always active: the publisher signs the envelope before it publishes, and it refuses to write an unsigned envelope. The canonical byte form that is signed and verified is produced through envelope.CanonicalBytes, so the publish and (future) verify sides agree on exactly the same bytes.

PropertyPosture
Any of the five dispatch ports nilHTTP 501, Problem code problemCodeSignedEventBusNotProvisioned
Production verifier wiringNot wired (SetSignatureVerifier / SetRelationChecker / SetNodeRepo never called)
Read surface when unwiredFails closed to 501, never half-on
Publish sideSigns before publish; refuses to emit an unsigned envelope

Seams: internal/transport/http/v1/handlers/events_dispatch.go, internal/mesh/sse/publisher.go, and the canonicaliser internal/signing/envelope/canonical.go. For where the production bus wiring is tracked, see Signed event bus roadmap.

Step-up enforcement

The Access Orchestrator can require a recently proven assurance class before it issues a session. The gate is an issuance-time check, not a per-action lookup table: there is no map from a named action (secret reveal, key rotation, …) to a fixed assurance class anywhere in the code. What exists is two triggers plus a per-Domain policy, evaluated when checkStepUp runs inside the IssueService issuance pipeline in internal/access/services/issue.go.

When the gate fires. Step-up is demanded when either trigger holds:

  • the addressed Resource carries the per-Resource StepUpRequired flag (internal/identity/tenancy/resource.go, surfaced to the service as the scope's StepUpRequired), or
  • the requested session Kind is a member of the Domain's StepUpRequiredKinds set (RequiresStepUpForKind in internal/access/policy.go).

What the gate checks. When a trigger fires, checkStepUp runs two arms and BOTH must pass. A failure returns a typed *access.StepUpError that unwraps to access.ErrStepUpRequired and carries the Domain's required acr values so the transport can render the RFC 9470 challenge.

ArmWhat it requiresFails closed when
ACR set-membershipThe principal's presented acr set must intersect the Domain's StepUpRequiredACRValues closed set; when the Domain names none, any non-empty presented acr satisfies a "stepped up at all" floor.The presented acr set is empty (for example an API-token principal) or shares no value with the required set.
FreshnessThe presented auth_time must be present and no older than the Domain's StepUpFreshnessSeconds window (default 600 seconds).auth_time is absent (zero) or older than the window.

Where the required class comes from. The acr classes that satisfy the ACR arm are a per-Domain policy — StepUpRequiredACRValues on the SessionPolicy snapshot in internal/identity/tenancy/domain.go — not a per-action constant. The elevated actions named in the platform readme (secret reveal, Node removal, signing-key rotation, Domain admin changes, IdP Binding edits) are the operator-facing examples of when an operator would configure step-up; the concrete class names mfa and hwk are themselves illustrative acr_values examples, not values the code hard-codes for any action.

Relationship to the requires_assurance caveat. The requires_assurance caveat in internal/authz/caveats.go (embedded in internal/authz/embedded_schema.zed) is a SEPARATE ReBAC mechanism: it gates whether a relation tuple is honoured under an assurance condition evaluated by the authorizer, and it is NOT consumed by the issuance-time checkStepUp ACR set-membership check. The two layers are independent — checkStepUp reads the presented acr / auth_time from the IdP-claims port at session issuance, while requires_assurance is evaluated by the ReBAC authorizer when a caveated tuple is checked.

For the issuance-time contract and its negative branches see Step-up enforcement contract.

Node-secret plaintext invariant

Node secret keys are stored wrapped-only. The schema in internal/platform/db/migrations/0008_node_secret_keys.sql carries only the wrapped material and no plaintext column, and that invariant is held in code by a compile-time column guard in internal/identity/nodes/repo/nsk_no_plaintext_assertion.go: if a future schema or model change reintroduced a plaintext column the guard would stop compiling, so the invariant cannot drift silently at runtime.

Secret read hot path

Secret reads are rate limited per Node and per Domain and are audited on every read. The rate limiter lives in internal/secrets/services/ratelimit.go. The fetch service in internal/secrets/services/fetch_service.go makes the audit a hard floor: it requires a sink at construction (ErrAuditSinkRequired) and emits ErrAuditAppendFailed when an audit row cannot be persisted, returning no ciphertext in that case. A read therefore never reveals secret material without a written audit trail — the audit append and the ciphertext return stand or fall together.

Audit hash chain and retention

The audit log is a tamper-evident hash chain. The verifier in internal/audit/chain/verify.go recomputes each entry hash and names any divergent sequence number, so an UPDATE to a chain row is detectable. The append-only property is also defended at the schema level: the migration in internal/platform/db/migrations/0011_audit_log.sql refuses its Down direction by raising SQLSTATE 0A000, so the audit table cannot be dropped through the normal migration path.

Retention windows and the right-to-erasure model are not re-tabulated here; see Retention and right to erasure for the authoritative matrix.

Security-review findings

The most recent security review of the branch produced the findings below. Each non-deferred finding names the test that now locks its behaviour; each deferred finding records its residual-risk acceptance in the security-model decision record.

FindingSeverityAffected seamResolutionLocking test
M-0 — the review over the branch diff introduced no new vulnerability; the regression tests assert real fail-closed behaviour.InformationalWhole diff (test-only)No action; recorded as the review meta-conclusion.n/a
F-1 — the signed-SSE-bus verification path is not wired in production (SetSignatureVerifier / SetRelationChecker / SetNodeRepo are never called in cmd/plexsphere), so the node-events read surface returns 501 signed_event_bus_not_provisioned; the surface fails closed and is never half-on.Medium (residual Low — fails closed to 501)internal/transport/http/v1/handlers/events_dispatch.go; publish side internal/mesh/sse/publisher.goDeferral — residual-risk acceptance in the security-model decision record (production bus wiring is out of scope and tracked in the signed event bus roadmap); the fail-closed 501 posture itself is locked by a passing regression test.TestGetNodeEvents_501WhenAnyOfFivePortsNil (and TestGetNodeEvents_DispatchGateNotTrippedWhenAllPortsPresent) in internal/transport/http/v1/handlers/events_test.go
F-2 — a secret read must fail closed when the audit append is rejected (no ciphertext returned without a written audit trail); the behaviour was correct in production but not pinned by a regression test.Mediuminternal/secrets/services/fetch_service.go (ErrAuditAppendFailed, ErrAuditSinkRequired); internal/secrets/services/ratelimit.goFix-with-test.TestSecretsFetch_FailsClosed_WhenAuditAppendRejected in tests/integration/secrets_audit_coverage_test.go
F-3 — OIDC id_token standard-claim negatives (exp past, nbf future, wrong aud, wrong iss) were not pinned by a regression test; the production validator already enforced them.Lowinternal/identity/idp/oidc/adapter.go (validateStd, ValidateWithLeeway, 60s clockLeeway)Fix-with-test.Test_Adapter_VerifyIDToken_RejectsExpiredAndNotYetValid and Test_Adapter_VerifyIDToken_RejectsWrongAudienceAndIssuer in internal/identity/idp/oidc/adapter_test.go
F-4 — step-up gate negative branches (missing acr, wrong acr class, stale auth_time, freshness boundary).Lowinternal/access/services/issue.go (checkStepUp, StepUpFreshnessSeconds)Already covered — both tiers exercise all four negatives plus amr-only and api-token fail-closed; no new test added.TestIssue_StepUp in internal/access/services/issue_test.go and TestAccessIssue_StepUp in tests/integration/access_stepup_test.go
F-5 — the e2e threat-scenario suite does not replay the step-up issuance denial over the wire; it records the control and cross-references its tighter tiers rather than minting a stale-acr session against a fully-wired stack.Low (control enforced and test-locked at the service + integration tiers; fails closed)internal/access/services/issue.go (checkStepUp); e2e tier tests/e2e/security/threat-scenarios/chainsaw-test.yamlDeferral — residual-risk acceptance in the security-model decision record (a wire-level replay needs the full identity + ReBAC issuance stack and a per-Node bearer the issuer mints in process); the issuance-denial behaviour itself is enforced and test-locked at the service + integration tiers.TestIssue_StepUp in internal/access/services/issue_test.go and TestAccessIssue_StepUp in tests/integration/access_stepup_test.go
F-6 — the e2e threat-scenario suite asserts only the wrapped-only storage invariant for secrets; the granted-path wrapped-ciphertext headers (X-Plexsphere-Secret-KID, Cache-Control: no-store) and the cross-node secret:<id>#read ReBAC denial are not replayed over the wire.Low (the granted-path header contract is positively pinned at the unit + integration tiers; the denial is pinned at both tiers)internal/transport/http/v1/secrets/handler.go; internal/secrets/services/fetch_service.go; e2e tier tests/e2e/security/threat-scenarios/chainsaw-test.yamlDeferral — residual-risk acceptance in the security-model decision record (the authenticated read path needs the full secrets graph + a per-Node NSK bearer a SQL/curl fixture cannot mint); the granted-path header contract and the ReBAC denial are positively pinned at the unit + integration tiers.TestFetchNodeSecretSuccess and TestFetchNodeSecretPermissionDenied in internal/transport/http/v1/secrets/handler_test.go, and TestSecretsAuditCoverage_GrantedAndDeniedThroughHTTP in tests/integration/secrets_audit_coverage_test.go