Last updated:
Row-level security (RLS) controls which rows a caller can see and change.
Where data masking decides which fields within a row
come back redacted, RLS decides which rows a procedure returns or is allowed to
write. Both are server-side, both attach to a procedure's .use(...) chain, and
both are opt-in per procedure: a bare query/mutation sees an unwrapped
ctx.db and no policy applies, even if another procedure guards the same table.
import { definePolicy, definePolicies, rls } from "lunorash/server";
import { query } from "./_generated/server";
// "you only see documents you own"
const ownDocuments = definePolicy({
table: "documents",
on: "read",
when: ({ auth }) => ({ ownerId: auth.userId }),
});
export const listDocuments = query.use(rls(definePolicies([ownDocuments]))).query(async ({ ctx }) => ctx.db.documents.findMany());Policies
A policy is a pure function bound to a table and an operation (on). At
request time the middleware calls its when(...) with the request context and
uses the return value to decide access:
when returns | On a read | On a write (insert / update / delete) |
|---|---|---|
WhereInput | AND-merged into every query on the table; non-matching rows are invisible. | Evaluated against the candidate/pre-write row; a mismatch throws FORBIDDEN. |
true | No predicate merged; unrestricted. | The row is allowed. |
false | The table matches zero rows. | The operation throws FORBIDDEN. |
undefined | This policy opts out (useful when branching on roles). | This policy opts out. |
The on operations are read (covers get / findFirst / findMany /
query / count), insert, update, and delete. A WhereInput predicate
uses the same operator set as the query compiler:
eq/ne/in/notIn/lt/lte/gt/gte/isNull/contains plus
AND/OR/NOT.
const documentPolicies = definePolicies([
// read: only your own documents
definePolicy({ table: "documents", on: "read", when: ({ auth }) => ({ ownerId: auth.userId }) }),
// insert: you can only create documents you own
definePolicy({ table: "documents", on: "insert", when: ({ auth, row }) => row?.ownerId === auth.userId }),
// delete: only the owner may delete
definePolicy({ table: "documents", on: "delete", when: ({ auth, row }) => row?.ownerId === auth.userId }),
]);The policy context
when receives a context with:
-
auth.userId: the resolved caller id (nullwhen unauthenticated). -
auth.roles: the request's role list. It is the union of two sources, so a role from either one counts:- the
rolesorroleclaim on the resolved identity — astring[], or a comma-separated string (the form better-auth'sadmin()plugin stores a multi-role value in, under the singularrole). On the built-in.auth()wiring that claim is forwarded for you; a hand-writtenresolveIdentityhas to include it. ctx.auth.rolesset by an upstream middleware, for providers whose envelope carries something other than roles.
Empty when neither is present, and
auth.can(...)then fails closed.Take roles only from a field an administrator controls. A
resolveIdentitythat forwards a user-editable profile column namedrolelets the end user grant themselves permissions. - the
-
auth.can(permission): whether any of those roles grants a named permission (see below). Fails closed for unknown roles. -
auth.identity: the raw identity claims. -
row: present only on write policies; the candidate document oninsert, the pre-write row onupdate/delete. -
ctx: the full procedure context the middleware closed over.
:::caution[No policy reaches a live shape unless the shape names it]
A live shape runs no procedure, so no .use(rls(...)) middleware fires for
it and none of the policies on this page apply to it by default. A shape declares
its own guards with
defineShape({ use }).
- A shape that names no guard is filtered by its
wherealone. Under.rls("required")it replicates nothing (fail-closed); otherwise codegen refuses to boot the app when the shape's table is governed on read, naming the shape and the table.use: []says the omission is deliberate. - A middleware's roles do not reach a shape even when it does name the
guard. Only the identity claim is read there:
ctx.auth.roleswritten by a middleware reaches queries, mutations and actions, but nothing runs to write it on the shape path, so the role list is empty. A policy that restricts on a role (!auth.roles.includes("restricted")) never takes its restricting branch, and rows replicate over a subscription the equivalent query withholds.
Derive roles at the identity, where both paths see them.
createAccessResolver's roles option maps
Cloudflare Access groups onto role labels this way.
:::
Permissions and roles
Rather than enumerate role strings inside every policy, declare a named
permission, grant it through a role, register the roles with the
middleware, and check it with auth.can(...):
import { definePermission, defineRole, definePolicy, definePolicies, rls } from "lunorash/server";
import { mutation } from "./_generated/server";
const deletePosts = definePermission("posts:delete");
const admin = defineRole("admin", { permissions: [deletePosts] });
const canDelete = definePolicy({
table: "posts",
on: "delete",
when: ({ auth }) => auth.can(deletePosts),
});
export const removePost = mutation.use(rls(definePolicies([canDelete]), { roles: [admin] })).mutation(async ({ ctx, args }) => ctx.db.delete(args.id));The middleware unions the permissions of every role in auth.roles at request
time. A role not passed in rls(..., { roles }) grants nothing: auth.can
is conservative and fails closed for unknown roles. Permissions and roles are
shared with data masking, which checks the same
auth.can(...) view.
Relation predicates
A policy predicate can cross a relation. Alongside the flat column operators, a
when decision may return a Prisma-style relation predicate (is/isNot
on a to-one relation, some/none/every on a to-many), which the @lunora/do
pre-resolver resolves (a batched semijoin, or a correlated EXISTS when the
related table is co-located on the same shard) before the read runs:
// "you can read a post when its author is in your org"
definePolicy({
table: "posts",
on: "read",
when: ({ ctx }) => ({ author: { is: { orgId: ctx.orgId } } }),
});
// "you can read an org row when it has at least one member that is you"
definePolicy({
table: "orgs",
on: "read",
when: ({ auth }) => ({ members: { some: { userId: auth.userId } } }),
});The child read is itself filtered by the child table's own read policy, so a
relation predicate can never widen visibility past what the caller could read
directly (every therefore means "every child the caller can read matches").
Relation predicates are a read-policy capability. A write policy evaluates a
single in-memory candidate row and has no fetcher, so a relation predicate in an
insert/update/delete decision throws RELATION_PREDICATE_UNSUPPORTED; use
a flat column check (or a read policy) instead. rank/rankPage likewise
reject them: their where only pins a partition, so a relation predicate there
would silently drop. They are also bounded: an overly broad some: {} that
collects more than 5,000 child keys (the maxRelationKeys cap) fails closed
with a descriptive error rather than silently truncating the IN (...) set. A
same-shard EXISTS push-down lifts the cap entirely, since it needs no key
list — so keeping the relation co-located is the way past it.
Typed authoring
Import definePolicy from your generated ./_generated/server (rather than
@lunora/server) to get a relation-aware authoring surface bound to your
schema: table autocompletes to a real table name and the when predicate
type-checks against the table's columns and its declared relations.
import { definePolicy } from "./_generated/server";
// `author` autocompletes; a typo or an unknown relation is a compile error.
definePolicy({ table: "posts", on: "read", when: ({ ctx }) => ({ author: { is: { orgId: ctx.orgId } } }) });It is runtime-identical to @lunora/server's definePolicy. Only the types
narrow, so the rls() chain discovers a policy authored either way the same.
What's covered, what isn't
- Reads are AND-merged. Every
get,findFirst/findMany, and thequery().withIndex().order().collect()/first()/take()/paginate()chain folds the policy predicate in, so a non-matching row is invisible, not an error. On that fluent chain over a sharded table, a policy made of equality checks (eq,ne,in,notIn,isNull) on string, id, number or boolean columns, with a value of the column's own type, goes into the SQL, sotake(n)reads aboutnrows. Any other policy (ranges,contains,NOT, relation predicates, a value whose type differs from the column's), and any policy behindmask(), is checked row by row: bounded reads then fetch rows in growing batches until they havenmatches. A.global()table only offers the search chain (withSearchIndex()); the same equality policies go into its search SQL, except string and id comparisons on MySQL, whose collation may fold case. Any other policy there is checked over the whole relevance window (at most 1,024 matches). Either way the policy is enforced on every returned row. A pushed policy makes a guarded search behave like an unguarded one at the cap:collect()or iteration over more than 1,024 visible matches, ortake(n)withnabove 1,024, throwsBAD_REQUESTinstead of returning a truncated window. - Writes throw on denial.
insert,patch/replace, anddeleteevaluate the candidate/pre-write row and throwFORBIDDEN(HTTP 403) on a mismatch. countis unsupported on a restricted table. It throwsCOUNT_RLS_UNSUPPORTED(HTTP 422), because a count over a row-filtered table would leak the size of rows you can't see.- Nested
withreads inherit the child's read policy. A row hydrated through awithclause is filtered by the related table's ownreadpolicy: the samebaseWherea direct read of that table would get is threaded down everywithlevel (and into_count), sofindMany({ with: { child: true } })can't return child rows the caller couldn't read directly. (Column-level masking is the boundary that still draws at the wrapped facade; see below.) - It's opt-in. Only procedures whose chain includes
.use(rls(...))are guarded. Therls_uncovered_tableadvisor warns when a public procedure reads or writes a table that's RLS-protected elsewhere but skips the middleware, leaving an authorization gap.
RLS over live queries
A subscription re-runs its query server-side on every relevant write, then
pushes the new result down the socket. That re-run evaluates RLS under the
socket's own verified identity: the userId/identity stamped at WebSocket
upgrade, unforgeable and carried by the connection, not a per-request claim. So
an rls()/ctx.auth-scoped live query returns the subscriber's rows over the
live channel exactly as it does on the initial HTTP fetch.
A relation-predicate policy keeps working live because the semijoin's child
fetch stamps a read dependency on the child table: a write to the related
table invalidates the subscription and triggers the re-run, so
{ author: { is: … } } refreshes when an author changes, not only when a
post does. The same holds on the same-shard EXISTS fast path (the subquery
stamps the child) and for a global (D1) child (a conservative table-level
dependency is stamped, since D1 has no per-row reactive tracker).
Testing policies
Policies are pure functions, so you can assert their behaviour in a plain unit
test, with no Worker, Durable Object, or live socket. expectPolicy(policies)
from @lunora/server/rls/testing evaluates them through the same primitives
the rls() middleware runs at request time, so a green test means the policy
behaves identically in production:
import { definePolicies, definePolicy } from "lunorash/server";
import { expectPolicy } from "lunorash/server/rls/testing";
const policies = definePolicies([
definePolicy({ table: "docs", on: "read", when: ({ auth }) => ({ ownerId: auth.userId }) }),
definePolicy({ table: "docs", on: "insert", when: ({ auth, row }) => row?.ownerId === auth.userId }),
]);
const ada = expectPolicy(policies).as({ userId: "ada" });
ada.can("read", "docs", { ownerId: "ada" }); // true — her row is visible
ada.cannot("read", "docs", { ownerId: "linus" }); // true — filtered out
ada.can("insert", "docs", { ownerId: "ada" }); // true
ada.cannot("insert", "docs", { ownerId: "x" }); // true — write denied.as(identity) binds a caller (userId / identity, both optional; an omitted
identity is the anonymous caller). There is no separate roles field — roles are
derived from the identity's roles claim by the same code the middleware uses,
so a role-branched policy is set up the way production produces it:
.as({ identity: { roles: ["admin"] }, userId: "u1" }). For read, .can(...) answers
"is this row visible?" (the effective read baseWhere matches it); for writes
it answers allow/deny exactly as the middleware would, including default-DENY on
a participating table with no policy for the op. Pass the roles registry via
expectPolicy(policies, { roles }) to resolve auth.can(...), and a ctx to
back any ctx-reading policy. For an update WITH CHECK, pass the post-image as
the fourth argument: can("update", "docs", oldRow, nextRow).
See also
- Data masking: column-level redaction, the read-path companion that shares roles/permissions with RLS.
- Advisors: the
rls_uncovered_tablelint. - Studio: the RLS Policies panel.