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_credentialsgrant (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::OidcDiscoveryfetches<issuer>/.well-known/openid-configurationat boot, cachesjwks_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 implementclient_credentials(compiled out of the server’sallSupportedGrantsmap) and is therefore not a supported backend — seeidp-integration.adoc. -
Web UI flow: Authorization Code + PKCE via
craig-uiclient. Endpoints resolved via OIDC discovery. -
CLI/test flow: Resource Owner Password Credentials via
craig-apiclient (transitional — Plan E retires ROPC bootstrap in favor of per-serviceclient_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,
nbfenforcement,typstrictness (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 throughauthz.check(…)(single row) orauthz.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 planmulti-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_usernamefor display. Lazy-populated intocraig_security.worker_identitiesfrom JWT claims on first authenticated request — no IdP admin API dependency. -
Session management: stateless encrypted cookie (AES-GCM via
tower-cookiesprivate jar) — HttpOnly, SameSite=Strict, configurable Secure flag, configurable Max-Age (default 30 min). Access + refresh tokens stored in the cookie. PKCE verifier + a per-loginnoncestored in a separate short-lived signedLaxcookie 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 loginnonce, and theat_hashbinding 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_accessmiddleware 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 isreadonly— 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 backendauthz.check, closing the gap wherereadonlywas only a display-only template hint. Layered inner torequire_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, soreadonlyheld alongside a writing role is not blocked. -
CSRF protection: the
verify_csrfmiddleware guards every state-changing request on the protected (session-authed) routes — Sec-Fetch-Site verification (acceptsame-origin/none, rejectsame-site/cross-site), falling back toOrigin/Refererauthority ==Host.SameSite=Strictis 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:8080vs. host-exposed port),OidcDiscovery::with_fetch_url(issuer, fetch_url)rewrites the discovery doc’sjwks_uriandtoken_endpointhost portions tofetch_urlso 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:
ammoniacrate 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-intakewithembed_ui=false):default-src 'none'; frame-ancestors 'none'. UI surfaces (thecraig-webBFF +craig-intakewith 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 incraig_test_lib::cspand 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(defaulttrue) -
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-storecrate
6. Secret Management
-
Environment variables only —
CRAIG_<SERVICE>__*pattern -
No PLAINTEXT PRODUCTION secret files committed (
.gitignoreexcludes.env/real key files). The one committed ENCRYPTED store issecrets/dev.yaml+ its public.sops.yamlpolicy (ADR-064) — devstack-scoped values only, encrypted at rest, guarded by the blockingsops-policylint (encrypted-only leaves, recipient parity, secret-key needle) and thesecrets-policyCI 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 anddevstack/devstack-actor-keys.envP-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_KEYage identity that unlocks the committed store (ADR-064 retired the per-secret variable pattern; the runtimeCRAIG_FIELD_ENCRYPTION_KEYenv 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 OAuthclient_secret`s in `secrecy::SecretString, and the boot-time signing-JWK JSON (which embeds the private scalar) inzeroize::Zeroizing. The parsed P-256 signing keys are alreadyZeroizeOnDropupstream (p256/craig-cryptoprecedent). -
Service-identity actor JWT signing keys (Plan E, ES256/P-256): inline env var
CRAIG_<SVC>SIGNING_JWKfor devstack; file-mounted viaCRAIG_<SVC>SIGNING_JWK_FILE=<path>for production. Same precedence applies to the deployment-wide peer-JWKS map (CRAIG_PEER_JWKS_JSONinline vsCRAIG_PEER_JWKS_JSON_FILEmounted). Rotation procedure indocs/modules/ROOT/pages/idp-integration.adoc § Service Identity Keypair Provisioning
7. Dependency Auditing
-
cargo denychecks 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_logtable; every row carriesactor_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_reviewstable) per 45 CFR § 95.621(f) -
NIST SP 800-53: Control mapping tracked in
nist_controlstable with implementation status
10. Vulnerability Reporting
See SECURITY.adoc in the repository root for the public-facing vulnerability reporting process.