Deployment verification: why data isolation lives in the database
docker compose up -d --build brings up runtime, Postgres, MinIO, and the console. Coming up is not the same as coming up correctly. Below: why several pieces of the security baseline are shaped this way, and how to verify they hold.
Topology and commands
Section titled “Topology and commands”cp docker/.env.example .envdocker compose up -d --build# http://localhost:8080 → console onboardingflowchart LR B[Browser] --> W[web :80] W -->|/api| R[runtime] R --> PG[(postgres)] R --> S3[(minio)]web is the host-facing entry; postgres / minio stay on the internal network, not published to the host, reachable only by runtime.
Why isolation is in the database
Section titled “Why isolation is in the database”If the app only concatenates WHERE tenant_id = ?, any missed clause leaks tenants, and the miss set is hard to exhaust. Agent Forge enables row-level security on Postgres; policies constrain both read and write. Sketch:
ALTER TABLE "session" ENABLE ROW LEVEL SECURITY;ALTER TABLE "session" FORCE ROW LEVEL SECURITY;CREATE POLICY "tenant_isolation" ON "session"USING (tenant_id = current_setting('app.tenant_id', true))WITH CHECK (tenant_id = current_setting('app.tenant_id', true));The same pattern covers session, message, trigger, and other business tables (aligned with the RLS table list in current migrations).
FORCE, not ENABLE alone. Postgres can skip RLS for the table owner by default. With only ENABLE, an owner connection role bypasses the policy. FORCE applies the policy to the owner too.
Both USING and WITH CHECK. USING constrains readable rows; WITH CHECK constrains writable rows. With only the former, rows for another tenant can still be written.
current_setting(..., true). When the second argument is true, a missing setting returns NULL instead of erroring; the policy compares to NULL and matches no rows. Forgetting to bind a tenant defaults to deny, not allow-all.
Bound per request. The runtime runs SET LOCAL app.tenant_id inside the transaction; it clears when the transaction ends and does not leak to the next request on a pooled connection.
Connection role NOBYPASSRLS. Superusers or roles with BYPASSRLS skip policies. Application roles are created with explicit NOBYPASSRLS, paired with FORCE, so database-enforced isolation holds.
CREATE ROLE agentforge LOGIN PASSWORD '…' NOBYPASSRLS;Details: Multitenancy and RLS.
Smoke: poll instead of assert immediately
Section titled “Smoke: poll instead of assert immediately”Trigger events are driven asynchronously: after an alert is raised, audit does not appear instantly. Acceptance scripts poll for eventual consistency instead of asserting immediately.
BEFORE="$(af_curl GET "/trigger/audit?limit=5" | af_json 'j.length' || echo 0)"bash "$SCRIPT_DIR/raise-alert.sh"for _ in $(seq 1 20); do sleep 0.5 AFTER="$(af_curl GET "/trigger/audit?limit=5")" if echo "$AFTER" | af_json_ok 'j[0] && j[0].kind==="event" && j[0].status==="ok"'; then ok=1; break fidoneThe criterion is that the newest entry has kind==="event" and status==="ok", confirming this alert fired; after roughly 10 seconds the script exits failed. Immediate asserts on async systems flake; poll + timeout + a clear criterion is stabler.
What /audit/verify returns
Section titled “What /audit/verify returns”af_curl GET "/audit/verify?limit=50" | af_json '{ok:j.ok, checked:j.checked, broken_at:j.broken_at}'Shape { ok: true, checked: 50, broken_at: null }: checked is recomputed count; broken_at points at a break (null means intact). An endpoint that only returns 200 cannot locate a break; returning broken_at shows per-record recompute. Mechanism: Hash-chain audit.
Two details
Section titled “Two details”Scripts parse JSON with Bun; jq is not required (runtime already depends on Bun).
Isolation headers are orthogonal — do not mix their meanings:
-H "X-AgentForge-Tenant-Id: ${TENANT}"-H "X-AgentForge-Owner-Id: ${OWNER}"-H "X-AgentForge-Role: ${ROLE}"tenant enters the RLS GUC; owner / role enter identity and RBAC. tenant answers which data slice; role answers what actions are allowed.
HITL two-person acceptance
Section titled “HITL two-person acceptance”A single admin self-approve is fine for local wiring; two-person split is the POC success bar:
- operator drives a change;
- wait for approval; that operator cannot self-approve in the approval center (
403); - a same-tenant approver accepts or rejects;
- audit distinguishes requester and decider (
approval.*includesrequester_id/decided_by).
Self-confirmation (not on the HITL list, permission is ask): the actor allows in-session; the approval center must not show that item; audit is permission.*. Keeping the two event types apart is what lets post-hoc review tell self-allow from third-party approval. See HITL and Metrics.
Related: