Data filtering
Turn what a caller may do into a filter for your own database query with planResources, then compile it to SQL, Prisma, or MongoDB.
iam.authorize answers "may Alice read document 42?". A list page needs a different answer: "which documents may
Alice read?", and it needs it inside the database query, so the application neither fetches every row nor calls
authorize once per row. listAccessible answers
that for the managed IAM stores, but
most application data lives in your own tables.
Data filtering covers those tables. iam.planResources evaluates everything the policy engine already knows about the
request (the action, the caller's roles, groups, attributes, session, and tenant) and hands back what is left as a
query plan: a filter over the resource's id and attributes. You compile the filter into your own query:
import { filterToSql } from 'better-iam/core';
const plan = await iam.planResources({
headers: request.headers, // or { token }
tenantId,
action: 'documents:read',
type: 'document',
});
if (plan.kind === 'never') return [];
const columns: Record<string, string> = {
id: 'd.id',
ownerId: 'd.owner_id',
classification: 'd.classification',
};
const where = filterToSql(plan.filter, {
dialect: 'postgres',
column: (field) => columns[field],
offset: 1, // $1 is the tenant below, so the filter's placeholders start at $2
});
const rows = await db.query(`SELECT * FROM documents d WHERE d.tenant_id = $1 AND ${where.sql}`, [
tenantId,
...where.params,
]);This is partial evaluation, the idea behind Cerbos PlanResources and OPA's partial evaluation. The filter has the
engine's semantics: a row passes it exactly when authorize would allow that resource. The one exception is a decision
refused for exceeding the evaluation work budget, which a database has no equivalent of.
listAccessible | planResources | |
|---|---|---|
| Resource types | Managed types registered with IAM | Any application or managed type |
| Returns | The resources, one page at a time, with a total | A filter for your own query |
| Paging and sorting | IAM's, by type and ID | Your database's |
Plans
A plan is { kind, filter }, together with the tenantId, action, and type it was made for:
kind | Which resources | Typical cause |
|---|---|---|
always | Every resource of the type | An owner, a root administrator, a grant without conditions |
never | None | No grant applies, the tenant is inactive, the action is not in the catalog, the session belongs to another tenant |
conditional | Those that pass filter | Grants or denies with conditions or resource patterns |
filter is always present: true for always and false for never, so it compiles without branching. Returning
early on never saves a query.
From policy to filter
Take a role with an owner rule, a public prefix, and a deny on secret documents:
{
"version": 1,
"statements": [
{
"effect": "allow",
"actions": ["documents:read"],
"resources": ["document/*"],
"conditions": { "StringEquals": { "resource.ownerId": "${principal.id}" } }
},
{ "effect": "allow", "actions": ["documents:*"], "resources": ["document/public-*"] },
{
"effect": "deny",
"actions": ["documents:*"],
"resources": ["document/*"],
"conditions": { "StringEquals": { "resource.classification": "secret" } }
}
]
}For Alice, the plan for documents:read on document is conditional, and describeFilter prints its filter as:
describeFilter(plan.filter);
// not (classification = "secret") and (ownerId = "usr_alice" or id like "public-*")The planner got there like this:
- Statements for other actions and types drop out, and about the
caller are substituted:
${principal.id}becomesusr_alice. - on the caller and the request (
principal.mfa,principal.groups,request.time, ...) are decided now, so they becometrueorfalseand disappear from the filter. - Resource patterns become conditions on
id:document/public-*isid like "public-*", anddocument/*matches every ID. - Conditions on
resource.{name}become conditions on the fieldname:resource.classificationis the fieldclassification, andresource.tag.teamis the fieldtag.team. - Relationship conditions become ID lists.
ArrayContainsonresource.relationsturns into the IDs of the resources the caller holds the relation on (directly or through a group), and onresource.parentRelationsintoparentTypeandparentIdpairs.
What a plan takes into account
Everything a decision uses applies to the plan too:
- deny statements across every role, and the tenant's ;
- the session's scope-down policy and an API key's scopes;
- access windows and just-in-time activations;
- an agent's and its delegation's scope.
Two kinds of session get special treatment. A "view as" session () gets
only what the administrator behind it could reach as well: its plan is the intersection of both. A delegated session
whose delegation holds the action back for the person's
confirmation plans never; the resources the person
confirmed a moment ago are left to authorize.
Filters
The filter is a small tree, ResourceFilter in better-iam/core:
| Kind | Passes when |
|---|---|
true, false | Always, never |
and, or, not | The usual combinations |
exists | The field is present |
type | The field holds a string, a number, a boolean, or an IP address |
equals | The field equals one of values, compared with the value's type (ignoreCase for strings) |
compare | A number field is lt, le, gt, or ge a value |
like | A string field matches a glob (*, ?, and \ to escape), optionally ignoring case |
date | An ISO 8601 timestamp field is before or after a moment |
ip | An IP address field is inside a network (an address or CIDR block) |
contains | An array field contains a value |
A missing field (or SQL NULL) satisfies nothing but not exists, just as a missing attribute satisfies no condition
in the engine, and every compiler keeps that rule under negation. The deny above compiles to
not (classification = "secret"), so a row whose classification is empty passes it, exactly as authorize would let
that document through. Negated operators such as StringNotEquals require the field to be present with the right type,
also as in the engine. Missing keys explains how to guard denies
on optional attributes.
Compile the filter
better-iam/core (also @better-iam/core) compiles a filter for several targets:
| Function | Output | Supports |
|---|---|---|
filterMatches(filter, row) | boolean | Every filter, exactly |
filterToSql(filter, options) | { sql, params }, with $n placeholders for PostgreSQL and ? for SQLite | All but ip and contains |
filterToPrisma(filter, options?) | A Prisma where | Globs that are exact, prefix*, *suffix, or *part*; no date or ip |
filterToMongo(filter, options?) | A MongoDB query | All but date and ip |
describeFilter(filter) | A readable string, for logs and tests | Every filter |
What a target cannot express refuses with UNSUPPORTED_FILTER rather
than returning a query that includes or leaves out the wrong rows. Fall back to filterMatches over candidate rows from
a coarser query, or to authorize for each row.
SQL
filterToSql produces a boolean expression for a WHERE clause:
Prop
Type
- Values are always bound parameters. Only the expressions
columnreturns are written into the SQL, so they must come from your code, never from the request. - Every comparison is written as
(column IS NOT NULL AND ...), which keeps SQL's three-valued logic from changing what a negation means. - PostgreSQL matches globs with
LIKE ... ESCAPE '\'and compares dates astimestamptz. SQLite matches withGLOB, which is case-sensitive like the engine, and compares dates withjulianday(). - Case-insensitive comparisons use
LOWER(), which SQLite applies to ASCII letters only. - Columns should hold the types conditions compare: text for string conditions, numbers, booleans (
0and1in SQLite), and ISO 8601 text for dates. Atypetest compiles toIS NOT NULL, so the column's own type stands in for it.
Prisma, MongoDB, and in memory
import { filterToPrisma } from 'better-iam/core';
const documents = await prisma.document.findMany({
where: {
AND: [{ tenantId }, filterToPrisma(plan.filter, { field: (name) => (name === 'classification' ? 'level' : name) })],
},
});field maps a plan field to a model field (the same name by default). Case-insensitive comparisons use
mode: 'insensitive', which Prisma offers on PostgreSQL and MongoDB, and contains becomes has on a scalar list.
Map fields to columns
The filter's fields are id and the attribute names the engine sees when it decides one resource. For
application-owned resources
those are the attributes your resolveResource returns; for managed resources, the registry record's attributes.
Relationship conditions on resource.parentRelations add parentType and parentId.
Keep the plan's field names and your columns in step. A plan that names a field your column mapper does not know
refuses to compile, which is the signal that a policy started using an attribute your table does not store.
Policies a plan cannot express
Planning itself refuses with UNSUPPORTED_FILTER when a statement that
could apply to the action and type:
- compares with the resource's own attributes through a variable, such as
${resource.ownerId}in a condition value or a resource pattern. The value differs from row to row, which a filter of constants cannot express.${resource.tenantId}is known for the whole plan and works. - has a condition on
resource.id. The engine reads that key as an attribute namedid, which a filter cannot tell apart from the resource's ID. Match IDs with the statement'sresourcespatterns instead. - is too complex to plan: policies full of wildcards that would take more than
planWorkLimit(200,000 units of work) to plan, so planning stays fast and filters stay small.
When planning refuses, check the rows with authorize, or authorizeMany for up to fifty at a time.
Where to call it
Plans cover application actions on application and managed . iam:*
administration actions and IAM's internal types (iam, role, saml, ...) refuse with INVALID_INPUT.
In process
iam.planResources({ token | headers, tenantId, action, type }) returns the caller's plan, taking the credential in the
request as authorize does. It needs no permission and is not audited
(reference).
In framework routes
The per-request helpers of the Node framework integrations have
plan({ action, type, tenantId? }): the request's own plan, in the session's tenant unless you name another, and
never when signed out.
| Framework | Call |
|---|---|
| Express | req.iam.plan(...) |
| Hono | c.get('iam').plan(...) |
| Fastify | request.iam.plan(...) |
| SvelteKit | event.locals.iam.plan(...) |
| React Router | iamRouter.helpers(args).plan(...) |
import { filterToSql } from 'better-iam/core';
app.get('/documents', async (req, res) => {
const plan = await req.iam.plan({ action: 'documents:read', type: 'document' });
if (plan.kind === 'never') return res.json([]);
const where = filterToSql(plan.filter, { dialect: 'postgres', column: (field) => columns[field], offset: 1 });
const sql = `SELECT * FROM documents d WHERE d.tenant_id = $1 AND ${where.sql}`;
res.json(await db.query(sql, [plan.tenantId, ...where.params]));
});In other frameworks, call iam.planResources with the request's headers.
Over HTTP
filters.plan({ tenantId, action, type }) (POST {basePath}/filters/plan) is the
same plan over HTTP and the typed client. It needs only a session of the tenant and is not audited: your queries decide
which rows come back, and authorize stays the check for one resource.
Another identity's plan
filters.planFor({ tenantId, identityId, action, type, assumeMfa? }) previews
another identity's plan without a session of theirs, as policies.simulate does for one decision. assumeMfa plans as
if the identity had signed in with MFA. It requires iam:policies:simulate on the identity and is audited as that
action.
Plan your own policies
The planner is a pure function over policy documents, planResources in better-iam/core, for planning policies of
your own without a server:
import { describeFilter, planResources } from 'better-iam/core';
const plan = planResources({
action: 'documents:read',
resourceType: 'document',
tenantId,
context: { 'principal.id': 'usr_alice' }, // principal, request and tenant keys; no resource keys
denies: [], // deny statements that apply across every grant path
boundaries: [], // ceilings over every path
paths: [{ grants: [role], boundaries: [] }], // a resource is allowed through any one path
});
describeFilter(plan.filter);
// not (classification = "secret") and (ownerId = "usr_alice" or id like "public-*")Next steps
plan and planFor with their permissions, audit events, and errors.
authorizeMany for a page of checks and listAccessible for managed resources.
The operators a plan translates, and how missing keys behave.
RelationshipsRelations on resources, which plans turn into ID lists.
Better IAM is created by Sean Filimon
Last updated
Batches and reverse queries
Check many actions at once with authorizeMany, list the resources a caller may act on with listAccessible, and render UI from advisory decisions.
Access reviews
Explain and review access without granting it. Simulate a decision, list who can act on a resource, see a person's effective actions, and scan for risk.