유대선
프로젝트로
·기술 회고·3

The multi-tenant spine: adversarial RLS design, then 13 impersonation tests (one caught a real cascade bug)

Replaced the prototype's flat using(true) RLS — a cross-tenant data leak — with a real Entity→Service→membership RBAC schema (4 roles), designed and adversarially audited by an 8-agent workflow, then verified by impersonating each role in psql. The tests passed 12/12 and surfaced a real cascade-delete bug the audit missed.

The web seam worked, but built for one user. The moment a second person signed up they'd share one board and see each other's everything — the prototype RLS was for all to authenticated using (true), i.e. no tenant isolation at all. "Build it like real people use it" means that has to die.

The model

Founder's structure, mapped to the existing desktop depth (Entity → Service):

Designed + audited by a workflow

An 8-agent workflow read the existing structure, designed the schema + RLS, then ran five adversarial lenses hunting data-isolation holes — and found 28 candidates, 11 critical/high. The non-obvious root cause it surfaced: RLS WITH CHECK can't see OLD.*, so a pure policy can't express "you may not raise your own role" or "owner_id is immutable." Those invariants moved into BEFORE triggers (memberships_guard, services_guard, tickets_guard) with a caller_role_rank() helper; RLS stays the coarse gate, the trigger enforces the tier boundary. Lock columns are writable only inside the claim_ticket / release_ticket SECURITY DEFINER RPCs (gated by a transaction-local GUC), so the atomic lock is real, not decorative. Every definer function is search_path = ''

Then actually run it

Drafted ≠ verified. Applied it to the live database, then impersonated each role in psql (set local role authenticated; set local request.jwt.claims = '{"sub":"…"}') and asserted:

12/12 passed. Then the cleanup raised CANNOT_REMOVE_HIGHER_ROLE — a real bug the audit missed: deleting an entity cascades to its services and then their memberships, but by the time the membership-delete trigger runs the parent service is already gone, so caller_role_rank() returns 0 and the guard blocks its own cascade. Net effect: you could never delete a service/entity that had an admin or owner member. Fix: the DELETE branch allows the cascade when the parent service no longer exists (only an owner can delete a service, and an owner outranks every membership, so it opens no hole). Re-verified: direct admin-delete-of-owner still blocked; owner cascade-delete now works; DB left clean.

The lesson is the old one — an adversarial design review is not a substitute for running it. The panel caught the subtle privilege holes a human would miss; a 13th test caught the operational bug the panel would miss. You need both.

Next: the web and desktop still speak the old flat startup_id/'s1' model, so they're broken against this schema until rewired to entities/services/memberships (signup → auto-create a workspace, a service picker, role-aware UI).