feat(store): logical backup and isolated restore drill - #102
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention.
0e8aa44 to
5e9e6aa
Compare
321e792
into
feat/issue-80-postgres-rustls
| async fn export_backup(client: &mut Client, tenant_id: &str) -> Result<ControlPlaneBackup, String> { | ||
| let snapshot = load_snapshot(client, tenant_id) | ||
| .await? | ||
| .ok_or_else(|| format!("tenant {tenant_id} has no snapshot to back up"))?; | ||
| let outbox = list_outbox(client, tenant_id, i64::MAX).await?; | ||
| let receipts = list_receipts(client, tenant_id).await?; | ||
| ControlPlaneBackup { | ||
| schema_version: MIGRATION_VERSION, | ||
| tenant_id: tenant_id.to_string(), | ||
| created_unix: unix_now_i64(), | ||
| snapshot, | ||
| outbox, | ||
| receipts, | ||
| payload_hash: String::new(), | ||
| } | ||
| .seal() | ||
| } |
There was a problem hiding this comment.
🟡 Backup export is not point-in-time consistent
export_backup reads the snapshot, the outbox (list_outbox), and the receipts (list_receipts) in three separate committed transactions. In PostgreSQL mode a leased outbox worker runs concurrently and every gateway event runs append_security_event, inserting a security_event and its outbox_message in one transaction. A write landing between the reads yields an artifact whose events, outbox rows, and receipts disagree, and restore reproduces that inconsistency.
Prompt for agents
export_backup in src/control_plane.rs runs load_snapshot, list_outbox, and list_receipts as three independent transactions, each of which sets tenant context and commits on its own. Because a leased outbox worker and gateway append_security_event calls mutate security_event/outbox_message/outbox_receipt concurrently in PostgreSQL mode, the exported ControlPlaneBackup can capture these three views at different points in time, producing an internally inconsistent artifact (e.g. an outbox_message without its security_event, or a receipt without its message). Consider performing the whole export within a single transaction (a single repeatable-read/serializable transaction that sets wardnet.tenant_id once and reads all tables) so the artifact is a true point-in-time snapshot, matching the one-transaction consistency guarantee used by save_snapshot.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub async fn restore_drill(&self) -> Result<BackupDrillReport, String> { | ||
| let started = Instant::now(); | ||
| let backup = self.logical_backup().await?; | ||
| let isolated = format!("restore-drill-{}-{}", std::process::id(), unix_now_i64()); | ||
| let mut client = self.client.lock().await; | ||
| restore_backup(&mut client, &isolated, &backup).await?; | ||
| let restored = export_backup(&mut client, &isolated).await?; | ||
| drop_tenant(&mut client, &isolated).await?; | ||
| let source_hash = backup.semantic_hash()?; | ||
| let restored_hash = restored.semantic_hash()?; | ||
| let passed = source_hash == restored_hash | ||
| && restored.snapshot.routes == backup.snapshot.routes | ||
| && restored.snapshot.events == backup.snapshot.events | ||
| && restored.snapshot.threats == backup.snapshot.threats | ||
| && restored.snapshot.dnsbl == backup.snapshot.dnsbl | ||
| && restored.outbox.len() == backup.outbox.len() | ||
| && restored.receipts.len() == backup.receipts.len(); | ||
| let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); | ||
| Ok(BackupDrillReport { | ||
| passed, | ||
| duration_ms, | ||
| rpo: BACKUP_RPO.to_string(), | ||
| rto_budget_ms: BACKUP_RTO_BUDGET_MS, | ||
| source_hash, | ||
| restored_hash, | ||
| route_count: backup.snapshot.routes.len(), | ||
| event_count: backup.snapshot.events.len(), | ||
| outbox_count: backup.outbox.len(), | ||
| receipt_count: backup.receipts.len(), | ||
| isolated_tenant_id: isolated, | ||
| }) | ||
| } |
There was a problem hiding this comment.
🟡 Failed restore drill leaks the isolated tenant
restore_drill commits the restored rows into the isolated tenant, then re-exports and only afterward calls drop_tenant. The re-export uses ?, so any failure there returns early and skips drop_tenant, leaving the throwaway tenant's committed rows in every table. Repeated failed drills accumulate orphan data.
Prompt for agents
In restore_drill (src/control_plane.rs), the isolated tenant is populated by restore_backup (which commits its own transaction) and only dropped by drop_tenant after export_backup succeeds. If export_backup returns an error, the `?` propagates and drop_tenant is never called, leaking the committed drill-tenant rows. Ensure the isolated tenant is always cleaned up even on the error path — e.g. run the re-export and comparison, but wrap the post-restore steps so drop_tenant runs regardless of success/failure before returning the error (a guard/defer-style cleanup or an explicit cleanup on the error branch).
Was this helpful? React with 👍 or 👎 to provide feedback.
| fn semantic_hash(&self) -> Result<String, String> { | ||
| let mut snapshot = self.snapshot.clone(); | ||
| snapshot.commercial.tenant_id.clear(); | ||
| let mut outbox: Vec<_> = self | ||
| .outbox | ||
| .iter() | ||
| .map(|message| { | ||
| ( | ||
| message.idempotency_key.clone(), | ||
| message.payload_hash.clone(), | ||
| message.message_status.clone(), | ||
| message.payload_json.clone(), | ||
| ) | ||
| }) | ||
| .collect(); | ||
| outbox.sort(); | ||
| let mut receipts: Vec<_> = self | ||
| .receipts | ||
| .iter() | ||
| .map(|row| (row.idempotency_key.clone(), row.receipt_evidence.clone())) | ||
| .collect(); | ||
| receipts.sort(); | ||
| let body = serde_json::json!({ | ||
| "snapshot": snapshot, | ||
| "outbox": outbox, | ||
| "receipts": receipts, | ||
| }) | ||
| .to_string(); | ||
| Ok(outbox::payload_hash(&body)) |
There was a problem hiding this comment.
📝 Info: semantic_hash / verify are deterministic
verify and the drill's passed check are stable: AppData has no map fields (only Vecs/scalars), semantic_hash sorts the outbox/receipt tuples and clears commercial.tenant_id, and those tuples omit tenant_id. A round-trip through an isolated tenant therefore hashes equal.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for message in &backup.outbox { | ||
| insert_restored_outbox(&tx, tenant_id, message).await?; | ||
| } |
There was a problem hiding this comment.
📝 Info: Restore re-arms pending outbox rows
restore_backup re-inserts outbox_message rows verbatim, including pending ones, so the worker re-dispatches them to stdout SIEM after a live restore even when receipts already exist. This matches the documented at-least-once contract, but operators should expect duplicate SIEM lines after a restore.
Was this helpful? React with 👍 or 👎 to provide feedback.
| match plane.load().await { | ||
| Ok(Some(loaded)) => { | ||
| *state.inner.write().await = loaded; | ||
| } | ||
| Ok(None) => { | ||
| return error( | ||
| StatusCode::INTERNAL_SERVER_ERROR, | ||
| "restore committed but tenant snapshot is empty", | ||
| ); | ||
| } | ||
| Err(message) => return error(StatusCode::INTERNAL_SERVER_ERROR, message), | ||
| } |
There was a problem hiding this comment.
📝 Info: Live restore can diverge DB from memory
In the restore handler plane.restore_logical_backup commits the DB restore before plane.load(). If load() fails transiently the handler returns 500 while the DB is already restored and state.inner still holds pre-restore data, leaving them divergent until the next mutation or restart.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub async fn restore_drill(&self) -> Result<BackupDrillReport, String> { | ||
| let started = Instant::now(); | ||
| let backup = self.logical_backup().await?; | ||
| let isolated = format!("restore-drill-{}-{}", std::process::id(), unix_now_i64()); |
There was a problem hiding this comment.
📝 Info: Drill tenant id can collide within a second
restore_drill names the isolated tenant restore-drill-{pid}-{unix_now_i64()} at second resolution. Two drills in the same process within one second collide on that id; since the lock is released between logical_backup and the restore, concurrent drills can interleave and interfere on the shared tenant. Unlikely, but not guaranteed unique.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub fn verify(&self) -> Result<(), String> { | ||
| if self.schema_version != MIGRATION_VERSION { | ||
| return Err(format!( | ||
| "backup schema_version {} is unsupported; expected {MIGRATION_VERSION}", | ||
| self.schema_version | ||
| )); | ||
| } | ||
| if self.tenant_id.trim().is_empty() { | ||
| return Err("backup tenant_id must be non-empty".to_string()); | ||
| } | ||
| let expected = outbox::payload_hash(&self.unsigned_json()?); | ||
| if expected != self.payload_hash { | ||
| return Err("backup payload_hash does not match contents".to_string()); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟨 Backup integrity check uses an unkeyed hash
ControlPlaneBackup::verify recomputes an unkeyed SHA-256 and compares it to the embedded payload_hash. The hash travels with the data and uses no secret, so a modified artifact can carry a freshly recomputed matching hash. The check detects corruption, not tampering. Restore is gated by an admin write token, so any bypass presupposes a privileged caller.
Was this helpful? React with 👍 or 👎 to provide feedback.
* feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline
* feat(store): require PostgreSQL as the production control plane Non-loopback binds fail closed without CONTROL_PLANE_DATABASE_URL. Migrations create 3NF two-word tables with default-deny RLS. Snapshot persist commits policy rows and audit records in one transaction. Loopback still uses the JSON file or memory adapter. * feat(store): transactional outbox and leased workers Issue #81 first slice on the PostgreSQL control plane. Security events append with an outbox row in one transaction instead of rewriting the snapshot. Workers claim with SKIP LOCKED, retry, dead-letter, and record unique receipts. Stdout SIEM is at-least-once; the receipt is the exactly-once ack. Also deterministic ORDER BY on postgres loads (still-valid #98 finding). Do not re-implement the postgres gate. * feat(store): rustls for production PostgreSQL sslmode=require (#100) * feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline
* feat(security): fail-closed destination policy for outbound HTTP One DestinationPolicy mediates gateway upstreams, threat-intel fetches, Clearfolio, SOC LLM, and the Coraza sidecar URL. Private, loopback, link-local, CGNAT, and metadata classes are denied unless DESTINATION_ALLOWLIST (or loopback development) permits them; DESTINATION_DENYLIST wins. Clients ignore ambient HTTP proxies and do not follow redirects. Refs #79. * docs: record PR #96 in the product-technical gap baseline * feat(security): harden destination policy per-IP CIDR and readiness order CIDR allowlist matches apply per resolved address, authorize non-default ports, and reject prefixes outside the address-family width. IPv6 site-local is a denied class. Hostnames that merely contain 0x are not hex IP literals. AppState constructors default to production policy; seeded fixtures opt into development. Blocking DNS runs on spawn_blocking with a timeout. Persistence and destination-list validation complete before the readiness line. * feat(security): pin outbound HTTP to evaluated destination addresses After destination policy allows a host, the reqwest client resolves only those IPs so a rebinding answer cannot reach loopback, private, or metadata classes. Host and SNI stay on the original name. Unpinned hostnames fail closed instead of falling back to OS DNS. * feat(waf): evaluate live gateway transactions with in-process libcoraza Issue #86 remainder: dlopen operator-supplied libcoraza and drive the C ABI on each /gateway request. Missing library or empty ruleset fail closed before bind. CI stays hermetic with a fixture cdylib that exports the same symbols. * docs: record PR #97 in the product-technical gap baseline * feat(waf): forward bounded client headers into in-process libcoraza In-process transactions now receive the same forwarded-header allowlist as the sidecar path (host, user-agent, accept, content-type, referer, origin, x-requested-with, x-forwarded-for, x-real-ip, cookie — never Authorization; 32 headers / 8 KiB caps enforced by proven_engine::engine_forwarded_headers). Each header crosses the C ABI via coraza_add_request_header before process_request_headers, so CRS rules that inspect headers evaluate real client input instead of a synthetic Host only. Brings in the PR #95 sidecar hardening via merge so both engines share one allowlist implementation and one status/bound contract. Behavioral header-battery evidence lands with the issue-11 battery fixture (PR #110); this slice ships the plumbing and keeps the stub contract unchanged. * feat(store): require PostgreSQL as the production control plane (#98) * feat(store): require PostgreSQL as the production control plane Non-loopback binds fail closed without CONTROL_PLANE_DATABASE_URL. Migrations create 3NF two-word tables with default-deny RLS. Snapshot persist commits policy rows and audit records in one transaction. Loopback still uses the JSON file or memory adapter. * feat(store): transactional outbox and leased workers Issue #81 first slice on the PostgreSQL control plane. Security events append with an outbox row in one transaction instead of rewriting the snapshot. Workers claim with SKIP LOCKED, retry, dead-letter, and record unique receipts. Stdout SIEM is at-least-once; the receipt is the exactly-once ack. Also deterministic ORDER BY on postgres loads (still-valid #98 finding). Do not re-implement the postgres gate. * feat(store): rustls for production PostgreSQL sslmode=require (#100) * feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline * feat(security): add bounded outbound fetch API (#113) * feat(security): add bounded outbound fetch API * fix(security): isolate fetch DNS pins per request * feat(security): route browser DNS and HTTPS through Wardnet (#116) * feat(security): add bounded outbound fetch API * feat(security): route browser DNS and HTTPS through Wardnet * fix(security): isolate fetch DNS pins per request * fix(dns): bound concurrent UDP query handling * fix(security): close destination policy review gaps * fix(runtime): make worker shutdown durable
* feat(security): fail-closed destination policy for outbound HTTP One DestinationPolicy mediates gateway upstreams, threat-intel fetches, Clearfolio, SOC LLM, and the Coraza sidecar URL. Private, loopback, link-local, CGNAT, and metadata classes are denied unless DESTINATION_ALLOWLIST (or loopback development) permits them; DESTINATION_DENYLIST wins. Clients ignore ambient HTTP proxies and do not follow redirects. Refs #79. * docs: record PR #96 in the product-technical gap baseline * feat(security): harden destination policy per-IP CIDR and readiness order CIDR allowlist matches apply per resolved address, authorize non-default ports, and reject prefixes outside the address-family width. IPv6 site-local is a denied class. Hostnames that merely contain 0x are not hex IP literals. AppState constructors default to production policy; seeded fixtures opt into development. Blocking DNS runs on spawn_blocking with a timeout. Persistence and destination-list validation complete before the readiness line. * feat(security): pin outbound HTTP to evaluated destination addresses After destination policy allows a host, the reqwest client resolves only those IPs so a rebinding answer cannot reach loopback, private, or metadata classes. Host and SNI stay on the original name. Unpinned hostnames fail closed instead of falling back to OS DNS. * feat(waf): evaluate live gateway transactions with in-process libcoraza Issue #86 remainder: dlopen operator-supplied libcoraza and drive the C ABI on each /gateway request. Missing library or empty ruleset fail closed before bind. CI stays hermetic with a fixture cdylib that exports the same symbols. * docs: record PR #97 in the product-technical gap baseline * feat(store): require PostgreSQL as the production control plane Non-loopback binds fail closed without CONTROL_PLANE_DATABASE_URL. Migrations create 3NF two-word tables with default-deny RLS. Snapshot persist commits policy rows and audit records in one transaction. Loopback still uses the JSON file or memory adapter. * feat(store): transactional outbox and leased workers Issue #81 first slice on the PostgreSQL control plane. Security events append with an outbox row in one transaction instead of rewriting the snapshot. Workers claim with SKIP LOCKED, retry, dead-letter, and record unique receipts. Stdout SIEM is at-least-once; the receipt is the exactly-once ack. Also deterministic ORDER BY on postgres loads (still-valid #98 finding). Do not re-implement the postgres gate. * feat(store): rustls for production PostgreSQL sslmode=require (#100) * feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline * feat(store): optimistic concurrency on postgres snapshots Issue #80 last remainder. tenant_account.snapshot_version must match the loaded token or persist fails closed (HTTP 409). Restores overwrite. File/memory stay single-writer. Do not re-implement rustls, outbox, runtime role, HASH, or backup/restore. * docs: record PR #105 in the product-technical gap baseline * fix(store): keep postgres snapshot_version aligned after startup save load_postgres was saving with OCC and leaving the in-memory token one behind the database, so every later management write returned HTTP 409. Advance the loaded snapshot_version to the value save() wrote. * feat(store): outbox consumers for TAXII, Clearfolio, and orchestrator Enqueue operator-triggered TAXII polls, Clearfolio submits, and contextual-orchestrator SOC analysis on the PostgreSQL leased outbox. Request path returns 202; GET /api/outbox/{id} exposes receipt evidence. Secrets stay in the credential registry. Startup postgres save advances snapshot_version so the first management write cannot false-conflict. * docs: record PR #106 in the product-technical gap baseline * feat(release): tagged GitHub Release with SHA-256 and immutable GHCR (#107) * feat(release): tagged GitHub Release with SHA-256 and immutable GHCR Issue #84 first slice. A vX.Y.Z tag builds a locked binary, checksums, a GitHub Release, and ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z with no moving latest tag. Promotion and rollback are tag-for-tag. Do not re-implement store slices or OCC. * docs: record PR #107 in the product-technical gap baseline * fix(release): basename checksums and serialize postgres GRANTs SHA256SUMS recorded dist/ prefixes so sha256sum -c failed next to the downloaded binary. Emit basenames. Parallel PostgresPlane connects raced HASH convert GRANT with SET ROLE GRANT (tuple concurrently updated); hold the advisory lock across both. Do not re-implement HASH layout. * feat(release): keyless cosign, SPDX SBOM, and SLSA on the same tag Issue #84 remainder. GitHub OIDC signs the binary, checksums, SBOMs, and the GHCR image by digest. Release is created only after signatures. Syft SPDX fails closed without syft or non-SPDX JSON. NIST SP 800-218 is attached. Do not re-implement checksums or store slices. * feat(release): refuse lightweight tags and pin k8s by digest Issue #84 remainder. Annotated vX.Y.Z tags only; lightweight tags fail closed before the release job builds. Kubernetes pin is the GHCR content digest; tag aliases are refused. Do not re-implement checksums or cosign/SBOM. * docs: record PR #109 in the product-technical gap baseline * feat(waf): detect OWASP CRS attack battery on the live binary Issue #11 first slice. The build-script libcoraza ABI stub gains a deterministic battery covering SQLi (942100), XSS (941100), path traversal (930100), Unix RCE (932100, with first-match ordering so '; cat /etc/passwd' attributes to RCE over traversal), and Log4j JNDI (944120) in raw and percent-encoded forms across URI and POST-body phases. tests/binary.rs now starts the real gateway with the stub engine, creates a block route through the admin API, fires nine cases over HTTP, and asserts each is 403-blocked citing the expected CRS rule id while a benign request still forwards; /api/events must record one event per attempt with the forwarded client IP kept unmasked. Doctoring: docs/doctoring/ci-attack-evidence-battery.md grounds the split between detection-path evidence (CI) and detection efficacy (operator-supplied libcoraza + Core Rule Set), APA 7th. * fix(control-plane): close OCC and credential race gaps * fix(release): capture pushed image digest
Summary
Issue #80 remainder stacked on #101. Production PostgreSQL tenants get a hashed logical snapshot export, fail-closed restore, and an isolated restore drill.
GET /api/backup(admin read) exports policy, events, outbox, and receipts with a payload hash. Client IPs, paths, and actor names stay unmasked.POST /api/backup(admin write) restores after schema-version and payload-hash checks, then audits.POST /api/backup/drillrestores into an isolated tenant, compares semantic invariants, drops the drill tenant, and does not replace production./healthz.backupisreadyon PostgreSQL anddisabledon file/memory.on-demand-logical-snapshot). Declared RTO: 60 seconds.docs/papers/nist-sp-800-34r1-contingency-planning.pdf, public domain).Do not re-implement #78, sidecar #95, pin #96, libcoraza #97, postgres gate #98, outbox #99, rustls #100, or outbox retention #101.
Merge order: #94 independently; #95 then #96 then #97 then #98 then #99 then #100 then #101 then this. Do not
--adminmerge.Test plan
cargo fmt --checkcargo test --locked --workspacewith liveCONTROL_PLANE_TEST_DATABASE_URL(includespostgres_backup_restore_drill_preserves_unmasked_invariantsandbackup_verify_fails_closed_on_schema_and_hash)cargo clippy --locked --workspace --all-targets -- -D warnings/healthzplus/adminand/api/commercial/readinesssmokes