Rules

The ents in your database are only accessible via server-side functions, and so you can rely on their implementation to enforce authorization rules (also known as "row level security").

But you might have multiple functions accessing the same data, and you might be using the different methods provided by Convex Ents to access them:

  • To read: get, getX, edge, edgeX, unique, uniqueX, first, firstX, take, etc.
  • To write: insert, insertMany, patch, replace, delete

Enforcing rules about when an ent can be read, created, updated or deleted at every callsite can be onerous and error-prone.

For this reason you can optionally define a set of "rules" implementations that are automatically enforced by the ctx.table API. This is an advanced feature, and so it requires a bit more setup.

Setup

Before setting up rules, make sure you understand how Convex Ents are configured via custom functions, see Configuring Functions.

Define your rules

Add a rules.ts file with the following contents:

convex/rules.ts
import { addEntRules } from "convex-ents";
import { entDefinitions } from "./schema";
import { QueryCtx } from "./types";
 
export function getEntDefinitionsWithRules(
  ctx: QueryCtx,
): typeof entDefinitions {
  return addEntRules(entDefinitions, {
    // "secrets" is one of our tables
    secrets: {
      read: async (secret) => {
        // Example: Only the viewer can see their secret
        return ctx.viewer?._id === secret.userId;
      },
    },
  });
}
 
// Example: Retrieve viewer ID using `ctx.auth`:
export async function getViewerId(
  ctx: Omit<QueryCtx, "table" | "viewerId" | "viewer" | "viewerX">,
): Promise<Id<"users"> | null> {
  const user = await ctx.auth.getUserIdentity();
  if (user === null) {
    return null;
  }
  const viewer = await ctx.skipRules
    .table("users")
    .get("tokenIdentifier", user.tokenIdentifier);
  return viewer?._id;
}

The rules are defined in the second argument to addEntRules, which takes entDefinitions from our schema, adds any rules you specify and returns augmented entDefinitions.

Authorization commonly has a concept of a viewer, although this is totally up to your use case. The rules.ts file is a good place for defining how to retrieve the viewer ID.

Apply rules

Replace your functions.ts file with the following code, which uses your implementations from rules.ts:

convex/functions.ts
import {
  customCtx,
  customMutation,
  customQuery,
} from "convex-helpers/server/customFunctions";
import { entsTableFactory } from "../../src";
import {
  MutationCtx,
  QueryCtx,
  internalMutation as baseInternalMutation,
  internalQuery as baseInternalQuery,
  mutation as baseMutation,
  query as baseQuery,
} from "./_generated/server";
import { getEntDefinitionsWithRules, getViewerId } from "./rules";
import { entDefinitions } from "./schema";
 
export const query = customQuery(
  baseQuery,
  customCtx(async (baseCtx) => {
    return await queryCtx(baseCtx);
  }),
);
 
export const internalQuery = customQuery(
  baseInternalQuery,
  customCtx(async (baseCtx) => {
    return await queryCtx(baseCtx);
  }),
);
 
export const mutation = customMutation(
  baseMutation,
  customCtx(async (baseCtx) => {
    return await mutationCtx(baseCtx);
  }),
);
 
export const internalMutation = customMutation(
  baseInternalMutation,
  customCtx(async (baseCtx) => {
    return await mutationCtx(baseCtx);
  }),
);
 
async function queryCtx(baseCtx: QueryCtx) {
  const ctx = {
    db: baseCtx.db as unknown as undefined,
    skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
  };
  const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
  const viewerId = await getViewerId({ ...baseCtx, ...ctx });
  (ctx as any).viewerId = viewerId;
  const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
  (ctx as any).table = table;
  // Example: add `viewer` and `viewerX` helpers to `ctx`:
  const viewer = async () =>
    viewerId !== null ? await table("users").get(viewerId) : null;
  (ctx as any).viewer = viewer;
  const viewerX = async () => {
    const ent = await viewer();
    if (ent === null) {
      throw new Error("Expected authenticated viewer");
    }
    return ent;
  };
  (ctx as any).viewerX = viewerX;
  return { ...ctx, table, viewer, viewerX, viewerId };
}
 
async function mutationCtx(baseCtx: MutationCtx) {
  const ctx = {
    db: baseCtx.db as unknown as undefined,
    skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
  };
  const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
  const viewerId = await getViewerId({ ...baseCtx, ...ctx });
  (ctx as any).viewerId = viewerId;
  const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
  (ctx as any).table = table;
  // Example: add `viewer` and `viewerX` helpers to `ctx`:
  const viewer = async () =>
    viewerId !== null ? await table("users").get(viewerId) : null;
  (ctx as any).viewer = viewer;
  const viewerX = async () => {
    const ent = await viewer();
    if (ent === null) {
      throw new Error("Expected authenticated viewer");
    }
    return ent;
  };
  (ctx as any).viewerX = viewerX;
  return { ...ctx, table, viewer, viewerX, viewerId };
}

In this example we pulled out the logic for defining query and mutation ctx into helper functions, so we don't have to duplicate the code between public and internal constructors (but you can inline this code if you actually need different setup for each).

The logic for setting up the query and mutation ctxs is the same, but we define them separately to get the right types inferred by TypeScript.

Here's an annotated version of the code with explanation of each step:
// The `ctx` object is mutated as we build it out.
// It starts off with `ctx.skipRules.table`, a version `ctx.table`
// that doesn't use the rules we defined in `rules.ts`:
const ctx = {
  db: undefined,
  skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
// We bind our rule implementations to this `ctx` object:
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
// We retrieve the viewer ID, without using rules (as our rules
// depend on having the viewer loaded), and add it to `ctx`:
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
// We get a `ctx.table` using rules and add it to `ctx`:
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// As an example we define helpers that allow retrieving
// the viewer as an ent. These have to be functions, to allow
// our rule implementations to use them as well.
// Anything that we want our rule implementations to have
// access to has to be added to the `ctx`.
const viewer = async () =>
  viewerId !== null ? await table("users").get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
  const ent = await viewer();
  if (ent === null) {
    throw new Error("Expected authenticated viewer");
  }
  return ent;
};
(ctx as any).viewerX = viewerX;
// Finally we again list everything we want our
// functions to have access to. We have to do this
// for TypeScript to correctly infer the `ctx` type.
return { ...ctx, table, viewer, viewerX, viewerId };

Read rules

For each table storing ents you can define a read rule implementation. The implementation is given the ent that is being retrieved, and should return a boolean of whether the ent is readable. This code runs before ents are returned by ctx.table:

  • If the retrieval method can return null, and the rule returns false, then null is returned. Examples: get, first, unique etc.
  • If the retrieval method throws when the ent does not exist, it will also throw when the ent cannot be read. Examples: getX, firstX, uniqueX
  • If the retrieval method returns a list of ents, then any ents that cannot be read will be filtered out.
    • except for getManyX, which will throw an Error

Understanding read rules performance

A read rule is essentially a filter, performed in the Convex runtime running your query or mutation. This means that adding a read rule to a table fundamentally changes the way methods like first, unique and take are implemented. These methods need to paginate through the underlying table (or index range), on top of the scanning that is performed by the built-in db API. You should be mindful of how many ents your read rules might filter out for a given query.

How exactly do `first`, `unique` and `take` paginate?

The methods first try to load the requested number of ents (1, 2 or n respectively). If the ents loaded first get filtered out, the method loads 2 times more documents, performs the filtering, and if again there aren't enough ents, it doubles the number again, and so on, for a maximum of 64 ents being evaluated at a time.

Common read rule patterns

Delegating to another ent

Example: When the user connected to the profile can be read, the profile can be read:

return addEntRules(entDefinitions, {
  profiles: {
    read: async (profile) => {
      return (await profile.edge("user")) !== null;
    },
  },
});

Watch out for infinite loops between read rules, and break them up using ctx.skipRules.

Testing for an edge

Example: A user ent can be read when it is the viewer or when there is a "friends" edge between the viewer and the user:

return addEntRules(entDefinitions, {
  users: {
    read: async (user) => {
      return (
        ctx.viewerId !== null &&
        (ctx.viewerId === user._id ||
          (await user.edge("friends").has(ctx.viewerId)))
      );
    },
  },
});

Write rules

Write rules determine whether ents can be created, updated or deleted. They can be specified using the write key:

return addEntRules(entDefinitions, {
  // "secrets" is one of our tables
  secrets: {
    // Note: The read rule is always checked for existing ents
    // for any updates or deletions
    read: async (secret) => {
      return ctx.viewer?._id === secret.userId;
    },
    write: async ({ operation, ent: secret, value }) => {
      if (operation === "delete") {
        // Example: No one is allowed to delete secrets
        return false;
      }
      if (operation === "create") {
        // Example: Only the viewer can create secrets
        return ctx.viewer?._id === value.ownerId;
      }
      // Example: secret's user edge is immutable
      return value.ownerId === undefined || value.ownerId === secret.ownerId;
    },
  },
});

If defined, the read rule is always checked first before ents are updated or deleted.

The write rule is given an object with

  • operation, one of "create", "update" or "delete"
  • ent, the existing ent if this is an update or delete
  • value, the value provided to .insert(), .replace() or .patch().

The methods insert, insertMany, patch, replace and delete throw an Error if the write rule returns false.

Ignoring rules

Sometimes you might want to read from or write to the database without abiding by the rules you defined. Perhaps you are running with ctx that isn't authenticated, or your code needs to perform some operation on behalf of a user who isn't the current viewer.

For this purpose the Setup section above defines ctx.skipRules.table, which is a version of ctx.table that can read and write to the database without checking the rules.

Remember that methods called on ents retrieved using ctx.skipRules.table also ignore rules! For this reason it's best to return plain documents or IDs when using ctx.skipRules.table:

// Avoid!!!
return await ctx.skipRules.table("foos").get(someId);
// Return an ID instead:
return (await ctx.skipRules.table("foos").get(someId))._id;

It is preferable to still use Convex Ents over using the built-in ctx.db API for this purpose, to maintain invariants around edges and unique fields. See Exposing built-in db.