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<Schema, 'issue'>,
  ) => cmp('creatorID', authData.sub);

  return {
    issue: {
      row: {
        delete: [allowIfIssueCreator],
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

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.
  } satisfies PermissionsConfig<AuthData, Schema>;
});

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.
  } satisfies PermissionsConfig<AuthData, Schema>;
});

Permission Evaluation

Zero permissions are "compiled" into a JSON-based format at build-time. This file is stored in the {ZERO_APP_ID}.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',
  {
    create: `npx zero-deploy-permissions -p ../../src/schema.ts`,
    // Run the Command on every deploy ...
    triggers: [Date.now()],
    environment: {
      ZERO_UPSTREAM_DB: commonEnv.ZERO_UPSTREAM_DB,
      // If the application has a non-default App ID ...
      ZERO_APP_ID: commonEnv.ZERO_APP_ID,
    },
  },
  // after the view-syncer is deployed.
  {dependsOn: viewSyncer},
);

See the SST Deployment Guide for more details.

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:

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

  return {
    issue: {
      row: {
        select: [allowIfIssueCreator],
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

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'.

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

  return {
    user: {
      row: {
        insert: [allowIfNonAdmin],
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

Update Permissions

There are two types of update rulesets: preMutation and postMutation. Both rulesets must pass for an update to be allowed.

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.

Like other rulesets, preMutation and postMutation default to NOBODY_CAN. This means that every table must define both these rulesets in order for any updates to be allowed.

For example, the following ruleset allows an issue's owner to edit, but not re-assign the issue. The postMutation rule enforces that the current user still own the issue after edit.

definePermissions<AuthData, Schema>(schema, () => {
  const allowIfIssueOwner = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<Schema, 'issue'>,
  ) => cmp('ownerID', authData.sub);

  return {
    issue: {
      row: {
        update: {
          preMutation: [allowIfIssueOwner],
          postMutation: [allowIfIssueOwner],
        },
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

This ruleset allows an issue's owner to edit and re-assign the issue:

definePermissions<AuthData, Schema>(schema, () => {
  const allowIfIssueOwner = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<Schema, 'issue'>,
  ) => cmp('ownerID', authData.sub);

  return {
    issue: {
      row: {
        update: {
          preMutation: [allowIfIssueOwner],
          postMutation: ANYONE_CAN,
        },
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

And this allows anyone to edit an issue, but only if they also assign it to themselves. Useful for enforcing "patches welcome"? 🙃

definePermissions<AuthData, Schema>(schema, () => {
  const allowIfIssueOwner = (
    authData: AuthData,
    {cmp}: ExpressionBuilder<Schema, 'issue'>,
  ) => cmp('ownerID', authData.sub);

  return {
    issue: {
      row: {
        update: {
          preMutation: ANYONE_CAN,
          postMutation: [allowIfIssueOwner],
        },
      },
    },
  } satisfies PermissionsConfig<AuthData, Schema>;
});

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.

Debugging

See Debugging Permissions.

Examples

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