Skip to main content

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 NOBYPASSRLS runtime 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:

ControlWhere it runs
gofmt, go vet, go test ./..., AsyncAPI contract-drift check, cryptographic-inventory checkapps/api/Dockerfile, on every Coolify build
lint + typecheck for each of the eight portalsthat portal's Dockerfile, on every Coolify build
SBOM generation and substance check, dependency exception + VEX policy, OpenAPI generated-client drift, integration suite, portal component testsscripts/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 the failureFactor/maxFailureWaitSeconds beside it inert. Now true, with permanentLockout: false.
  • The resource-owner password grant (ROPC) was live and unthrottled — 13 consecutive password attempts drew 13 × 401 invalid_grant with no lockout, delay or 429, and the API's own limiters do not apply because the request never reaches the API. Now directAccessGrantsEnabled: false.
  • PKCE was sent by all eight portals and required by nobody. Now pkce.code.challenge.method: S256 is enforced: an authorization request without a challenge is refused with Missing 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.