From ef8e38adf81e7a76646da00bb6b98e243dc77511 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 14 Jul 2026 00:40:46 +0200 Subject: [PATCH] feat(saas): prove durable authenticated mutation req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003 --- PLAN.md | 4 +- examples/saas/README.md | 6 +- examples/saas/src/lib.rs | 143 +++++++++++++++- examples/saas/src/main.rs | 56 +++++-- examples/saas/tests/production_reference.rs | 174 ++++++++++++++++++++ 5 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 examples/saas/tests/production_reference.rs diff --git a/PLAN.md b/PLAN.md index 26319e0..fa27d94 100644 --- a/PLAN.md +++ b/PLAN.md @@ -60,13 +60,13 @@ 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:** Active; Slice 5 is complete. +- **State:** In progress — one public server-first project mutation now proves current bearer authentication, origin/CSRF denial, bounded input, atomic durable commit, rollback on persistence failure, and process-restart recovery. - **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`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts. - **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. -Execution cursor: evolve the existing SaaS reference so one authenticated project mutation is authorized, origin/CSRF checked, transactionally durable across process restart, and proven alongside its denied and rolled-back cases through the public server-first entry point. +`cargo test -p hemx-saas-example --test production_reference` proves that first production-reference path. Execution cursor: add structured/redacted failure diagnostics plus health/readiness and vendor-neutral tracing/metrics hooks around this mutation, then prove mixed-deploy fail-closed recovery. ## Slice 7 — v1 compatibility and closure diff --git a/examples/saas/README.md b/examples/saas/README.md index c148461..e55c328 100644 --- a/examples/saas/README.md +++ b/examples/saas/README.md @@ -7,7 +7,8 @@ What it proves: - typed form/newtype inputs for project creation - auth/session context passed through normal Rust state - CSRF-safe mutation checked before persistence -- local in-memory persistence adapter instead of a vendored SQL/auth provider +- local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider +- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, and CSRF token - generated form, slot, keyed row, page-swap, and live-status commands - page shell with plain CSS and one explicit metrics island script - compile-time surface generation plus interaction tests @@ -25,6 +26,7 @@ Those production concerns belong in app adapters and recipes so the tutorial rem Run: ```sh -cargo run -p hemx-saas-example +HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example +cargo test -p hemx-saas-example --test production_reference cargo test -p hemx-saas-example ``` diff --git a/examples/saas/src/lib.rs b/examples/saas/src/lib.rs index 9caa6e7..7ff2591 100644 --- a/examples/saas/src/lib.rs +++ b/examples/saas/src/lib.rs @@ -9,6 +9,9 @@ use hemx_axum::{ }; use std::convert::Infallible; use std::fmt::Display; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::{Arc, Mutex}; @@ -19,6 +22,8 @@ pub struct Session { user_id: UserId, email: String, csrf: CsrfToken, + origin: String, + bearer: String, } impl Session { @@ -27,6 +32,8 @@ impl Session { user_id: UserId(42), email: "founder@example.com".to_owned(), csrf: CsrfToken("demo-csrf".to_owned()), + origin: "http://127.0.0.1:3000".to_owned(), + bearer: "Bearer demo-session".to_owned(), } } } @@ -91,12 +98,56 @@ pub struct ProjectRecord { owner: String, } +impl ProjectRecord { + fn encode(&self) -> String { + format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name) + } + + fn decode(line: &str) -> io::Result { + let mut fields = line.splitn(3, '\t'); + let id = fields + .next() + .and_then(|value| value.parse().ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?; + let owner = fields + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?; + let name = fields + .next() + .filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t'])) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?; + Ok(Self { + id: ProjectId(id), + name: name.to_owned(), + owner: owner.to_owned(), + }) + } +} + #[derive(Clone, Default)] pub struct LocalProjectStore { projects: Arc>>, + path: Option>, } impl LocalProjectStore { + pub fn durable(path: impl Into) -> io::Result { + let path = path.into(); + let projects = match fs::read_to_string(&path) { + Ok(contents) => contents + .lines() + .map(ProjectRecord::decode) + .collect::>>()?, + Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(error), + }; + Ok(Self { + projects: Arc::new(Mutex::new(projects)), + path: Some(Arc::new(path)), + }) + } + pub fn insert(&self, name: ProjectName, session: &Session) -> Result { if name.as_str() == "fail-store" { return Err(AppError::StoreUnavailable); @@ -109,7 +160,12 @@ impl LocalProjectStore { name: name.as_str().to_owned(), owner: session.email.clone(), }; - projects.push(record.clone()); + let mut next = projects.clone(); + next.push(record.clone()); + if let Some(path) = self.path.as_deref() { + persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?; + } + *projects = next; Ok(record) } @@ -118,6 +174,24 @@ impl LocalProjectStore { } } +fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let temporary = path.with_extension("tmp"); + let mut file = fs::File::create(&temporary)?; + for project in projects { + file.write_all(project.encode().as_bytes())?; + } + file.sync_all()?; + if let Err(error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(temporary); + return Err(error); + } + #[cfg(unix)] + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + #[derive(Clone)] pub struct AppContext { session: Session, @@ -132,6 +206,33 @@ impl AppContext { } } + pub fn durable(path: impl Into, origin: impl Into) -> io::Result { + let mut session = Session::demo(); + session.origin = origin.into(); + Ok(Self { + session, + store: LocalProjectStore::durable(path)?, + }) + } + + pub fn authorize_mutation( + &self, + bearer: &str, + csrf: &CsrfToken, + origin: &str, + ) -> Result<(), AppError> { + if self.session.email.is_empty() || bearer != self.session.bearer { + return Err(AppError::MissingSession); + } + if csrf != &self.session.csrf { + return Err(AppError::CsrfRejected); + } + if origin != self.session.origin { + return Err(AppError::OriginRejected); + } + Ok(()) + } + pub fn csrf(&self) -> &CsrfToken { &self.session.csrf } @@ -139,12 +240,37 @@ impl AppContext { pub fn projects(&self) -> Vec { self.store.list() } + + pub fn create_project_authorized( + &self, + name: &str, + bearer: &str, + csrf: &str, + origin: &str, + ) -> Result { + let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings"); + self.authorize_mutation(bearer, &csrf, origin)?; + self.create_project( + ProjectName::from_str(name).expect("project names are infallible strings"), + ) + } + + fn create_project(&self, name: ProjectName) -> Result { + if name.as_str().is_empty() { + return Err(AppError::Validation("Project name required")); + } + if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) { + return Err(AppError::Validation("Project name is invalid")); + } + self.store.insert(name, &self.session) + } } #[derive(Debug)] pub enum AppError { MissingSession, CsrfRejected, + OriginRejected, StoreUnavailable, Validation(&'static str), } @@ -154,12 +280,21 @@ impl AppError { match self { Self::MissingSession => "Sign in to continue", Self::CsrfRejected => "Refresh the page before creating another project", + Self::OriginRejected => "Origin verification failed", Self::StoreUnavailable => "Project storage is temporarily unavailable", Self::Validation(message) => message, } } } +impl Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for AppError {} + impl IntoHandlerFailure for AppError { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { match self { @@ -289,11 +424,7 @@ mod dashboard_handlers { if form.csrf != ctx.session.csrf { return Err(AppError::CsrfRejected); } - if form.name.as_str().is_empty() { - return Err(AppError::Validation("Project name required")); - } - - let project = ctx.store.insert(form.name, &ctx.session)?; + let project = ctx.create_project(form.name)?; let total = ctx.projects().len(); Ok(( dashboard::project_row.append(ProjectRow::from(project)), diff --git a/examples/saas/src/main.rs b/examples/saas/src/main.rs index 38a9542..dfa4146 100644 --- a/examples/saas/src/main.rs +++ b/examples/saas/src/main.rs @@ -1,7 +1,8 @@ use axum::body::Body; -use axum::extract::{Query, State}; +use axum::extract::{DefaultBodyLimit, Form, Query, State}; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; use futures_util::stream; use hemx::IntoEffect; @@ -9,26 +10,30 @@ use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionReq use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext}; use std::collections::BTreeMap; use std::convert::Infallible; +use std::path::PathBuf; #[tokio::main] -async fn main() { - let app = app(AppContext::demo()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3003") - .await - .expect("bind saas tutorial example"); - axum::serve(listener, app) - .await - .expect("serve saas tutorial example"); +async fn main() -> Result<(), Box> { + let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned()); + let store = std::env::var_os("HEMX_SAAS_STORE") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv")); + let app = app(AppContext::durable(store, format!("http://{address}"))?); + let listener = tokio::net::TcpListener::bind(&address).await?; + axum::serve(listener, app).await?; + Ok(()) } fn app(ctx: AppContext) -> Router { Router::new() .route("/", get(home).post(interact)) .route("/settings", get(settings)) + .route("/projects", post(create_project)) .route("/events", get(events)) .route(runtime_js_path(), get(runtime)) .route("/app.css", get(css)) .route("/metrics.js", get(metrics_js)) + .layer(DefaultBodyLimit::max(8 * 1024)) .with_state(ctx) } @@ -63,6 +68,37 @@ async fn events( )])) } +// req: auth/001 req: auth/002 req: auth/004 +// req: security/004 req: v1_release/003 +async fn create_project( + State(ctx): State, + headers: HeaderMap, + Form(form): Form>, +) -> Response { + let bearer = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let origin = headers + .get("origin") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let name = form.get("name").map(String::as_str).unwrap_or_default(); + let csrf = form.get("csrf").map(String::as_str).unwrap_or_default(); + match ctx.create_project_authorized(name, bearer, csrf, origin) { + Ok(_) => (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(), + Err( + error @ (hemx_saas_example::AppError::MissingSession + | hemx_saas_example::AppError::CsrfRejected + | hemx_saas_example::AppError::OriginRejected), + ) => (StatusCode::FORBIDDEN, error.to_string()).into_response(), + Err(error @ hemx_saas_example::AppError::Validation(_)) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } + Err(error) => (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(), + } +} + async fn runtime() -> impl IntoResponse { runtime_js() } diff --git a/examples/saas/tests/production_reference.rs b/examples/saas/tests/production_reference.rs new file mode 100644 index 0000000..4cc2aea --- /dev/null +++ b/examples/saas/tests/production_reference.rs @@ -0,0 +1,174 @@ +use hemx_test::TestProcess; +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); + +fn available_address() -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port"); + let address = listener.local_addr().expect("test address"); + drop(listener); + address.to_string() +} + +fn test_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id())) +} + +fn start(address: &str, store: &Path) -> TestProcess { + let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example")); + command + .env("HEMX_SAAS_ADDR", address) + .env("HEMX_SAAS_STORE", store); + TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app") +} + +fn request( + address: &str, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &str, +) -> String { + let mut stream = TcpStream::connect(address).expect("connect to SaaS app"); + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n", + body.len() + ) + .expect("write request line"); + for (name, value) in headers { + write!(stream, "{name}: {value}\r\n").expect("write request header"); + } + write!(stream, "\r\n{body}").expect("finish request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("read response"); + response +} + +fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String { + request( + address, + "POST", + "/projects", + &[ + ("Authorization", bearer), + ("Origin", origin), + ("Content-Type", "application/x-www-form-urlencoded"), + ], + &format!("name={name}&csrf={csrf}"), + ) +} + +#[test] +fn authenticated_project_mutation_is_atomic_and_survives_restart() { + // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003 + let address = available_address(); + let origin = format!("http://{address}"); + let store = test_path("durable"); + + { + let _app = start(&address, &store); + for denied in [ + create(&address, "DeniedAuth", "Bearer wrong", "demo-csrf", &origin), + create( + &address, + "DeniedCsrf", + "Bearer demo-session", + "stale", + &origin, + ), + create( + &address, + "DeniedOrigin", + "Bearer demo-session", + "demo-csrf", + "https://attacker.invalid", + ), + ] { + assert!(denied.starts_with("HTTP/1.1 403"), "{denied}"); + } + let wrong_content_type = request( + &address, + "POST", + "/projects", + &[ + ("Authorization", "Bearer demo-session"), + ("Origin", origin.as_str()), + ("Content-Type", "text/plain"), + ], + "name=WrongType&csrf=demo-csrf", + ); + assert!( + wrong_content_type.starts_with("HTTP/1.1 415"), + "{wrong_content_type}" + ); + let oversized = request( + &address, + "POST", + "/projects", + &[ + ("Authorization", "Bearer demo-session"), + ("Origin", origin.as_str()), + ("Content-Type", "application/x-www-form-urlencoded"), + ], + &format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)), + ); + assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}"); + let before = request(&address, "GET", "/", &[], ""); + assert!(!before.contains("DeniedAuth")); + assert!(!before.contains("DeniedCsrf")); + assert!(!before.contains("DeniedOrigin")); + assert!(!before.contains("WrongType")); + + let allowed = create( + &address, + "Durable%20Project", + "Bearer demo-session", + "demo-csrf", + &origin, + ); + assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}"); + assert!(request(&address, "GET", "/", &[], "").contains("Durable Project")); + } + + { + let _restarted = start(&address, &store); + let restored = request(&address, "GET", "/", &[], ""); + assert!(restored.contains("Durable Project"), "{restored}"); + assert!(restored.contains("1 project"), "{restored}"); + } + + let _ = fs::remove_file(store); +} + +#[test] +fn failed_durable_commit_rolls_back_visible_state() { + // test req: failure/004 req: operations/002 req: v1_release/003 + let address = available_address(); + let origin = format!("http://{address}"); + let store = test_path("rollback"); + let _app = start(&address, &store); + fs::create_dir(&store).expect("block atomic rename destination"); + + let rejected = create( + &address, + "Must%20Rollback", + "Bearer demo-session", + "demo-csrf", + &origin, + ); + assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}"); + assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback")); + assert!(!store.with_extension("tmp").exists()); + + let _ = fs::remove_dir(store); +}