feat(db): audited, idempotent per-tenant data deletion#2019
feat(db): audited, idempotent per-tenant data deletion#2019Antonio-RiveroMartnez wants to merge 8 commits into
Conversation
Add an admin capability to permanently remove all data belonging to a
single tenant from a multi-tenant (PostgreSQL) deployment, exposed as a
non-interactive CLI command: `agor tenant delete --tenant-id <id>`.
Operators of multi-tenant Agor deployments need a safe, verifiable way to
erase one tenant (offboarding, data-removal requests, regulatory erasure).
Design:
- Runtime-owned deletion manifest derived from the PostgreSQL schema:
every table is classified as directly tenant-scoped (`tenant_id`
column), transitively scoped (FK chain to a scoped table), or explicitly
global. An exhaustiveness test fails the build if any table escapes all
three buckets, so a future table cannot silently avoid deletion.
- FK-safe deletion order via a topological sort over blocking foreign keys
(children before parents), inside a single tenant-scoped transaction so
RLS pins every statement to the target tenant.
- Idempotent: a second run deletes zero rows and still reports success.
- Verifies committed state by re-scanning the whole manifest and failing
unless zero tenant rows remain.
- Stable machine-readable stdout contract for external automation:
{ "tenantDataDeleted": true, "schemaVersion": "<applied migration>",
"rowCounts": { "<table>": <n>, ... } } — identifiers/numbers/booleans
only, never row content or secrets. Human audit logs go to stderr.
- `--dry-run` reports would-be counts without mutating data.
- Refuses empty/blank/whitespace-padded/wildcard tenant ids, and refuses
to run against the single-tenant SQLite schema.
Tests cover manifest exhaustiveness and FK-safe ordering, tenant-id
validation, the verification-failure path, direct/transitive scope SQL,
and the SQLite guard. PostgreSQL-gated integration tests (scoped deletion,
cross-tenant isolation, idempotency, dry-run) live in a *.postgres.test.ts
suite that runs against a real database and skips in the SQLite CI lane.
…rd, transitive/unclassified guards, stdout flush
Review — verdict: not mergeable as-is (fixable, not architectural)AI-assisted review (Codex The core design is sound. Blocking1. 2. Topo-sort ignores 3. "Provably in lockstep" is overstated ( 4. Empty manifest → false success. If schema discovery ever yields zero tables, both phases no-op and it returns Non-blocking hardening
Advisory (over-engineering, does not affect the verdict)The transitive-scope machinery (~85 lines: the recursive Bottom line: #1 is the one true blocker — the "nothing can silently escape" guarantee doesn't hold while the tenant-table set is derived purely from Drizzle exports. #2–#4 are correctness-hardening a destructive erasure command should carry. None require rethinking the approach. |
Architectural note (approach-level — advisory, distinct from the correctness blockers above)A separate architectural-lens pass landed, and it makes a case worth putting in front of the team: the manifest models the binary's schema, not the live database — which is the wrong source of truth for a tool whose defining requirement is exhaustive erasure.
The suggested direction: derive the deletion plan from the live PostgreSQL catalog rather than Drizzle exports. Postgres already owns every input the plan needs:
Discovery would select application-schema tables carrying Two smaller structural notes from the same pass:
Tradeoff: this is a material rewrite of the ~400-line manifest layer, but not a bigger system — a catalog query + plan builder should be smaller than the current Drizzle classification/transitive machinery. The payoff is that the command certifies the live database rather than the binary's partial view of it, which for a regulatory/offboarding tool is the correct boundary. Flagging as approach-level input for you to weigh against a narrower fix (e.g. a catalog cross-check bolted onto the existing manifest just to close the exhaustiveness gap) — the correctness items above stand regardless of which route you take. |
Addressed (pushed)
Intentionally not changed (rationale)
|
Re-review after your push — big improvement; one safety-critical item leftThe catalog-as-auditor + typed-manifest-as-executor split is a good resolution, and the fail-closed reconciliation genuinely closes the exhaustiveness gap (verified: One correctness item I'd hold the merge on, plus two cheap hardening items: Must-fix: the RLS argument against cross-tenant cascade is unsound
It doesn't. PostgreSQL referential-integrity checks bypass row-level security by design — the FK lookup on the parent runs with RI-trigger privileges, not the caller's RLS-scoped view. So a write carrying Concretely during deletion: explicit Reachability is admittedly an anomalous cross-tenant reference (shouldn't arise in normal scoped operation), so if you'd rather scope it as a follow-up, the honest framing is "cross-tenant FK references are an unhandled integrity anomaly," not "RLS prevents them." Cleanest real fix: a pre-delete Cheap hardening
Nit
Net: the exhaustiveness blocker is resolved. The cross-tenant cascade is the one I'd resolve-or-consciously-accept before merge; the TOCTOU + search_path items are cheap and worth folding in. |
|
Thanks for the follow-up. You are correct that PostgreSQL referential-integrity checks and referential actions bypass RLS. I withdraw the earlier statement that RLS prevents a cross-tenant foreign-key reference from being created. I pushed Addressed
Validation at this commit: core and CLI typechecks passed; full SQLite suites passed (core 2912, CLI 21); the PostgreSQL 16 gated suite passed 11/11; Biome passed; the throwaway database containers were removed. Not implemented: a deletion-local row-level
|
Good to merge from my sideVerified this push: catalog access is fully On the cross-tenant referential-action risk — I agree with your call to not build a deletion-local row-level scanner, and your reasoning is more precise than my suggestion: Residual, now honestly documented rather than claimed away: this command assumes (a) the tenant stays quiesced for the full operation and (b) the existing FK graph is tenant-consistent. Both are the right preconditions for an offline DB-owner erasure tool. Worth a tracked follow-up for the tenant-inclusive-FK structural remedy. No blocking items left from the review. 👍 |
Linked issue
None.
Summary
agor tenant delete --tenant-id <id>(with--dry-run): an audited, idempotent admin command that permanently deletes all data for exactly one tenant from a multi-tenant (PostgreSQL) deployment.@agor/core/db) with an exhaustiveness test, so no future table can silently escape tenant deletion.Why / context
Operators of multi-tenant deployments need a safe, verifiable way to fully remove one tenant — offboarding, data-removal requests, GDPR-style erasure. Hand-written SQL is error-prone: it can miss a table, trip a foreign key, or leave orphaned rows and still look like it worked. This command makes tenant erasure exhaustive, FK-ordered, transactional, and self-verifying, and returns a result automation can assert on.
Implementation notes
Manifest (
tenant-deletion-manifest.ts) — derived directly from the PostgreSQL Drizzle schema (not a hand-maintained list). Every table is classifieddirect(tenant_idcolumn),transitive(reaches a scoped table via an FK chain), or explicitlyglobal. Deletion order is a topological sort over blocking foreign keys (children before parents).buildTenantDeletionManifest()throws at runtime if any table isunclassified, or if atransitivetable is present — the transitive delete/verify path is not yet proven, so it fails loud instead of deleting partially.Deletion (
tenant-deletion.ts) —deleteTenantData():hasPending, or a database migrated by a newer release) — a mismatched manifest could silently miss a renamed/dropped/added tenant table.FORCE ROW LEVEL SECURITY) is pinned to the target via theagor.tenant_idGUC, and every statement also carries an explicittenant_idpredicate — defense-in-depth that holds whether or not the maintenance role bypasses RLS.ON DELETE CASCADEcan't undercount.CLI (
commands/tenant/delete.ts) — resolves the database exactly like the daemon (env/config); prints one JSON object to stdout —{ tenantDataDeleted, schemaVersion, rowCounts }(dry-run addsdryRun: true), values limited to booleans, numbers, and identifier strings; the payload is flushed before exit so a piped consumer can't get truncated output. Human logs and errors (credentials redacted) go to stderr. Exit codes:0verified success,2invalid input,1runtime failure.This PR was hardened through an adversarial advocate/skeptic review on two independent model families. Fixes landed from that review: the fail-closed schema-lockstep guard, the ambient-scope guard, runtime
unclassified/transitiveguards, and the stdout-flush fix.Validation / test plan
pnpm --filter @agor/core typecheck— cleanpnpm --filter @agor/cli typecheck— cleanpnpm --filter @agor/core test— 2906 passed, 5 skippedbiome check— clean on all changed filestenant-deletion.postgres.test.ts) run against a real PostgreSQL: scoped deletion, cross-tenant isolation, idempotency, frozen output contract, dry-run — all pass. Gated onAGOR_TEST_POSTGRES_URL+AGOR_DB_DIALECT=postgresql, mirroring the existing*.postgres.test.tsconvention; skipped in the SQLite fast lane.Example success output (stdout):
{"tenantDataDeleted":true,"schemaVersion":"0066_gateway_listener_discovery","rowCounts":{"sessions":3,"tasks":5,"messages":42,"branches":1,"repos":1}}Risks / rollout / rollback
--dry-runpreviews counts without deleting.checkMigrationStatus. Blast radius on existing deployments is zero until the command is invoked.Out of scope / follow-ups
tasks.postgres.test.ts) is env-gated and not exercised by current CI. Follow-up: add a Postgres-service job so the deletion path runs underFORCE RLSin CI.