diff --git a/PLAN.md b/PLAN.md index 7297c1a..88c011b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -59,19 +59,19 @@ encryption, retention, backup, and deployment policy remain host concerns. ## Slice 6 — production integration reference -- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy. -- **State:** In progress — the requirements audit corrected stale namespace aliases and found one missing production-reference control: stable request/session/user correlation. The mutation now returns a generated request ID and emits typed request/session/user diagnostic correlation without accepting caller-controlled identifiers or secrets. +- [x] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy. +- **State:** Complete — the corrected audit found and closed two production-reference gaps: stable request/session/user correlation and strict deployable response policy. The app returns generated request IDs, emits typed secret-free correlation, and applies a no-inline/no-eval CSP plus nosniff and referrer policy to every response. - **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery. - **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework. -- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, and the existing `auth/*`, `axum/*`, `failure/*`, and `diag/*` contracts (the former `adapter/*`, `integration/*`, and `diagnostics/*` cursor names do not exist in `REQUIREMENTS.md`). +- **Requirements:** `operations/001-008`, `security/003-004`, `security/006`, `security/009`, `v1_release/003`, and the applicable existing `auth/*` and `failure/004` contracts. The audit removed stale/nonexistent `adapter/*`, `integration/*`, and `diagnostics/*` aliases; `axum/*` and `diag/*` are adapter/editor contracts already owned by their focused slices, while the release-only advisory/license/unsafe-code audit in `security/008` remains a Slice 7 gate. - **Proof:** end-to-end test survives process restart and mixed deployment, proves allowed/denied/rolled-back mutations and redacted diagnostics, and maps each framework-owned ASVS-relevant control to a failing/passing case. -`cargo test -p hemx-saas-example --test production_reference` proves current bearer authentication, origin/CSRF denial, bounded input, atomic durable commit, rollback, restart recovery, live-vs-ready dependency failure, redacted structured problem responses, aggregate metrics, stale-fingerprint rejection followed by current-build recovery, and generated request correlation on allowed, denied, mismatch, and failed mutations. The typed diagnostic hook carries only generated request ID, fixed session/user IDs, outcome, and duration, preventing request secrets from entering framework-owned records. Execution cursor: finish the corrected `security/*`, `operations/*`, `auth/*`, `axum/*`, `failure/*`, and `diag/*` citation audit; add only any remaining missing end-to-end control, then close Slice 6 if clean. +`cargo test -p hemx-saas-example --test production_reference` proves current bearer authentication, origin/CSRF denial, bounded input, strict CSP/security headers, atomic durable commit, rollback, restart recovery, live-vs-ready dependency failure, redacted structured problem responses, aggregate metrics, stale-fingerprint rejection followed by current-build recovery, and generated request correlation on allowed, denied, mismatch, and failed mutations. The typed diagnostic hook carries only generated request ID, fixed session/user IDs, outcome, and duration. Existing focused Kanban backpressure proof covers `operations/004`; the production-reference mismatch and restart proofs explicitly cover `operations/006` and `operations/008`. Slice 6 is complete. ## Slice 7 — v1 compatibility and closure - [ ] **User value:** maintainers and adopters receive a reproducible, migration-aware v1 with no known material contradiction and no hidden publication side effect. -- **State:** Blocked by Slices 1-6 and explicit authority for any missing local audit tool installation. +- **State:** Active; Slices 1-6 are complete, and publishing remains separately authorized. - **Build:** freeze the supported Rust/browser/WASM/integration matrix; reconcile public/generated/Surface/symbol/wire/runtime/persisted-schema compatibility; add migration fixtures; make canonical examples compatibility tests; update the progressive tutorial path; run all local release gates and disposition every P0/P1, advisory, unsafe-code, license, performance, accessibility, and documentation finding. - **Refusals:** no publish, deploy, upload, store submission, speculative feature, or weakening a gate to make it pass. - **Requirements:** `v1_release/001-010`, `versioning/*`, `test/*`, `diag/*`, `performance/*`, `security/008`, and all requirements changed by the preceding slices. diff --git a/examples/saas/README.md b/examples/saas/README.md index 1b4f58c..aa20167 100644 --- a/examples/saas/README.md +++ b/examples/saas/README.md @@ -22,7 +22,7 @@ What it deliberately keeps out of the tutorial crate: - provider credentials, external services, migrations, or browser automation - billing, account administration, or other SaaS platform scope -Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. +Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009 Run: diff --git a/examples/saas/src/lib.rs b/examples/saas/src/lib.rs index a812572..cb8cfe9 100644 --- a/examples/saas/src/lib.rs +++ b/examples/saas/src/lib.rs @@ -619,7 +619,7 @@ mod tests { #[test] fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() { - // req: operations/003 req: operations/005 req: security/008 + // req: operations/003 req: operations/005 let diagnostics = Arc::new(RecordingDiagnostics::default()); let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone()); let request_id = ctx.next_request_id(); diff --git a/examples/saas/src/main.rs b/examples/saas/src/main.rs index e6b4eac..672668b 100644 --- a/examples/saas/src/main.rs +++ b/examples/saas/src/main.rs @@ -1,6 +1,7 @@ use axum::body::Body; -use axum::extract::{DefaultBodyLimit, Form, Query, State}; +use axum::extract::{DefaultBodyLimit, Form, Query, Request, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; @@ -38,9 +39,29 @@ fn app(ctx: AppContext) -> Router { .route("/app.css", get(css)) .route("/metrics.js", get(metrics_js)) .layer(DefaultBodyLimit::max(8 * 1024)) + .layer(middleware::from_fn(security_headers)) .with_state(ctx) } +// req: security/006 req: security/009 +async fn security_headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"), + ); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert( + "referrer-policy", + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + response +} + async fn home(State(ctx): State) -> impl IntoResponse { axum::response::Html(home_page(&ctx).into_string()) } diff --git a/examples/saas/tests/production_reference.rs b/examples/saas/tests/production_reference.rs index e66c1d3..84921ef 100644 --- a/examples/saas/tests/production_reference.rs +++ b/examples/saas/tests/production_reference.rs @@ -102,13 +102,29 @@ fn ready_fingerprint(response: &str) -> &str { #[test] fn authenticated_project_mutation_is_atomic_and_survives_restart() { - // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: operations/001 req: v1_release/003 + // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006 + // test req: security/009 req: operations/001 req: operations/006 req: v1_release/003 let address = available_address(); let origin = format!("http://{address}"); let store = test_path("durable"); { let _app = start(&address, &store); + let home = request(&address, "GET", "/", &[], ""); + let csp = response_header(&home, "content-security-policy"); + assert!(csp.contains("default-src 'self'"), "{csp}"); + assert!(csp.contains("script-src 'self'"), "{csp}"); + assert!(csp.contains("object-src 'none'"), "{csp}"); + assert!(csp.contains("form-action 'self'"), "{csp}"); + assert!(!csp.contains("unsafe-inline"), "{csp}"); + assert!(!csp.contains("unsafe-eval"), "{csp}"); + assert_eq!(response_header(&home, "x-content-type-options"), "nosniff"); + assert_eq!( + response_header(&home, "referrer-policy"), + "strict-origin-when-cross-origin" + ); + assert!(!home.contains("