Security Baseline
Security is by design. This baseline is inherited by every module.
The canonical, long-form security documents live in the repository under docs/security/
(SECURITY.md, THREAT_MODEL.md, CI_AND_WEB_HARDENING.md, SUPPLY_CHAIN.md,
CRYPTO_INVENTORY.yaml). This page summarizes them and points there; where the two
disagree, the repository is right.
Controls in place
- AuthN: verified OIDC bearer tokens (Keycloak/JWKS) — signature, issuer, audience and expiry are checked and claims mapped to the actor; the API refuses to start without OIDC config unless the development bypass is explicitly enabled (impossible outside development). Fails closed on any verification error.
- AuthZ: relationship-based via the OpenFGA HTTP adapter, deny-by-default, checks fail closed; the API refuses to start in non-development environments without OpenFGA configured. Sensitive relations are explicit and time-boxable.
- App: strict JSON decode + 1 MiB cap; parameterized SQL only; secure headers
(
nosniff,DENY,no-referrer,no-store); per-IP rate limiting (see below); RFC 7807 errors without internal leakage. - Data: separation of private / emergency / public projections; unguessable QR tokens
with uniform 404 on unknown/disabled; append-only immutable audit; row-level security
on 73 tables, with the application connected as a
NOBYPASSRLSruntime role (ADR-013, ADR-017, ADR-018, ADR-026 — see Multi-tenancy).
Supply chain — where these controls actually run
GitHub Actions does not execute for this project. Jobs never start (the account's
Actions billing is unpaid), so every control that was written as a workflow step has never
run against a change. gitleaks, govulncheck, go-licenses, trivy and semgrep are in
that category: configured and never executed. docs/security/SUPPLY_CHAIN.md records
the resulting gap; ROADMAP.md tracks it as FOUND-006.
What does run, and where:
| Control | Where it runs |
|---|---|
gofmt, go vet, go test ./..., AsyncAPI contract-drift check, cryptographic-inventory check | apps/api/Dockerfile, on every Coolify build |
| lint + typecheck for each of the eight portals | that portal's Dockerfile, on every Coolify build |
| SBOM generation and substance check, dependency exception + VEX policy, OpenAPI generated-client drift, integration suite, portal component tests | scripts/verify.sh, run by a person before pushing |
The SBOM is generated from scratch on every scripts/verify.sh run and deliberately not
committed — a committed SBOM is stale from the first lockfile change that lands without a
regeneration. No vulnerability scanner runs anywhere today. The Go toolchain is pinned in
the Dockerfiles (golang:1.26.5-alpine, except trust-consumer at 1.26.4-alpine) and the
pnpm lockfile is committed.
See Build and deployment for the full gate inventory.
Rate limiting — corrected 2026-08-28 (ADR-030)
Every limiter the code describes as "per client IP" was in fact one global bucket in
production: the API sits behind Coolify's Traefik and ClientIP read RemoteAddr, which is
the proxy's container address for every caller. This was demonstrated, not inferred — a probe
from an unrelated public IP received 429 while a different machine drove load, and 404
again once that load stopped.
The fix is a trusted-proxy CIDR allowlist (API_TRUSTED_PROXY_CIDRS): X-Forwarded-For is
honoured only from an address inside the allowlist, and an unset allowlist preserves the old
behaviour exactly rather than guessing a range. It is live and confirmed by re-running the
same measurement on 2026-08-28, with the loading client still throttled as the control.
Limits are still per-process token buckets; a Redis-backed shared limiter across replicas does not exist. Read ADR-030 for the resolution rules and the trust boundary the allowlist creates.
Identity hardening (2026-08-28)
Three settings on the production Keycloak realm were checked directly and changed, each verified by its refusal rather than by its configured value:
- Brute-force protection was
false, making thefailureFactor/maxFailureWaitSecondsbeside it inert. Nowtrue, withpermanentLockout: false. - The resource-owner password grant (ROPC) was live and unthrottled — 13 consecutive
password attempts drew 13 ×
401 invalid_grantwith no lockout, delay or429, and the API's own limiters do not apply because the request never reaches the API. NowdirectAccessGrantsEnabled: false. - PKCE was sent by all eight portals and required by nobody. Now
pkce.code.challenge.method: S256is enforced: an authorization request without a challenge is refused withMissing parameter: code_challenge_method.
scripts/synthetic-journey.sh was the grant's only caller and is knowingly broken by this.
The bundled local-development realm export still carries the old values. See
docs/security/THREAT_MODEL.md and ADR-031 §4.
Live verification (2026-07-27)
OIDC and OpenFGA were exercised end-to-end against real, running instances (not fakes):
a genuine Keycloak-issued token drove the full HTTP server through real signature/issuer/
audience/expiry checks and a real OpenFGA Check decision — no token → 401; valid token
for the resource owner → 200; a different authenticated user with no relationship → 403.
See ADR-004, ADR-005, and the Roadmap for the exact scenarios.
This is run on demand with -tags=live_keycloak / -tags=live_openfga against a local
stack. It is not automated anywhere, and there is no automation surface it could be
added to short of scripts/verify.sh — which would then need both services running.
Open items
File-upload antivirus (no scanner is wired; uploads stay quarantined and the control fails
closed — see docs/MOCKED_DEPENDENCIES.md); a secret manager (secrets come from Coolify's
store today); edge rate limiting/WAF; a vulnerability scanner that actually executes; and a
Redis-backed limiter before the API runs more than one replica.