Skip to content
↑↓Navigate↵SelectescClose

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).

  • Operators control the host running nexus ops serve or 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 in vortex-auth-core. Long-running services load one pre-provisioned 32-byte wrapping key from NEXUS_KMS_KEY_PATH without 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.log file 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).
# 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).
# 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.
# 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.
# 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.
I10 Extract a Nexus API key from an in-browser client the gateway serves cross-origin Anyone who can load the browser client’s page The nxs_ key embedded in or entered into the page Cross-origin access is off unless NEXUS_GATEWAY_CORS_ALLOWED_ORIGINS is set, and may be limited to exact origins. Credentialed (cookie) requests are never allowed, so a page on an allowed origin cannot ride another user’s session; it can only send a key it already holds. Per-key spend caps and rate limits bound what a lifted key can spend, and revocation takes effect within NEXUS_GATEWAY_API_KEY_CACHE_TTL_SECS. The gateway cannot tell a legitimate page from a copied key used elsewhere: the origin allow-list constrains browsers, not scripts. Issue browser clients a dedicated key with tight spend caps and rate limits, and rotate it on suspected exposure.
# 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, because startup fails without NEXUS_REDIS_URL, per-API-key rate limits (NEXUS_GATEWAY_RL_RPS/RPM, plus optional per-key request and token overrides) plus workspace, organization, and project request and token limits set on the Limits pages; 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; 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, 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. One further buffer sits behind the Responses serializer rather than beside it: providers::responses_chat_bridge::ChatToResponsesSse, which serves /v1/responses through a chat-completions adapter, accumulates the stream’s text and tool-call arguments to build its closing events; that buffer mirrors deltas the Responses serializer has already weighed against the stream cap. At upstream end the accumulated values move into a single set of finished output items, and the closing *.done events and the terminal response.completed/incomplete event are serialized one frame at a time from those items, which move into the terminal event, so the bridge holds one copy of the stream plus one serialized frame. Each closing frame repeats its item’s full text or arguments on the wire, and the frames before the terminal one are weighed against the stream cap like any other frame. 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.
# 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.
E6 Use a leaked or narrowly-minted personal developer key (nxd_) to act beyond its binding, or to mint a broader key Holder of a developer key Control-plane resources and keys outside the key’s workspace, organization, or project The control plane clips a developer-key principal’s memberships to its binding before every role check, withholds workspace authority from organization and project bindings, and refuses workspace creation and invitation acceptance. Developer-key lifecycle calls made by a key may only mint, list, or revoke keys inside its binding, with scopes it holds, and still capped by the user’s live role. Scopes are re-intersected with the user’s current role on every verification, and verifiers do not cache developer-key principals, so revocation, demotion, membership removal, and user disablement apply to the next request. A key with management scopes can mint a replacement before it expires, so a leaked key persists until it or its replacements are revoked; expiry stays bounded by the maximum key age at each mint.
E7 Obtain platform-funded auto-refill credit without operator authorization Tenant user or workspace manager Operator funds and downstream provider spend Auto-refill is off unless NEXUS_WALLET_AUTO_REFILL=1. Policies are set only through nexus ops wallet auto-refill; the tenant wallet settings route accepts only the spend limit. Each policy change emits wallet.auto_refill.default_updated or wallet.auto_refill.workspace_updated, a reset tops the balance up to the allowance at most once per period under the wallet row lock, and each reset is an auto_refill ledger row carrying the request id. The allowance and period have no ceiling, and a reset emits no audit event of its own.
  • Classification enforcement is a ceiling check. The policy engine denies requests whose nexus-classification tier 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.
  • The gateway does not start without a reachable Redis-compatible endpoint (NEXUS_REDIS_URL). An unset, empty, or unreachable URL exits the process before it listens, so rate limits and the response cache cannot be silently off.
  • 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 with AUTH_JWT_SECRET can mint a token for either audience. Treat disclosure of that secret as compromise of both the tenant and administrator surfaces, and rotate it. The admins re-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.