ConceptsSecurity

Security

Flightdeck’s security posture is a set of properties enforced at the layer where they cannot be forgotten: in the database schema, at the composition root, and at the two network edges. This page is the trust model in full — what each credential proves, what a scope can and cannot do, how tokens are handled, and the specific threats each control closes.

Credential-carries-ownership

Tenant-facing auth follows one pattern everywhere (ADR-0001, FD-8): API keys are resource-bound rows. A key’s scope says “some tenant can do X”; the key row says which tenant. Ownership is always read from the credential, never from a path or body parameter the caller supplied. This is the enforcement half of fail-closed tenancy — there is no default tenant to fall back to.

The merchant fdk_ credential (defined in modules/stores) is the concrete case:

  • Stored hashed. Only sha256(key) is written to stores.tenant_credentials.key_hash; the plaintext key is returned exactly once at issuance and never again. verifyCredential looks a caller up by key_hash where revoked_at IS NULL, returning the owning tenant_ref and scopes — an unknown or revoked key resolves to nothing.
  • Bound to a tenant in the row. stores.tenant_credentials carries tenant_ref on the credential row itself. Every tenant-facing route resolves the tenant from the verified credential, so one tenant’s key can never read or write another tenant’s data.
  • Rate-limited before the lookup. verifyTenantAuth increments a per-key bucket (fdk_verify) before touching the database, so an online key-guessing attempt is throttled (429 with retry-after) rather than turned into unbounded hash lookups. A lookup failure is 503 auth_unavailable, never a silent pass.

The scope model

Scopes are a closed allowlistTENANT_SCOPES in modules/stores/src/domain/credentials.ts. parseScopes rejects any string not in that list and requires at least one, so a credential can never carry a scope the platform does not define. Scopes are coarse and ownership-shaped (read:own_orders, manage:own_catalog, manage:own_domains, manage:own_credentials, and so on).

Two rules keep the scope system from becoming an escalation vector:

  • Subset-gating. A key can only mint another key whose scopes are a subset of its own (scopesAreSubset). POST /v1/my/credentials requires the manage:own_credentials scope, and any requested scope the issuing key does not hold is a 403 scope_escalation naming the excess scopes — a merchant key can never manufacture a more powerful key.
  • First key by identity session, not by another key. The bootstrap problem — how do you get your first credential — is solved by POST /v1/my/credentials/initial, which authenticates an identity session bearer (a signed-in human), not an fdk_ key. It is owner-only, the tenant is derived from the session, and the scopes granted depend on store status: a live store gets the full merchant scope set, a draft store gets a deliberately narrow pre-live build set (store, catalog, media, domains — no money scopes, and notably not manage:own_credentials), a suspended or missing store mints nothing.

Because the initial-credential broker derives the tenant from the session and refuses any non-owner, a compromised or malicious caller cannot mint a key for a tenant they do not own — the cross-tenant mint is unconstructible, not merely checked.

Token handling — the storefront token stays server-side

There are three distinct credential families, and the important security property is where each one is allowed to live.

CredentialHeaderVerified byLives where
Merchant fdk_ keyAuthorization: Bearer fdk_...hash lookup, tenant from rowserver-side, per tenant
Storefront tokenx-storefront-tokenconstant-time compareserver-side only
Operator session fdo_ / operator tokenauthorization / x-operator-tokensession verifier / constant-time compareoperator tooling only

The storefront token is the credential a headless frontend uses to reach public commerce and checkout routes. Today it is a single platform-wide static secret (STOREFRONT_API_TOKEN), operator-provisioned — there is no self-serve mint — and it is verified with the same constant-time comparison used for the operator token. It is meant for a server-side runtime only; the SDK’s storefront client sends it as x-storefront-token from a server, never from a browser. There is deliberately no browser-side SDK and no CORS opening (ADR-0008, ADR-0014) — a token in browser JavaScript is a token you have published. The token string is also a redaction target in the logging and Sentry-scrubbing layers, so it cannot leak through an error report.

⚠️

Never ship the storefront token to a browser, a mobile app bundle, or any client you do not control. It is platform-wide today: one leaked token is not scoped down to a single tenant. Keep it on your server and proxy storefront calls through your own backend.

The operator front door

Operator (internal staff) access is gated once, at the composition root, in app/src/operator-auth.ts — mounted before any module so no operator route is reachable around it. It does three things on every request:

  1. Strips x-operator-actor from the inbound request. Only this middleware may assert who the operator is; a caller cannot forge an actor.
  2. Resolves an fdo_ operator session against the identity module’s session verifier, enforcing per-area scopes. Postures are fail-closed: a verifier infrastructure failure is 503 auth_unavailable; an unknown or expired session is an opaque 401; a session on a path outside the registry, or missing the area scope, is 403 insufficient_scope.
  3. On success, exchanges the session for the in-process static operator token and stamps the verified actor, so every module’s own route gate (conventions rule 6, ADR-0011) stays untouched and the static token never leaves the process.

Operator roles are a closed set (admin, support, finance) mapped to area scopes (operator:money, operator:commerce, and so on). Notably support does not carry operator:money. built-must-be-wired extends here: a mounted module with no operator area mapping is a boot error, because a module the front door cannot place would silently refuse every operator session.

The SSRF guard on outbound webhooks

Merchant webhook URLs are attacker-influenced — a merchant can point one at http://169.254.169.254/ and try to make the platform fetch its own cloud metadata. edges/outbound-webhooks closes this at two enforcement points: at subscription-create time, and again immediately before every dispatch, retry, and redrive.

  • Hostname guard (assertPublicHttpUrl): the scheme must be http/https (invalid_scheme); localhost and the cloud metadata address 169.254.169.254 are rejected explicitly; and any literal IP in a private or special range is rejected (private_host) — IPv4 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 0/8, plus IPv6 loopback, unique-local fc00::/7, link-local fe80::/10, and IPv4-mapped forms unwrapped and re-checked.
  • DNS-rebinding close. A hostname that passes the string guard is still a TOCTOU risk: DNS could resolve to a public IP at check time and a private one at connect time. The dispatcher resolves the hostname exactly once, re-applies the private-range checks to the resolved address, and then pins the TCP connect to that literal IP — nothing re-resolves DNS after the pin. TLS SNI and certificate verification still target the real hostname. Every delivery uses this pinned client, with a 10-second timeout; a plain fetch is never used for egress.

There is a single dev escape hatch (OUTBOUND_WEBHOOKS_ALLOW_LOOPBACK=1) that permits loopback for local testing — and it hard-throws when NODE_ENV=production, and still rejects malformed URLs, non-http schemes, and the metadata address.

The secret model

Secret management is a compliance control, not a convenience (ADR-0009). The rules that matter to anyone reading config:

  • No empty-string defaults, ever. requiredEnv throws at boot on a missing or empty value. env("X") ?? "", X || "default", and new Secret("X", "") are banned. A missing secret crashes the process with a named error rather than shipping an empty credential that reads as “set.”
  • Fail-startup at construction. requiredEnv is what each module receives as its env dependency, so any secret read at module construction fails the whole boot. Config that only one route needs is read lazily, so that surface simply refuses when the value is absent rather than blocking boot.
  • No AUTH_DISABLED. There is no global auth-off switch anywhere.
  • Secrets never touch disk in dev. Authoring lives in one 1Password vault; local and CI runs inject values through op from op:// references, and production reads from AWS Secrets Manager inside the compliance account boundary. gitleaks runs pre-commit, in CI, and as a weekly full-history sweep.

The RUO-claims guardrail

The RUO vertical sells research-use-only compounds, so store copy must not make disease, structure-function, dosing, or prescription-drug claims. The guardrail (packages/guardrails) scans a manifest draft’s entire object tree for matching terms, classifying findings by category and severity (CRITICAL for disease/dosing/prescription-drug-name, HIGH for structure-function), and reports JSON-path findings.

It is enforced at the manifest perimeter in modules/stores:

  • Fail-closed on scanner error. If the scan itself throws, the publish is 503 scan_unavailable — “this manifest was NOT verified” — never a silent pass.
  • Merchants get a hard block, no override. A merchant publish (PUT /v1/my/store/manifest) with any CRITICAL finding is a 422 ruo_claims_violation telling the merchant to contact the operator. There is no merchant-facing bypass — the escape hatch does not exist for them by construction.
  • Operators can override with a written reason. An operator publish may proceed past CRITICAL findings only by supplying x-guardrails-override: <reason>, and that reason is recorded verbatim in the audit row alongside the findings. HIGH findings return as warnings, not blocks.

Threats and mitigations

ThreatMitigation
Cross-tenant data access via a forged path/body tenantTenant read from the credential row only; tenant_ref NOT NULL no-default columns
Stolen credential replayedKeys hashed at rest; revocation checked on every verify; per-key rate limit before lookup
Privilege escalation by minting a stronger keySubset-gating; 403 scope_escalation; first key requires an owner identity session
Storefront token exfiltrated from a browserServer-side only, no browser SDK, no CORS; token is a log/Sentry redaction target
SSRF / cloud-metadata fetch via a merchant webhook URLPrivate-range guard at create and dispatch; single-resolve DNS pinning; prod-hard loopback block
Empty or missing secret shipping as “working”Fail-loud requiredEnv at boot; no empty defaults; no AUTH_DISABLED
Non-compliant RUO claims going liveManifest guardrail: hard 422 for merchants, audited operator-only override, 503 fail-closed on scanner error
Uncaught error leaking internals to a clientSingle error boundary: internals to log/Sentry only, client sees { error: { code, message, correlationId } }