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.

😱Danger

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: {
      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 issue is insert. So if they query the database, they will see that the inserted row is 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 with role 'admin'.

const definePermissions<AuthData, Schema>(schema, () => {
  const allowIfNonAdmin = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<typeof userSchema>,
  ) => cmp('role', '!=', 'admin');

  return {
    issue: {
      insert: [allowIfAdmin],
    },
  };
});

Update Permissions

There are two types of update rulesets: preMutation and postMutation.

preMutation rules are evaluated before the mutation is applied. This is useful for things like checking whether a user owns an entity before editing it. postMutation rules are evaluated after the mutation is applied. This is useful for things like restricting the kinds of changes a user is allowed to make.

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: {
      preMutation: [allowIfIssueCreator],
      // postMutation defaults to _allow_.
    },
  };
});

And this allows an edit if the the loggged 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: {
      // preMutation defaults to _allow_.
      postMutation: [allowIfIssueCreator],
    },
  };
});

But this policy will never allow an edit, because the two rulesets can't be true at the same time:

const definePermissions<AuthData, Schema>(schema, () => {
  const allowIfIssueCreator = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<typeof issueSchema>,
  ) => cmp('creatorID', '=', authData.sub);

  const allowIfNotIssueCreator = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<typeof issueSchema>,
  ) => cmp('creatorID', '!=', authData.sub);

return {
    issue: {
      preMutation: [allowIfIssueCreator],
      postMutation: [allowIfNotIssueCreator],
    },
  };
});

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.

Helpers

Zero defines the ANYONE_CAN and NOBODY_CAN helpers to make these common cases more readable. They are just named constants for undefined (anyone) and [] (nobody).

export const permissions = definePermissions<AuthData, Schema>(schema, () => {
  return {
    issue: {
      row: {
        // anyone can insert issues
        insert: ANYONE_CAN,
        // nobody can delete issues
        delete: NOBODY_CAN,
      },
    },
  };
});

Examples

See hello-zero for a simple example of write auth and zbugs for a much more involved one.