Threat Model
STRIDE-style threat model scoped to current repository behavior
STRIDE-style threat list scoped to behaviour that exists in this repository today: the nexus CLI + Postgres-backed ControlPlane, the gateway’s BYOK request paths, the Rust services started by Compose (control-plane, admin-control-plane, auth, policy, ingest, trace-ingest, audit, query), the tenant console, the platform-administrator console, and shared vortex-* crates.
We use STRIDE: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege. Each row lists the mitigation as implemented now and calls out gaps plainly (without deferring to external schedules).
Assumptions
Section titled “Assumptions”- Operators control the host running
nexus ops serveor Compose; host compromise is out of scope here. - Inter-process traffic between services is whatever Docker networking provides; there is no additional nexus-specific mTLS layer in-tree. Service-authority RPCs use the shared
NEXUS_SERVICE_TOKEN; administrator user-management RPCs instead forward and re-verify the administrator bearer token. - BYOK provider material at rest uses the file KMS (
FileKms) wrapping implemented invortex-auth-core. Long-running services load one pre-provisioned 32-byte wrapping key fromNEXUS_KMS_KEY_PATHwithout creating or modifying it and authenticate all stored wrapped credentials before serving. Compose provisions that key once in a dedicated volume and mounts it read-only into consumers. - The local
audit.logfile sink used by CLI/dev flows is plain JSONL and is operational evidence only. Cryptographic audit proofs come from the audit service pipeline: canonical hashes, per-tenant Merkle batches, signed envelopes, and archive anchors (rows T2/T3 and R1 below).
Spoofing
Section titled “Spoofing”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| S1 | Present another tenant’s Nexus API key | Anyone with a stolen nxs_ secret |
Cross-tenant requests | Keys are verified through ControlPlane::verify_api_key; unknown, revoked, and expired secrets return 401 without upstream calls. Successful verifications are cached for NEXUS_GATEWAY_API_KEY_CACHE_TTL_SECS (default 5s), so a revocation takes effect within that window; 0 disables the cache and makes it immediate. |
No hosted rotation UI; operator must re-mint keys manually. A revoked key keeps authorizing requests until its cache entry ages out — up to the configured TTL — and raising the TTL widens that window; there is no push invalidation from the control plane to the gateway. |
| S2 | MITM between gateway and a provider | Network attacker | Provider traffic | Provider requests verify TLS against the roots compiled into the binary; the operating system trust store is never consulted. NEXUS_GATEWAY_UPSTREAM_CA_BUNDLE_PATH adds trust anchors for a private CA or TLS-inspecting proxy, loaded fail-closed — an unreadable, unparseable, or certificate-free entry aborts startup rather than falling back to the default roots. |
Operators must actually deploy TLS in front of the gateway in real deployments. |
| S3 | Disable provider certificate verification | Operator, or anyone who can set the gateway’s environment | Provider traffic and the credentials it carries | NEXUS_GATEWAY_UPSTREAM_INSECURE_TLS turns off certificate and hostname verification for every provider request. It is opt-in, accepts only an explicitly truthy value, and logs a warning naming itself on every start. |
Nothing surfaces the state after startup: no readiness signal, metric, or periodic reminder, so a long-running deployment can keep serving with verification off. Treat the start-up warning as the only detection and keep the variable out of production configuration. |
| S4 | Replay a tenant-console access token against the admin control plane | Holder of a stolen tenant session token | Administrator APIs (/api/users, /api/workspaces, wallet grants) |
Tenant tokens carry the nexus-console audience and administrator tokens the nexus-admin-console audience; VerifyAdminToken accepts only the administrator audience and both claim structs use #[serde(deny_unknown_fields)], so a tenant token fails verification before any handler runs. Every VerifyAdminToken call additionally re-checks the admins table for an active assignment in the deployment tenant, so a valid administrator-audience token whose assignment was revoked also fails. |
Both audiences are signed with the same AUTH_JWT_SECRET, so a holder of that secret can mint either one (see the residual risk below). |
Tampering
Section titled “Tampering”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| T1 | Tamper with Postgres rows (nexus_control, nexus_auth) |
Local user with DB credentials | Org / project / API key / provider key rows | Database authentication and OS-level access controls; row-level integrity is not signed. | No row-level cryptographic integrity. |
| T2 | Tamper with audit-staging rows | Operator with DB shell | audit_events_staging |
The audit service backfill recomputes canonical hashes and reports rows whose staged event body no longer matches already-sealed audit state. | Staging rows remain mutable until backfilled; protect DB credentials and monitor backfill reports. |
| T3 | Tamper with sealed audit rows | Operator with DB shell | nexus_audit.audit_events |
Batched events carry canonical hashes, batch roots, and signatures; the audit schema rejects updates/deletes after batching, and the verifier detects forced-row mutation. | A privileged operator can still disrupt availability by stopping the audit service or deleting an unanchored batch before object-lock archival. |
| T4 | Forge or replay a payment-provider webhook to credit a wallet | Remote attacker posting to /api/payments/webhooks/stripe |
Workspace wallet balance | The webhook route verifies the provider HMAC-SHA256 signature over the raw body with a constant-time comparison and a timestamp tolerance before acting; each event is recorded once on (provider, provider_event_id), so a replayed or duplicated event never credits the wallet twice. Every credit emits a wallet.topup.succeeded audit event. |
Verification depends on NEXUS_STRIPE_WEBHOOK_SECRET being secret; rotate it on suspected compromise. |
| T5 | Feed a tampered or forged registry artifact into nexus-cli registry import to plant false model pricing, capabilities, or endpoints |
Holder of nexus-cli / DB credentials, or a compromised registry-generator output pipeline |
Model catalog, rate cards, endpoint routing served to every tenant | RegistryArtifact::validate rejects unknown fields, non-canonical bytes, digest/count mismatches, and cross-record reference errors before any row is written; with SignaturePolicy::Require (--expected-key-id plus --verifying-key-path) an Ed25519 signature is verified against an operator-supplied trusted key and key id, and a signed artifact is never accepted without one; the import prints the full plan and applies nothing until the operator confirms it, then binds an approval to an exact artifact_digest and baseline_hash so a stale or swapped artifact, or one applied against a database that moved on in the meantime, is rejected (ImportError::Stale); a hard-reject finding (e.g. malformed data the contract itself forbids) blocks the import outright, while destructive_endpoint_change, generator_transition, contract_transition, and quarantine_recovery findings flag changes the operator should scrutinize before confirming. |
Omitting the signature flags accepts any unsigned artifact, and --yes applies a plan nobody read — neither must be pointed at a production database, and the CLI does not itself know which database is “production”; a single actor both reviews and applies, so only the audit log records who approved what. |
Repudiation
Section titled “Repudiation”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| R1 | Deny sending a prompt | Tenant user | Request attribution | Gateway emits gateway.request.completed with request_id, token counts, and cost fields to the configured AuditSink; the audit service seals canonical events into signed Merkle batches and archive manifests. |
Events are strongest after an anchor has been written to object-lock storage; recently ingested unanchored rows are still in the hot store. |
Information disclosure
Section titled “Information disclosure”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| I1 | Leak provider key in logs | Operator reading stdout/stderr | BYOK secret | Structured logging is expected to redact sensitive headers; review gateway tracing before enabling verbose logs in shared environments. | No automated redaction audit in this document—verify in code when changing logging. |
| I2 | Leak key material from Postgres backups | Operator with backup access | Wrapped ciphertext in provider_keys |
Provider secrets stored as ciphertext+nonce wrapped by FileKms; plaintext only in memory during unwrap. |
Backup exfiltration still sensitive — protect backup ACLs and rotate the KMS wrapping key on suspected compromise. |
| I3 | Read another org’s audit events through the audit read facade (/api/audit/*, routable through Traefik) |
Holder of a valid bearer token | Sealed audit events (actors, subjects, request metadata) | Every facade route and gRPC RPC requires the audit:read scope plus an active manager or auditor membership in the requested org (principal_audits_org); the request tenant is pinned to the principal’s tenant; results are filtered to events attributed to the requested org; cursor lookups carry the same org predicate so foreign event ids behave as nonexistent. Successful reads are themselves recorded as data_access.audit_viewed events. |
Events attributed only to a workspace or user (no org attribution) are not readable through the facade at all — invisible rather than over-exposed. |
| I4 | Enumerate a tenant’s activity shape via /api/audit/filter-options |
Org auditor | Distinct event kinds/classifications | The route is gated identically to event reads and returns only kinds recorded for orgs the caller audits. | Filter-option reads are not recorded as data-access events (picker metadata only). |
| I5 | Uploaded vector-store content carries prompt-injection text | Tenant user who can upload files | Hosted file_search responses |
Hosted file_search retrieves only scoped vector-store chunks and injects them as a user-context message labeled as untrusted document content; the wrapper tells the model not to follow instructions inside snippets. Audit events record only metadata, not query text or chunk content. |
Model behavior is probabilistic; callers should treat grounded answers as model output, not policy enforcement. |
| I6 | Leak the payment-provider secret key | Operator reading logs | NEXUS_STRIPE_API_KEY |
The Stripe secret is read from the environment and used only server-side as an HTTP Authorization header; it is never logged, returned in responses, or exposed to the browser bundle (only the publishable key NEXUS_STRIPE_PUBLISHABLE_KEY, served to the console via GET /api/payments/config, reaches the client). |
Protect the deployment environment and rotate keys on suspected compromise. |
| I7 | Expand one authorized request-body read into adjacent packed bodies | Tenant user with payload-read access to one request | Other request and response bodies sharing an organization pack | Query re-reads the canonical org-scoped row, applies payload authorization, parses the stored locator, pins the pack key to the row’s organization prefix, performs one exact bounded range read, requires one complete zstd frame, and verifies the decoded byte count and BLAKE3 hash. gRPC returns the authenticated query proxy rather than an object-store URL, and the filesystem fallback rejects pack paths. | Operators must keep the body bucket private to ingest and query identities; direct object-store credentials bypass application authorization. |
| I8 | Read another workspace’s spend, or probe which workspace ids exist, through GET /api/reports/workspace |
Holder of a valid bearer token | Organization, project, API-key, provider, and model spend for a workspace | The route requires query:read and metrics:read and rejects gateway API-key subjects. The caller supplies no organization id: the report’s organization set is resolved by the control plane’s GetWorkspaceScope, which applies the workspace-manager gate to the caller’s own forwarded bearer token, never the query service’s service token. An unknown workspace, a caller holding no workspace membership, a workspace user or auditor, and a manager of an empty workspace all receive the same HTTP 200 empty report with no workspace name. A control-plane transport failure returns 503 rather than an empty report. Reads are recorded as one data_access.metrics_viewed event per report. |
A workspace auditor cannot read the report at all, only a workspace manager — invisible rather than over-exposed. |
| I9 | Leak deployment-internal detail through an upstream error body forwarded to the tenant | Tenant user whose request fails upstream | Upstream-provider error text (AllAttemptsFailed/single-attempt provider_message) |
Deliberately surfaced to the caller as actionable detail (not just logged server-side), matching the PricingUnavailable/ConnectionNotConfigured pattern of keeping non-actionable internals server-side-only. Bedrock’s AccessDeniedException/UnrecognizedClientException messages, which routinely embed the caller’s own IAM identity (arn:aws:sts::<account>:assumed-role/...), are redacted (ARNs and standalone 12-digit account ids) before reaching this field. |
The three other providers (OpenAI, Anthropic, Azure Foundry) forward the raw upstream response body verbatim on a non-2xx status; a provider-side 5xx or a misconfigured self-hosted/Azure Foundry deployment could echo backend hostnames or other operator-internal detail through this same path. |
Denial of service
Section titled “Denial of service”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| D1 | Oversized POST body exhausts memory | Remote client | Gateway RAM | The gateway enforces a request-body limit (NEXUS_GATEWAY_REQUEST_BODY_LIMIT_BYTES) and per-API-key rate limits when a Redis-compatible cache is configured (NEXUS_REDIS_URL + NEXUS_GATEWAY_RL_RPS/RPM); configure reverse proxies for stricter caps in production. |
Rate limiting is off when no Redis-compatible endpoint is configured (the stock compose file does not set NEXUS_REDIS_URL). |
| D2 | A hung or slow-to-respond upstream provider connection blocks a request indefinitely | Misbehaving or unreachable upstream provider | Gateway request-handling capacity | The shared upstream HTTP client applies a connect timeout (NEXUS_GATEWAY_TIMEOUT_CONNECT_SECS) and a per-read idle timeout (NEXUS_GATEWAY_TIMEOUT_READ_SECS) that resets on every byte received, so a stalled connection is bounded without cutting off a healthy long-running stream; non-streaming calls additionally get a tighter total-time budget per operation class (NEXUS_GATEWAY_TIMEOUT_{FAST,STANDARD,IMAGES}_SECS). |
A slow-but-technically-alive trickle (bytes arriving just often enough to keep resetting the idle timer) is not time-bounded by this mechanism; see D3 for the matching size bound. Separately, none of these budgets bound the whole request: each is re-armed in full on every retry of one candidate (a retry exists to give a transient stall another full chance, not a shrinking one), and a request can have several fallback candidates. The retry budget is shared across every candidate rather than re-granted to each, so the worst case for one non-streaming request is bounded by call count — one mandatory try per candidate plus the shared budget’s extra tries spent anywhere — i.e. (num_candidates + shared_retry_budget) * per_operation_timeout, plus up to shared_retry_budget * 2s of full-jitter backoff delay slept between consumed retries, bounded by the nexus-retries budget rather than wall-clock time on its own. A streaming request has no per-operation total-time budget at all — only the idle-read timeout applies, re-armed on every retry of every candidate. That bounds the establishment phase against a fully silent upstream at (num_candidates + shared_retry_budget) * read_timeout: at the maximum client-supplied nexus-retries (16) and the default 900s idle timeout, establishment alone can occupy the connection for up to 17 * 900s, about 4.25 hours, before backoff delay. It does not bound the request: the retry helper is invoked with no per-try timeout on the streaming path and returns as soon as the stream is established, after which the body is relayed outside the retry loop and only the idle timer applies, per gap between bytes. A stream emitting one byte just inside every idle window is therefore not time-bounded at all — the same trickle residual noted above, and the reason this formula must not be read as a total-duration ceiling. Neither term is unbounded, but only one is a fixed constant: nexus-retries is capped at 16, and the caller-controlled input to num_candidates — the number of comma-separated segments in the model route, which the client sets via the body’s model field concatenated with the nexus-fallback header — is capped at pipeline::MAX_ROUTE_CANDIDATES (16), the route being rejected with 400 above it. num_candidates itself is larger than the segment count: a segment naming no provider fans out to one attempt per registry endpoint serving that canonical model, and each endpoint can contribute both a BYOK and a PTB attempt when NEXUS_GATEWAY_PTB=1. Those multipliers come from the operator’s catalog and configuration rather than from the request, so the worst case is bounded by deployment shape (segments × endpoints_per_model × auth_types) rather than by a single constant. A deployment wanting a predictable ceiling should size it from its own catalog width and lower the retry budget it accepts, not rely on these timeouts alone. |
| D3 | An oversized or unbounded upstream response (one-shot or streamed) exhausts gateway memory | Misbehaving or malicious upstream provider | Gateway RAM (shared across tenants) | Non-streaming response bodies are capped (NEXUS_GATEWAY_MAX_RESPONSE_BYTES, read incrementally so an oversized body is never fully buffered — the internal body the Anthropic adapter derives from a capped response can carry its text and reasoning a second time in a positional sidecar, so peak memory for that body is a small bounded multiple of the cap, not the cap itself; a body arriving under a failure status is truncated rather than rejected, so an oversized error page cannot displace the upstream status the caller routes on — and to the tighter of MAX_ERROR_BODY_BYTES (a fixed 64 KiB) and the operator’s own cap, since a failure body is cloned once per candidate into the AllAttemptsFailed envelope; a body cut this way carries a trailing truncation marker so the caller can tell an excerpt from a malformed upstream response — omitted only when the effective cap — the tighter of the two, with 0 meaning the 64 KiB ceiling — is not larger than the 24-byte marker itself — and the 64 KiB ceiling binds even when the operator sets the response cap to 0) and cumulative bytes across one SSE stream are capped (NEXUS_GATEWAY_MAX_STREAM_BYTES), terminating the stream with an in-band error once exceeded. What that stream cap weighs depends on what the serializer is handed: the two serializers fed a normalized event stream (the OpenAI-chat and Anthropic-Messages dialects) weigh the client frame they serialize from each event, i.e. the bytes going on the wire, and charge a nominal 64 bytes for a normalized event that yields no client frame (a usage preview, or a chunk left empty by stripping a provider-internal delta; the frame-less event carrying the terminal signal is weighed at zero) so an upstream cannot hold a stream open with events that render nothing — upstream frames that produce no event at all (ping, empty deltas, unrelayed block types) remain covered only by D2’s read timeout — while the OpenAI-Responses serializer is fed raw upstream bytes and weighs each chunk as it arrives. It bounds the bytes weighed before the terminal frame: the frame carrying the terminal signal is delivered even when it crosses the stream cap (it remains subject to the fixed 8 MiB frame bound), and a further fixed 64 KiB tail allowance is permitted after it, so the effective ceiling an operator should size against is NEXUS_GATEWAY_MAX_STREAM_BYTES + 8 MiB + 64 KiB rather than the env var alone. On the hosted file_search rewrite path the weighed bytes stay the raw upstream chunks rather than the rewritten output — counting the output would double-count any frame straddling more than one upstream read — so that path is the one that can place more than the cap on the wire, by two bounded amounts: the rewrite injects synthesized events a fixed number of times per stream (once, guarded on the first response.created), and re-serializing each event costs a per-frame overhead that is not a small constant: every event carrying a response object has the caller’s original tools array and tool_choice restored onto it, and every event typed response.completed/failed/incomplete has the synthesized call_item spliced into its output. Both are proportional to caller-supplied input, and neither is once-per-stream, so an upstream emitting many small response-bearing or terminal-typed frames places a multiple of the weighed bytes on the wire — this path’s overflow is bounded by upstream conformance rather than by a gateway-side constant. Two further bounds are fixed rather than configurable: a single un-terminated upstream frame is capped at 8 MiB in every reassembly buffer — the shared SSE accumulator (behind the OpenAI, Anthropic, and Responses-bridge decoders), the model-echo rewriter that fronts the Responses byte stream, both of the OpenAI-Responses serializer’s buffers, and the Bedrock event-stream decoder — and audit capture is capped at 4 MiB independently of the wire cap. |
Either limit can be set to 0 (unlimited) via its env var; combined with D2’s residual trickle gap, a deployment that disables both loses the cumulative caps. The fixed 8 MiB per-frame and 4 MiB audit-capture bounds still apply, so an upstream that opens a frame and never closes it stays bounded even then. |
| D4 | Repeated readiness snapshots amplify traffic or hang the administrator API | Platform administrator or holder of a stolen administrator token | Admin control-plane availability and internal services | GET /api/platform/readiness probes only the fixed startup inventory with bounded concurrency, a per-probe timeout, a whole-snapshot deadline, redirects disabled, and a 64 KiB body limit per probe; the BFF independently limits the aggregate response to 1 MiB. Every unfinished probe becomes unknown without delaying the response beyond the aggregate deadline. |
The route has no independent rate limit; deployments should constrain administrator-surface access at the ingress. |
| D5 | A caller takes a shared endpoint out of rotation for every tenant, or obtains unbilled requests, by triggering billing failures | Authenticated tenant; misbehaving or malicious upstream provider | Endpoint availability for all tenants of that endpoint; pass-through-billing charges | A billing failure does not remove the endpoint from routing on the request path. The ingest billing-health evaluator reads request analytics every NEXUS_BILLING_QUARANTINE_INTERVAL_SECS over a closed window and marks an endpoint degraded only when its failed requests reach both NEXUS_BILLING_QUARANTINE_MIN_FAILURES (default 10) and NEXUS_BILLING_QUARANTINE_MIN_RATIO_BPS (default 100, 1%) of its priced and failed requests; under NEXUS_BILLING_QUARANTINE_ACTION=block that quarantines it, and each gateway drops it at its next registry reload. One request cannot take an endpoint out of rotation, and on a busy endpoint a caller has to fail at least the ratio floor of its traffic to do so. One of the two conditions that fail a request is reachable from request content (the other, a reported share larger than its total, is chosen by the upstream rather than the caller): the request-side audio signal is read from the caller’s own audio and modalities fields and input_audio content parts, so on an endpoint whose rate card prices audio and whose upstream returns no audio breakdown, a caller can fail billing on demand. The exposure is bounded to endpoints an operator has given audio rates — no shipped rate card carries any, which a test asserts — and the compatibility matrix tells operators to make that decision against a known upstream. |
Accepted residual: unbilled requests below the threshold. Billing runs after the response is served and a failed request settles its escrow hold at $0, so every failure the evaluator has not acted on is a request served without charge. On an audio-priced endpoint whose upstream omits the breakdown, a caller can take one fewer than NEXUS_BILLING_QUARANTINE_MIN_FAILURES such requests per window on a quiet endpoint, or just under the ratio floor of its traffic on a busy one, plus whatever is served between a window closing and the block reaching the gateway (ingest lag, evaluation interval, and registry poll); under allow there is no bound. In block mode a caller who reaches both thresholds removes the endpoint for every tenant, and because a blocked endpoint receives no traffic it cannot re-qualify until an operator runs nexus models unquarantine. The signal is request intent, not evidence the upstream processed audio. Every failure is enqueued for persistence, but a record is dropped if that queue is full or its writer has gone and abandoned after five failed attempts; the evaluator reads request analytics rather than those records, so an endpoint can be quarantined without persisted failure rows explaining why. Reaching this condition at all requires the upstream to accept the request and return billable usage that omits the audio breakdown: a transport failure or unparseable usage fails earlier, by a different path. Narrowing the trigger to content parts alone would not close this: audio and modalities are the only signals for requested audio output, so dropping them would blind the output-side guard instead. |
Elevation of privilege
Section titled “Elevation of privilege”| # | Threat | Attacker | Asset | Mitigation (today) | Gap |
|---|---|---|---|---|---|
| E1 | Use an API key outside its org/project | Tenant user | Other projects’ spend | Verified principals carry org_id / project_id; gateway rejects keys missing those bindings. Control-plane membership routes enforce the user < auditor < manager role ranking (see E2). |
Fine-grained per-resource permissions beyond the three-role model are not implemented. |
| E2 | An auditor member acts beyond read-only audit access |
Org/workspace auditor | Control-plane mutations, query/trace data | The auditor role grants only the user-level scopes plus audit:read; every managerial gate (role_is_managerial, principal_manages_org, workspace-manager checks) matches manager exclusively, and the role rank ordering (user < auditor < manager) is enforced in one place (Role::rank). |
Auditors inherit org-wide audit visibility from workspace auditor memberships by design; revoke the workspace membership to narrow access. |
| E3 | Issue operator-funded wallet credit without administrator authorization | Tenant user or workspace manager | Operator funds and downstream provider spend | POST /api/workspaces/{workspace_id}/wallet/grants exists only on the admin control plane and requires a verified admin token for the deployment tenant. Positive amounts, reasons, and idempotency keys are required; each grant records the admin actor in the wallet ledger and emits wallet.grant.created. |
A compromised administrator can issue grants of any positive amount; there is no per-grant ceiling or second-person approval. |
| E4 | Redeem a tenant refresh token on the administrator refresh path (or the reverse) to obtain the other credential | Holder of a stolen refresh token | Administrator session, or a tenant session escalated from an administrator one | sessions.kind ('user' | 'admin', CHECK constrained) is part of the refresh lookup triple (tenant_id, refresh_hash, kind); rotation and revocation match on it, so a refresh token minted for one credential finds no row on the other’s path. Every console access token also carries its session id, and every path that authorizes on one — token verification, the console principal gate, and developer-token create/list/revoke — requires that session row to be active and unrevoked, and, unless AUTH_SESSION_IDLE_TIMEOUT_SECS=0, used within that timeout. admin_refresh additionally re-checks the administrator assignment and the user’s active state on every rotation. |
A valid console session is active until explicit sign-out/revocation, its token expires, or its configured inactivity limit ends it. |
| E5 | Sign in to the administrator console with a correct tenant-user password | Tenant user who knows their own credentials | Administrator console and the administrator surface behind it | Password authentication is only the first step: admin_sign_in then requires an active row in the admins table for the deployment tenant, and the (admin) route group’s server layout re-verifies the assignment on every render. Administrator user-management calls forward the same administrator bearer to auth, which re-verifies the token and active assignment before each read or mutation and derives the audit actor from the verified principal. Every rejection returns 401 with no distinguishing detail and the console renders one generic message, so the route cannot enumerate administrators. A correct password with no assignment records admin.session.denied. |
No code path in this repository writes a row into admins, so assignments are granted and revoked by direct SQL against nexus_auth.admins — unreviewed, and recorded only in created_by/revoked_at rather than as an audit event. |
Residual risks acknowledged now
Section titled “Residual risks acknowledged now”- Classification enforcement is a ceiling check. The policy engine denies requests whose
nexus-classificationtier exceeds the subject’s ceiling; richer ABAC attribute sources, policy-as-code authoring, and body-redaction obligations are not implemented. Enforcement runs at a single request gate inside gateway attempt construction: one policy decision covers the full candidate endpoint set (fallbacks included), provider keys are fetched only after an allow, and dispatch paths accept only gated attempts, so no edge handler calls the policy decision point directly. - Rate limiting and response caching are off unless the operator configures a Redis-compatible endpoint (
NEXUS_REDIS_URL). - Unanchored audit state is weaker than anchored state. Events are strongest after an anchor manifest reaches object-lock storage; the hot store and staging rows depend on database access controls.
- One signing secret covers both token audiences. Tenant-console and administrator access tokens are both HS256 over
AUTH_JWT_SECRET; only the audience claim separates them (rows S4 and E4). The split is an authorization boundary between two honest verifiers, not a defense against holding the key — anyone withAUTH_JWT_SECRETcan mint a token for either audience. Treat disclosure of that secret as compromise of both the tenant and administrator surfaces, and rotate it. Theadminsre-check on every administrator verification is the remaining control in that case: a forged administrator token still fails unless its subject holds an active assignment. - Administrator assignment has no in-product provisioning path. Creating the first platform administrator, and every one after it, requires a direct SQL insert into
nexus_auth.admins; the administrator console neither grants nor revokes assignments (row E5). Access to the auth database is therefore equivalent to the ability to appoint administrators.
Revisit this document whenever merged code changes trust boundaries, logging, or persistence.
