Skip to content

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.

Terminal window
cp docker/.env.example .env
docker compose up -d --build
# http://localhost:8080 → console onboarding
flowchart 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.

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.

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.

Terminal window
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
fi
done

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

Terminal window
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.

Scripts parse JSON with Bun; jq is not required (runtime already depends on Bun).

Isolation headers are orthogonal — do not mix their meanings:

Terminal window
-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.

A single admin self-approve is fine for local wiring; two-person split is the POC success bar:

  1. operator drives a change;
  2. wait for approval; that operator cannot self-approve in the approval center (403);
  3. a same-tenant approver accepts or rejects;
  4. audit distinguishes requester and decider (approval.* includes requester_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: