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:
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
:
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 ctx
s 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 returnsfalse
, thennull
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 anError
- except for
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 deletevalue
, 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
.