Skip to main content
The definePermissions API is deprecated. Use server-side mutators and custom queries for authorization instead.
Zero’s legacy permission system provides row-level and cell-level security for queries and mutations. While this API is deprecated, it’s still supported for existing applications.

Why Permissions are Deprecated

The original permissions API had several limitations:
  1. Complex authorization logic was difficult to express
  2. Performance could be unpredictable for complex rules
  3. Testing required running the full sync stack
  4. Limited flexibility for business logic
The recommended approach is: This gives you full control, better testability, and clearer code.

Legacy Permissions API

For applications still using the legacy API, here’s how it works.

Overview

Permissions are defined as functions that return conditions:
import {definePermissions, ANYONE_CAN, NOBODY_CAN} from '@rocicorp/zero';

export const permissions = await definePermissions(schema, () => ({
  issue: {
    row: {
      select: [
        // Allow if user is issue creator OR issue is public
        (authData, {cmp, or}) => or(
          cmp('creatorID', '=', authData.sub),
          cmp('visibility', '=', 'public'),
        ),
      ],
    },
  },
}));

Permission Structure

export type PermissionsConfig<TAuthDataShape, TSchema extends Schema> = {
  [TableName in keyof TSchema['tables']]?: {
    row?: {
      select?: PermissionRule[];
      insert?: PermissionRule[];  // Deprecated
      update?: {                   // Deprecated
        preMutation?: PermissionRule[];
        postMutation?: PermissionRule[];
      };
      delete?: PermissionRule[];  // Deprecated
    };
    cell?: {
      [ColumnName]?: {
        select?: PermissionRule[];
        // insert, update, delete also supported
      };
    };
  };
};

Permission Rules

Rules are functions that return conditions:
export type PermissionRule<TAuthDataShape, TSchema, TTable> = (
  authData: TAuthDataShape,
  eb: ExpressionBuilder<TTable, TSchema>,
) => Condition;
Parameters:
  • authData: Authentication data from your auth provider (e.g., JWT claims)
  • eb: Expression builder for constructing conditions

Expression Builder

The expression builder provides methods for building conditions:
cmp
function
Compare a field to a value:
cmp('status', '=', 'open')
cmp('priority', '>', 5)
cmp('creatorID', '=', authData.sub)
and
function
Combine conditions with AND:
and(
  cmp('status', '=', 'open'),
  cmp('priority', '>', 3)
)
or
function
Combine conditions with OR:
or(
  cmp('creatorID', '=', authData.sub),
  cmp('visibility', '=', 'public')
)
exists
function
Check if a related row exists:
exists('comments', (qb) => qb.where('creatorID', authData.sub))

Row-Level Permissions

Row-level permissions filter which rows a user can access:
const permissions = await definePermissions(schema, () => ({
  issue: {
    row: {
      select: [
        // User can see issues in projects they're a member of
        (authData, {exists}) => 
          exists('project', (qb) => 
            qb.whereExists('members', (memberQb) =>
              memberQb.where('userID', authData.sub)
            )
          ),
      ],
    },
  },
  
  comment: {
    row: {
      select: [
        // User can see comments on issues they can see
        (authData, {exists}) =>
          exists('issue'),  // Inherits issue permissions
      ],
    },
  },
}));

Cell-Level Permissions

Cell-level permissions hide specific columns:
const permissions = await definePermissions(schema, () => ({
  user: {
    row: {
      select: ANYONE_CAN,  // Everyone can see users
    },
    cell: {
      email: {
        select: [
          // Only the user themselves can see their email
          (authData, {cmp}) => cmp('id', '=', authData.sub),
        ],
      },
      phoneNumber: {
        select: [
          // Only admins can see phone numbers
          (authData) => authData.role === 'admin' ? {type: 'true'} : {type: 'false'},
        ],
      },
    },
  },
}));

Auth Data Reference

Reference auth data in conditions:
const permissions = await definePermissions(schema, () => ({
  issue: {
    row: {
      select: [
        (authData, {cmp}) => 
          cmp('creatorID', '=', authData.sub),  // Compare to JWT sub claim
      ],
    },
  },
}));
From the implementation:
// From packages/zero-schema/src/permissions.ts
export const authDataRef = baseTracker('authData');

// Compiled to:
{
  type: 'static',
  anchor: 'authData',
  field: 'sub',  // or ['nested', 'field']
}

Multiple Rules (OR Logic)

Permission rules in an array are combined with OR:
const permissions = await definePermissions(schema, () => ({
  issue: {
    row: {
      select: [
        // Rule 1: User is creator
        (authData, {cmp}) => cmp('creatorID', '=', authData.sub),
        
        // Rule 2: User is assignee
        (authData, {cmp}) => cmp('assigneeID', '=', authData.sub),
        
        // Rule 3: Issue is public
        (authData, {cmp}) => cmp('visibility', '=', 'public'),
        
        // Results in: rule1 OR rule2 OR rule3
      ],
    },
  },
}));
All rules in the array must pass for at least one to allow access. This is why it’s an array: each element is an independent “allow” rule.

Pre-defined Rules

import {ANYONE_CAN} from '@rocicorp/zero';

const permissions = await definePermissions(schema, () => ({
  project: {
    row: {
      select: ANYONE_CAN,  // No restrictions
    },
  },
}));
Allows all users to access the resource.

Relationship-Based Permissions

Use relationships to check access:
const permissions = await definePermissions(schema, () => ({
  comment: {
    row: {
      select: [
        // Can see comment if you can see its issue
        (authData, {exists}) =>
          exists('issue', (issueQb) =>
            issueQb.where('visibility', 'public')
          ),
      ],
    },
  },
  
  issue: {
    row: {
      select: [
        // Can see issue if you're a project member
        (authData, {exists}) =>
          exists('project', (projectQb) =>
            projectQb.whereExists('members', (memberQb) =>
              memberQb.where('userID', authData.sub)
            )
          ),
      ],
    },
  },
}));

Compilation and Enforcement

Permissions are compiled to SQL conditions:
// From packages/zero-schema/src/permissions.ts
function compileRules(
  clientToServer: NameMapper,
  tableName: string,
  rules: PermissionRule[],
  expressionBuilder: ExpressionBuilder,
): ['allow', Condition][] {
  return rules.map(rule => {
    const cond = rule(authDataRef, expressionBuilder);
    return ['allow', mapCondition(cond, tableName, clientToServer)];
  });
}
Example compiled permission:
{
  tables: {
    issue: {
      row: {
        select: [
          ['allow', {
            type: 'simple',
            op: '=',
            left: {type: 'column', name: 'creatorID'},
            right: {type: 'static', anchor: 'authData', field: 'sub'},
          }],
        ],
      },
    },
  },
}
The compiled conditions are:
  1. Serialized and sent to zero-cache
  2. Applied as SQL WHERE clauses
  3. Evaluated for every query the user makes

Migration to Custom Mutators/Queries

To migrate from permissions to the new approach:

Before (Permissions)

const permissions = await definePermissions(schema, () => ({
  issue: {
    row: {
      select: [
        (authData, {cmp}) => cmp('creatorID', '=', authData.sub),
      ],
      insert: [
        (authData, {cmp}) => cmp('creatorID', '=', authData.sub),
      ],
    },
  },
}));

After (Custom Queries and Mutators)

import {defineQuery} from '@rocicorp/zero';

export const myIssues = defineQuery(async (tx, userID: string) => {
  return tx.query.issue
    .where('creatorID', userID)
    .run();
});

// Client usage
const issues = useQuery(zero.queries.myIssues, [currentUser.id]);
Benefits of the new approach:
  • ✅ Explicit authorization logic
  • ✅ Easy to test in isolation
  • ✅ Full TypeScript support
  • ✅ Better error messages
  • ✅ Can implement complex business logic
  • ✅ Standard transaction semantics

Testing Permissions (Legacy)

If you’re still using permissions, test them with the full stack:
import {test, expect} from 'vitest';
import {Zero} from '@rocicorp/zero';

test('user can only see their own issues', async () => {
  const zero = new Zero({
    server: TEST_SERVER_URL,
    schema,
    userID: 'user-1',
  });
  
  const issues = await zero.query.issue.run();
  
  // All issues should belong to user-1
  expect(issues.every(i => i.creatorID === 'user-1')).toBe(true);
});

Performance Considerations

Complex permission rules can impact query performance. Each rule adds to the SQL WHERE clause.
Tips for performance:
  1. Keep rules simple: Complex joins in permissions can be slow
  2. Index foreign keys: Ensure columns referenced in permissions are indexed
  3. Avoid deep relationship checks: Multiple exists() calls can be expensive
  4. Profile your queries: Use EXPLAIN ANALYZE on the generated SQL
Example of a slow permission:
// ❌ Slow: Multiple relationship hops
select: [
  (authData, {exists}) =>
    exists('issue', (issueQb) =>
      issueQb.whereExists('project', (projectQb) =>
        projectQb.whereExists('team', (teamQb) =>
          teamQb.whereExists('members', (memberQb) =>
            memberQb.where('userID', authData.sub)
          )
        )
      )
    ),
]

// ✅ Better: Use a custom query with optimized SQL

Common Patterns

Owner-Only Access

const permissions = await definePermissions(schema, () => ({
  userSettings: {
    row: {
      select: [
        (authData, {cmp}) => cmp('userID', '=', authData.sub),
      ],
    },
  },
}));

Role-Based Access

const permissions = await definePermissions(schema, () => ({
  adminLog: {
    row: {
      select: [
        (authData) => 
          authData.role === 'admin' 
            ? {type: 'true'} 
            : {type: 'false'},
      ],
    },
  },
}));

Team-Based Access

const permissions = await definePermissions(schema, () => ({
  document: {
    row: {
      select: [
        (authData, {exists}) =>
          exists('team', (teamQb) =>
            teamQb.whereExists('members', (memberQb) =>
              memberQb.where('userID', authData.sub)
            )
          ),
      ],
    },
  },
}));

Public + Owner Access

const permissions = await definePermissions(schema, () => ({
  post: {
    row: {
      select: [
        (authData, {or, cmp}) => or(
          cmp('visibility', '=', 'public'),
          cmp('authorID', '=', authData.sub)
        ),
      ],
    },
  },
}));

Debugging Permissions

To see the SQL generated from permissions:
  1. Enable query logging in zero-cache
  2. Check the WHERE clause in the SQL logs
  3. Use EXPLAIN to understand query execution
// Compiled permission becomes SQL:
SELECT * FROM issue 
WHERE creatorID = $1  -- $1 is bound to authData.sub

Next Steps

Custom Mutators

Implement authorization with custom mutators

Custom Queries

Filter data server-side with custom queries

Authentication

Set up authentication for your app

Schema

Define your data model

Build docs developers (and LLMs) love