Row-level security
Compile the permission DSL into Postgres policies, so psql, a BI tool and a warehouse connector are filtered the way the API is.
Your permission rules are enforced by the API. Every read
goes through compileCondition, which turns a stored rule into a WHERE
fragment, and every row you get back is one your role was allowed to see.
That is complete for anything that comes through the API — and it is nothing
at all for anything that does not. psql, Metabase, a warehouse connector, an
analytics job: each opens a connection and reads the physical table, and every
row condition you wrote is simply absent.
This compiles the same rules into Postgres row-level security, so the database enforces them too.
backlex rls plan # the exact statements, and what they cannot carrybacklex rls apply # install thembacklex rls status # what is installed, and whether it still matchesPostgres only. On SQLite/D1 there is nothing to compile into, and the commands say so rather than pretending.
It cannot break backlex
Section titled “It cannot break backlex”Row security exempts a table’s owner unless the table is set to FORCE ROW LEVEL SECURITY. backlex connects as the owner, and this feature never forces —
so applying cannot change one thing the API does. The policies only ever apply
to somebody else’s connection.
If backlex is not the owner of a covered table, applying is refused, with the table named. There the same statement would start filtering the product itself — with none of the session settings a policy reads set, which means every read would return nothing.
Who the database thinks you are
Section titled “Who the database thinks you are”A policy needs an identity, and a raw connection has none. It reads one from session settings:
SET backlex.user_id = 'usr_123';SET backlex.roles = 'editor,viewer'; -- comma-separatedSET backlex.tenant_id = 'wsp_abc';SET backlex.org_id = 'org_9'; -- optionalSET backlex.orgs = 'org_9,org_4'; -- optionalA PostgREST-shaped request.jwt.claims JSON blob is accepted too, with the same
keys — so a client that already sets one works unchanged.
A connection that sets none of them is nobody, and sees nothing. An
unreadable claims blob is also nobody: backlex.claim() catches the parse
failure and returns NULL rather than anything more forgiving.
Every DSL variable has one expression:
| DSL | SQL |
|---|---|
$user.id | backlex.uid() |
$user.email | backlex.email() |
$user.roles | backlex.roles() |
$tenant.id | backlex.tenant_id() |
$org.id | backlex.org_id() |
$org.role | backlex.org_role() |
$user.orgs | backlex.orgs() |
$now | now() |
The helper functions live in a backlex schema, granted to PUBLIC — every
policy calls them, so a connection without access would get
permission denied for schema backlex instead of a narrower result set.
What gets installed
Section titled “What gets installed”One policy per (collection, role, action), named
backlex_<collection>_<role>_<action>:
CREATE POLICY backlex_notes_reader_read ON c_ab12_notes AS PERMISSIVE FOR SELECT TO PUBLIC USING (backlex.has_role('reader') AND "tenant_id" = backlex.tenant_id() AND "deleted_at" IS NULL AND ("holder" = backlex.uid()));TO PUBLIC, unless you setRLS_APP_ROLE. That is the strict choice, not the loose one: a policy bound to one named role leaves every other role unfiltered, so the next connection somebody creates would be a hole.INSERTgetsWITH CHECK;UPDATEgets both.USINGdecides which rows you can see,WITH CHECKwhich rows you may produce. AnUPDATEwith onlyUSINGwould let a session move a row out of its own scope.- Soft-deleted rows are excluded from read/update/delete, so a direct reader sees what the API would.
- An admin role gets no policy. Its API access is unconditional, and a
policy saying
TRUEfor it would be a standing bypass sitting in the database for anyone who can setbacklex.roles.
Applying is idempotent — each policy is dropped and recreated, so re-running after a rule change replaces rather than accumulates.
What a policy cannot carry
Section titled “What a policy cannot carry”plan, apply and status all return an omissions list. Read it. Each
entry is a part of your permission model the database will not enforce, so a
direct reader sees a coarser view than the API there:
| Not represented | Why |
|---|---|
| A field allow-list | Column privileges are per database role, and the union across the backlex roles one session holds cannot be expressed as one. A direct reader sees every column of the rows it may read. |
A condition that walks a relation (author_id.name) | It lowers to a correlated subquery that would need the joined table’s own policy to agree. |
_near | It filters against a query origin, and a policy has no caller to take one from. |
Actions with no SQL command (publish, …) | There is no statement for a policy to attach to. The API still enforces them. |
A condition that cannot be compiled gets no policy at all, rather than being downgraded to “no condition” — the downgrade would install a rule strictly wider than the one it came from.
Policies are a snapshot of the rules at the moment they were applied. Editing a
role afterwards changes the API immediately and the database not at all, so
status reports the gap:
applies to PUBLICinstalled 14expected 16stale 0missing 2Re-run apply to close it. Applying automatically on every permission edit was
deliberately not built: DDL against every physical table is not something a
checkbox toggle should trigger, and a failure there would surface as a failed
permission save.
Turning it off
Section titled “Turning it off”backlex rls disableDrops the policies backlex installed. Row security itself is disabled only on tables left with no policies at all — a hand-written policy somebody else added keeps its table protected.
Surfaces
Section titled “Surfaces”| Surface | Where |
|---|---|
| REST | /api/admin/rls/{status,plan,apply,disable} |
| SDK | backlex.rls.* |
| GraphQL | rlsStatus, rlsPlan, applyRls, disableRls |
| MCP | rls.status, rls.plan, rls.apply, rls.disable |
| CLI | backlex rls … |