Skip to content
DocsconceptsDocumentation

Row-level security

Server-side authorization that decides which rows a procedure can read or write.

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 returnsOn a readOn a write (insert / update / delete)
WhereInputAND-merged into every query on the table; non-matching rows are invisible.Evaluated against the candidate/pre-write row; a mismatch throws FORBIDDEN.
trueNo predicate merged; unrestricted.The row is allowed.
falseThe table matches zero rows.The operation throws FORBIDDEN.
undefinedThis 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 (null when unauthenticated).

  • auth.roles: the request's role list. It is the union of two sources, so a role from either one counts:

    • the roles or role claim on the resolved identity — a string[], or a comma-separated string (the form better-auth's admin() plugin stores a multi-role value in, under the singular role). On the built-in .auth() wiring that claim is forwarded for you; a hand-written resolveIdentity has to include it.
    • ctx.auth.roles set 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 resolveIdentity that forwards a user-editable profile column named role lets the end user grant themselves permissions.

  • 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 on insert, the pre-write row on update/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 }).

  1. A shape that names no guard is filtered by its where alone. 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.
  2. 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.roles written 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 the query().withIndex().order().collect()/first()/take()/paginate() chain folds the policy predicate in, so a non-matching row is invisible, not an error.
  • Writes throw on denial. insert, patch/replace, and delete evaluate the candidate/pre-write row and throw FORBIDDEN (HTTP 403) on a mismatch.
  • count is unsupported on a restricted table. It throws COUNT_RLS_UNSUPPORTED (HTTP 422), because a count over a row-filtered table would leak the size of rows you can't see.
  • Nested with reads inherit the child's read policy. A row hydrated through a with clause is filtered by the related table's own read policy: the same baseWhere a direct read of that table would get is threaded down every with level (and into _count), so findMany({ 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. The rls_uncovered_table advisor 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_table lint.
  • Studio: the RLS Policies panel.