@cerbos/orm-convex
v0.1.0
Published
An adapter library that takes a [Cerbos](https://cerbos.dev) Query Plan ([PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan)) response and converts it into a [Convex](https://convex.dev/) filter function. It is d
Downloads
12
Readme
Cerbos + Convex Adapter
An adapter library that takes a Cerbos Query Plan (PlanResources API) response and converts it into a Convex filter function. It is designed to run alongside a project that is already using the Cerbos JavaScript SDK to fetch query plans so that authorization logic can be pushed down to Convex queries.
How it works
- Use a Cerbos client (
@cerbos/httpor@cerbos/grpc) to callplanResourcesand obtain aPlanResourcesResponse. - Provide
queryPlanToConvexwith that plan and an optional mapper that describes how Cerbos attribute paths relate to your Convex document fields. - The adapter walks the Cerbos expression tree and returns
{ kind, filter?, postFilter? }:filteris a Convex-native filter function(q) => Expression<boolean>pushed to the DB.postFilteris a JS predicate(doc) => booleanfor operators Convex can't express natively (string ops, collection ops). RequiresallowPostFilter: true— see below.
- Inspect
result.kind:ALWAYS_ALLOWED: the caller can query without any additional filters.ALWAYS_DENIED: short-circuit and return an empty result set.CONDITIONAL: applyresult.filterserver-side andresult.postFilterclient-side (see usage example below).
Supported operators
| Category | Operators | Behavior |
| --- | --- | --- |
| Logical | and, or, not | Builds q.and(...), q.or(...), q.not(...) groups. |
| Comparisons | eq, ne, lt, le, gt, ge | Emits q.eq, q.neq, q.lt, q.lte, q.gt, q.gte against the mapped field. |
| Membership | in | Composed as q.or(q.eq(field, v1), q.eq(field, v2), ...). |
| Existence | isSet | Uses q.neq(field, undefined) for set, q.eq(field, undefined) for unset. |
Post-filter operators
The following operators cannot be expressed as Convex DB filters. When the adapter encounters them, it returns a postFilter function that evaluates them in JavaScript against each document:
| Category | Operators | JS Behavior |
| --- | --- | --- |
| String | contains, startsWith, endsWith | String.prototype.includes / startsWith / endsWith |
| Collection | hasIntersection | a.some(v => b.includes(v)) |
| Quantifiers | exists, exists_one, all | Array.prototype.some / filter-count / every with lambda |
| Higher-order | filter, map, lambda | Used internally by quantifier operators |
For mixed expressions (e.g. and(eq(...), contains(...))), the adapter splits the tree: DB-pushable children go to filter, the rest go to postFilter. For or(...) with any unsupported child, the entire expression goes to postFilter (partial OR push-down would miss results).
allowPostFilter opt-in
By default, queryPlanToConvex throws an error when the query plan requires a postFilter. This is because post-filter operators cause data to be fetched from the database before authorization filtering is applied — the DB-level filter alone may not fully enforce the authorization policy.
To enable post-filtering, pass allowPostFilter: true:
const { kind, filter, postFilter } = queryPlanToConvex({
queryPlan,
mapper,
allowPostFilter: true,
});If your Cerbos policies only use operators that Convex supports natively (comparisons, in, isSet, logical combinators), you don't need this flag — filter alone will enforce the full policy at the DB level.
Requirements
- Cerbos > v0.16 plus either the
@cerbos/httpor@cerbos/grpcclient
System Requirements
- Node.js >= 20.0.0
- Convex 1.x
Installation
npm install @cerbos/orm-convexAPI
import {
queryPlanToConvex,
PlanKind,
type Mapper,
} from "@cerbos/orm-convex";
const { kind, filter, postFilter } = queryPlanToConvex({
queryPlan, // PlanResourcesResponse from Cerbos
mapper, // optional Mapper - see below
allowPostFilter: true, // opt in to client-side filtering (see note below)
});
if (kind === PlanKind.ALWAYS_DENIED) return [];
if (kind === PlanKind.ALWAYS_ALLOWED && !postFilter) {
return await ctx.db.query("myTable").collect();
}
let query = ctx.db.query("myTable");
if (filter) query = query.filter(filter);
let results = await query.collect();
if (postFilter) results = results.filter(postFilter);PlanKind is re-exported from @cerbos/core:
export enum PlanKind {
ALWAYS_ALLOWED = "KIND_ALWAYS_ALLOWED",
ALWAYS_DENIED = "KIND_ALWAYS_DENIED",
CONDITIONAL = "KIND_CONDITIONAL",
}Mapper configuration
The Cerbos query plan references fields using paths such as request.resource.attr.title. Use a mapper to translate those names to the field names in your Convex documents.
export type MapperConfig = {
field?: string;
};
export type Mapper =
| Record<string, MapperConfig>
| ((key: string) => MapperConfig);fieldrewrites a single Cerbos path to a different field name in your Convex document. Dot-notation is supported for nested fields.
If you omit the mapper the adapter will use the query plan paths verbatim.
Direct fields
const mapper: Mapper = {
"request.resource.attr.aBool": { field: "aBool" },
"request.resource.attr.title": { field: "title" },
"request.resource.attr.nested.value": { field: "metadata.value" },
};Mapper functions
You can also supply a function if your mappings follow a predictable pattern:
const mapper: Mapper = (path) => ({
field: path.replace("request.resource.attr.", ""),
});Usage example
import { GRPC as Cerbos } from "@cerbos/grpc";
import {
queryPlanToConvex,
PlanKind,
type Mapper,
} from "@cerbos/orm-convex";
const cerbos = new Cerbos("localhost:3592", { tls: false });
const mapper: Mapper = {
"request.resource.attr.title": { field: "title" },
"request.resource.attr.status": { field: "status" },
"request.resource.attr.priority": { field: "priority" },
};
// Inside a Convex query function:
const queryPlan = await cerbos.planResources({
principal: { id: "user1", roles: ["USER"] },
resource: { kind: "document" },
action: "view",
});
const { kind, filter, postFilter } = queryPlanToConvex({
queryPlan,
mapper,
allowPostFilter: true,
});
if (kind === PlanKind.ALWAYS_DENIED) {
return [];
}
if (kind === PlanKind.ALWAYS_ALLOWED && !postFilter) {
return await ctx.db.query("documents").collect();
}
let query = ctx.db.query("documents");
if (filter) query = query.filter(filter);
let results = await query.collect();
if (postFilter) results = results.filter(postFilter);
return results;Error handling
queryPlanToConvex throws descriptive errors in the following scenarios:
- The plan kind is not one of the Cerbos
PlanKindvalues (Invalid query plan.). - A conditional plan omits the
operator/operandsstructure (Invalid Cerbos expression structure). - An operator listed in the plan is not implemented by this adapter (
Unsupported operator for Convex: <name>orUnsupported operator: <name>). - The
inoperator is given a non-array value. - The query plan requires client-side filtering and
allowPostFilteris not set totrue.
Limitations
- String and collection operators (
contains,startsWith,endsWith,hasIntersection,exists,all, etc.) are evaluated as a JavaScriptpostFilterafter the DB query returns. This means these conditions do not reduce the number of documents read from the database. - For
or(...)expressions where any child uses an unsupported operator, the entire OR is evaluated client-side viapostFilter. Onlyand(...)expressions can be split between DB filter and post-filter. - The
inoperator is composed as multipleeqcomparisons joined withor, which may be less efficient for large value lists.
