Plan: Partner Edge + Browser-Facing Operational Hardening

On this page
Contents

Status

Step Description Status

1

Plan filing + GitLab epic + 18 step issues. nav.adoc + CHANGELOG. No code changes.

Done (2026-05-08) — MR !230

2

F-026 implementation: bind JWS signer’s partner_id to authenticated API-key partner. P0.

Done (2026-05-14) — MR !284 (2026-05-13)

3

F-008 implementation: signer-key expires_at enforcement at JWS-verify lookup. P0.

Done (2026-05-14) — MR !289

4

F-005 implementation: JWS replay defense (jti + iat protected-header params + Postgres seen-jti table + freshness window). P0.

Done (2026-05-14) — MR !286 (2026-05-13)

5

F-006 implementation: per-partner rate-limit redesign (parallel IP + partner limiters; consume rate_limit_rpm). P0.

Done (2026-05-14) — MR !288 (2026-05-13)

6

F-007 implementation: X-Forwarded-For trust per env-configured trusted-proxy CIDRs. P1.

Done (2026-05-14) — MR !287 (2026-05-13)

7

F-009 implementation: partner API key rotation (expires_at column + lookup constraint + admin rotate endpoint). P1.

Done (2026-05-14) — MR !290

8

F-010 implementation: replace Uuid::nil() partner_id sentinel with typed PartnerContext enum. P1.

Done (2026-05-14) — MR !291

9

F-012 implementation: hash PartnerAuthClient cache key. P2.

Done (2026-05-14) — MR !293

10

F-013 implementation: typed error enum on /verify adapter. P3.

Done (2026-05-14) — MR !292

11

F-014 implementation: collapse JWS-verify error messages to single uniform 401. P2.

Done (2026-05-14) — MR !285 (2026-05-13)

12

F-011 implementation: replace misleading "constant-time" comment. P3.

Done (2026-05-15) — MR !299 (2026-05-14)

13

F-017 implementation: HSTS layer extracted to shared crate; wired into craig-web + craig-intake. P1.

Done (2026-05-14) — MR !294

14

F-018 implementation: tighten CSP on craig-web + craig-intake (remove unsafe-inline + unsafe-eval). P1.

Done (2026-05-14) — MR !295

15

F-019 implementation: Postgres statement_timeout configured at pool construction. P1.

Done (2026-05-14) — MR !296

16

F-020 implementation: redact Keycloak token-exchange error logging. P1.

Done (2026-05-14) — MR !297

17

F-021 implementation: extend free-text sanitization beyond concern_description; new xtask lint preventing Askama | safe regressions. P2.

Done (2026-05-15) — MR !298 (2026-05-14)

18

Plan completion audit + archive.

Done (2026-05-14) — this MR

Epic: &26 (epic: partner edge + browser-facing operational hardening (Plan C))
Issues: #343 (Step 1) · #344 (Step 2 F-026) · #345 (Step 3 F-008) · #346 (Step 4 F-005) · #347 (Step 5 F-006) · #348 (Step 6 F-007) · #349 (Step 7 F-009) · #350 (Step 8 F-010) · #351 (Step 9 F-012) · #352 (Step 10 F-013) · #353 (Step 11 F-014) · #354 (Step 12 F-011) · #355 (Step 13 F-017) · #356 (Step 14 F-018) · #357 (Step 15 F-019) · #358 (Step 16 F-020) · #359 (Step 17 F-021) · #360 (Step 18 archive)
Branch prefix: feat/partner-edge- / fix/partner-edge- / chore/partner-edge-
*Milestone
: TBD (no fixed milestone — security/hardening work; ship on readiness)

Context

The 2026-05-08 audit surfaced 16 partner-edge + browser-facing operational findings beyond the BOLA/IDOR + PII/concurrency tracks. Severity ranges from P0 (cross-partner JWS forgery; signer-key expiry ignored; rate_limit_rpm dead code; JWS replay) to P3 (typed error enum, comment polish). All cluster around the partner-submission edge or browser-facing security headers — different concerns from authorization/encryption/concurrency.

Steps 2-5 are P0 and ship first; remainder ship in priority order. Several steps interact — see § Step DAG below for the actual sequencing constraints (the original "each step independent except 13→14" claim was incomplete; verified-corrected 2026-05-13).

26 application-layer hardening findings (F-001..F-026) were validated across 5 audit passes; this plan covers the partner-edge subset (16 of 26). Findings are durably anchored as the bodies of the 18 step-tracking GitLab issues filed by this plan’s Step 1.

Threat model

Plan C closes 6 distinct partner-edge attacker capabilities plus 5 browser-facing operational hardening gaps. Anchoring these here so secondary design decisions (window sizes, retention periods, granularity) have a referent.

Attacker capability What’s exposed today Plan C closes via Severity

Cross-partner JWS forgery. A compromised or rogue partner with a valid API key + signer key mints JWS that claims to be from a different partner. JWS verification doesn’t bind the signer key’s owning partner_id to the API-key-authenticated partner_id.

Any partner can submit reports as any other partner; partner-isolation invariant violated; audit trail mis-attributed.

F-026 (Step 2) — binding check at JWS-verify time

P0

JWS replay. MITM (e.g. compromised TLS-terminating proxy) captures a valid JWS-signed request and replays it. No jti + iat enforcement; no seen-jti table.

Reports duplicate; idempotency-key dedup only catches verbatim re-submits, not freshness violations.

F-005 (Step 4) — RFC 7519 jti + iat in JWS protected header + Postgres seen-jti table + freshness window

P0

Expired-key continued use. Signer key’s expires_at is recorded but not enforced at verify time.

Long-lived signer keys remain valid past their stated expiry — defeats key rotation.

F-008 (Step 3) — enforce expires_at in get_by_kid WHERE clause

P0

Quota bypass. Partner advertises rate_limit_rpm in its registration, but the limiter is keyed only on IP. A single partner across N IPs consumes N × rate_limit_rpm.

DoS / cost-amplification by a single partner deployed across distributed compute.

F-006 (Step 5) — per-partner limiter parallel to IP limiter; consume the advertised rate_limit_rpm

P0

Upstream-IP spoofing. Bare ConnectInfo reads the immediate-peer IP (the LB); no XFF parsing against trusted-proxy CIDR allowlist.

IP-based rate-limiter buckets, IP hash audit trail, geo-restriction enforcement all unreliable.

F-007 (Step 6) — XFF parser with env-configured trusted-proxy CIDRs

P1

Error-message kid enumeration. Distinguishable error responses (key-not-found vs signature-invalid vs key-revoked) let attackers enumerate live kid values.

Recon precursor to targeted key compromise.

F-014 (Step 11) — collapse all JWS-verify errors to uniform 401 with identical body

P2

Browser-facing operational hardening (F-017..F-021):

Capability Closes Severity

HSTS missing on browser surfaces

F-017 (Step 13)

P1

CSP allows unsafe-inline + unsafe-eval (XSS uplift surface)

F-018 (Step 14)

P1

Long-running Postgres queries can stall service (no statement_timeout)

F-019 (Step 15)

P1

Keycloak token-exchange errors log raw upstream body (potential token-substring leak)

F-020 (Step 16)

P1

Free-text sanitization only on concern_description + no lint preventing Askama | safe re-introduction

F-021 (Step 17)

P2

P3 hygiene findings (F-009 API key rotation, F-010 PartnerContext, F-011 comment fix, F-012 cache-key hash, F-013 typed errors) close lower-severity defense-in-depth gaps; no specific attacker capability hinges on them but they tighten the audit + maintainability surface.

Cross-cutting invariants

These properties must hold across all 18 steps; codify in inline tests where practical.

  1. Uniform 401 body. Every JWS-verify error path returns HTTP 401 with an identical RFC 9457 Problem-Details body — no distinguishing key_not_found vs signature_invalid vs expired_key vs replay_detected. Step 11 enforces; Steps 2/3/4 must respect this when they ship.

  2. jws_seen_jti is partner-scoped. The PK is (partner_id, jti), not jti alone. Cross-partner jti collisions are not replay events. Step 4 SQL DDL must reflect this even though the threat model focuses on within-partner replay.

  3. Health endpoints bypass rate limit + auth + JWS. GET /healthz + GET /readyz never enter the rate-limit middleware stack or the partner-auth chain. Step 5 must explicitly exclude /healthz + /readyz from limiter; Step 6 XFF parser unused for these paths.

  4. All new env vars follow CRAIG_<SERVICE>*. Steps 5, 6, 7, 13, 15 introduce env vars (CRAIG_INTAKETRUSTED_PROXY_CIDRS, CRAIG_INTAKEJWS_FRESHNESS_WINDOW_SECONDS, CRAIG_<SVC>HSTS_MAX_AGE_SECONDS, CRAIG_<SVC>STATEMENT_TIMEOUT_MS). Pattern is CRAIG_<UPPERCASE_SERVICE><UPPERCASE_KEY>.

  5. No new IdP admin API clients. Per ADR-026 and the IdP-neutrality design, Plan C must not introduce a Keycloak-admin or other IdP-admin client. Step 16’s log redaction is purely client-side string handling.

  6. Migrations are additive. Per coding-conventions § "Additive only — no renames or drops without a two-step deprecation." Step 4’s jws_seen_jti CREATE is additive; Step 7’s partner_api_keys.expires_at ADD COLUMN is additive with a sane default for pre-existing rows.

  7. All partner-edge tests run in craig-intake test crate, not craig-test-lib. Plan C is intake-internal; cross-service E2E uplift waits for Plan A’s hardened authz layer (already shipped).

Step DAG

Several steps interact in ways the original "each step independent" claim missed. The implementing agent should land steps in this order:

  • Critical path: Step 2 (F-026 binding) → Step 11 (F-014 error collapse) → Step 4 (F-005 replay).

  • Why 2 → 11: Step 2’s binding-check code returns 401 on mismatch; that 401 must use the Step 11 collapsed shape. Landing Step 2 before Step 11 forces Step 2 to inline a temporary error response that Step 11 then has to clean up.

  • Why 11 → 4: Same — Step 4’s replay-detected error must use the collapsed 401, not a distinguishing "replay" response that enables jti enumeration.

  • Critical path: Step 6 (F-007 XFF trust) → Step 5 (F-006 per-partner limiter).

  • Why 6 → 5: Per-partner limiter still consults IP-side limiter for unauthenticated paths; without F-007 the IP key collapses all distributed-LB partners to one bucket. Landing 5 before 6 produces false confidence in the rate-limit fix.

  • Sequence: Step 13 (F-017 HSTS) → Step 14 (F-018 CSP).

  • Why: Step 14’s CSP-tighten benefits from Step 13’s shared-helper extraction so two services apply the same posture.

  • Sequence: Step 2 (F-026) before Step 4 (F-005) — Step 4’s jws_seen_jti.partner_id column must hold the F-026-bound partner identity, not the unverified-header partner_id.

  • Independent: Steps 3 (F-008), 7 (F-009), 8 (F-010), 9 (F-012), 10 (F-013), 12 (F-011), 15 (F-019), 16 (F-020), 17 (F-021) can ship in any order after their respective predecessors (none have hard cross-step dependencies).

Recommended global order: 2 → 11 → 4 → 6 → 5 → 3 → 7 → 8 → 10 → 9 → 13 → 14 → 15 → 16 → 17 → 12 → 18.

Scope

In scope (16 findings):

  • Partner edge: F-005, F-006, F-007, F-008, F-009, F-010, F-011, F-012, F-013, F-014, F-026

  • Operational / browser-facing: F-017, F-018, F-019, F-020, F-021

Out of scope:

  • Authorization on partner-submitted reports — Plan A

  • Reports table encryption — Plan B

  • Code-quality discipline — Plan D

Design

D1. JWS partner-binding (F-026)

services/craig-intake/src/api/jws.rs:31::JwsVerification currently drops the signer’s owning partner_id even though services/craig-intake/src/api/signer_auth.rs:31::SignerKeyInfo carries it. Cross-partner forgery surface: partner A authenticated by API-key A could sign with partner B’s signer key.

Fix:

// jws.rs:31
pub struct JwsVerification {
    pub verified: bool,
    pub kid: String,
    pub algorithm: String,
    pub user_identifier: String,
    pub signer_key_id: Uuid,
    pub partner_id: Uuid,        // NEW — populated from signer_key.partner_id
}

// partner.rs:85+ caller
let verification = jws::verify_detached_jws(client, jws_str, canonical.as_bytes()).await
    .map_err(|e| { /* per F-014 collapse */ })?;
if verification.partner_id != api_key.id {
    tracing::warn!(
        api_key_partner = %api_key.id,
        signer_partner = %verification.partner_id,
        "cross-partner JWS attempt rejected"
    );
    return Err(ApiError::forbidden("JWS signer key does not belong to authenticated partner"));
}

Audit-emit partner.cross_partner_attempt event when the check fires (operational signal of misconfiguration or attack).

D2. Signer-key expires_at enforcement (F-008)

services/craig-security/src/store/signer_keys.rs:105-141::get_by_kid currently:

WHERE sk.key_id = $1 AND p.status = 'active'

becomes:

WHERE sk.key_id = $1
  AND p.status = 'active'
  AND (sk.expires_at IS NULL OR sk.expires_at > now())

D3. JWS replay defense (F-005)

JWS protected-header param additions at services/craig-intake/src/api/jws.rs:20-30::JwsHeader:

#[derive(Debug, Deserialize)]
pub struct JwsHeader {
    pub alg: String,
    pub kid: String,
    pub jti: Uuid,                              // NEW — required
    #[serde(rename = "iat")]
    pub issued_at: i64,                         // NEW — required (Unix seconds)
}

New table services/craig-cases/migrations/<TS>_jws_seen_jti.sql (the table lives in craig-cases, not intake — intake is stateless per ADR-017 and has no DbPool; cases already owns the cross-service plumbing the forwarder writes through, so the jti-uniqueness check happens at the forwarder seam inside cases):

CREATE TABLE jws_seen_jti (
    partner_id    UUID NOT NULL,
    jti           UUID NOT NULL,
    iat           BIGINT NOT NULL,
    received_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (partner_id, jti)
);
CREATE INDEX idx_jws_seen_jti_received ON jws_seen_jti (received_at);

PK is (partner_id, jti) per Cross-cutting Invariant #2 (cross-partner jti collisions are not replay events).

Locked decision (2026-05-14) — write shape: the dedupe insert is a single statement that serializes naturally on the UNIQUE PK, no check-then-insert window:

INSERT INTO jws_seen_jti (partner_id, jti, iat, received_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (partner_id, jti) DO NOTHING
RETURNING jti;

Empty RETURNING → replay detected → 401 (uniform-401 body per Step 11’s collapsed shape). Non-empty → first-sighting → continue authentication.

24h cleanup task; iat freshness window: reject if iat < now - 5min or iat > now + 30s (clock skew tolerance).

Coordinated SDK update across all three SDKs (JWS protected-header is wire-format):

  • crates/craig-intake-sdk/src/sign.rs + crates/craig-signing/src/lib.rs — Rust path

  • sdks/python/src/craig_intake/signing.py::sign_detached — Python path; inject jti (UUID v7) + iat (epoch seconds) into the header dict before jwt.encode

  • sdks/typescript/src/signing.ts::signDetached — TypeScript path; same fields on the header object before new SignJWT(…​).setProtectedHeader(header).sign(…​)

  • sdks/test-vectors/ — bump fixture vectors to include jti/iat so cross-SDK round-trip tests pin the wire format

D4. Per-partner rate-limit redesign (F-006)

services/craig-intake/src/api/rate_limit.rs:14-22 currently builds a single KeyedRateLimiter<IpAddr, …​> via build_rate_limiter(per_hour). New shape:

pub type IpRateLimiter = RateLimiter<IpAddr, governor::state::keyed::DefaultKeyedStateStore<IpAddr>, DefaultClock>;
pub type PartnerRateLimiter = RateLimiter<Uuid, governor::state::keyed::DefaultKeyedStateStore<Uuid>, DefaultClock>;

pub struct RateLimits {
    pub by_ip: Arc<IpRateLimiter>,
    pub by_partner: Arc<PartnerRateLimiter>,
}

Middleware checks both. Rejection: HTTP 429 + Retry-After. Per-partner quota uses api_key.rate_limit_rpm (per-minute). The IP quota uses a global default.

D5. X-Forwarded-For trust (F-007)

Extend rate-limit middleware:

fn extract_client_ip(headers: &HeaderMap, conn_addr: SocketAddr, trusted: &[IpNet]) -> IpAddr {
    if let Some(forwarded) = headers.get("forwarded").and_then(|v| v.to_str().ok()) {
        // RFC 7239 parser: leftmost untrusted IP
        // ...
    }
    if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
        // CSV; rightmost-first; walk back over trusted proxies
        // ...
    }
    conn_addr.ip()
}

services/craig-intake/src/config.rs::IntakeSettings (intake’s own settings struct, not craig-common’s ServiceSettings — Plan B already added ip_hash_secret there; same place) adds trusted_proxies: Vec<IpNet> (env var CRAIG_INTAKE__TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12).

Devstack harness coupling (locked 2026-05-14): craig-test-lib’s integration tests connect from `127.0.0.1. After F-007 lands, IP-derived behavior (rate-limit bucketing, hashed-IP audit) silently breaks if the loopback isn’t in the trusted-proxy CIDR. docker-compose.yml must set CRAIG_INTAKE__TRUSTED_PROXIES=127.0.0.1/32,::1/128 for both craig-intake (integrated) and craig-intake-standalone. .env.example mirrors. Test fixtures continue using ConnectInfo from loopback; XFF is honored when present.

D6. Partner API key rotation (F-009)

Migration adds expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '1 year') to partner_api_keys; pre-1.0 backfill applies same default to existing rows.

Lookup at services/craig-security/src/store/partners.rs:233::verify_by_hash (note: there is no partner_api_keys.rs store module — API key verification lives on the partners store) adds AND expires_at > now() to its WHERE clause.

Pre-expiry warning: at request-time, log tracing::warn! if expires_at - now() < 30 days.

New admin endpoint POST /v1/security/partners/{id}/api-keys/{key_id}/rotate: generates new key value, updates the row, returns the new value (one-time view; never re-displayed).

Backfill lock posture (locked 2026-05-14): ALTER TABLE partner_api_keys ADD COLUMN expires_at NOT NULL DEFAULT (now() + INTERVAL '1 year') acquires AccessExclusiveLock and rewrites every row in a single transaction. Pre-1.0 + devstack-with-~10-rows: acceptable. Documented here so future deployments with non-trivial partner_api_keys size know to use the additive-migration dance: (1) ADD COLUMN expires_at TIMESTAMPTZ (nullable, no default — instant); (2) batched UPDATE partner_api_keys SET expires_at = now() + INTERVAL '1 year' WHERE expires_at IS NULL LIMIT 1000 in a cleanup task; (3) ALTER TABLE …​ ALTER COLUMN expires_at SET NOT NULL once backfilled. Plan C ships the simple form because we know devstack is small + no production data exists pre-1.0.

D7. PartnerContext sentinel removal (F-010)

New crate type at crates/craig-cases-contracts/src/partner_context.rs:

#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PartnerContext {
    Anonymous,
    Authenticated { id: Uuid },
}

Replace Uuid::nil() sentinels at services/craig-intake/src/api/public.rs:138 and services/craig-intake/src/api/api_key_lookup.rs:75 with PartnerContext::Anonymous. Update consumer at services/craig-intake/src/sink/cases_forwarder.rs:116::CasesForwarderSink::forward (the forward fn body; line 107 is inside service_token()) to pass tagged JSON to cases. Cases-side services/craig-cases/src/api/reports.rs::create_report deserializes the tagged form and branches accordingly.

Locked decision (2026-05-13): the cases-side branch behavior is — PartnerContext::Anonymous → omit partner_id from the report row (NULL column); PartnerContext::Identified(id) → set partner_id = id. Authz unaffected (already keyed on per-record fields via Plan A). Audit attribution unaffected (already keyed on claims.sub via Plan E).

D8. PartnerAuthClient cache key hash (F-012)

At services/craig-intake/src/api/partner_auth.rs:29, hash incoming raw API keys via existing hashing::hash_api_key helper (mirror config-mode pattern at api_key_lookup.rs) before HashMap insert/lookup.

D9. Typed verify error enum (F-013)

services/craig-intake/src/api/partner_auth.rs:53-100::verify returns Result<_, PartnerVerifyError> instead of Result<_, String>:

pub enum PartnerVerifyError {
    Timeout,
    UnexpectedStatus(u16),
    JsonParse(serde_json::Error),
}

Caller at api_key_auth.rs:44 handles each variant explicitly.

D10. JWS error message collapse (F-014)

All 11 distinguishable error paths in services/craig-intake/src/api/jws.rs::verify_detached_jws collapse at the caller boundary in services/craig-intake/src/api/partner.rs:85-87:

let verification = jws::verify_detached_jws(client, jws_str, canonical.as_bytes()).await
    .map_err(|e| {
        tracing::warn!(
            error = %e,
            partner = %api_key.organization,
            "JWS verification failed"
        );
        ApiError::unauthorized("JWS verification failed")
    })?;

Status code becomes 401 (was 400). Response body carries no per-cause distinction.

D11. Misleading constant-time comment (F-011)

At services/craig-intake/src/api/api_key_lookup.rs:67, replace the misleading "constant-time lookup" comment with: // Hashes each key once at startup; lookups use standard HashMap. The lookup is not constant-time in the cryptographic sense, but the input is already a SHA-256 hash so timing variance does not leak the original key.

D12. HSTS shared helper (F-017)

Extract HSTS layer from crates/craig-api/src/lib.rs into crates/craig-common/src/middleware/security_headers.rs:

pub fn hsts_layer() -> SetResponseHeaderLayer<HeaderValue> {
    SetResponseHeaderLayer::overriding(
        HeaderName::from_static("strict-transport-security"),
        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
    )
}

Wire into:

  • crates/craig-api/src/lib.rs (refactor existing inline definition to use shared helper)

  • services/craig-web/src/main.rs (add layer to top-level axum router)

  • services/craig-intake/src/main.rs (add layer to top-level axum router)

D13. CSP tightening (F-018)

services/craig-web/src/main.rs and services/craig-intake/src/main.rs CSP layer drops 'unsafe-inline' and 'unsafe-eval'. Inline <script> and style="…​" blocks in services/craig-web/templates/* and services/craig-intake/static/ converted to:

  • External <script src="…​"> for scripts; OR

  • Nonce-based inline scripts using a per-request nonce

  • External CSS files for inline styles

Embedded-UI mode and standalone mode for craig-intake converge on the same strict policy.

D14. Postgres statement_timeout (F-019)

crates/craig-db/src/lib.rs adds after_connect callback to PgPool setup:

.after_connect(|conn, _meta| Box::pin(async move {
    conn.execute(format!("SET statement_timeout = '{}ms'", settings.db_statement_timeout_ms).as_str()).await?;
    Ok(())
}))

crates/craig-common::settings adds db_statement_timeout_ms: u64 with default 30000 (30s); env-overridable.

Per-query overrides for known long ops (xtask reconcile, batch backfills) via SET LOCAL.

D15. Keycloak log redaction (F-020)

services/craig-web/src/auth.rs:281-285 (verified 2026-05-14) handles the non-success token-exchange path. The leak is the line 283 tracing::error!("Token exchange failed: {body}"){body} is the raw upstream response which may contain partial tokens, kid hints, or partner-specific error context. Replace with logs that pull only error + error_description per OAuth2 RFC 6749 §5.2:

let parsed: OAuth2ErrorBody = serde_json::from_str(&body).unwrap_or_else(|_| OAuth2ErrorBody::generic());
tracing::error!(
    error = %parsed.error,
    error_description = ?parsed.error_description,
    "Keycloak token exchange failed"
);
// Drop request-context fields (client_id, redirect_uri, code) from log

D16. Sanitization extension + Askama lint (F-021)

Extend services/craig-intake/src/api/validation.rs::sanitize_text to all free-text intake fields. Strip control chars; NFC-normalize Unicode; cap length per-field.

New xtask at xtask/src/cmd/validate_template_safety.rs. Walks Askama templates under services/craig-web/templates/ and services/craig-intake/static/ and fails the build if any {{ …​ | safe }} or {{ …​ | unescape }} patterns are found.

Lint mechanism (clarified 2026-05-14): Askama templates are Jinja-like (not Rust), so syn::visit::Visit (which parses Rust syntax) is the wrong primitive. Use a token-level scan instead — for each .html file under the configured roots (services/craig-web/templates/ + services/craig-intake/static/; Askama templates use the .html extension in this repo, e.g. services/craig-web/templates/base.html, error.html), read as UTF-8 and run a regex::Regex matcher against \{\{\|\s(safe|unescape)\s*[}|] (escape-the-pipe filter syntax). For directory walking, model on xtask/src/cmd/check_docs.rs’s `walkdir-based file traversal (the existing precedent in this xtask). Do not model on quality_budgets.rs::316-358 (that’s syn-based Rust source analysis, wrong tool for this job — original plan body cited it in error).

Steps

Step 1: Plan filing + GitLab issue tree

Files: docs/modules/ROOT/pages/plans/partner-edge-hardening.adoc (filed by prep MR; this step is bookkeeping).

Verification: prep MR’s check-docs + epic + 18 issues created.

Step 2: F-026 JWS partner-binding (P0)

Files:

  • services/craig-intake/src/api/jws.rs:31 (add partner_id field to JwsVerification)

  • services/craig-intake/src/api/jws.rs (~line 96 area; populate partner_id from signer_key.partner_id after lookup)

  • services/craig-intake/src/api/partner.rs:85-100 (assert verification.partner_id == api_key.id; emit audit event on mismatch)

  • services/craig-intake/tests/api/jws_partner_binding.rs (NEW — 3 tests, all guarded by if !devstack_available().await { return; } per the Plan B idiom: same-partner JWS passes; cross-partner JWS rejected with 403 + audit event emitted; partner-id mismatch logged with both ids)

Branch: fix/partner-edge-step2-jws-partner-binding

MR title: fix(craig-intake): bind JWS signer’s partner_id to authenticated API-key partner [Step 2 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api jws_partner_binding — 3 tests pass

  2. Manual: provision partners A and B with their own API keys + signer keys; submit POST /partner/v1/reports with API-key A + signer kid B; confirm 403; check audit log for partner.cross_partner_attempt event

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== JWS signer's partner_id now bound to authenticated API-key partner [Step 2 of partner-edge] (DATE)

Closes the cross-partner JWS forgery surface surfaced by 2026-05-08 audit.
JwsVerification struct gains a partner_id field populated from the signer
key's owning partner. submit_report_partner asserts verification.partner_id
== api_key.id and rejects with 403 + audit event on mismatch. F-026 closed.

Step 3: F-008 signer-key expires_at enforcement (P0)

Files:

  • services/craig-security/src/store/signer_keys.rs:105-141::get_by_kid (add AND (sk.expires_at IS NULL OR sk.expires_at > now()) to the WHERE clause near the bottom of the function body; existing clauses are sk.status = 'approved' AND sk.revoked_at IS NULL AND p.status = 'active')

  • services/craig-security/tests/api/signer_keys_expiry.rs (NEW — 3 tests, all guarded by if !devstack_available().await { return; } per the Plan B idiom: expired key returns None; null-expiry key returns Some; future-expiry key returns Some)

Branch: fix/partner-edge-step3-signer-key-expiry

MR title: fix(craig-security): enforce signer-key expires_at at JWS-verify lookup [Step 3 of partner-edge]

Verification:

  1. cargo nextest run -p craig-security --test api signer_keys_expiry — 3 tests pass

  2. Manual: insert a signer key with expires_at = now() - INTERVAL '1 day'; sign + submit a JWS using its kid; confirm 401 from intake (post Step 2’s collapse, error is generic)

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Signer-key expires_at enforced at JWS verify [Step 3 of partner-edge] (DATE)

services/craig-security/src/store/signer_keys.rs::get_by_kid now
respects sk.expires_at; expired keys cease to verify JWS payloads. F-008 closed.

Step 4: F-005 JWS replay defense (P0)

Files:

  • services/craig-intake/src/api/jws.rs:20-30 (extend JwsHeader with jti: Uuid + iat: i64)

  • services/craig-intake/src/api/jws.rs::verify_detached_jws (insert seen-jti row + check freshness window)

  • services/craig-cases/migrations/<TS>_jws_seen_jti.sql (NEW — schema per §D3; cleanup job for >24h rows. Lives in craig-cases because intake is stateless per ADR-017; the forwarder seam writes through cases.)

  • services/craig-intake/src/jobs/jti_cleanup.rs (NEW — periodic task)

  • crates/craig-intake-sdk/src/sign.rs (inject jti+iat into protected header at sign time)

  • crates/craig-signing/src/lib.rs (analogous if used directly by partners)

  • services/craig-intake/tests/api/jws_replay.rs (NEW — 5 tests, all guarded by if !devstack_available().await { return; } per the Plan B idiom: first submission accepted; second with same jti rejected with 401; iat in past beyond window rejected; iat in future beyond window rejected; missing jti rejected with 401)

  • services/craig-intake/tests/api/jws_replay_concurrent.rs (NEW — 2 parallel submissions of the same JWS via craig_test_lib::concurrent::concurrent_fire_collect(2, …​); assert exactly one returns 200 and one returns 401. This proves the ON CONFLICT (partner_id, jti) DO NOTHING RETURNING jti write shape serializes correctly under contention; mirror of Plan B Step 6’s concurrent_fire_collect pattern.)

  • crates/craig-intake-sdk/tests/sign.rs (NEW — round-trip test that Rust-SDK-signed envelope carries jti+iat)

  • sdks/python/tests/test_signing.py (NEW — analogous round-trip test for the Python SDK)

  • sdks/typescript/test/signing.test.ts (NEW — analogous round-trip test for the TypeScript SDK)

  • sdks/test-vectors/ — bump fixtures to include jti/iat so cross-SDK conformance pins the wire format

Branch: feat/partner-edge-step4-jws-replay-defense

MR title: feat(craig-intake, craig-intake-sdk, craig-signing, sdks): JWS replay defense via jti + iat protected-header params [Step 4 of partner-edge]

Verification:

  1. cargo xtask dev reseed (new schema lives in craig-cases per §D3)

  2. cargo nextest run -p craig-intake -p craig-intake-sdk -p craig-signing — all tests pass

  3. cargo nextest run -p craig-intake --test api jws_replay_concurrent — concurrency invariant test 20/20 iterations (mirror of Plan B Step 6 soak)

  4. (cd sdks/python && pytest) + (cd sdks/typescript && npm test) — both SDKs round-trip with jti+iat

  5. Manual: submit a valid JWS twice; confirm second rejected

  6. Manual: submit JWS with iat = now - 10 minutes; confirm rejected with 401

  7. cargo xtask validate --skip-docker

CHANGELOG draft:

=== JWS replay defense via jti + iat [Step 4 of partner-edge] (DATE)

JWS protected header now requires jti (replay-uniqueness) and iat (freshness)
parameters. Postgres jws_seen_jti table dedupes per partner with 24h retention;
iat skew tolerance ±5min. SDK + signing helpers (craig-intake-sdk, craig-signing)
inject both at sign time — coordinated breaking change for partner SDK consumers
acceptable pre-1.0. F-005 closed.

Step 5: F-006 per-partner rate-limit redesign (P0)

Files:

  • services/craig-intake/src/api/rate_limit.rs (refactor to support parallel IP + partner-id keyed limiters per §D4)

  • services/craig-intake/src/api/api_key_auth.rs middleware (consume api_key.rate_limit_rpm; inject the per-partner limiter)

  • services/craig-intake/tests/api/rate_limit.rs (extend with per-partner test, guarded by if !devstack_available().await { return; } per the Plan B idiom: 2 partners with rate_limit_rpm=10 each; 11 requests to partner A returns 429; 11th request to partner B succeeds)

Branch: feat/partner-edge-step5-per-partner-rate-limit

MR title: feat(craig-intake): per-partner rate limiter consumes rate_limit_rpm [Step 5 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api rate_limit — extended tests pass

  2. Manual: configure partner with rate_limit_rpm=5; submit 6 requests within 1 minute; confirm 6th returns 429 + Retry-After

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Per-partner rate limiting now enforced [Step 5 of partner-edge] (DATE)

services/craig-intake/src/api/rate_limit.rs runs parallel IP + partner-keyed
limiters; api_key.rate_limit_rpm drives the partner limiter. Was previously
dead code. F-006 closed.

Step 6: F-007 X-Forwarded-For trust (P1)

Files:

  • services/craig-intake/src/api/rate_limit.rs::extract_client_ip (NEW helper per §D5)

  • crates/craig-common/src/settings.rs::IntakeSettings (add trusted_proxies: Vec<IpNet>)

  • services/craig-intake/.env.example (document new env var)

  • services/craig-intake/tests/api/xff_extraction.rs (NEW — 4 tests: no proxy headers → ConnectInfo IP; trusted proxy XFF → leftmost untrusted IP; untrusted proxy XFF → ConnectInfo IP ignored; multiple chained trusted proxies → walk back correctly)

Branch: feat/partner-edge-step6-xff-trust

MR title: feat(craig-intake): rate-limiter respects X-Forwarded-For from trusted proxies [Step 6 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api xff_extraction — 4 tests pass

  2. Manual: nginx proxy in front; XFF set; rate-limit applies per-client (not collapsed to nginx IP)

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Rate limiter respects X-Forwarded-For from trusted proxies [Step 6 of partner-edge] (DATE)

services/craig-intake/src/api/rate_limit.rs::extract_client_ip parses RFC 7239
Forwarded: and X-Forwarded-For headers per env-configured CRAIG_INTAKE__TRUSTED_PROXIES.
Production deployments behind LBs no longer collapse to a single global rate-limit
bucket. F-007 closed.

Step 7: F-009 partner API key rotation (P1)

Files:

  • services/craig-security/migrations/<TS>_partner_api_keys_expires_at.sql (NEW — ADD column + backfill default)

  • services/craig-security/src/store/partners.rs:233::verify_by_hash (add AND expires_at > now() to the WHERE clause. Note: there is no partner_api_keys.rs store module — API key verification lives on the partners store.)

  • services/craig-security/src/store/partner_api_keys.rs::create_partner_api_key (default expires_at = now() + 1 year)

  • services/craig-security/src/api/partners.rs::rotate_partner_api_key (NEW endpoint POST /v1/security/partners/{id}/api-keys/{key_id}/rotate)

  • crates/craig-cases-contracts/src/partners.rs (DTO updates)

  • services/craig-security/tests/api/partner_api_key_rotation.rs (NEW — 4 tests: expired key fails lookup; 30-day-pre-expiry warning logged; rotate returns new key value + invalidates old; rotate requires admin role)

Branch: feat/partner-edge-step7-api-key-rotation

MR title: feat(craig-security): partner API key rotation + expires_at enforcement [Step 7 of partner-edge]

Verification:

  1. cargo xtask dev reseed (new column)

  2. cargo nextest run -p craig-security --test api partner_api_key_rotation — 4 tests pass

  3. Manual: rotate an existing partner’s key; confirm new key works + old key fails

  4. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Partner API key rotation + expiry enforcement [Step 7 of partner-edge] (DATE)

services/craig-security partner_api_keys gains expires_at column (default 1 year).
Lookup respects expiry. New admin endpoint POST /v1/security/partners/{id}/api-keys/
{key_id}/rotate generates a new key value and invalidates the old. Pre-expiry-warning
log fires at expires_at - 30 days. F-009 closed.

Step 8: F-010 PartnerContext sentinel removal (P1)

Files:

  • crates/craig-cases-contracts/src/partner_context.rs (NEW — PartnerContext enum per §D7)

  • crates/craig-cases-contracts/src/lib.rs (export)

  • services/craig-intake/src/api/public.rs:138 (replace let partner_id = Uuid::nil(); with let partner_context = PartnerContext::Anonymous;)

  • services/craig-intake/src/api/api_key_lookup.rs:75 (return PartnerContext::Anonymous for standalone-config path)

  • services/craig-intake/src/sink/cases_forwarder.rs:116::CasesForwarderSink::forward (signature: partner_context: PartnerContext instead of partner_id: Uuid)

  • services/craig-cases/src/api/reports.rs::create_report (deserialize PartnerContext from incoming payload; branch handler logic accordingly)

  • crates/craig-cases-contracts/src/reports.rs::CreateReportRequest (replace partner_id: Uuid with partner_context: PartnerContext)

  • services/craig-intake/tests/api/partner_context.rs (NEW — 2 tests: anonymous public-form path round-trips; authenticated api-key path round-trips)

Branch: feat/partner-edge-step8-partner-context

MR title: feat(craig-cases-contracts, craig-intake, craig-cases): replace Uuid::nil() partner sentinel with typed PartnerContext [Step 8 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake -p craig-cases -p craig-cases-contracts — all tests pass

  2. Manual: submit anonymous public-form report; confirm cases.reports.partner_id is NULL or a tagged form (per design choice)

  3. Manual: submit authenticated partner report; confirm partner_id stored correctly

  4. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Typed PartnerContext replaces Uuid::nil() partner sentinel [Step 8 of partner-edge] (DATE)

crates/craig-cases-contracts/src/partner_context.rs introduces PartnerContext::
Anonymous | Authenticated{id}. services/craig-intake's 2 sentinel sites
(public.rs:138, api_key_lookup.rs:75) replaced. forwarder + cases-side handler
deserialize the tagged form. F-010 closed.

Step 9: F-012 PartnerAuthClient cache-key hash (P2)

Files:

  • services/craig-intake/src/api/partner_auth.rs:29 (hash incoming raw API keys via hashing::hash_api_key before HashMap insert/lookup)

  • services/craig-intake/tests/api/partner_auth_cache.rs (NEW — 2 tests: same raw key produces stable cache hit; raw key never appears as a HashMap key)

Branch: fix/partner-edge-step9-cache-key-hash

MR title: fix(craig-intake): hash PartnerAuthClient cache key (raw API keys no longer cached) [Step 9 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api partner_auth_cache

  2. cargo xtask validate --skip-docker

CHANGELOG draft:

=== PartnerAuthClient cache hashes API keys [Step 9 of partner-edge] (DATE)

services/craig-intake/src/api/partner_auth.rs:29 cache mirrors api_key_lookup.rs's
hashing pattern. Raw API keys no longer reside in process memory beyond the
request lifetime. F-012 closed.

Step 10: F-013 typed verify error enum (P3)

Files:

  • services/craig-intake/src/api/partner_auth.rs:53-100 (replace Result<_, String> with typed PartnerVerifyError enum per §D9)

  • services/craig-intake/src/api/api_key_auth.rs:44-52 (handle each variant explicitly)

  • services/craig-intake/tests/api/partner_verify_errors.rs (NEW — 3 tests, one per non-Ok variant: Timeout maps to ApiError::internal; UnexpectedStatus maps to ApiError::internal with hidden status; JsonParse maps to ApiError::internal)

Branch: fix/partner-edge-step10-typed-verify-error

MR title: fix(craig-intake): typed PartnerVerifyError enum on /verify adapter [Step 10 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api partner_verify_errors

  2. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Typed PartnerVerifyError enum [Step 10 of partner-edge] (DATE)

services/craig-intake/src/api/partner_auth.rs::verify returns Result<_,
PartnerVerifyError> instead of Result<_, String>. Caller in api_key_auth.rs
handles each variant explicitly. F-013 closed.

Step 11: F-014 JWS error message collapse (P2)

Files:

  • services/craig-intake/src/api/partner.rs:85-87 (collapse all Err paths to ApiError::unauthorized("JWS verification failed"); verbose detail to tracing::warn!)

  • services/craig-intake/tests/api/jws_error_uniformity.rs (NEW — 4 tests, each triggering a different verify-failure path; assert response body is identical across all 4)

Branch: fix/partner-edge-step11-jws-error-collapse

MR title: fix(craig-intake): collapse JWS verify errors to uniform 401 [Step 11 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api jws_error_uniformity — 4 tests pass with identical response body

  2. Manual: probe with 4 distinct failure modes (invalid format, unknown kid, expired key, signature mismatch); confirm all return identical 401 body

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== JWS verify errors collapse to uniform 401 [Step 11 of partner-edge] (DATE)

All 11 distinguishable verify-failure paths in jws.rs::verify_detached_jws now
collapse at the caller boundary in partner.rs to ApiError::unauthorized("JWS
verification failed"). Per-cause detail goes to tracing::warn! server-side only.
Closes the kid-enumeration vector. F-014 closed.

Step 12: F-011 misleading constant-time comment (P3)

Files:

  • services/craig-intake/src/api/api_key_lookup.rs:67 (replace comment per §D11)

Branch: chore/partner-edge-step12-comment-fix

MR title: chore(craig-intake): clarify misleading "constant-time" comment in api_key_lookup [Step 12 of partner-edge]

Verification:

  1. cargo xtask check-docs

  2. cargo xtask validate --skip-docker

CHANGELOG draft: === api_key_lookup comment polish [Step 12 of partner-edge] (DATE) — F-011 closed; documentation only.

Step 13: F-017 HSTS shared helper + craig-web/intake wiring (P1)

Files:

  • crates/craig-common/src/middleware/security_headers.rs (NEW — hsts_layer() helper per §D12)

  • crates/craig-common/src/middleware/mod.rs (NEW or extended — pub mod security_headers;)

  • crates/craig-api/src/lib.rs (refactor existing inline HSTS layer to use the shared helper)

  • services/craig-web/src/main.rs (add .layer(craig_common::middleware::security_headers::hsts_layer()))

  • services/craig-intake/src/main.rs (same)

  • services/craig-web/tests/security_headers.rs (NEW — assert HSTS header present on a sample response)

  • services/craig-intake/tests/security_headers.rs (NEW — same)

Branch: feat/partner-edge-step13-hsts

MR title: feat(craig-common, craig-web, craig-intake): HSTS layer extracted to shared helper + wired into web/intake [Step 13 of partner-edge]

Verification:

  1. cargo nextest run -p craig-web -p craig-intake --test security_headers — passes

  2. Manual: curl -I http://localhost:8080/ against running craig-web; confirm Strict-Transport-Security header present

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== HSTS uniform across craig-api, craig-web, craig-intake [Step 13 of partner-edge] (DATE)

HSTS layer extracted to crates/craig-common/src/middleware/security_headers.rs;
craig-api refactored to use the shared helper; craig-web + craig-intake wired
in. Header: strict-transport-security: max-age=31536000; includeSubDomains.
F-017 closed.

Step 14: F-018 CSP tighten (P1)

Files:

  • services/craig-web/src/main.rs (CSP layer: drop unsafe-inline + unsafe-eval)

  • services/craig-intake/src/main.rs (same; collapse embedded-UI vs. standalone modes to single strict policy)

  • services/craig-web/templates/** (audit all inline <script> and style="…​" blocks; convert to external files or nonce-based)

  • services/craig-intake/static/** (same)

  • services/craig-web/src/main.rs::nonce_middleware (NEW if nonce-based path is chosen — generates per-request nonce, injects into template context + CSP header)

  • services/craig-web/tests/csp.rs (NEW — assert response CSP doesn’t contain 'unsafe-inline' or 'unsafe-eval')

Branch: feat/partner-edge-step14-csp

MR title: feat(craig-web, craig-intake): tighten CSP — remove 'unsafe-inline' and 'unsafe-eval' [Step 14 of partner-edge]

Verification:

  1. cargo nextest run -p craig-web -p craig-intake --test csp — passes

  2. cargo xtask e2e — full E2E suite passes (catches any inline-script regressions in BFF templates)

  3. Manual: load every BFF route in a browser; verify no console errors about CSP violations

  4. cargo xtask validate --skip-docker

CHANGELOG draft:

=== CSP tightened on craig-web + craig-intake [Step 14 of partner-edge] (DATE)

unsafe-inline and unsafe-eval removed from script-src + style-src. Inline
<script> and style= blocks converted to external files or nonce-based via
new per-request nonce middleware. Embedded-UI mode and standalone mode for
craig-intake converged on the same strict policy. F-018 closed.

Step 15: F-019 Postgres statement_timeout (P1)

Files:

  • crates/craig-db/src/lib.rs (after_connect callback issuing SET statement_timeout per §D14)

  • crates/craig-common/src/settings.rs (add db_statement_timeout_ms: u64, default 30000)

  • .env.example (document new env var)

  • crates/craig-db/tests/statement_timeout.rs (NEW — 2 tests: a query >30s aborts with statement_timeout error; a query <30s completes; per-query SET LOCAL override extends the timeout)

Branch: feat/partner-edge-step15-statement-timeout

MR title: feat(craig-db): Postgres statement_timeout enforced at connection acquire [Step 15 of partner-edge]

Verification:

  1. cargo nextest run -p craig-db --test statement_timeout — 2 tests pass

  2. Manual: run xtask reconcile (which has long-running queries); confirm SET LOCAL overrides apply

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Postgres statement_timeout enforced [Step 15 of partner-edge] (DATE)

crates/craig-db's PgPool sets statement_timeout (default 30s, env-overridable
via CRAIG_<SVC>__DB_STATEMENT_TIMEOUT_MS) on every acquired connection.
Closes the DoS-amplifier surface where a single long query exhausts the pool.
Per-query overrides for known long ops via SET LOCAL. F-019 closed.

Step 16: F-020 Keycloak log redaction (P1)

Files:

  • services/craig-web/src/auth.rs:281-285 (parse error response body; the leak is line 283 tracing::error!("Token exchange failed: {body}"); log only error + error_description)

  • services/craig-web/src/auth.rs (define OAuth2ErrorBody { error, error_description } struct)

  • services/craig-web/tests/keycloak_error_logging.rs (NEW — 2 tests: error body with full context is parsed + only error fields logged; malformed body falls back to generic log)

Branch: fix/partner-edge-step16-keycloak-log-redact

MR title: fix(craig-web): redact Keycloak token-exchange error log fields [Step 16 of partner-edge]

Verification:

  1. cargo nextest run -p craig-web --test keycloak_error_logging

  2. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Keycloak token-exchange error logging redacted [Step 16 of partner-edge] (DATE)

services/craig-web/src/auth.rs:281-285 parses error response per OAuth2 RFC
6749 §5.2; logs only `error` + `error_description` fields. Drops request-context
fields (client_id, redirect_uri, code) from operator log lines. F-020 closed.

Step 17: F-021 sanitization extension + Askama lint (P2)

Files:

  • services/craig-intake/src/api/validation.rs::sanitize_text (extend per §D16: strip control chars + NFC-normalize Unicode + length cap)

  • services/craig-intake/src/api/{partner,public}.rs (apply to every accepted free-text field)

  • xtask/src/cmd/validate_template_safety.rs (NEW — regex-based scanner per §D16; model directory walk on check_docs.rs’s walkdir pattern, not on `quality_budgets.rs’s `syn::visit::Visit which is for Rust source. Scans services/craig-web/templates/ and services/craig-intake/static/ for \| safe or \| unescape; fails build if any found.)

  • xtask/src/cmd/mod.rs (add pub mod validate_template_safety;)

  • xtask/src/cmd/validate.rs (call into validate_template_safety::run)

  • xtask/tests/template_safety_test.rs (NEW — 2 fixtures: with-safe-filter fails the lint; without-safe-filter passes)

  • services/craig-intake/tests/api/sanitization.rs (NEW — 4 tests: control-char input stripped; non-NFC Unicode normalized; over-length input truncated; sanitization applied to each free-text field)

Branch: feat/partner-edge-step17-sanitization-and-lint

MR title: feat(craig-intake, xtask): extend free-text sanitization + Askama \| safe lint [Step 17 of partner-edge]

Verification:

  1. cargo nextest run -p craig-intake --test api sanitization

  2. cargo xtask validate-template-safety — 0 violations

  3. Add a fixture template with | safe to a test crate; confirm xtask fails the build; remove fixture

  4. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Free-text intake sanitization + Askama safety lint [Step 17 of partner-edge] (DATE)

services/craig-intake/src/api/validation.rs::sanitize_text now applies to all
free-text intake fields (control chars stripped, Unicode NFC-normalized,
length-capped). New xtask/src/cmd/validate_template_safety.rs lint walks Askama
templates for `| safe` and `| unescape` filters; fails the build if any found.
Hooked into cargo xtask validate. F-021 closed.

Step 18: Plan completion audit + archive

Mirror Plan B Step 8 / platform-stab-2 Step 13 pattern.

Files Touched

File Step Change

services/craig-intake/src/api/jws.rs

2,4,11

EDIT

services/craig-intake/src/api/partner.rs

2,11

EDIT

services/craig-security/src/store/signer_keys.rs

3

EDIT

services/craig-cases/migrations/<TS>_jws_seen_jti.sql

4

NEW (in cases, not intake — intake is stateless per ADR-017)

crates/craig-intake-sdk/src/sign.rs + crates/craig-signing/src/lib.rs

4

EDIT

services/craig-intake/src/api/rate_limit.rs

5,6

EDIT

crates/craig-common/src/settings.rs

6,15

EDIT

services/craig-security/migrations/<TS>_partner_api_keys_expires_at.sql

7

NEW

services/craig-security/src/{api,store}/partner*.rs

7

EDIT

crates/craig-cases-contracts/src/partner_context.rs

8

NEW

services/craig-intake/src/api/{public,api_key_lookup,partner_auth}.rs

8,9,10

EDIT

services/craig-intake/src/sink/cases_forwarder.rs

8

EDIT

services/craig-cases/src/api/reports.rs

8

EDIT

crates/craig-common/src/middleware/security_headers.rs

13

NEW

services/craig-{web,intake}/src/main.rs

13,14

EDIT

services/craig-{web,intake}/templates/**

14

EDIT (CSP cleanup)

crates/craig-db/src/lib.rs

15

EDIT

services/craig-web/src/auth.rs

16

EDIT

services/craig-intake/src/api/validation.rs

17

EDIT

xtask/src/cmd/validate_template_safety.rs

17

NEW

Verification

After every step:

  1. cargo xtask validate --skip-docker — pre-push gate green per step

  2. cargo xtask dev reseed — required after schema changes (Steps 4, 7)

  3. cargo nextest run --workspace

  4. cargo xtask check-docs

  5. cargo xtask validate-template-safety (Step 17 onward)

  6. cargo xtask e2e (Step 14 verifies CSP doesn’t break BFF)

Plan-wide:

  1. End-to-end JWS replay test (Step 4): submit valid JWS twice; second rejected

  2. Cross-partner forgery test (Step 2): partner A’s API key + partner B’s signer kid → 403

  3. Browser headers smoke test (Step 13/14): curl -I; confirm HSTS + tight CSP

  4. Manual: revoke partner key via rotate endpoint (Step 7); old fails, new succeeds

Documentation Updates

  • Per-step CHANGELOG entries

  • .claude/docs/security.md — partner-edge hardening posture; JWS replay defense; signer-key expiry; partner key rotation

  • .claude/docs/services.md — new jws_seen_jti table; new expires_at column on partner_api_keys; new endpoint POST /v1/security/partners/{id}/api-keys/{key_id}/rotate

  • docs/modules/ROOT/pages/architecture.md — partner-edge addendum

  • docs/modules/ROOT/pages/deployment-guide.adoc — new env vars (CRAIG_INTAKETRUSTED_PROXIES, CRAIG_<SVC>DB_STATEMENT_TIMEOUT_MS); HSTS + CSP guidance

  • Step 18 archive entry

Risks

Risk Mitigation

Step 4 JWS protected-header change breaks existing partner SDK consumers

Pre-1.0; coordinated SDK update in same MR; document in deployment-guide

Step 5 partner-keyed limiter introduces failure mode if partner_id is mis-derived

Test integration carefully; allow per-partner limit override during incident response

Step 14 CSP tightening reveals inline scripts/styles that some BFF templates depend on

Step 14 is a sweep; allocate time for nonce-based or external refactoring; E2E suite catches regressions

Step 7 backfill on partner_api_keys sets expires_at in the past for stale keys

Pre-1.0 + reseed; for first production deployment, backfill policy adjusted at that time

Step 17 lint produces false positives

Test fixtures cover known-safe patterns (e.g., legitimate | safe on rendered Markdown via ammonia-cleaned content); whitelist via comment annotation

After this plan lands

  • Cross-partner JWS forgery surface closed (F-026)

  • Signer-key expiry enforced (F-008)

  • JWS replay rejected via jti/iat (F-005)

  • Per-partner rate-limit enforced (F-006)

  • X-Forwarded-For honored from trusted proxies (F-007)

  • Partner API key rotation enforced (F-009)

  • Typed PartnerContext replaces sentinel (F-010)

  • Cache hashes API keys (F-012)

  • /verify error type tightened (F-013)

  • JWS error messages collapse (F-014)

  • Misleading comment fixed (F-011)

  • HSTS uniform across browser-facing services (F-017)

  • CSP tightened (F-018)

  • statement_timeout enforced (F-019)

  • Keycloak error logs redacted (F-020)

  • Free-text sanitization extended + lint regression-proofs Askama safety (F-021)

Edit this page · latest