a service_role client in a shared db module returned another org's invoices Multi-Tenant
Writing this up because it was entirely preventable and I want to know how other people structure against it.
One dashboard query was slow. Someone swapped the client in lib/db.ts for one built with the service role key because "RLS was making the plan bad". That module is imported by 30 files. Nine days later a customer opened a ticket with a screenshot of an invoice list containing 84,120 rows across 611 organisations, including names of companies that are their competitors.
Nobody was malicious, the diff was four lines and reviewed. What is the structural fix - not "be more careful"?
@wren_oyelaran · 2mo ago · 3 replies
Make the dangerous client hard to reach and easy to spot:
adminDbfromlib/admin-db.ts. Never a default export, never re-exported.no-restricted-importsrule that fails the build for any file outsidelib/jobs/**importing it. This is fifteen lines of config and it is the actual fix.whereclause.And stop treating "RLS makes the plan bad" as a reason to bypass RLS. It is almost always a bare function call in the predicate, which is a two-character fix, not an architecture change.
Reply
Report
@thesis_wrangler · 2mo ago
The lint rule went in the same day. What stings is that the original slow query was the
auth.uid()-not-in-a-subselect thing, so the entire incident traces back to a missing pair of parentheses.Reply
Report
@arrayformula_al · 2mo ago
Add the test too: seed two orgs, run your list endpoint as a user in org A, assert zero rows belong to org B. Run it against the real code path, not the query builder. That test would have failed on the PR that introduced this, which is worth more than any review checklist.
Reply
Report