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.

😬Warning

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.