Don't build sharing with row-level security
Tenant isolation is one indexable equality on every table, and that uniformity is the whole value. Widening it with an OR so colleagues can share records gives away every column on the row, costs you the index on your hottest predicate, and ships a change no feature flag can hold back. Publish a projection instead.
The short version
A multi-tenant app isolates its tenants with one row-level security policy per
table: user_id = current_setting('app.current_user_id'). A single equality,
identical everywhere, indexable, and easy to reason about. Then someone asks for
"share this record with a colleague," and the cheapest-looking implementation
is to widen that policy with OR EXISTS (SELECT 1 FROM shares …).
Don't. RLS is row-level: if the policy lets someone read the row, they get every column on it, and Postgres gives you no way to mask the rest. You have also turned the most-evaluated predicate in the database into a correlated subquery, taken on a schema change that no feature flag can gate, and made widening a live policy a one-shot migration your idempotent DDL can't express. Share by writing a projection — a separate row, in a separate table, holding exactly the columns the recipient may see — and leave the isolation policy alone forever.
The rest of this post is the deep dive: why the single-equality policy is worth
protecting, the four concrete ways an OR clause damages it, the
publish-projection shape we'd build instead, and the trade-offs that shape
actually costs. The question came up while we were designing shared workspaces
for teams — a feature that is in development and has not shipped — but nothing
here is specific to Dhaga.
The setup: one predicate, everywhere
Tenant isolation in a shared database is usually one rule repeated on every
table. Each table carries a user_id (or tenant_id), and each has one policy:
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY notes_tenant_isolation ON notes
USING (user_id = current_setting('app.current_user_id', true));The request sets app.current_user_id before it runs any query, and the database
does the filtering. What makes this design good is not that it is clever — it is
that it is boring in exactly the same way on every table:
- It is provable. One equality has one meaning. You can read every policy in the schema in a minute and convince yourself no row escapes.
- It is fast. An equality on an indexed column is the cheapest predicate Postgres has, and the planner can use that index to satisfy the policy rather than filtering after the fact.
- It is uniform. Every table's policy is the same shape, so a new table is correct by copy-paste and a reviewer only has to check one thing.
That uniformity is the entire value. Anything that makes one table's isolation predicate different from the others spends it.
The tempting move: one OR away
Then the sharing feature arrives. A user wants a colleague to see one record. RLS already decides who sees which row — so the change looks like it belongs right there:
-- the trap
CREATE POLICY notes_tenant_isolation ON notes
USING (
user_id = current_setting('app.current_user_id', true)
OR EXISTS (
SELECT 1 FROM shares s
WHERE s.record_id = notes.id
AND s.shared_with = current_setting('app.current_user_id', true)
)
);One clause, and sharing works. No new table to keep in sync, no application code to write, no chance of a read path that forgets to check the grant. It is genuinely the smallest diff that makes the feature work, which is exactly why it is worth arguing against carefully.
Why the OR clause is the wrong place
RLS is row-level, and there is no per-row column masking
This is the reason that matters most, and it is structural rather than a bug you can test your way out of.
A policy decides whether a row is visible. It does not decide which
columns of that row are visible. There is no "return only these columns" in a
policy body. So the moment the predicate above returns true, the recipient can
SELECT * and get the name, the email, the phone number, the address, the
private note body, and every column you add to that table next year.
Product asked to share the company name. The policy shared the record.
Postgres does have column-level privileges — GRANT SELECT (col) ON … — but
those are per-role and static; they cannot say "this column, for this row, for
this grantee." A view with a fixed column list helps only if nobody can reach the
base table, and in a codebase where the ORM queries tables directly, they can.
Unless you already run a redaction layer that every read passes through — most
codebases don't — widening the policy quietly turns a narrow share into a full
record disclosure, and it will not show up in any test that only asserts "the
colleague can see the shared item."
You just made the hottest predicate in the database non-trivial
The isolation policy runs against every row of every query on that table.
It is the single most-evaluated expression you own. Putting a correlated EXISTS
there is a per-row lookup on your most-read table.
The per-row cost is the smaller half of the problem: OR short-circuits, so for
rows the tenant already owns the subquery is never evaluated. The real damage is
to the scan. a = $1 can be answered by an index on a; a = $1 OR <correlated subquery> cannot, because rows the index would have excluded might still qualify
through the second branch. The planner has to consider rows the equality alone
would never have touched, and on a large table under unhelpful statistics that
becomes a sequential scan with a subplan attached.
Sometimes the planner finds a good shape anyway. The point is that you now have
to prove it — on every query against that table, at every data size, after
every statistics change — where before the answer was obviously "index scan."
Note too that RLS makes the table behave as a security-barrier subquery: your own
WHERE clauses are not freely pushed below the policy, so the policy's cost is
paid first by design. That is correct behaviour, and it is exactly why the policy
is the worst place in the schema to put something expensive.
Policies are not re-runnable
If your schema is expressed as idempotent DDL — the pattern where the app brings
any database up to the current shape on startup — policies are the awkward
exception. CREATE POLICY has no IF NOT EXISTS form, so idempotent schema code
guards it with a catalogue check:
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'notes' AND policyname = 'notes_tenant_isolation'
) THEN
CREATE POLICY notes_tenant_isolation ON notes USING (…);
END IF;
END $$;That guard is create-if-absent, not converge-to-this-definition. Editing the policy body in your template changes what a brand-new database gets and does nothing to any database that already has a policy by that name. A dev box seeded from scratch is then running a different security predicate from production, and nothing reports it.
Converging an existing database means an explicit ALTER POLICY, or a
DROP POLICY followed by CREATE POLICY — a one-shot, ordered migration step,
which is precisely the machinery the idempotent template was chosen to avoid. To
be fair to Postgres: DDL is transactional, so a drop-and-create inside one
transaction has no window where the table is readable unprotected — it just holds
an ACCESS EXCLUSIVE lock on your busiest table for the duration. And a table
with RLS enabled (plus FORCE ROW LEVEL SECURITY, so the owner is covered too)
and no policy denies everything, so a migration that dies between the drop and
the create fails closed, not open. That is the good news. The bad news is
that failing closed on your most sensitive table is still an outage, and you have
taken on hand-ordered migration machinery to buy a product feature.
It cannot be feature-flagged
Application code can hide behind a flag. You ship it dark, enable it for yourself, then for a cohort, then for everyone, and you can switch it off in seconds.
A policy applies the instant the DDL runs. There is no "off" position — the widened predicate is live for every query on that table from the moment of deploy, whether or not a single user can see a share button. So the riskiest part of the change ships unconditionally, and the staged rollout that normally contains a mistake contains nothing at all. Rolling back means another DDL change under the same lock.
The alternative: publish a projection
Sharing writes a new row in a new table, containing exactly the columns the recipient is allowed to see. The base table's policy is never touched.
CREATE TABLE shared_record_projections (
id uuid PRIMARY KEY,
source_record_id uuid NOT NULL,
owner_user_id text NOT NULL,
shared_with_user_id text NOT NULL,
-- exactly the fields the recipient may see, and nothing else
display_name text NOT NULL,
organisation text,
last_interaction date
);
ALTER TABLE shared_record_projections ENABLE ROW LEVEL SECURITY;
CREATE POLICY shared_projection_read ON shared_record_projections
USING (shared_with_user_id = current_setting('app.current_user_id', true));The properties that matter:
- The recipient's read never reaches the base table. There is nothing to mask, because the columns you didn't copy do not exist in the row they can reach. Adding a sensitive column to the base table later cannot silently widen an existing share.
- The new table's policy is the same single equality as everything else —
shared_with_user_id = <current user>for a direct share, ororg_id = current_setting('app.current_org_id', true)for an org-wide one. The uniformity property survives intact. - The projection is written under the owner's scope, at share time, by code that already has legitimate access to the source row. Authorization is decided once, on the write path, rather than re-decided on every read by a subquery.
- Revocation is a
DELETE, not a schema change. It is immediate, per-share, and needs no lock on the base table.
This is not an exotic design. It is the access-grant / derived-index shape large systems settle on: a table of materialized grants, authorization resolved in the application or in a view over that table, and RLS kept underneath purely as the tenant-isolation backstop that catches an application bug. The database still guarantees "you cannot read another tenant's rows." It is simply no longer asked to express what your product means by shared.
What it costs — honestly
This is not free, and it is worth being straight about the bill.
It duplicates data, so it can go stale. A copy is only as good as its refresh. The projection has to be updated on the owner's write path — every mutation touching a projected column must fan out to the projections derived from that row — and that fan-out is code you own and can forget. Budget for a periodic reconciliation job that rebuilds projections from source and reports drift, and treat a stale share as a bug with an owner rather than acceptable eventual consistency.
It is more code than one OR. A grant table, a writer, a refresh hook on
every mutation path, a reconciler, a revoke path, and tests for all of it. If
your honest answer is "we are never going to build the refresh job," the OR
clause is still not the safer option — read the separate tables alternative
below instead.
You are copying some sensitive fields into a second table. That is real, and it should be said plainly rather than glossed over. It is still strictly less exposure than widening the policy, for two reasons: the copy contains only the fields you deliberately chose, so the blast radius is bounded by a column list a reviewer can read in one screen; and unsharing hard-deletes the row, so revocation removes the data instead of merely removing permission to see data that is still sitting there. But it is a second place your users' data lives, and it belongs in your deletion cascade and your export path from day one.
Two other options worth naming
A SECURITY DEFINER function, or a security-barrier view with a fixed column
list. This solves the masking problem by construction: the caller can only
reach the columns the function returns, and there is no copy to keep fresh. It
fits well when the shared view is cheap to compute and must always be current.
The caution is that a SECURITY DEFINER function is itself a
privilege-escalation primitive — it runs with its owner's rights, so a mistake
inside it bypasses RLS entirely. If you use one, pin its search_path, own it
with a dedicated role holding only the privileges it needs, and review it as
security code, because that is what it is.
Fully separate org-tenanted tables. The record is copied into an
organisation-scoped table whose policy keys on org_id, and the two then live
independent lives. This is the cleanest model conceptually — a personal graph and
a shared workspace are genuinely different objects, with different lifecycles and
different owners — and it is the most duplication. Where a share is really a
handoff rather than a window, this is usually the right answer.
The takeaways
- Keep the isolation predicate a single equality, forever. Its value comes from being identical and trivial on every table. Every branch you add spends that, on the one expression that runs against every row of every query.
- RLS filters rows, not columns. If a policy admits the row, it admits every column on it, including the ones you add next year. A feature phrased as "share only these fields" cannot be built in a policy.
- Express sharing one level up, over a projection you control. Write the allowed columns into their own table at share time, under the owner's scope, with its own single-equality policy. The recipient never touches the base table.
- A copy is a sync path you own. Refresh on the owner's writes, reconcile
periodically, hard-delete on revoke. If you won't build that, pick separate
tables — not an
ORclause. - Prefer changes you can switch off. Application authorization ships dark and rolls back in seconds; a policy is live the moment the DDL runs and rolls back under an exclusive lock on your most sensitive table.
- RLS is the floor you cannot fall through, not the mechanism for expressing product features. Its job is to make an application bug non-catastrophic. Give it that job and nothing else.
Discussion
The summariser didn't fabricate. It overclaimed.
We rebuilt our public build timeline by summarising 847 commits with language models, then audited every line against git and the live code. The audit turned up no invented features — and a worse failure underneath: sentences that were true when written, and a code path described as shipped that no user could reach.
By profession
Your network is your livelihood, and it currently lives in business cards, half-remembered conversations, and a phone full of names with no context. Pick the guide that matches your work.