Security

On this page

See security-baseline.md for Kerckhoffs’s principle and public visibility enforcement.

3. Authentication & Authorization

  • Identity provider: Any OIDC-compliant issuer that implements the client_credentials grant (ADR-026 IdP-neutrality). Devstack ships Keycloak as a reference implementation; production deployers use whatever OIDC backend they have (Keycloak, authentik, Okta, Azure AD/Entra, ForgeRock, PingFederate, Auth0). Plan E § Step 2 (!248) moved the IdP-coupling out: craig_auth::OidcDiscovery fetches <issuer>/.well-known/openid-configuration at boot, caches jwks_uri + token_endpoint + authorization_endpoint + end_session_endpoint, and refreshes on a TTL (default 1h). Pre-Step-2 code path constructed /protocol/openid-connect/* paths directly from the issuer URL — Keycloak-only by accident. Dex 2.45.1 does not implement client_credentials (compiled out of the server’s allSupportedGrants map) and is therefore not a supported backend — see idp-integration.adoc.

  • Web UI flow: Authorization Code + PKCE via craig-ui client. Endpoints resolved via OIDC discovery.

  • CLI/test flow: Resource Owner Password Credentials via craig-api client (transitional — Plan E retires ROPC bootstrap in favor of per-service client_credentials).

  • JWT validation: the accepted signature algorithm is derived from the resolved JWK’s key type — RSA→RS256, EC P-256/P-384→ES256/ES384, OKP-Ed25519→EdDSA (#797) — never from the token header, so exactly one algorithm is accepted per token (alg-confusion defense) while ES256/EdDSA issuers work under the IdP-neutral default (ADR-026); a symmetric key in a verification JWKS is refused. Plus JWKS rotation support, issuer/audience verification, nbf enforcement, typ strictness (present-and-wrong rejected, absent tolerated for forward-compat). See ADR-021.

  • Authorization: data-driven policy engine on zen-engine/JDM. Per-resource policies in rulesets/{jurisdiction}-authz-{resource}.json. Handlers route access decisions through authz.check(…​) (single row) or authz.auto_scope_list(…​) (LIST). RBAC roles are inputs to JDM decision tables rather than hardcoded role checks (the single recorded exception is the scope-gated JWS replay-check dedup endpoint, #489). See plan multi-jurisdictional-authz.adoc (archived). Mutations whose authorization consumed a concurrently-mutable resource field (the assignment-gated placement/kinship updates) additionally re-check that field against the locked row inside the mutation transaction — the ADR-060 conjunctive two-snapshot policy (409 on drift; no engine call under the lock).

  • 9 RBAC roles: the six operational roles (admin, supervisor, caseworker, eligibility_worker, icpc_coordinator, readonly) plus the three ADR-054 office-authority roles (county_director, regional_director, state_office — the subsidy approval matrix axis; office principals also hold a base operational role)

  • Worker identity: claims.sub (UUID) for data, claims.preferred_username for display. Lazy-populated into craig_security.worker_identities from JWT claims on first authenticated request — no IdP admin API dependency.

  • Session management: stateless encrypted cookie (AES-GCM via tower-cookies private jar) — HttpOnly, SameSite=Strict, configurable Secure flag, configurable Max-Age (default 30 min). Access + refresh tokens stored in the cookie. PKCE verifier + a per-login nonce stored in a separate short-lived signed Lax cookie during the auth flow only (needed on the cross-site OIDC callback). SESSION_SECRET must be ≥64 bytes. OIDC RP verification (#813/#814): at the callback the BFF verifies BOTH tokens against the IdP JWKS before minting a session — the id_token (signature, issuer, exp, audience == the login client, azp, typ:"ID", the login nonce, and the at_hash binding to the access token when present) and the access token (signature, issuer, exp, azp); the two are bound to the same subject. Any failure is a hard login rejection (redirect to /welcome), never a degraded empty-roles session. The id_token is verified then discarded — the session rides the verified access token. Per ADR-013 the session lifetime is the cookie Max-Age; tokens are not re-verified per request (the backends re-verify the forwarded bearer on every data call).

  • Read-only write guard (#812): the require_write_access middleware on the protected (session-authed) craig-web routes fail-closed-rejects (403) any unsafe-method request (POST/PUT/PATCH/DELETE) from a principal whose sole role is readonly — except the self-service /personalize/ tree (own-sub-scoped, ADR-035 §3). It is a coarse, single-invariant defense-in-depth edge guard atop the authoritative per-handler backend authz.check, closing the gap where readonly was only a display-only template hint. Layered inner to require_auth (so it runs after the session is injected) and outer to the per-route role gates: verify_csrf → require_auth → require_write_access → [role gate] → handler. The role model is additive, so readonly held alongside a writing role is not blocked.

  • CSRF protection: the verify_csrf middleware guards every state-changing request on the protected (session-authed) routes — Sec-Fetch-Site verification (accept same-origin/none, reject same-site/cross-site), falling back to Origin/Referer authority == Host. SameSite=Strict is the backstop; a per-session synchronizer token is tracked as follow-up defense-in-depth (#950). See #763.

  • Split-DNS deployments: when the IdP’s public issuer URL and the internal hostname differ (Docker keycloak:8080 vs. host-exposed port), OidcDiscovery::with_fetch_url(issuer, fetch_url) rewrites the discovery doc’s jwks_uri and token_endpoint host portions to fetch_url so internal services can reach the IdP. Browser-facing endpoints (authorization_endpoint, end_session_endpoint) keep the public URL since those land in HTTP redirects to the user-agent.

4. Input Handling

  • SQL injection prevention: sqlx compile-time query validation — no string interpolation in SQL

  • HTML sanitization: ammonia crate strips all tags on public intake text input

  • CAPTCHA: Cloudflare Turnstile on public report form (disabled in devstack)

  • Rate limiting: Governor crate — per-hour on public intake endpoints, per-minute on all authenticated API services (configurable via RATE_LIMIT_RPM, default 600, 0=disabled)

  • Content-Security-Policy: API surfaces (the 8 backend services + craig-intake with embed_ui=false): default-src 'none'; frame-ancestors 'none'. UI surfaces (the craig-web BFF + craig-intake with the embedded UI mounted — both standalone modes and, since P3.6/#721, integrated): default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' — strict on both directives, no 'unsafe-inline' (Alpine.js uses its CSP-friendly build; Plan C Step 14 + #414). The exact values are pinned in craig_test_lib::csp and asserted per service.

  • Honeypot field: Public report form includes hidden anti-bot field

  • File upload validation: MIME type allowlist, max file size, filename sanitization via craig-store

  • Enum validation: Database CHECK constraints on all enum columns, reference data validation via craig-reference

5. Transport Security

  • CORS: Explicit method allowlist (GET, POST, PUT, DELETE, OPTIONS), configurable origin list (default * for dev, must be restricted in production)

  • Session cookies: Secure flag configurable via CRAIG_WEB__SESSION_SECURE (default true)

  • Inter-service communication: All services communicate over HTTP within Docker network (TLS at reverse proxy in production)

  • Object storage: S3-compatible via Garage (devstack) or any S3 provider (production), accessed via craig-store crate

6. Secret Management

  • Environment variables only — CRAIG_<SERVICE>__* pattern

  • No PLAINTEXT PRODUCTION secret files committed (.gitignore excludes .env/real key files). The one committed ENCRYPTED store is secrets/dev.yaml + its public .sops.yaml policy (ADR-064) — devstack-scoped values only, encrypted at rest, guarded by the blocking sops-policy lint (encrypted-only leaves, recipient parity, secret-key needle) and the secrets-policy CI job. Kerckhoffs-compliant: the ciphertext + policy + tooling are fully public; security rides the age identities alone. (Deliberately-public devstack TEST fixtures are a separate, documented class: the committed Keycloak realm and devstack/devstack-actor-keys.env P-256 signing JWKs, ADR-064 §Context — dev-only, never production key material.)

  • Keycloak realm config committed (devstack only — production uses external Keycloak)

  • API keys: SHA-256 hashed before storage in api_keys.key_hash

  • JWS signing keys: ECDSA P-256, private keys never stored server-side (public JWK only)

  • CI/CD secrets: ONE masked+protected GitLab variable — the CRAIG_CI_AGE_KEY age identity that unlocks the committed store (ADR-064 retired the per-secret variable pattern; the runtime CRAIG_FIELD_ENCRYPTION_KEY env var contract for deployments is unchanged)

  • In-memory secret hygiene (#798): long-lived secrets held in process memory are wrapped so they zeroize on drop and redact in Debug — service OAuth client_secret`s in `secrecy::SecretString, and the boot-time signing-JWK JSON (which embeds the private scalar) in zeroize::Zeroizing. The parsed P-256 signing keys are already ZeroizeOnDrop upstream (p256/craig-crypto precedent).

  • Service-identity actor JWT signing keys (Plan E, ES256/P-256): inline env var CRAIG_<SVC>SIGNING_JWK for devstack; file-mounted via CRAIG_<SVC>SIGNING_JWK_FILE=<path> for production. Same precedence applies to the deployment-wide peer-JWKS map (CRAIG_PEER_JWKS_JSON inline vs CRAIG_PEER_JWKS_JSON_FILE mounted). Rotation procedure in docs/modules/ROOT/pages/idp-integration.adoc § Service Identity Keypair Provisioning

7. Dependency Auditing

  • cargo deny checks license compatibility (AGPL-3.0-or-later allowlist) and known advisories

  • OpenSSL banned — rustls used for all TLS (Alpine musl compatibility)

  • GitLab dependency scanning in CI pipeline

  • CVE response timeline:

    • Critical: Patch within 24 hours

    • High: Patch within 1 week

    • Medium: Patch within 1 month

    • Low: Address in next scheduled dependency update

8. CI/CD Security

  • SAST: GitLab SAST template (semgrep) in .gitlab-ci.yml

  • Secret detection: GitLab secret detection template

  • Dependency scanning: GitLab dependency scanning (gemnasium)

  • Container scanning: Per-service Docker image scanning (main branch)

  • Pre-push hook: cargo fmt --check, cargo clippy — -D warnings, cargo build, cargo nextest run

9. Audit & Detection

  • Audit logging: Wildcard RabbitMQ subscriber captures all 23+ event types to audit_log table; every row carries actor_service (caller service) + actor_user_sub (acting worker UUID) attribution columns per Plan E Step 11, so BFF-mediated and direct-caller requests are distinguishable from a single audit query

  • Breach detection: Configurable detection rules with threshold-based anomaly detection on audit log patterns

  • Security reviews: Biennial review workflow (security_reviews table) per 45 CFR § 95.621(f)

  • NIST SP 800-53: Control mapping tracked in nist_controls table with implementation status

10. Vulnerability Reporting

See SECURITY.adoc in the repository root for the public-facing vulnerability reporting process.

Edit this page · latest