Declarative Permissions
Learn how to write declarative permissions, from simple ownership to relationship-based access, and how to test them.Declarative permissions control who can read, insert, update, and delete rows. You declare them as policies in permissions.ts, and Jazz enforces them server-side. Clients that fail a policy check have their writes rejected and their reads filtered.
If you know Postgres row-level security (RLS), declarative permissions are similar: rules over rows and the signed-in user. In Jazz they are typed TypeScript next to your schema, every operation is denied until a policy grants it, and you test them like the rest of your app.
Jazz decides permissions per table:
- every read, insert, update, and delete requires an explicit grant
- every omitted operation is denied, including on tables with no policy declarations
For example, declaring allowRead without allowInsert, allowUpdate, or
allowDelete makes that table read-only. A table without any grants returns no
rows to permission-scoped reads and rejects writes on the server. Optimistic
local writes can still appear before the server accepts or rejects them.
Authoring workflow
Permissions are authored in TypeScript.
If we have the following schema:
schema.tsconst schema = {projects: s.table({name: s.string(),owner_id: s.uuid(),},{ todos: s.reverse("todos", "project") },),todos: s.table({title: s.string(),done: s.boolean(),parentId: s.uuid().optional(),projectId: s.uuid().optional(),owner_id: s.uuid(),},{parent: s.rel("todos", "parentId"),children: s.reverse("todos", "parent"),project: s.rel("projects", "projectId"),shares: s.reverse("todoShares", "todo"),},),todoShares: s.table({todoId: s.uuid(),user_id: s.uuid(),can_read: s.boolean(),},{ todo: s.rel("todos", "todoId") },),};
We can define permissions in permissions.ts:
permissions.tsimport { schema as s } from "jazz-tools";import { app } from "./schema.js";export default s.definePermissions(app, ({ policy, session }) => {policy.projects.allowRead.always();policy.projects.allowInsert.always();policy.todos.allowRead.where({ owner_id: session.user });policy.todos.allowInsert.where({ owner_id: session.user });policy.todos.allowUpdate.where({ owner_id: session.user });policy.todos.allowDelete.where({ owner_id: session.user });});
Apps that do not need user-scoped filtering must still declare every required operation explicitly, using grants such as policy.todos.allowRead.always() and policy.todos.allowInsert.always().
Basic policies
Simple conditions
Use the policy helpers allowRead, allowInsert, allowUpdate, and allowDelete with .where(...) to restrict access based on column values.
permissions.tss.definePermissions(exampleApp, ({ policy, allOf, session }) => {policy.todos.allowRead.where({ owner_id: session.user });// Users cannot create todos with different ownerspolicy.todos.allowInsert.where({ owner_id: session.user });// Users can update their own todos, but only if not already donepolicy.todos.allowUpdate.whereOld(allOf([{ owner_id: session.user }, { done: false }])).whereNew({ owner_id: session.user });// Users can only delete their own todospolicy.todos.allowDelete.where({ owner_id: session.user });});
.always()
Use .always() when an operation should always be permitted. It is equivalent to .where({}).
permissions.tss.definePermissions(exampleApp, ({ policy }) => {policy.todos.allowRead.always();policy.todos.allowInsert.always();policy.todos.allowUpdate.always();policy.todos.allowDelete.always();});
.never()
Use .never() when an operation should be impossible. It is equivalent to
.where(anyOf([])).
permissions.tss.definePermissions(exampleApp, ({ policy }) => {policy.todos.allowRead.never();policy.todos.allowInsert.never();policy.todos.allowUpdate.never();policy.todos.allowDelete.never();});
Composing policies
Combining conditions (allOf / anyOf)
Combine conditions with allOf (all must match) or anyOf (any can match).
permissions.tss.definePermissions(exampleApp, ({ policy, allOf, anyOf, allowedTo, session }) => {// Users can read a todo if they own it, or if it's not done and they can read its project.policy.todos.allowRead.where(anyOf([{ owner_id: session.user }, allOf([{ done: false }, allowedTo.read("project")])]),);});
Plain objects in a condition are row predicates. This includes tables with columns such as type,
column, or operation that also appear in Jazz's policy representation. Results from helpers such
as allowedTo.*, allOf, and anyOf remain directly composable with those row
predicates.
An object such as { type: "True" } is row data only when the table schema declares a type column.
On a table without that column, Jazz rejects the unbranded policy-shaped value rather than silently
interpreting it as either row data or policy IR. For advanced cases that need manually-authored
policy IR, use raw(...) to opt in explicitly:
definePermissions(app, ({ policy, raw }) => {policy.documents.allowRead.where(raw({ type: "True" }));});
Session claims
When your backend's authenticate returns claims, session.where(...) lets you check them directly in permissions without mapping them onto row columns first.
permissions.tss.definePermissions(exampleApp, ({ policy, anyOf, session }) => {policy.todos.allowRead.where(anyOf([{ owner_id: session.user }, session.where({ "claims.role": "manager" })]),);});
A claim can also be the value a row is compared with, for example rows of the user's organization:
policy.projects.allowRead.where({ orgId: session.claims["org"] });
Inherited access (allowedTo.*)
A row can inherit its access from a related row. Use
allowedTo.read/insert/update/delete(relationName) to express that inheritance.
The argument is an explicit relationship name declared on the protected table,
using the same names as hopTo. For writer: s.rel("users", "authorId"), use
allowedTo.update("writer"); the stored column authorId is not accepted here.
Reverse names work with the same helpers. Given
authored: s.reverse("posts", "writer") on users,
allowedTo.delete("authored") grants deletion only when at least one referencing
post permits deletion for the current identity. Readability or mere existence
of a referencing post does not grant deletion. No matching reference means no
grant; nullable references contribute no match, and UUID arrays use membership.
The explicit source form allowedTo.deleteReferencing(policy.posts, "writer")
uses the named forward relationship on posts that targets the protected table.
Relationship-name resolution preserves existing operation-policy semantics and
each update rule's old/new row checks. The global helper's TypeScript name check covers the app's declared
relationships; permission compilation also checks the particular rule's table,
and the source direction and target for *Referencing.
permissions.tss.definePermissions(exampleApp, ({ policy, anyOf, allOf, allowedTo }) => {// Users can read a todo if it's not done, or if they can read its project.policy.todos.allowRead.where(anyOf([{ done: false }, allowedTo.read("project")]));// Users can update a todo if they can update its project and it's not done.policy.todos.allowUpdate.whereOld(allOf([allowedTo.update("project"), { done: false }])).whereNew(allowedTo.update("project"));});
Share-based access
Sometimes access isn't determined by ownership or a parent relationship, but by a separate "shares" table. Use policy.<table>.exists.where(...) to check whether a matching row exists in another table.
permissions.tss.definePermissions(exampleApp, ({ policy, anyOf, session }) => {// Users can read a todo if they own it, or if someone shared it with them.policy.todos.allowRead.where((todo) =>anyOf([{ owner_id: session.user },policy.todoShares.exists.where({todoId: todo.id,user_id: session.user,can_read: true,}),]),);});
The callback form (todo) => ... gives you access to the current row, so you can correlate it with rows in other tables.
A column you correlate on must be declared as a relation with s.rel in your schema. Correlating on
a plain UUID column fails validation with JoinNotRefCompatible.
To match the userId your authenticate returned (for example an assignee text column holding
your auth's user ids), compare with session.identity.subject: session.user is the Jazz account,
not your user id.
Inside a transaction, exists also sees the transaction's own writes, so a row can be written in
the same transaction as the row that grants it access, such as a task inserted together with its
project. A row that already existed still counts if the same transaction deletes it, so you can,
for example, admit a member and remove their join request together. A row the transaction both
creates and deletes never counts, and neither do writes from other transactions that the server
has not accepted yet. Rows a transaction writes cannot grant each other access in a cycle:
each one has to be allowed by rows that already exist or that the transaction writes and are
themselves allowed. Every write must also still be allowed by the state the whole transaction
leaves behind, so demoting your own membership from admin in the same transaction as creating a
document that requires admin is rejected. The order in which you make the writes doesn't matter.
Importing many rows whose permissions rest on rows that already exist (for example, thousands of
folders under existing parents) is checked once per row. A transaction whose rows mostly depend on
each other (for example, thousands of folders nested inside one another in the same transaction)
is rejected with the write_rejected code and a reason ending in "is not supported yet" once its
checks would have to read more than 262,144 of its own rows. Split such an import into smaller
transactions.
If a table references itself (e.g. a comment that can have sub-comments), you can inherit access recursively up to a fixed depth.
permissions.tss.definePermissions(exampleApp, ({ policy, allowedTo }) => {// Users can read a todo if they can read its parent (follows the chain upward).policy.todos.allowRead.where(allowedTo.read("parent"));// Users can update a todo if they can update its parent, up to 5 levels deep.policy.todos.allowUpdate.whereOld(allowedTo.update("parent", { maxDepth: 5 })).whereNew(allowedTo.update("parent", { maxDepth: 5 }));});
maxDepth must be a non-negative integer. A value of 0 performs no inheritance
hop, so the inheritance condition cannot grant access; 1 checks only the
directly referenced row. These bounds apply to forward relationships. Reverse
inheritance currently rejects maxDepth rather than ignoring it; cyclic reverse
policy expansion is rejected during schema validation.
Update policies: old row vs new row
Update policies can check both the row before the update (.whereOld(...)) and the row
after the update (.whereNew(...)). This is useful when you need to verify that the user had
permission to modify the original row and that the result is also valid.
permissions.tss.definePermissions(exampleApp, ({ policy, session }) => {// User can only update their own rows, and the result must still be owned by thempolicy.todos.allowUpdate.whereOld({ owner_id: session.user }).whereNew({ owner_id: session.user });});
If you only use .whereOld(...), the same condition is applied to both the old and new row. The
same applies if you only use .whereNew(...). Use both when the old-row and new-row checks differ.
exists runs before a write is applied, so it can be used to prevent some columns from being updated.
The row being written is always seen as it was before the write, even inside a transaction.
permissions.tss.definePermissions(exampleApp, ({ policy, allOf, session }) => {policy.todos.allowUpdate.whereOld({ owner_id: session.user }).whereNew((updatedTodo) =>allOf([{ owner_id: session.user },// `parentId` and `projectId` cannot be updated.policy.todos.exists.where({id: updatedTodo.id,parentId: updatedTodo.parentId,projectId: updatedTodo.projectId,}),]),);});
Policy enforcement and request context
Every policy is evaluated against a session. On the frontend, this is the authenticated user's session. In a backend handler, create a scoped session per request so queries run with the right identity.
handler.tsexport async function listTodosForRequester(request: Request): Promise<Response> {try {const requester = await jazz.forReqUser(request);const rows = await requester.all(schemaApp.todos.where({ done: true }));return Response.json(rows);} catch {return queryError();}}
handler.ts// Written against `Jazz` only, so the same code runs in a browser, in a test// against an in-memory database, or here as the requesting user.export const listDoneTodos = Effect.gen(function* () {const jazz = yield* Jazz;return yield* jazz.all(schemaApp.todos.where({ done: true }));});// A standalone route: mount it with the quickstart routes below if you want it served.export const listDoneTodosRoute = HttpRouter.add("GET","/api/todos/done",listDoneTodos.pipe(Effect.flatMap((rows) => HttpServerResponse.json(rows)),JazzBackend.forCurrentReqUser(),Effect.catchTag("JazzError", () =>HttpServerResponse.json({ error: "Failed to query todos" }, { status: 500 }),),),);
See Main Backend for setting up your backend.
Check permissions before an action
Use db.canInsert(), db.canRead(), db.canUpdate(), or db.canDelete() when your app needs to
check an action before showing it to the user. Each method returns "allowed", "denied", or
"unknown".
These results are advice, not permission enforcement. A result can become stale, and "unknown"
means Jazz could not give a definite answer. The authority still makes the final decision when the
read or write reaches it.
app.tsexport async function canReadTodo(db: Db, todoId: string) {return db.canRead(app.todos, todoId);}export async function readTodosWithDeletePermission(db: Db) {const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));const advice = await Promise.all(todos.map((todo) => db.canDelete(app.todos, todo.id)));return todos.filter((_, index) => advice[index] === "allowed");}export async function readEditableTodos(db: Db) {const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));const advice = await Promise.all(todos.map((todo) => db.canUpdate(app.todos, todo.id, { title: todo.title })),);return todos.filter((_, index) => advice[index] === "allowed");}export async function canCreateTodo(db: Db, title: string) {return db.canInsert(app.todos, { title, done: false });}
app.tsexport const canReadTodo = (todoId: string) => Jazz.use((jazz) => jazz.canRead(app.todos, todoId));export const readTodosWithDeletePermission = Effect.gen(function* () {const jazz = yield* Jazz;const todos = yield* jazz.all(app.todos.select("id", "title").orderBy("title", "asc"));return yield* Effect.filter(todos,(todo) => Effect.map(jazz.canDelete(app.todos, todo.id), (advice) => advice === "allowed"),{ concurrency: "unbounded" },);});export const readEditableTodos = Effect.gen(function* () {const jazz = yield* Jazz;const todos = yield* jazz.all(app.todos.select("id", "title").orderBy("title", "asc"));return yield* Effect.filter(todos,(todo) =>Effect.map(jazz.canUpdate(app.todos, todo.id, { title: todo.title }),(advice) => advice === "allowed",),{ concurrency: "unbounded" },);});export const canCreateTodo = (title: string) =>Jazz.use((jazz) => jazz.canInsert(app.todos, { title, done: false }));
Magic columns
Jazz exposes a small set of system-provided magic columns at query time. They do not exist in your
schema, and they are omitted from select("*"), so opt in explicitly when you want them.
Edit metadata columns
Jazz also tracks row authorship and timestamps automatically:
$createdBy— the Jazz principal that created the row$createdAt— when the row was first created$updatedBy— the Jazz principal that last updated the row$updatedAt— when the row was last updated
Jazz always tracks this metadata; select the columns explicitly to include them in query results. Backend writes that are not attributed to a user use the reserved system account and an internal system identity. See System-authored rows for their account, issuer, and originating-node subject.
Authorship-based policies
You can use these edit metadata magic columns directly in permissions.ts. This is useful for simple
"creator can read/edit/delete their own rows" policies without adding explicit owner_id columns.
Compare $createdBy with session.user to scope a rule to the row's creator. session.user is
the account, so the rule matches rows written through any identity linked to it, including
alpha.59 rows whose author was imported with an account. To match only the exact login that wrote the row, compare
$createdBy.identity with session.identity (its { issuer, subject }). session.claims
holds the claims your authenticate returned, not a raw JWT payload; with
fromJWT(), session.identity is
{ issuer: "jazz:backend", subject }, not your provider's iss. Claims never replace the
canonical author identity.
permissions.tss.definePermissions(exampleApp, ({ policy, session }) => {// Only the row's creator can read, create, update, or delete it.policy.todos.allowRead.where({ $createdBy: session.user });policy.todos.allowInsert.where({ $createdBy: session.user });policy.todos.allowUpdate.where({ $createdBy: session.user });policy.todos.allowDelete.where({ $createdBy: session.user });});
The creator condition is a plain row predicate, so it composes inside larger rules:
permissions.tss.definePermissions(exampleApp, ({ policy, anyOf, session }) => {// The creator condition composes with other rules like any row predicate.policy.todos.allowRead.where(anyOf([{ $createdBy: session.user }, { done: true }]));});
For more dynamic sharing or ownership models, prefer explicit tables and relations rather than encoding extra meaning into authorship alone.
Changing permissions in production
Each deployment of your backend serves the permissions it was built with, and every client is checked by the permissions of the deployment it connected through. When a deployment with new permissions starts, every connected client reconnects, including clients of your other deployments (previews too), and each is checked again by the permissions of the deployment it now reaches. Deploying permissions that have been deployed before, such as a rollback, reconnects no one.
To retire permissions that grant too much, deploy fixed ones and delete every deployment that still serves the old ones, including older production and preview deployments that are still reachable. Until it is gone, any client that connects through such a deployment is checked by its permissions.
Testing permissions
Jazz provides utilities to test permissions in isolation without testing your whole app's logic. See Testing permissions.
Troubleshooting denied access
If a write appears locally but does not persist, wait for
server durability and handle the rejection.
The local-first client rejects with PersistedWriteRejectedError, code permission_denied. The
thin client rejects with a ThinRemoteError whose code is WriteRejected and whose message
starts with the reason, such as authorization_denied: …. Use
db.onMutationError(...) as a fallback for rejections without an active wait;
see Handling mutation errors.
A backend connected to Jazz Cloud doesn't refuse a session's write up front either: the cloud
decides it, and a denial arrives through wait(...) with the same permission_denied code. A store
with no authority above it, such as a client with serverUrl: null or a backend on its own
storage, refuses some denied writes at once (the write call throws), and accepts others, such as a
denied update of a row it holds, then rejects them through wait(...). So in backend code, await every write
and handle both:
try {await db.update(app.deals, dealId, { stage: "won" }).wait({ tier: "global" });} catch (error) {// permission_denied, thrown by update() or by wait()}
In Node, a write's rejected wait() that nothing awaits or catches is an unhandled rejection, which
ends the process.
An empty query result can also mean that read permissions filtered out rows.
As the app developer, check your own configuration:
- Run
npx jazz-tools validateand check that every required table and operation has an explicit grant. For updates and upserts that update an existing row, also check the required read permission. - Check that the grants match the authenticated session and the relevant row values, including both old and new values for updates. Test these cases with permission tests.
- Deploy your main backend with the intended policies
and verify the app ID and deployed configuration. Editing
permissions.tslocally does not update the server's policies.
Treat permission_denied as a generic rejection: it does not tell the caller
whether a policy is missing, a predicate failed, or a protected row exists.
Keep these configuration checks in developer tooling and tests; do not expose
protected policies, row values, or row-existence details in client-facing errors.