Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- PostgreSQL `security_event` is HASH-partitioned by `tenant_id` (8 children). Unpartitioned tables convert in place and keep unmasked client IPs and paths. `/healthz.event_partitions` reports the child count (0 on file/memory). Logical restore still accepts schema 2 through the current migration version; HASH does not change the snapshot shape.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. After migrate, the session runs as `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS). `/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). `security_event` is HASH-partitioned by `tenant_id`. `/healthz.persistence` reports `postgres` when connected; `/healthz.event_partitions` reports the child count.
- `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`.
Expand Down
2 changes: 1 addition & 1 deletion crates/waf-ids-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ fn buyer_evidence_endpoints() -> Vec<BuyerEvidenceEndpoint> {
"GET",
"/healthz",
"application/json",
"runtime health, persistence mode, DNSBL origin, and event retention limit",
"runtime health, persistence mode, DNSBL origin, event retention, and HASH event partitions",
true,
),
buyer_evidence_endpoint(
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 under `wardnet_runtime` (not superuser/owner). `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. `security_event` is HASH-partitioned by `tenant_id` (`/healthz.event_partitions`). 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.
Expand Down
7 changes: 4 additions & 3 deletions docs/doctoring/outbox-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ 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: HASH partitioning and additional consumers
(TAXII poll, Clearfolio, contextual-orchestrator) on the same
message/receipt contract. Backup/restore drill is on the PostgreSQL plane.
`outbox=disabled`. `security_event` HASH partitioning is on the PostgreSQL
plane. Remaining consumers: TAXII poll, Clearfolio, contextual-orchestrator
on the same message/receipt contract. Backup/restore drill is on the
PostgreSQL plane.
25 changes: 19 additions & 6 deletions docs/doctoring/postgres-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ Transaction isolation*. https://www.postgresql.org/docs/current/transaction-iso.
commits in one transaction so a policy mutation cannot land without its audit
records.

PostgreSQL Global Development Group. (2026). *PostgreSQL documentation: Table
partitioning*. https://www.postgresql.org/docs/current/ddl-partitioning.html

- **Design impact:** `security_event` is `PARTITION BY HASH (tenant_id)` with
eight children so tenant-scoped SOC queries prune and high-volume appends do
not share one btree. The partition key is part of the primary key. Logical
backups stay a tenant snapshot; HASH is an on-disk layout, not a restore
schema bump that voids prior artifacts.

National Institute of Standards and Technology. (2022). *Secure Software
Development Framework (SSDF) version 1.1* (NIST SP 800-218).
https://doi.org/10.6028/NIST.SP.800-218
Expand All @@ -50,10 +59,10 @@ NOBYPASSRLS, not table owner) so FORCE RLS binds. Provision that role and

`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
and payload-hash checks. Role-only migrations (v3 `wardnet_runtime`) and HASH
layout (v4) do not change the logical snapshot 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.
Expand All @@ -68,5 +77,9 @@ 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: HASH partitioning for `security_event`,
optimistic concurrency.
`security_event` is HASH-partitioned by `tenant_id` (8 children) so tenant
queries prune and high-volume appends do not share one btree. Existing
unpartitioned tables convert under `pg_advisory_lock`; rows keep unmasked
client IPs and paths. `/healthz.event_partitions` reports the child count.

Remaining: optimistic concurrency.
43 changes: 26 additions & 17 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Product and technical gap baseline

Snapshot date: 2026-08-23T18:20Z (exact-head inventory of then-open GitHub PRs
Snapshot date: 2026-08-23T19:00Z (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
Expand All @@ -25,7 +25,8 @@ not “waiting on review/CI time”.

| PR | Title | Head | Checks | Reviews | Merge blocker |
| --- | --- | --- | --- | --- | --- |
| [#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. |
| [#104](https://github.com/ContextualWisdomLab/wardnet/pull/104) | feat(store): HASH-partition security_event by tenant | `feat/issue-80-event-hash-partition` stacked on #103 | local fmt/test/clippy + two `scripts/smoke.sh` + live postgres `/healthz.event_partitions=8` | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #100 then #103 first. Do not `--admin`. Do not re-implement runtime role. |
| [#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 | still-valid Devin restore-window finding fixed this pass (`MIN_RESTORABLE_SCHEMA_VERSION=2`); local fmt/test/clippy + smokes | Author this pass; Devin COMMENTED (v3 backup voiding addressed) | 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, backup, or HASH. |
| [#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. |
Expand Down Expand Up @@ -59,7 +60,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/backup on #100; non-owner role this pass** |
| [#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 on #103; HASH partitions 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 |
Expand Down Expand Up @@ -95,7 +96,7 @@ Management auth is shared secrets (`X-Admin-Token`) plus optional multi-token
RBAC. Keyverse (OIDC/SCIM/FIDO2) is not wired. Fail-closed (#78) is the
prerequisite shipped on PR #94.

### Durable control plane (issue #80) — **production gate on #98; rustls on #100; backup this pass**
### Durable control plane (issue #80) — **production gate on #98; rustls on #100; backup on #102; runtime role on #103; HASH this pass**

PostgreSQL is required for non-loopback binds (`CONTROL_PLANE_DATABASE_URL`).
`src/control_plane.rs` migrates 3NF two-word tables with default-deny RLS
Expand All @@ -110,8 +111,13 @@ 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.
Runtime is `wardnet_runtime` (NOSUPERUSER, NOBYPASSRLS) after migrate.
Remaining: event HASH partitioning, optimistic concurrency. Physical/PITR
backups stay a DBA concern.
Logical restore accepts schema 2 through the current migration version
(`MIN_RESTORABLE_SCHEMA_VERSION`); role-only and HASH-layout migrations do not
void pre-upgrade snapshots. `security_event` is `PARTITION BY HASH (tenant_id)`
with 8 children. Unpartitioned tables convert in place under `pg_advisory_lock`.
`/healthz.event_partitions` is 8 on PostgreSQL, 0 on file/memory. Client IPs
and paths stay unmasked across convert. Remaining: optimistic concurrency.
Physical/PITR backups stay a DBA concern.

### Transactional outbox (issue #81) — **first slice on #99; retention on #101**

Expand Down Expand Up @@ -157,7 +163,8 @@ replays `security_event.recorded` as stdout SIEM with receipts.
| Ten UI-UX areas | Inventoried in `docs/ui-ux/storybook-scene-inventory.md` |
| Node Storybook | **Not hosted in `/admin`** (embedded-console architecture). File:// inventory is the scene/edge-case contract this pass. |
| Outbox card | Embedded `/admin` Outbox section |
| Backup card | Embedded `/admin` Control-plane backup section this pass |
| Backup card | Embedded `/admin` Control-plane backup section |
| Event partitions KPI | Embedded `/admin` KPI tile from `/healthz.event_partitions` this pass |

### CSAP / SOC 2 vs PII unmasking

Expand All @@ -169,9 +176,9 @@ future encryption-at-rest.
### Coverage / docstring bar

Org 100% line/branch/docstring applies to **changed** surfaces this loop
(backup export/verify/restore/drill, health/API, admin card, EVENT_LIMIT
prune on save/ack). Remaining holes on untouched handlers stay listed
for later loops.
(HASH convert SQL, restorable schema window, `/healthz.event_partitions`,
admin KPI tile, probe upgrade preserving unmasked IPs/paths). Remaining
holes on untouched handlers stay listed for later loops.

### Ecosystem connectors (leverage order)

Expand All @@ -185,17 +192,19 @@ for later loops.

## This loop’s shipped gap

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.
Issue **#80** remaining: HASH-partition `security_event` by `tenant_id` (8
children) stacked on #103. Still-valid #103 Devin finding: `verify()` now
accepts schema 2..=current so a role-only/HASH-layout upgrade cannot void
the last pre-upgrade logical backup. Do not re-implement #78, sidecar, pin,
libcoraza, the postgres gate, outbox, rustls, retention, backup/restore, or
the runtime role.

## 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 and this runtime-role PR merge-ready.
2. Keep #94/#95/#96/#97/#98/#99/#100/#103 and #104 merge-ready.
Merge order #94 independently; #95 then #96 then #97 then #98 then #99
then #100 then this.
then #100 then #103 then #104.
3. Next runtime gap if policy still blocks: extra #81 consumers (TAXII /
Clearfolio / orchestrator) or HASH partitioning.
Clearfolio / orchestrator) or optimistic concurrency.
4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`.
1 change: 1 addition & 0 deletions docs/runbooks/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Expected fields:
- `credentials_source`: `file`, `env`, or `none`
- `admin_auth_configured`: whether any admin write token is configured
- `backup`: `ready` on PostgreSQL (logical export/restore available) or `disabled` on file/memory
- `event_partitions`: HASH child count for `security_event` (8 on PostgreSQL, 0 on file/memory)

## Control-plane backup and restore drill

Expand Down
1 change: 1 addition & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ assert_json_field "$health" 'data["proven_engine_fail_closed"] is False'
assert_json_field "$health" 'data["destination_mode"] == "development"'
assert_json_field "$health" 'data["outbox"] == "disabled"'
assert_json_field "$health" 'data["outbox_pending"] == 0'
assert_json_field "$health" 'data["event_partitions"] == 0'

engine_status="$(curl -fsS "$BASE_URL/api/waf/engine-status")"
assert_json_field "$engine_status" 'data["mode"] == "ingest_hints_only"'
Expand Down
Loading
Loading