Review request

Multi-tenant invoicing SaaS, is a tenant_id column enough isolation?

Solved · 2203 viewsasked by priya_ships

Repo or live app

git.example.com/priyanair/ledgerlite ↗

Unsure about: SecurityStructure

Small accounting firms each get their own workspace. Lovable put a tenant_id on every table and filters on it in each query. Nine firms on it now, which is exactly the number where one of them leaking into another stops being theoretical. I would like to know what else I need beyond the column itself.

Comment
Where does tenant_id come from on insert? If the client sends it, that's the first thing to fix. amir_h
From the client, yes. priya_ships

2 answers

Marked as helpful by the asker
mira_dev

A tenant_id column with filtering in application code is not isolation, it is a convention that holds until one query forgets. With nine firms you want the database to refuse, not your code to remember.

Put RLS on every table with membership as the condition, not a claim you pass in:

create policy tenant_isolation on invoices for all
  using (tenant_id in (select tenant_id from memberships where user_id = auth.uid()))
  with check (tenant_id in (select tenant_id from memberships where user_id = auth.uid()));

Three things that bite people after this. Index memberships (user_id, tenant_id) or every query pays for the subquery. Views do not inherit RLS unless you create them with security_invoker = true. And any code path using the service role key bypasses all of it, so audit those routes specifically, they are where the real leak will come from.

Comment
RLS with the tenant looked up from a membership table is live. Two queries I'd forgotten to filter suddenly returned nothing, which was the point. priya_ships
Put the tenant lookup in a security definer function with a fixed search_path. It keeps the policies short and fast. hannah_reyes
lena_ops

Add a test rather than a review. Two seed tenants, one signed-in user from each, then assert that user A selecting every table returns zero rows belonging to tenant B. Run it in CI. New table without a policy fails the suite, which is the only mechanism I have seen actually survive a year of feature work.

Comment
The CI test found a table I'd missed the same day. priya_ships