Auth & Permissions

Permissions

Jazz's approach to row-level security using relationship-based access controls, and how to build policies of varying complexity.

Permissions control who can read, insert, update, and delete rows. Jazz enforces them server-side using row-level policies defined in permissions.ts. Clients that fail a policy check have their writes rejected and their reads filtered.

Locally, Jazz distinguishes between two runtime states:

  • no compiled policy bundle loaded: local session-scoped reads and writes stay permissive
  • compiled policy bundle loaded: every read, insert, update, and delete needs an explicit grant, or it is denied

Authoring workflow

Permissions are authored in TypeScript.

If we have the following schema:

schema.ts
const schema = {
  projects: s.table({
    name: s.string(),
    owner_id: s.string(),
  }),
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    parentId: s.ref("todos").optional(),
    projectId: s.ref("projects").optional(),
    owner_id: s.string(),
  }),
  todoShares: s.table({
    todoId: s.ref("todos"),
    user_id: s.string(),
    can_read: s.boolean(),
  }),
};

We can define permissions in permissions.ts:

permissions.ts
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";

export default s.definePermissions(app, ({ policy, session }) => {
  // Users can only read, create, update, and delete their own todos.
  policy.todos.allowRead.where({ owner_id: session.user_id });
  policy.todos.allowInsert.where({ owner_id: session.user_id });
  policy.todos.allowUpdate.where({ owner_id: session.user_id });
  policy.todos.allowDelete.where({ owner_id: session.user_id });
});

Write your permissions policies in permissions.ts next to schema.ts. By default Jazz looks for both files at your project root — except in SvelteKit projects, where they should live in src/lib/ (the standard location for shared app code).

Run pnpm dlx jazz-tools@alpha validate before publishing to surface warnings about tables with no explicit permission policy. You do not need to write a schema migration to update permissions policies. Push the updated policies by running pnpm dlx jazz-tools@alpha deploy <appId>.

Apps that do not need user-scoped filtering should still declare explicit grants (policy.todos.allowRead.always() or policy.todos.allowRead.where({})) once a compiled bundle is loaded.

Basic policies

Simple conditions

Use the policy helpers allowRead, allowInsert, allowUpdate, and allowDelete with .where(...) to restrict access based on column values.

permissions.ts
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
  policy.todos.allowRead.where({ owner_id: session.user_id });
  // Users cannot create todos with different owners
  policy.todos.allowInsert.where({ owner_id: session.user_id });
  // Users can update their own todos, but only if not already done
  policy.todos.allowUpdate
    .whereOld(allOf([{ owner_id: session.user_id }, { done: false }]))
    .whereNew({ owner_id: session.user_id });
  // Users can only delete their own todos
  policy.todos.allowDelete.where({ owner_id: session.user_id });
});

.always()

Use .always() when an operation should always be permitted. It is equivalent to .where({}).

permissions.ts
s.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.ts
s.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.ts
s.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_id }, allOf([{ done: false }, allowedTo.read("project")])]),
  );
});

JWT session claims

When external auth JWTs carry claims, session.where(...) lets you check them directly in permissions without mapping them onto row columns first.

permissions.ts
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user_id }, session.where({ "claims.role": "manager" })]),
  );
});

Inherited access (allowedTo.*)

A row can inherit its access from a related row. Use allowedTo.read/insert/update/delete(...) to express that inheritance.

permissions.ts
s.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.ts
s.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_id },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user_id,
        can_read: true,
      }),
    ]),
  );
});

The callback form (todo) => ... gives you access to the current row, so you can correlate it with rows in other tables.

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.ts
s.definePermissions(exampleApp, ({ policy, session }) => {
  // User can only update their own rows, and the result must still be owned by them
  policy.todos.allowUpdate
    .whereOld({ owner_id: session.user_id })
    .whereNew({ owner_id: session.user_id });
});

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.

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.ts
export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
  try {
    const requester = await context.forRequest(req, schemaApp);
    const rows = await requester.all(schemaApp.todos.where({ done: true }));
    res.json(rows);
  } catch {
    sendQueryError(res);
  }
}

See Server Setup for backend context setup details.

Structural-only client runtimes stay permissive locally so offline reads and writes keep working. Once a compiled bundle is loaded, Jazz enforces explicit grants locally too. Sync servers still reject violating writes, and server-scoped reads are filtered before data is sent to the client.

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.

Permission introspection columns

  • $canRead — whether the current session can read the row. Since read policies already gate which rows appear, this will usually be true.
  • $canEdit — whether the current session passes the row's update policy.
  • $canDelete — whether the current session passes the row's delete policy.
  • Without a session, all three return null.
export async function readTodoPermissionIntrospection(db: Db) {
  return db.all(
    app.todos.select("title", "$canRead", "$canEdit", "$canDelete").orderBy("title", "asc"),
  );
}

export async function readTodosWithDeletePermission(db: Db) {
  return db.all(app.todos.select("*", "$canDelete").orderBy("title", "asc"));
}

export async function readEditableTodos(db: Db) {
  return db.all(app.todos.where({ $canEdit: true }).select("title", "$canEdit"));
}

export async function readDeletableTodos(db: Db) {
  return db.all(app.todos.where({ $canDelete: true }).select("title", "$canDelete"));
}
pub async fn read_todos_with_permissions(client: &JazzClient) -> jazz_tools::Result<usize> {
    let query = QueryBuilder::new("todos")
        .select(&["title", "$canRead", "$canEdit", "$canDelete"])
        .build();

    let rows = client.query(query, None).await?;
    Ok(rows.len())
}

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 are authored as jazz:system.

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.

For the most common case, Jazz also exposes policy.<table>.managedByCreator() and isCreator as shorthand for the same $createdBy === session.user_id condition. You can still write the raw $createdBy comparison yourself whenever you want the explicit form.

permissions.ts
s.definePermissions(exampleApp, ({ policy }) => {
  // Sugar for applying `$createdBy === session.user_id` to read/insert/update/delete.
  policy.todos.managedByCreator();
});

When you want to reuse the same check inside a larger rule, compose isCreator directly:

permissions.ts
s.definePermissions(exampleApp, ({ policy, anyOf, isCreator }) => {
  // The same creator condition can still be composed with other rules.
  policy.todos.allowRead.where(anyOf([isCreator, { done: true }]));
});

For more dynamic sharing or ownership models, prefer explicit tables and relations rather than encoding extra meaning into authorship alone.

On this page