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:
Complex authorization logic was difficult to express
Performance could be unpredictable for complex rules
Testing required running the full sync stack
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:
Compare a field to a value: cmp ( 'status' , '=' , 'open' )
cmp ( 'priority' , '>' , 5 )
cmp ( 'creatorID' , '=' , authData . sub )
Combine conditions with AND: and (
cmp ( 'status' , '=' , 'open' ),
cmp ( 'priority' , '>' , 3 )
)
Combine conditions with OR: or (
cmp ( 'creatorID' , '=' , authData . sub ),
cmp ( 'visibility' , '=' , 'public' )
)
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
ANYONE_CAN
NOBODY_CAN
ANYONE_CAN_DO_ANYTHING
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. import { NOBODY_CAN } from '@rocicorp/zero' ;
const permissions = await definePermissions ( schema , () => ({
internalLog: {
row: {
select: NOBODY_CAN , // Deny all access
},
},
}));
Denies all users access to the resource. import { ANYONE_CAN_DO_ANYTHING } from '@rocicorp/zero' ;
// Deprecated shorthand for all operations
const permissions = await definePermissions ( schema , () => ({
project: ANYONE_CAN_DO_ANYTHING ,
}));
Deprecated : Use specific rules for each operation instead.
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:
Serialized and sent to zero-cache
Applied as SQL WHERE clauses
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)
Custom Query
Custom Mutator
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 ]);
import { defineMutator } from '@rocicorp/zero' ;
export const createIssue = defineMutator ( async ( tx , args : CreateIssueInput ) => {
const { userID , projectID , title } = args ;
// Check permissions explicitly
const project = await tx . query . project
. where ( 'id' , projectID )
. one ()
. run ();
if ( ! project ) {
throw new Error ( 'Project not found' );
}
// Check if user is project member
const member = await tx . query . projectMember
. where ( 'projectID' , projectID )
. where ( 'userID' , userID )
. one ()
. run ();
if ( ! member ) {
throw new Error ( 'Not authorized to create issues in this project' );
}
// Create issue
await tx . issue . insert ({
id: nanoid (),
title ,
projectID ,
creatorID: userID ,
status: 'open' ,
});
});
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 );
});
Complex permission rules can impact query performance. Each rule adds to the SQL WHERE clause.
Tips for performance:
Keep rules simple : Complex joins in permissions can be slow
Index foreign keys : Ensure columns referenced in permissions are indexed
Avoid deep relationship checks : Multiple exists() calls can be expensive
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:
Enable query logging in zero-cache
Check the WHERE clause in the SQL logs
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