Skip to content

feat(store): require PostgreSQL as the production control plane - #98

Merged
seonghobae merged 6 commits into
feat/issue-79-destination-policyfrom
feat/issue-80-postgres-control-plane
Aug 26, 2026
Merged

feat(store): require PostgreSQL as the production control plane#98
seonghobae merged 6 commits into
feat/issue-79-destination-policyfrom
feat/issue-80-postgres-control-plane

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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-deny FORCE ROW LEVEL SECURITY on wardnet.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_url in 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 --admin merge.

Operator-visible

  • CONTROL_PLANE_DATABASE_URL / credentials key control_plane_url
  • /healthz.persistence = postgres | file | memory
  • Production bind without the URL never prints the readiness line
  • TLS sslmode=require is fail-closed until rustls is wired

Tests

  • cargo fmt --check
  • cargo test --locked --workspace (includes live postgres_roundtrip_seeded_snapshot_when_database_url_is_set against CI postgres:16)
  • cargo clippy --locked --workspace --all-targets -- -D warnings
  • Two scripts/smoke.sh runs (/healthz + /admin, 2B KRW readiness) plus a postgres-backed /healthz.persistence=postgres smoke

Doctoring: 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.


Open in Devin Review

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.
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 03e9bf0b-cda8-473a-9b12-0fb3992db23f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 7 potential issues.

Open in Devin Review

Comment thread src/lib.rs
Comment thread src/control_plane.rs
Comment thread src/control_plane.rs
Comment on lines +220 to +234
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)
}

@devin-ai-integration devin-ai-integration Bot Aug 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/control_plane.rs
Comment thread src/control_plane.rs
Comment on lines +530 to +544
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}"))?,
})

@devin-ai-integration devin-ai-integration Bot Aug 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/control_plane.rs Outdated
Comment thread src/control_plane.rs
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.

Copy link
Copy Markdown
Contributor Author

Still-valid Devin findings from this head are addressed on stacked #99 (feat/issue-81-outbox-workers), not by re-implementing the postgres gate:

  • Gateway path no longer DELETE+INSERTs the whole snapshot per record_event; events append with an outbox_message in the same transaction.
  • load_routes / load_threats / load_dnsbl / load_feeds now ORDER BY primary-key columns so overlapping prefixes are stable across restarts.

TLS (sslmode=require), non-owner runtime role, reconnect, and backup/restore remain #80 remainder. Do not --admin merge.

seonghobae added a commit that referenced this pull request Aug 23, 2026
* 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

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before 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 head ea621985e27691a6b80629b36888e4defcc361c4.

  • 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"]
Loading

@opencode-agent

Copy link
Copy Markdown
Contributor

OpenCode Review Overview

  • Head SHA: ea621985e27691a6b80629b36888e4defcc361c4
  • Workflow run: 32702415204
  • Workflow attempt: 1
  • Gate result: REQUEST_CHANGES (approval step)

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 success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before 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 head ea621985e27691a6b80629b36888e4defcc361c4.

  • 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"]
Loading

Merge Conflict Guidance

  • Current merge state: DIRTY
  • Base branch: feat/issue-86-in-process-libcoraza
  • Head branch: feat/issue-80-postgres-control-plane
  • Fix direction: merge or rebase origin/feat/issue-86-in-process-libcoraza into feat/issue-80-postgres-control-plane, resolve conflict markers in the changed files, rerun the focused checks, then push the same branch.
  • Repair commands:
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
Base automatically changed from feat/issue-86-in-process-libcoraza to feat/issue-79-destination-policy August 25, 2026 23:41

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 new potential issues.

Open in Devin Review

Comment thread src/lib.rs
Comment on lines 962 to +963
async fn healthz(State(state): State<AppState>) -> Json<HealthStatus> {
Json(state.health_status())
Json(state.health_status_live().await)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/control_plane.rs
Comment on lines +582 to +587
pub struct PostgresPlane {
client: Mutex<Client>,
tenant_id: String,
/// Processed-outbox retention; mirrors operator `EVENT_LIMIT`.
event_limit: i64,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/control_plane.rs
Comment on lines +1035 to +1050
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}"))?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/control_plane.rs
(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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/lib.rs
Comment on lines +1229 to +1232
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");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae
seonghobae dismissed opencode-agent[bot]’s stale review August 26, 2026 02:28

Stale review: coverage-evidence now passes on this head; all required checks green.

@seonghobae
seonghobae merged commit 3b92271 into feat/issue-79-destination-policy Aug 26, 2026
7 checks passed
@seonghobae
seonghobae deleted the feat/issue-80-postgres-control-plane branch August 26, 2026 03:20
seonghobae added a commit that referenced this pull request Aug 26, 2026
* 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
seonghobae added a commit that referenced this pull request Aug 26, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant