From f7b7e2b0c5bdc1507bc30ceb5946eec98216e106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:10:51 +0900 Subject: [PATCH 1/3] 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. --- CHANGELOG.md | 1 + README.md | 2 +- docs/architecture.md | 2 +- docs/doctoring/outbox-workers.md | 5 +- docs/doctoring/postgres-control-plane.md | 7 +- docs/product-technical-gap-baseline.md | 31 ++-- docs/security/threat-model.md | 2 +- src/control_plane.rs | 171 ++++++++++++++++++++++- 8 files changed, 198 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33722771..7583aa7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- PostgreSQL control-plane runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS, not the table owner). Migrations run as the login role, then `SET ROLE` so FORCE RLS binds even when the URL user is a superuser. Missing `wardnet.tenant_id` yields no rows. DDL (`DROP TABLE`, `DISABLE ROW LEVEL SECURITY`) is denied. - PostgreSQL control-plane logical backup and isolated restore drill (issue #80 remainder). `GET /api/backup` exports a hashed tenant snapshot (policy, events, outbox, receipts). `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 tenant. Declared RPO is the last successful export; declared RTO is 60 seconds. File/memory adapters report `/healthz.backup=disabled`. Client IPs, paths, and actor names stay unmasked. - PostgreSQL control-plane mutations enqueue a transactional outbox row in the same transaction (issue #81). Security events append incrementally instead of rewriting the snapshot. A leased worker claims with `FOR UPDATE SKIP LOCKED`, retries with bounded backoff, dead-letters exhausted/permanent failures, and records unique receipts. Stdout SIEM export is at-least-once; the receipt is the exactly-once ack. `/healthz.outbox` and `GET /api/outbox` are operator-visible; `POST /api/outbox/{id}/replay` requeues dead letters with audit. File/memory adapters report `outbox=disabled`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (dead letters and pending first). Processed `outbox_message` rows are pruned to that same cap on append, snapshot save, and worker ack; receipts stay as the exactly-once ack. - Control-plane PostgreSQL URLs honor `sslmode=require` / `verify-ca` / `verify-full` with rustls and Mozilla roots (certificates always verified). tokio-postgres 0.7 only parses `require`, so verification modes are rewritten to `require` before connect. `sslmode=allow` / `prefer` are rejected so the process cannot silently drop to plaintext. diff --git a/README.md b/README.md index 3df88fab..c01d5c89 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Useful environment variables: - `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` - `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). After a host is allowed, outbound HTTP connects only to those evaluated addresses (original Host/SNI). `/healthz.destination_mode` reports `production` or `development`. - `WAF_IDS_STATE_PATH`: optional JSON state path for loopback/community. When omitted, the service runs with seeded in-memory state. Production (non-loopback) binds require `CONTROL_PLANE_DATABASE_URL` instead. -- `CONTROL_PLANE_DATABASE_URL`: PostgreSQL URL for the production control plane (`postgres://…`). Secret; prefer `WAF_IDS_CREDENTIALS_PATH` key `control_plane_url`. `sslmode=require` / `verify-full` uses rustls with Mozilla roots (certificates always verified). `sslmode=disable` or omitted is plaintext. `allow`/`prefer` are rejected. `/healthz.persistence` reports `postgres` when connected. +- `CONTROL_PLANE_DATABASE_URL`: PostgreSQL URL for the production control plane (`postgres://…`). Secret; prefer `WAF_IDS_CREDENTIALS_PATH` key `control_plane_url`. `sslmode=require` / `verify-full` uses rustls with Mozilla roots (certificates always verified). `sslmode=disable` or omitted is plaintext. `allow`/`prefer` are rejected. After migrate, the session runs as `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS). `/healthz.persistence` reports `postgres` when connected. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero. Also caps `GET /api/outbox` and processed outbox-row retention. - `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES`: optional in-process libcoraza. A missing library or empty ruleset fails startup. `/healthz.proven_engine` reports `coraza_in_process`. diff --git a/docs/architecture.md b/docs/architecture.md index d5671acf..f020b2d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ flowchart LR - `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. - `src/lib.rs`: Axum app, routing, management APIs, optional JSON persistence, gateway handler, upstream proxying, admin console, support bundle assembly, NDJSON event export, and in-crate HTTP tests. Persistence, destination-list, and sidecar settings validate before the readiness line is printed. -- `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS. `sslmode=require` uses rustls. The JSON file adapter remains loopback/community only. +- `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS under `wardnet_runtime` (not superuser/owner). `sslmode=require` uses rustls. The JSON file adapter remains loopback/community only. - `src/outbox.rs`: transactional outbox + leased workers (issue #81). Security events append incrementally with an outbox row in the same transaction. Workers claim with `SKIP LOCKED`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (processed rows pruned; receipts kept). `/healthz.outbox` is operator-visible. - `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. The outbound HTTP client DNS resolver returns only addresses that already passed policy (TCP peer pin / DNS-rebinding TOCTOU close). - `crates/waf-ids-core`: reusable domain models plus validation, upsert, scoring, DNSBL zone export, event retention, threat-feed freshness, KPI snapshot, and commercial readiness logic. diff --git a/docs/doctoring/outbox-workers.md b/docs/doctoring/outbox-workers.md index e592f6fd..120f9a18 100644 --- a/docs/doctoring/outbox-workers.md +++ b/docs/doctoring/outbox-workers.md @@ -49,7 +49,6 @@ append, snapshot save, and worker ack. `outbox_receipt` rows stay; they are the exactly-once ack. Dead letters are never pruned. Loopback file/memory adapters keep in-process stdout SIEM and report -`outbox=disabled`. Remaining: a -non-owner runtime role, HASH partitioning, and additional -consumers (TAXII poll, Clearfolio, contextual-orchestrator) on the same +`outbox=disabled`. Remaining: HASH partitioning and additional consumers +(TAXII poll, Clearfolio, contextual-orchestrator) on the same message/receipt contract. Backup/restore drill is on the PostgreSQL plane. diff --git a/docs/doctoring/postgres-control-plane.md b/docs/doctoring/postgres-control-plane.md index d35d826c..bf48b937 100644 --- a/docs/doctoring/postgres-control-plane.md +++ b/docs/doctoring/postgres-control-plane.md @@ -43,6 +43,11 @@ before binding a non-loopback address. Use `sslmode=require` or `sslmode=verify-full` for rustls. `/healthz.persistence` reports `postgres`. Loopback still uses `WAF_IDS_STATE_PATH` or in-memory state. +After migrations, the session `SET ROLE`s to `wardnet_runtime` (NOSUPERUSER, +NOBYPASSRLS, not table owner) so FORCE RLS binds. Provision that role and +`GRANT` it to the login user if the URL user cannot `CREATE ROLE`. Missing +`wardnet.tenant_id` yields no rows. + `GET /api/backup` (admin read) exports a hashed logical snapshot. `POST /api/backup` restores after schema-version and payload-hash checks. `POST /api/backup/drill` restores into an isolated tenant, compares invariants, and drops the drill @@ -59,5 +64,5 @@ https://doi.org/10.6028/NIST.SP.800-34r1 `pg_dump`) so RLS tenant context is preserved and secrets (admin tokens, database URL) are never copied. -Remaining: non-owner runtime role, HASH partitioning for `security_event`, +Remaining: HASH partitioning for `security_event`, optimistic concurrency. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4c51d86b..a52525f7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T18:00Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T18:20Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,7 +25,8 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#102](https://github.com/ContextualWisdomLab/wardnet/pull/102) | feat(store): logical backup and isolated restore drill | `feat/issue-80-backup-restore` stacked on #101 | local fmt/test/clippy + two `/healthz` smokes; live `postgres_backup_restore_drill_preserves_unmasked_invariants` | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 then #101 first. Do not `--admin`. Do not re-implement rustls, outbox, or retention. | +| runtime-role (this pass) | feat(store): non-owner PostgreSQL runtime role | `feat/issue-80-runtime-role` stacked on #100 | in progress | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 first. Do not `--admin`. | +| [#102](https://github.com/ContextualWisdomLab/wardnet/pull/102) | feat(store): logical backup and isolated restore drill | squash-merged into #100 (`321e792`) | prior hour | Author prior hour | Folded into rustls stack. Do not re-implement. | | [#101](https://github.com/ContextualWisdomLab/wardnet/pull/101) | feat(store): bound outbox listing and prune processed rows | `feat/issue-81-outbox-retention` (`0c2167a`) stacked on #100 | still-valid Devin prune-cap finding fixed this pass (`EVENT_LIMIT` on save/ack) | Author; Devin COMMENTED (prune thread addressed) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 first. Do not `--admin`. | | [#100](https://github.com/ContextualWisdomLab/wardnet/pull/100) | feat(store): rustls for production PostgreSQL `sslmode=require` | `feat/issue-80-postgres-rustls` stacked on #99 | local fmt/test/clippy + two `/healthz` smokes; live `sslmode=require` fails closed against plaintext postgres | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 first. Do not `--admin`. Do not re-implement the postgres gate or outbox. | | [#99](https://github.com/ContextualWisdomLab/wardnet/pull/99) | feat(store): transactional outbox and leased workers | `feat/issue-81-outbox-workers` stacked on #98 | local fmt/test/clippy + two `/healthz` smokes + postgres `/healthz.outbox=ready` prior hour | Author; Devin COMMENTED (unbounded list closed on #101) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 first. Do not `--admin`. | @@ -58,7 +59,7 @@ by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. | [#83](https://github.com/ContextualWisdomLab/wardnet/issues/83) | [P1] Add bounded distributed admission control, trusted client attribution, and overload behavior | high | | [#82](https://github.com/ContextualWisdomLab/wardnet/issues/82) | [P1] Integrate Keyverse identity, tenant authorization, consent, and human approval evidence | high (blocked) | | [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical — first slice on #99; bounded list/retention on #101** | -| [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical — gate on #98; rustls on #100; backup/restore this pass; non-owner remainder** | +| [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical — gate on #98; rustls/backup on #100; non-owner role this pass** | | [#79](https://github.com/ContextualWisdomLab/wardnet/issues/79) | [P0] Enforce a fail-closed destination policy for all outbound traffic | **critical — closed in runtime on #96** | | [#78](https://github.com/ContextualWisdomLab/wardnet/issues/78) | [P0] Fail closed when management credentials are absent | **critical — closed in runtime on #94** | | [#75](https://github.com/ContextualWisdomLab/wardnet/issues/75) | Rename Kubernetes manifest to wardnet.yaml after external-secret hardening lands | medium | @@ -108,8 +109,9 @@ restores after schema and payload-hash checks; `POST /api/backup/drill` restores into an isolated tenant, compares unmasked invariants, and drops the drill tenant. Declared RPO: last successful export. Declared RTO: 60s. `/healthz.backup` is `ready` on PostgreSQL, `disabled` on file/memory. -Remaining: non-owner runtime role, event HASH partitioning, optimistic -concurrency. Physical/PITR backups stay a DBA concern. +Runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS) after migrate. +Remaining: event HASH partitioning, optimistic concurrency. Physical/PITR +backups stay a DBA concern. ### Transactional outbox (issue #81) — **first slice on #99; retention on #101** @@ -183,18 +185,17 @@ for later loops. ## This loop’s shipped gap -Issue **#80** backup/restore remainder: hashed logical snapshot export, -fail-closed restore, and isolated restore drill stacked on #101. Also the -still-valid #101 prune-cap fix (`EVENT_LIMIT` on save and ack). Do not -re-implement #78, sidecar, pin, libcoraza, the postgres gate, the #81 first -outbox slice, rustls, or bounded list/retention. +Issue **#80** still-valid #98 finding: non-owner `wardnet_runtime` after +migrate so FORCE RLS binds (CI `POSTGRES_USER` is a superuser). Do not +re-implement #78, sidecar, pin, libcoraza, the postgres gate, outbox, rustls, +retention, or backup/restore. ## Next hourly loop (do, do not report) 1. Second independent APPROVE on #91/#92. Do not `--admin`. -2. Keep #94/#95/#96/#97/#98/#99/#100/#101 and #102 merge-ready. Merge - order #94 independently; #95 then #96 then #97 then #98 then #99 then #100 - then #101 then #102. -3. Next runtime gap if policy still blocks: non-owner runtime role remainder of - #80, or additional #81 consumers (TAXII / Clearfolio / orchestrator). +2. Keep #94/#95/#96/#97/#98/#99/#100 and this runtime-role PR merge-ready. + Merge order #94 independently; #95 then #96 then #97 then #98 then #99 + then #100 then this. +3. Next runtime gap if policy still blocks: extra #81 consumers (TAXII / + Clearfolio / orchestrator) or HASH partitioning. 4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index be7a2ea0..f54b0928 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -24,7 +24,7 @@ | --- | --- | --- | --- | | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | -| State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error; production binds require PostgreSQL (`src/control_plane.rs`) with RLS; `sslmode=require` uses rustls; hashed logical backup plus isolated restore drill (`GET /api/backup`, `POST /api/backup/drill`) | Non-owner runtime role, physical/PITR backups owned by the DBA | +| State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error; production binds require PostgreSQL (`src/control_plane.rs`) with RLS; runtime is `wardnet_runtime` (not superuser/owner); `sslmode=require` uses rustls; hashed logical backup plus isolated restore drill (`GET /api/backup`, `POST /api/backup/drill`) | Physical/PITR backups owned by the DBA | | Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects. After evaluation, HTTP connects only to those IPs (Host/SNI preserved). Coraza sidecar URLs use the same policy. | Kubernetes NetworkPolicy egress as defense in depth | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | diff --git a/src/control_plane.rs b/src/control_plane.rs index 9e18660f..ee7f3967 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -25,7 +25,9 @@ use waf_ids_core::{ /// Default tenant used until Keyverse supplies claims (#82). pub const DEFAULT_TENANT_ID: &str = "local-lab"; -const MIGRATION_VERSION: i32 = 2; +const MIGRATION_VERSION: i32 = 3; +/// Non-owner, non-superuser role used after migrations so FORCE RLS binds. +pub const RUNTIME_ROLE: &str = "wardnet_runtime"; /// Declared RPO: last successful `GET /api/backup` (on-demand logical snapshot). pub const BACKUP_RPO: &str = "on-demand-logical-snapshot"; /// Declared RTO budget for an isolated restore drill. @@ -233,6 +235,24 @@ DROP POLICY IF EXISTS tenant_isolation ON outbox_receipt; CREATE POLICY tenant_isolation ON outbox_receipt USING (tenant_id = current_setting('wardnet.tenant_id', true)) WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'wardnet_runtime') THEN + CREATE ROLE wardnet_runtime + NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOLOGIN + NOBYPASSRLS NOREPLICATION; + END IF; +END +$$; +GRANT wardnet_runtime TO CURRENT_USER; +GRANT USAGE ON SCHEMA public TO wardnet_runtime; +REVOKE CREATE ON SCHEMA public FROM wardnet_runtime; +GRANT SELECT, INSERT, UPDATE, DELETE ON + tenant_account, tenant_profile, route_config, threat_indicator, + dnsbl_entry, security_event, audit_record, threat_feed, + outbox_message, outbox_receipt TO wardnet_runtime; +GRANT SELECT ON schema_migration TO wardnet_runtime; "#; /// Fail closed when a non-loopback bind has no control-plane URL. @@ -333,6 +353,60 @@ fn rustls_connector() -> Result { Ok(MakeRustlsConnect::with_webpki_roots()) } +async fn assume_runtime_role(client: &Client) -> Result<(), String> { + let current: String = client + .query_one("SELECT current_user", &[]) + .await + .map_err(|error| format!("control plane current_user failed: {error}"))? + .get(0); + if current != RUNTIME_ROLE { + client + .batch_execute( + "DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'wardnet_runtime') THEN + CREATE ROLE wardnet_runtime + NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOLOGIN + NOBYPASSRLS NOREPLICATION; + END IF; + END + $$; + GRANT wardnet_runtime TO CURRENT_USER; + GRANT USAGE ON SCHEMA public TO wardnet_runtime; + REVOKE CREATE ON SCHEMA public FROM wardnet_runtime; + GRANT SELECT, INSERT, UPDATE, DELETE ON + tenant_account, tenant_profile, route_config, threat_indicator, + dnsbl_entry, security_event, audit_record, threat_feed, + outbox_message, outbox_receipt TO wardnet_runtime; + GRANT SELECT ON schema_migration TO wardnet_runtime; + SET ROLE wardnet_runtime;", + ) + .await + .map_err(|error| { + format!( + "control plane runtime role {RUNTIME_ROLE} failed (provision NOSUPERUSER NOBYPASSRLS and GRANT it to the login role): {error}" + ) + })?; + } + let row = client + .query_one("SELECT current_user, current_setting('is_superuser')", &[]) + .await + .map_err(|error| format!("control plane runtime identity failed: {error}"))?; + let user: String = row.get(0); + let superuser: String = row.get(1); + if user != RUNTIME_ROLE { + return Err(format!( + "control plane must run as {RUNTIME_ROLE}, current_user is {user}" + )); + } + if superuser == "on" { + return Err(format!( + "control plane role {RUNTIME_ROLE} must not be a superuser" + )); + } + Ok(()) +} + /// Serializes schema application across connections (DROP/CREATE POLICY is not concurrent-safe). static MIGRATION_GATE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(())); @@ -385,6 +459,7 @@ impl PostgresPlane { event_limit: LIST_LIMIT, }; plane.migrate().await?; + plane.assume_runtime_role().await?; Ok(plane) } @@ -424,6 +499,54 @@ impl PostgresPlane { Ok(()) } + async fn assume_runtime_role(&self) -> Result<(), String> { + let client = self.client.lock().await; + assume_runtime_role(&client).await + } + + #[cfg(test)] + async fn runtime_identity(&self) -> Result<(String, bool), String> { + let client = self.client.lock().await; + let row = client + .query_one( + "SELECT current_user, current_setting('is_superuser') = 'on'", + &[], + ) + .await + .map_err(|error| error.to_string())?; + Ok((row.get(0), row.get(1))) + } + + #[cfg(test)] + async fn unscoped_route_count(&self) -> Result { + let mut client = self.client.lock().await; + let tx = client + .transaction() + .await + .map_err(|error| error.to_string())?; + let count: i64 = tx + .query_one("SELECT COUNT(*)::bigint FROM route_config", &[]) + .await + .map_err(|error| error.to_string())? + .get(0); + tx.rollback().await.map_err(|error| error.to_string())?; + Ok(count) + } + + #[cfg(test)] + async fn runtime_ddl_is_denied(&self) -> Result { + let client = self.client.lock().await; + let drop_denied = client + .batch_execute("DROP TABLE route_config") + .await + .is_err(); + let disable_denied = client + .batch_execute("ALTER TABLE route_config DISABLE ROW LEVEL SECURITY") + .await + .is_err(); + Ok(drop_denied && disable_denied) + } + /// Load the tenant snapshot, or `None` when the tenant has no rows yet. pub async fn load(&self) -> Result, String> { let mut client = self.client.lock().await; @@ -2036,6 +2159,8 @@ mod tests { } assert!(MIGRATION_SQL.contains("FORCE ROW LEVEL SECURITY")); assert!(MIGRATION_SQL.contains("wardnet.tenant_id")); + assert!(MIGRATION_SQL.contains("wardnet_runtime")); + assert!(MIGRATION_SQL.contains("NOBYPASSRLS")); assert!(MIGRATION_SQL.contains("PRIMARY KEY (tenant_id, route_id)")); assert!(MIGRATION_SQL.contains("REFERENCES tenant_account")); assert!(MIGRATION_SQL.contains("UNIQUE (tenant_id, idempotency_key)")); @@ -2559,4 +2684,48 @@ mod tests { "drill must not drop the production tenant" ); } + + #[tokio::test] + async fn postgres_runtime_role_is_not_superuser_and_rls_default_denies() { + let Some(url) = test_database_url() else { + return; + }; + let tenant_a = unique_tenant("runtime-a"); + let tenant_b = unique_tenant("runtime-b"); + let plane_a = PostgresPlane::connect_tenant(&url, &tenant_a) + .await + .expect("plane a"); + let (role, superuser) = plane_a.runtime_identity().await.expect("runtime identity"); + assert_eq!(role, RUNTIME_ROLE); + assert!(!superuser, "runtime role must not be a superuser"); + assert!( + plane_a.runtime_ddl_is_denied().await.expect("ddl probe"), + "runtime role must not DROP TABLE or DISABLE ROW LEVEL SECURITY" + ); + plane_a + .save(&AppData::seeded()) + .await + .expect("save tenant a"); + assert_eq!( + plane_a + .unscoped_route_count() + .await + .expect("unscoped count"), + 0, + "missing wardnet.tenant_id must yield no rows under FORCE RLS" + ); + let plane_b = PostgresPlane::connect_tenant(&url, &tenant_b) + .await + .expect("plane b"); + assert!( + plane_b.load().await.expect("load tenant b").is_none(), + "tenant b must not observe tenant a rows" + ); + let loaded_a = plane_a + .load() + .await + .expect("load tenant a") + .expect("tenant a rows"); + assert!(!loaded_a.routes.is_empty()); + } } From b10631864eee4ee90c43d34332cfeaa45af5fe13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:11:22 +0900 Subject: [PATCH 2/3] docs: record PR #103 in the product-technical gap baseline --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a52525f7..a81bbef2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,7 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| runtime-role (this pass) | feat(store): non-owner PostgreSQL runtime role | `feat/issue-80-runtime-role` stacked on #100 | in progress | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 first. Do not `--admin`. | +| [#103](https://github.com/ContextualWisdomLab/wardnet/pull/103) | feat(store): non-owner PostgreSQL runtime role after migrate | `feat/issue-80-runtime-role` stacked on #100 | local fmt/test/clippy + two `/healthz` smokes; live `postgres_runtime_role_is_not_superuser_and_rls_default_denies` | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 first. Do not `--admin`. Do not re-implement rustls, outbox, retention, or backup. | | [#102](https://github.com/ContextualWisdomLab/wardnet/pull/102) | feat(store): logical backup and isolated restore drill | squash-merged into #100 (`321e792`) | prior hour | Author prior hour | Folded into rustls stack. Do not re-implement. | | [#101](https://github.com/ContextualWisdomLab/wardnet/pull/101) | feat(store): bound outbox listing and prune processed rows | `feat/issue-81-outbox-retention` (`0c2167a`) stacked on #100 | still-valid Devin prune-cap finding fixed this pass (`EVENT_LIMIT` on save/ack) | Author; Devin COMMENTED (prune thread addressed) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 first. Do not `--admin`. | | [#100](https://github.com/ContextualWisdomLab/wardnet/pull/100) | feat(store): rustls for production PostgreSQL `sslmode=require` | `feat/issue-80-postgres-rustls` stacked on #99 | local fmt/test/clippy + two `/healthz` smokes; live `sslmode=require` fails closed against plaintext postgres | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 first. Do not `--admin`. Do not re-implement the postgres gate or outbox. | From ca3caca547ec36c5cc196542172efc4615c58415 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:54:29 +0900 Subject: [PATCH 3/3] 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. --- CHANGELOG.md | 2 +- docs/doctoring/postgres-control-plane.md | 14 ++++++++---- src/control_plane.rs | 29 ++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7583aa7a..1ced0317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- PostgreSQL control-plane runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS, not the table owner). Migrations run as the login role, then `SET ROLE` so FORCE RLS binds even when the URL user is a superuser. Missing `wardnet.tenant_id` yields no rows. DDL (`DROP TABLE`, `DISABLE ROW LEVEL SECURITY`) is denied. +- PostgreSQL control-plane runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS, not the table owner). Migrations run as the login role, then `SET ROLE` so FORCE RLS binds even when the URL user is a superuser. Missing `wardnet.tenant_id` yields no rows. DDL (`DROP TABLE`, `DISABLE ROW LEVEL SECURITY`) is denied. Logical restore accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade backup. - PostgreSQL control-plane logical backup and isolated restore drill (issue #80 remainder). `GET /api/backup` exports a hashed tenant snapshot (policy, events, outbox, receipts). `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 tenant. Declared RPO is the last successful export; declared RTO is 60 seconds. File/memory adapters report `/healthz.backup=disabled`. Client IPs, paths, and actor names stay unmasked. - PostgreSQL control-plane mutations enqueue a transactional outbox row in the same transaction (issue #81). Security events append incrementally instead of rewriting the snapshot. A leased worker claims with `FOR UPDATE SKIP LOCKED`, retries with bounded backoff, dead-letters exhausted/permanent failures, and records unique receipts. Stdout SIEM export is at-least-once; the receipt is the exactly-once ack. `/healthz.outbox` and `GET /api/outbox` are operator-visible; `POST /api/outbox/{id}/replay` requeues dead letters with audit. File/memory adapters report `outbox=disabled`. `GET /api/outbox` is bounded to `EVENT_LIMIT` (dead letters and pending first). Processed `outbox_message` rows are pruned to that same cap on append, snapshot save, and worker ack; receipts stay as the exactly-once ack. - Control-plane PostgreSQL URLs honor `sslmode=require` / `verify-ca` / `verify-full` with rustls and Mozilla roots (certificates always verified). tokio-postgres 0.7 only parses `require`, so verification modes are rewritten to `require` before connect. `sslmode=allow` / `prefer` are rejected so the process cannot silently drop to plaintext. diff --git a/docs/doctoring/postgres-control-plane.md b/docs/doctoring/postgres-control-plane.md index bf48b937..a7ac2ba9 100644 --- a/docs/doctoring/postgres-control-plane.md +++ b/docs/doctoring/postgres-control-plane.md @@ -48,11 +48,15 @@ NOBYPASSRLS, not table owner) so FORCE RLS binds. Provision that role and `GRANT` it to the login user if the URL user cannot `CREATE ROLE`. Missing `wardnet.tenant_id` yields no rows. -`GET /api/backup` (admin read) exports a hashed logical snapshot. `POST /api/backup` -restores after schema-version and payload-hash checks. `POST /api/backup/drill` -restores into an isolated tenant, compares invariants, and drops the drill -rows. Declared RPO: last successful export (`on-demand-logical-snapshot`). -Declared RTO: 60 seconds. `/healthz.backup` is `ready` on PostgreSQL. +`GET /api/backup` (admin read) exports a hashed logical snapshot stamped with +the current `MIGRATION_VERSION`. `POST /api/backup` restores after schema-version +and payload-hash checks. Role-only migrations (v3 `wardnet_runtime`) do not +change table shape, so `verify()` accepts schema versions +`MIN_RESTORABLE_SCHEMA_VERSION` (2) through the current version rather than +rejecting pre-upgrade snapshots. `POST /api/backup/drill` restores into an +isolated tenant, compares invariants, and drops the drill rows. Declared RPO: +last successful export (`on-demand-logical-snapshot`). Declared RTO: 60 seconds. +`/healthz.backup` is `ready` on PostgreSQL. National Institute of Standards and Technology. (2010). *Contingency planning guide for federal information systems* (NIST SP 800-34 rev. 1). diff --git a/src/control_plane.rs b/src/control_plane.rs index ee7f3967..e4d65b5b 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -26,6 +26,12 @@ use waf_ids_core::{ pub const DEFAULT_TENANT_ID: &str = "local-lab"; const MIGRATION_VERSION: i32 = 3; +/// Oldest logical-backup schema that restores on this binary. +/// +/// v3 only provisions `wardnet_runtime`; it does not change table shape. A +/// snapshot exported at schema 2 is restorable here so a role-only upgrade +/// cannot void the declared RPO. +const MIN_RESTORABLE_SCHEMA_VERSION: i32 = 2; /// Non-owner, non-superuser role used after migrations so FORCE RLS binds. pub const RUNTIME_ROLE: &str = "wardnet_runtime"; /// Declared RPO: last successful `GET /api/backup` (on-demand logical snapshot). @@ -705,9 +711,11 @@ impl ControlPlaneBackup { /// Fail closed when the schema is unsupported or the artifact was tampered with. pub fn verify(&self) -> Result<(), String> { - if self.schema_version != MIGRATION_VERSION { + if self.schema_version < MIN_RESTORABLE_SCHEMA_VERSION + || self.schema_version > MIGRATION_VERSION + { return Err(format!( - "backup schema_version {} is unsupported; expected {MIGRATION_VERSION}", + "backup schema_version {} is unsupported; accepted {MIN_RESTORABLE_SCHEMA_VERSION}..={MIGRATION_VERSION}", self.schema_version )); } @@ -2587,6 +2595,23 @@ mod tests { .expect("seal"); assert!(backup.verify().is_ok()); + let mut prior = backup.clone(); + prior.schema_version = MIN_RESTORABLE_SCHEMA_VERSION; + let prior = prior.seal().expect("re-seal compatible prior schema"); + assert!( + prior.verify().is_ok(), + "role-only schema 3 must restore schema-{MIN_RESTORABLE_SCHEMA_VERSION} logical backups" + ); + + let mut too_old = backup.clone(); + too_old.schema_version = MIN_RESTORABLE_SCHEMA_VERSION - 1; + assert!( + too_old + .verify() + .expect_err("older than restorable window") + .contains("unsupported") + ); + let mut bad_schema = backup.clone(); bad_schema.schema_version = MIGRATION_VERSION + 1; assert!(