feat(store): require PostgreSQL as the production control plane - #98
Conversation
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.
|
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 |
| pub async fn connect(url: &str) -> Result<Self, String> { | ||
| let url = parse_database_url(url)?; | ||
| let (client, connection) = tokio_postgres::connect(&url, NoTls) | ||
| .await | ||
| .map_err(|error| format!("control plane connect failed: {error}"))?; | ||
| tokio::spawn(async move { | ||
| let _ = connection.await; | ||
| }); | ||
| let plane = Self { | ||
| client: Mutex::new(client), | ||
| tenant_id: DEFAULT_TENANT_ID.to_string(), | ||
| }; | ||
| plane.migrate().await?; | ||
| Ok(plane) | ||
| } |
There was a problem hiding this comment.
📝 Info: No reconnection if the PostgreSQL connection drops
PostgresPlane holds one Client and spawns the connection future once (src/control_plane.rs:222-231). If that connection drops mid-run, every later save returns an error with no reconnect, so writes fail permanently until restart while in-memory reads keep working.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let features: String = row.get(9); | ||
| Ok(CommercialProfile { | ||
| tenant_id: tenant_id.to_string(), | ||
| deployment_id: row.get(0), | ||
| edition: parse_edition(row.get(1))?, | ||
| license_status: parse_license(row.get(2))?, | ||
| license_id: row.get(3), | ||
| licensee: row.get(4), | ||
| licensed_until_unix: row.get::<_, Option<i64>>(5).map(|v| v as u64), | ||
| licensed_node_count: row.get::<_, Option<i32>>(6).map(|v| v as u32), | ||
| annual_contract_value_krw: row.get::<_, Option<i64>>(7).map(|v| v as u64), | ||
| support_contact: row.get(8), | ||
| features: serde_json::from_str(&features) | ||
| .map_err(|error| format!("control plane feature_list is not JSON: {error}"))?, | ||
| }) |
There was a problem hiding this comment.
📝 Info: CommercialProfile tenant_id overwritten on postgres reload
save_snapshot ignores commercial.tenant_id and load_commercial reconstructs it from the RLS tenant (src/control_plane.rs:531-532). A tenant_id set via the license API round-trips through the file adapter but is overwritten with DEFAULT_TENANT_ID after a postgres reload.
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
|
Still-valid Devin findings from this head are addressed on stacked #99 (
TLS ( |
* 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): 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
There was a problem hiding this comment.
Pull request overview
OpenCode cannot approve yet because required coverage evidence did not pass.
Review outcome
1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
-
Problem: The required coverage-evidence job result was
failure, so OpenCode cannot establish approval sufficiency for this head. -
Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.
-
Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports
successwith required evidence or explicit no-source not-applicable evidence. -
Regression test: Keep the approval branch checking
needs.coverage-evidence.result == successbefore posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present. -
Result: REQUEST_CHANGES
-
Reason: coverage-evidence result was
failure, so required test/docstring evidence was not proven for current headea621985e27691a6b80629b36888e4defcc361c4. -
Head SHA:
ea621985e27691a6b80629b36888e4defcc361c4 -
Workflow run: 32702415204
-
Workflow attempt: 1
Coverage evidence
Coverage Decision
- Result: FAIL
- Test evidence: not proven passing
- Docstring evidence: not proven passing when configured
- Failure count: 1
Changed-File Evidence Map
flowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Workflow: ci.yml"]
S1 --> I1["GitHub Actions review job"]
I1 --> Conflict["Merge conflict blocks this path"]
Conflict --> V1["actionlint plus required checks"]
Evidence --> S2["Changed file (8 files)"]
S2 --> I2["repository behavior"]
I2 --> Conflict["Merge conflict blocks this path"]
Conflict --> V2["required checks"]
Evidence --> S3["Docs (4 files)"]
S3 --> I3["operator or user guidance"]
I3 --> Conflict["Merge conflict blocks this path"]
Conflict --> V3["docs review"]
Evidence --> S4["Test: binary.rs"]
S4 --> I4["regression suite"]
I4 --> Conflict["Merge conflict blocks this path"]
Conflict --> V4["targeted test run"]
OpenCode Review Overview
Pull request overviewOpenCode cannot approve yet because required coverage evidence did not pass. Review outcome1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
Coverage evidenceCoverage Decision
Changed-File Evidence Mapflowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Workflow: ci.yml"]
S1 --> I1["GitHub Actions review job"]
I1 --> Conflict["Merge conflict blocks this path"]
Conflict --> V1["actionlint plus required checks"]
Evidence --> S2["Changed file (8 files)"]
S2 --> I2["repository behavior"]
I2 --> Conflict["Merge conflict blocks this path"]
Conflict --> V2["required checks"]
Evidence --> S3["Docs (4 files)"]
S3 --> I3["operator or user guidance"]
I3 --> Conflict["Merge conflict blocks this path"]
Conflict --> V3["docs review"]
Evidence --> S4["Test: binary.rs"]
S4 --> I4["regression suite"]
I4 --> Conflict["Merge conflict blocks this path"]
Conflict --> V4["targeted test run"]
Merge Conflict Guidance
gh pr checkout 98 --repo ContextualWisdomLab/wardnet
git fetch origin feat/issue-86-in-process-libcoraza
git merge --no-ff origin/feat/issue-86-in-process-libcoraza # or: git rebase origin/feat/issue-86-in-process-libcoraza
git status --short
# resolve files, then git add <resolved-files>
# merge path: git commit
# rebase path: git rebase --continue
git push origin HEAD:feat/issue-80-postgres-control-plane
# rebase path only: git push --force-with-lease origin HEAD:feat/issue-80-postgres-control-plane |
…stgres-control-plane Resolves a fresh conflict against the (non-main) stacked base branch: docs/product-technical-gap-baseline.md merged additively, keeping all three loops' shipped-gap entries in chronological order. src/lib.rs auto-merged cleanly. Verified: cargo fmt --check clean, cargo test 150 passed (0 failed).
…outbox-workers Resolves a fresh conflict against the (non-main) stacked base branch: docs/product-technical-gap-baseline.md merged additively, keeping all loops' shipped-gap entries in chronological order. src/lib.rs auto-merged cleanly. Verified: cargo fmt --check clean, cargo test 168 passed (0 failed).
…workers feat(store): transactional outbox and leased workers
| async fn healthz(State(state): State<AppState>) -> Json<HealthStatus> { | ||
| Json(state.health_status()) | ||
| Json(state.health_status_live().await) |
There was a problem hiding this comment.
🟡 Liveness probe now blocks on the control-plane database
In PostgreSQL mode healthz now awaits health_status_live, which runs two database round-trips on the single shared, mutex-guarded connection with no timeout. A slow or hung database makes the liveness endpoint hang, so a database incident can drive pod restarts, and every probe contends with gateway event persistence on the one connection.
Prompt for agents
The /healthz handler (async fn healthz) was changed to call state.health_status_live().await, which performs two PostgreSQL queries (outbox_health and event_partition_count) against the single shared, Mutex-guarded control-plane connection with no timeout. /healthz is documented as the liveness probe (see the comment on readyz). Coupling liveness to a database round-trip means a slow or unreachable DB makes /healthz hang or fail, which can cause Kubernetes to kill and restart otherwise-live pods (restart loop) during a DB incident. Consider keeping /healthz as a pure in-process liveness signal (using the synchronous health_status()) and exposing the live outbox/partition DB-backed fields on readyz or a separate endpoint, and/or wrapping the DB calls in a bounded timeout so the probe degrades instead of hanging.
Was this helpful? React with 👍 or 👎 to provide feedback.
| pub struct PostgresPlane { | ||
| client: Mutex<Client>, | ||
| tenant_id: String, | ||
| /// Processed-outbox retention; mirrors operator `EVENT_LIMIT`. | ||
| event_limit: i64, | ||
| } |
There was a problem hiding this comment.
📝 Info: Single shared PostgreSQL connection serializes all control-plane work
PostgresPlane wraps one tokio_postgres::Client in a Mutex, so every operation — snapshot saves on each write, per-event appends, the 250ms worker drain, /healthz queries, and backup/restore — serializes on that one connection. For high-throughput gateway traffic this becomes a single choke point. A pool would decouple these paths.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for table in [ | ||
| "route_config", | ||
| "threat_indicator", | ||
| "dnsbl_entry", | ||
| "security_event", | ||
| "audit_record", | ||
| "threat_feed", | ||
| "tenant_profile", | ||
| ] { | ||
| tx.execute( | ||
| &format!("DELETE FROM {table} WHERE tenant_id = $1"), | ||
| &[&tenant_id], | ||
| ) | ||
| .await | ||
| .map_err(|error| format!("control plane delete {table} failed: {error}"))?; | ||
| } |
There was a problem hiding this comment.
📝 Info: Every management write rewrites all security_event rows
In PostgreSQL mode each management write goes through save_snapshot, which deletes and re-inserts every tenant row including all in-memory security_event rows, in addition to the incremental append path used elsewhere. It is correct because in-memory events are capped at EVENT_LIMIT, but the per-write full rewrite scales with EVENT_LIMIT.
Was this helpful? React with 👍 or 👎 to provide feedback.
| (message_status = $2 AND next_available_unix <= $5) | ||
| OR (message_status = $3 AND COALESCE(lease_expires_unix, 0) <= $5) | ||
| ) | ||
| ORDER BY aggregate_id, aggregate_version, created_unix |
There was a problem hiding this comment.
📝 Info: Outbox claim order is lexicographic on aggregate_id
claim_batch orders by aggregate_id (a stringified event.id), so claiming/dispatch order is lexicographic (10 before 2) rather than numeric. At-least-once stdout SIEM export tolerates this, but consumers relying on emission order see non-chronological events.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async fn get_backup(State(state): State<AppState>, headers: HeaderMap) -> Response { | ||
| if !admin_authenticated(&state, &headers) { | ||
| return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); | ||
| } |
There was a problem hiding this comment.
🔍 GET /api/backup exposes full snapshot to readonly tokens
get_backup gates on admin_authenticated, so readonly tokens receive the entire backup: all routes, threats, DNSBL, unmasked security events, audit logs, and outbox payloads. This is a much larger exposure than the existing readonly audit-log read; confirm readonly principals are meant to export the full tenant snapshot.
Was this helpful? React with 👍 or 👎 to provide feedback.
Stale review: coverage-evidence now passes on this head; all required checks green.
* 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 first slice. Does not re-implement #78, the #86 sidecar/libcoraza slices, or the #79 TCP-peer pin.
Production (non-loopback) binds fail closed without
CONTROL_PLANE_DATABASE_URL. PostgreSQL is the production authority: 3NF two-word tables, default-denyFORCE ROW LEVEL SECURITYonwardnet.tenant_id, and snapshot persist in one transaction (policy rows + audit). Loopback/community still uses the JSON file or memory adapter. The URL is a secret (control_plane_urlin the credential registry).Stacked on #97 (
feat/issue-86-in-process-libcoraza). Merge order: #95, then #96, then #97, then this PR. Org ruleset 18156473 still requires two independent approvals; do not--adminmerge.Operator-visible
CONTROL_PLANE_DATABASE_URL/ credentials keycontrol_plane_url/healthz.persistence=postgres|file|memorysslmode=requireis fail-closed until rustls is wiredTests
cargo fmt --checkcargo test --locked --workspace(includes livepostgres_roundtrip_seeded_snapshot_when_database_url_is_setagainst CI postgres:16)cargo clippy --locked --workspace --all-targets -- -D warningsscripts/smoke.shruns (/healthz+/admin, 2B KRW readiness) plus a postgres-backed/healthz.persistence=postgressmokeDoctoring:
docs/doctoring/postgres-control-plane.md(APA 7th).Remaining on #80: rustls, non-owner runtime role, backup/restore drill, event HASH partitioning, optimistic concurrency. #81 outbox builds on this authority.