Permissions
Permissions are expressed using ZQL and run automatically with every read and write.
Define Permissions
Permissions are defined in schema.ts
using the definePermissions
function.
Here's an example of limiting deletes to only the creator of an issue:
// The decoded value of your JWT.
type AuthData = {
// The logged-in user.
sub: string;
};
export const permissions = definePermissions<AuthData, Schema>(schema, () => {
const allowIfIssueCreator = (
authData: AuthData,
{cmp}: ExpressionBuilder<IssueSchema>,
) => cmp('creatorID', '=', authData.sub);
return {
issue: {
row: {
delete: [allowIfIssueCreator],
},
},
};
});
definePermission
returns a policy object for each table in the schema. Each policy defines a ruleset for the operations that are possible on a table: select
, insert
, update
, and delete
.
Access is Denied by Default
If you don't specify any rules for an operation, it is denied by default. This is an important safety feature that helps ensure data isn't accidentally exposed.
To enable full access to an action (i.e., during development) use the ANYONE_CAN
helper:
import { ANYONE_CAN } from '@rocicorp/zero';
const permissions = definePermissions<AuthData, Schema>(schema, () => {
return {
issue: {
row: {
select: ANYONE_CAN,
// Other operations are denied by default.
},
},
// Other tables are denied by default.
};
});
To do this for all actions, use ANYONE_CAN_DO_ANYTHING
:
import { ANYONE_CAN_DO_ANYTHING } from '@rocicorp/zero';
const permissions = definePermissions<AuthData, Schema>(schema, () => {
return {
// All operations on issue are allowed to all users.
issue: ANYONE_CAN_DO_ANYTHING,
// Other tables are denied by default.
};
});
Permission Evaluation
Zero permissions are "compiled" into a JSON-based format at build-time. This file is stored in the zero.permissions
table of your upstream database. Like other tables, it replicates live down to zero-cache
. zero-cache
then parses this file, and applies the encoded rules to every read and write operation.
Permission Deployment
During development, permissions are compiled and uploaded to your database completely automatically as part of the zero-cache-dev
script.
For production, you need to call npx zero-deploy-permissions
within your app to update the permissions in the production database whenever they change. You would typically do this as part of your normal schema migration or CI process. For example, the SST deployment script for zbugs looks like this:
new command.local.Command(
"zero-deploy-permissions",
{
dir: join(process.cwd(), "../../packages/zero/"),
create: `npx zero-deploy-permissions --schema-path ../../apps/zbugs/schema.ts`,
// Run the Command on every deploy ...
triggers: [Date.now()],
},
// after the view-syncer is deployed.
{ dependsOn: viewSyncer },
);
Rules
Each operation on a policy has a ruleset containing zero or more rules.
A rule is just a TypeScript function that receives the logged in user's AuthData
and generates a ZQL where expression. At least one rule in a ruleset must return a row for the operation to be allowed.
Select Permissions
You can limit the data a user can read by specifying a select
ruleset.
Select permissions act like filters. If a user does not have permission to read a row, it will be filtered out of the result set. It will not generate an error.
For example, imagine a select permission that restricts reads to only issues created by the user:
const definePermissions<AuthData, Schema>(schema, () => {
const allowIfIssueCreator = (
authData: AuthData,
{cmp}: ExpressionBuilder<typeof issueSchema>,
) => cmp('creatorID', '=', authData.sub);
return {
issue: {
row: {
select: [allowIfIssueCreator],
},
},
};
});
If the issue table has two rows, one created by the user and one by someone else, the user will only see the row they created in any queries.
Insert Permissions
You can limit what rows can be inserted and by whom by specifying an insert
ruleset.
Insert rules are evaluated after the entity is inserted. So if they query the database, they will see the inserted row present. If any rule in the insert ruleset returns a row, the insert is allowed.
Here's an example of an insert rule that disallows inserting users that have the role 'admin'.
const definePermissions<AuthData, Schema>(schema, () => {
const allowIfNonAdmin = (
authData: AuthData,
{cmp}: ExpressionBuilder<typeof userSchema>,
) => cmp('role', '!=', 'admin');
return {
user: {
row: {
insert: [allowIfNonAdmin],
},
},
};
});
Update Permissions
There are two types of update rulesets: preMutation
and postMutation
.
preMutation
rules see the version of a row before the mutation is applied. This is useful for things like checking whether a user owns an entity before editing it.
postMutation
rules see the version of a row after the mutation is applied. This is useful for things like ensuring a user can only mark themselves as the creator of an entity and not other users.
Both the preMutation
and postMutation
rulesets must allow an update. But note that as with other rulesets, if either preMutation
or postMutation
ruleset is undefined, it defaults to allow.
So this allows an edit if the user is the creator of the issue:
const definePermissions<AuthData, Schema>(schema, () => {
const allowIfIssueCreator = (
authData: AuthData,
{cmp}: ExpressionBuilder<typeof issueSchema>,
) => cmp('creatorID', '=', authData.sub);
return {
issue: {
row: {
update: {
preMutation: [allowIfIssueCreator],
// postMutation defaults to _allow_.
},
},
},
};
});
And this allows an edit if the the logged in user is the creator after the edit:
const definePermissions<AuthData, Schema>(schema, () => {
const allowIfIssueCreator = (
authData: AuthData,
{cmp}: ExpressionBuilder<typeof issueSchema>,
) => cmp('creatorID', '=', authData.sub);
return {
issue: {
row: {
update: {
// preMutation defaults to _allow_.
postMutation: [allowIfIssueCreator],
},
},
},
};
});
Delete Permissions
Delete permissions work in the same way as insert
positions except they run before the delete is applied. So if a delete rule queries the database, it will see that the deleted row is present. If any rule in the ruleset returns a row, the delete is allowed.
Examples
See hello-zero for a simple example of write auth and zbugs for a much more involved one.