BetterIAM
Authorization

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.

new@better-iam/core@better-iam/server@better-iam/middlewaredata-filtering.mdplan.tsfilter-compilers.tsfilters.tsindex.ts

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:

Server: the documents a person may read (PostgreSQL)
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.

listAccessibleplanResources
Resource typesManaged types registered with IAMAny application or managed type
ReturnsThe resources, one page at a time, with a totalA filter for your own query
Paging and sortingIAM's, by type and IDYour database's

Plans

A plan is { kind, filter }, together with the tenantId, action, and type it was made for:

kindWhich resourcesTypical cause
alwaysEvery resource of the typeAn owner, a root administrator, a grant without conditions
neverNoneNo grant applies, the tenant is inactive, the action is not in the catalog, the session belongs to another tenant
conditionalThose that pass filterGrants 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:

A role
{
  "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} becomes usr_alice.
  • on the caller and the request (principal.mfa, principal.groups, request.time, ...) are decided now, so they become true or false and disappear from the filter.
  • Resource patterns become conditions on id: document/public-* is id like "public-*", and document/* matches every ID.
  • Conditions on resource.{name} become conditions on the field name: resource.classification is the field classification, and resource.tag.team is the field tag.team.
  • Relationship conditions become ID lists. ArrayContains on resource.relations turns into the IDs of the resources the caller holds the relation on (directly or through a group), and on resource.parentRelations into parentType and parentId pairs.

What a plan takes into account

Everything a decision uses applies to the plan too:

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.

Filter lists, check single resources

A plan decides which rows a list shows. Before changing or returning one specific resource, call iam.require (or authorize) as usual: it is the audited check, and access can change between rendering a list and acting on it.

Filters

The filter is a small tree, ResourceFilter in better-iam/core:

KindPasses when
true, falseAlways, never
and, or, notThe usual combinations
existsThe field is present
typeThe field holds a string, a number, a boolean, or an IP address
equalsThe field equals one of values, compared with the value's type (ignoreCase for strings)
compareA number field is lt, le, gt, or ge a value
likeA string field matches a glob (*, ?, and \ to escape), optionally ignoring case
dateAn ISO 8601 timestamp field is before or after a moment
ipAn IP address field is inside a network (an address or CIDR block)
containsAn 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:

FunctionOutputSupports
filterMatches(filter, row)booleanEvery filter, exactly
filterToSql(filter, options){ sql, params }, with $n placeholders for PostgreSQL and ? for SQLiteAll but ip and contains
filterToPrisma(filter, options?)A Prisma whereGlobs that are exact, prefix*, *suffix, or *part*; no date or ip
filterToMongo(filter, options?)A MongoDB queryAll but date and ip
describeFilter(filter)A readable string, for logs and testsEvery 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 column returns 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 as timestamptz. SQLite matches with GLOB, which is case-sensitive like the engine, and compares dates with julianday().
  • 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 (0 and 1 in SQLite), and ISO 8601 text for dates. A type test compiles to IS 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 named id, which a filter cannot tell apart from the resource's ID. Match IDs with the statement's resources patterns 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.

FrameworkCall
Expressreq.iam.plan(...)
Honoc.get('iam').plan(...)
Fastifyrequest.iam.plan(...)
SvelteKitevent.locals.iam.plan(...)
React RouteriamRouter.helpers(args).plan(...)
Express: a document list
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

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page