Row-level data filtering
Build a list endpoint that returns only the rows a caller may read, by compiling an iam.planResources query plan into a raw SQL, Prisma, or Drizzle where clause.
A list page asks a different question than a single check. iam.require answers "may Alice read document 42?",
but GET /documents needs "which documents may Alice read?", and it needs the answer inside the database query so
paging, sorting, and counts stay correct. In this guide you build that endpoint. Better IAM evaluates the caller's
roles, groups, session, and tenant once and returns a query plan: a filter over the document's id and
attributes. You compile the filter into your own WHERE clause.
You will:
- declare an application-owned
documenttype with the attributes your policies compare; - write a role whose access depends on those attributes;
- map plan fields to your table's columns in one place;
- compile the plan for raw SQL (
pg), Prisma, or Drizzle; - keep
iam.requireas the check before acting on one row, and fall back safely when a plan cannot be compiled.
Prerequisites
- A working Better IAM instance exported as
iamfromsrc/iam.ts, an organization tenant, and a signed-in member. The Quickstart sets all of this up. - A PostgreSQL
documentstable in your application database with at leastid,tenant_id,owner_id, andclassificationcolumns. The examples use the column names below; adjust the mapping in step 3 to yours. - For the Express route:
better-iam/expressmounted as in Express, Hono, and Fastify. Any other server works withiam.planResourcesand the request's headers.
CREATE TABLE documents (
id text PRIMARY KEY,
tenant_id text NOT NULL,
owner_id text,
classification text,
title text NOT NULL
);
CREATE INDEX documents_tenant_owner ON documents (tenant_id, owner_id);Declare the resource type and its attributes
The plan's fields are the attribute names the policy engine sees when it decides one document. For an
application-owned type those come from your resolveResource callback, so the resolver and the table must agree:
whatever resolveResource returns as attributes.ownerId is what the plan calls ownerId.
import pg from 'pg';
import { betterIam, IamError } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
export const pool = new pg.Pool({ connectionString: process.env.APP_DATABASE_URL });
export const iam = betterIam({
database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
secret: process.env.BETTER_IAM_SECRET,
baseURL: process.env.BETTER_IAM_BASE_URL,
permissions: {
resourceTypes: {
document: {
actions: ['documents:read', 'documents:write', 'documents:delete'],
attributes: { ownerId: 'string', classification: 'string' },
},
},
},
// Single-resource checks (iam.require, iam.authorize) load the document from trusted storage.
resolveResource: async ({ type, id }) => {
const { rows } = await pool.query(
'SELECT tenant_id, owner_id, classification FROM documents WHERE id = $1',
[id],
);
const row = rows[0];
if (!row) throw new IamError('NOT_FOUND', 'Document not found', 404);
return {
type,
id,
tenantId: row.tenant_id,
attributes: { ownerId: row.owner_id, classification: row.classification },
};
},
});permissions.resourceTypes.document.attributesdeclares the attribute schema that policy conditions read asresource.ownerIdandresource.classification. Declaring resource types also makes saved policies name only known resource types; condition keys themselves are not checked against the schema.- The resolver must return the
tenantId,type, andidit was asked for. A row from another tenant fails the check withRESOURCE_MISMATCH(403). resolveResourceis what single checks use. Plans never call it: they only describe which attribute values pass, and your query applies that to every row at once.
Write a policy that depends on attributes
A plan is only interesting when access depends on the row. This role lets members read their own documents and
any document whose ID starts with public-, and denies every document classified secret.
import { iam } from '../src/iam.js';
const owner = { token: process.env.OWNER_TOKEN! };
const tenantId = process.env.TENANT_ID!;
const reader = await iam.api.roles.create(owner, {
tenantId,
name: 'Document reader',
document: {
version: 1,
statements: [
{
sid: 'OwnDocuments',
effect: 'allow',
actions: ['documents:read'],
resources: ['document/*'],
conditions: { StringEquals: { 'resource.ownerId': '${principal.id}' } },
},
{ sid: 'PublicDocuments', effect: 'allow', actions: ['documents:*'], resources: ['document/public-*'] },
{
sid: 'NoSecrets',
effect: 'deny',
actions: ['documents:*'],
resources: ['document/*'],
conditions: { StringEquals: { 'resource.classification': 'secret' } },
},
],
},
});
console.log('Role', reader.id);Bind the role to your members' group with bindings.create (see Roles and bindings).
For a member usr_alice, the plan for documents:read on document becomes:
not (classification = "secret") and (ownerId = "usr_alice" or id like "public-*")The ${principal.id} variable was substituted, the resource pattern document/public-* became a condition on
id, and each resource.{name} condition became a condition on the field {name}.
Map plan fields to columns
Keep one mapping from plan fields to column expressions. Every compiler takes it, and a plan that names a field
the mapping does not know refuses to compile (INVALID_INPUT) instead of silently ignoring a policy condition.
/** Plan field -> SQL expression. Only trusted, hard-coded expressions belong here. */
export const documentColumns: Record<string, string> = {
id: '"documents"."id"',
ownerId: '"documents"."owner_id"',
classification: '"documents"."classification"',
};
/** Plan field -> Prisma model field (only needed where the names differ). */
export const documentPrismaFields: Record<string, string> = {
id: 'id',
ownerId: 'ownerId',
classification: 'classification',
};The SQL expressions are written into the query text, so they must come from your code and never from a request. Values are always bound as parameters.
Plan the request
Ask for the caller's plan. It takes the same credential as iam.authorize: the request's headers (cookie
session) or a token. Planning needs no permission and writes no audit event.
import { Router } from 'express';
import { listReadableDocuments } from '../documents/list.js';
export const documentsRouter = Router();
documentsRouter.get('/documents', async (req, res) => {
// In the session's tenant; signed-out requests get kind 'never'.
const plan = await req.iam.plan({ action: 'documents:read', type: 'document' });
res.json(await listReadableDocuments(plan.tenantId, plan));
});req.iam is added by createIamExpress(iam).middleware. Hono uses c.get('iam').plan(...), Fastify
request.iam.plan(...), and SvelteKit event.locals.iam.plan(...).
The plan is { kind, filter, tenantId, action, type }:
kind | Meaning | What to do |
|---|---|---|
always | Every document of the tenant (filter is true) | Query the tenant's rows |
never | None (filter is false) | Return an empty list without querying |
conditional | Rows that pass filter | Compile filter into the WHERE clause |
Compile the filter for your query layer
Better IAM ships compilers for SQL (filterToSql), Prisma (filterToPrisma), and MongoDB (filterToMongo), all
from better-iam/core. There is no Drizzle compiler; the Drizzle tab wraps filterToSql output in Drizzle's
sql builder, which keeps every value bound. Always add the tenant condition yourself: a plan filters by id and
attributes, never by tenant.
import { filterToSql, type ResourcePlan } from 'better-iam/core';
import { pool } from '../iam.js';
import { documentColumns } from './fields.js';
export async function listReadableDocuments(tenantId: string, plan: ResourcePlan) {
if (plan.kind === 'never') return [];
const where = filterToSql(plan.filter, {
dialect: 'postgres',
column: (field) => documentColumns[field],
offset: 1, // $1 is the tenant, so the filter's placeholders start at $2
});
const { rows } = await pool.query(
`SELECT id, title FROM documents WHERE tenant_id = $1 AND ${where.sql} ORDER BY title LIMIT 50`,
[tenantId, ...where.params],
);
return rows;
}filterToSql returns { sql, params } with $n placeholders for dialect: 'postgres' and ? for
dialect: 'sqlite'. Every comparison is wrapped as (column IS NOT NULL AND ...), so a row with a NULL
classification passes not (classification = "secret"), exactly as iam.authorize would allow it.
Check one row before acting on it
A plan decides which rows a list shows. Before you change, delete, or return the full content of one document,
call iam.require: it is the audited check, and access can change between rendering a list and clicking a button.
import { iam, pool } from '../iam.js';
export async function deleteDocument(request: Request, tenantId: string, documentId: string) {
await iam.require({
headers: request.headers,
tenantId,
action: 'documents:delete',
resource: { type: 'document', id: documentId },
});
await pool.query('DELETE FROM documents WHERE tenant_id = $1 AND id = $2', [tenantId, documentId]);
}iam.require throws IamError with code ACCESS_DENIED (403) unless the request is allowed.
Fall back when a plan cannot be compiled
Some policies have no exact filter. Planning refuses with UNSUPPORTED_FILTER when a statement compares with the
resource's own attributes through a variable (${resource.ownerId} in a condition value), has a condition on
resource.id, or is too complex to plan. A compiler refuses with the same code when its target cannot express
part of the filter (for example ip conditions in SQL, or date conditions in Prisma). Never answer with an
unfiltered list in that case. Narrow the candidates with a coarse query and check them exactly:
import { IamError } from 'better-iam';
import { filterMatches, type ResourcePlan } from 'better-iam/core';
import { iam, pool } from '../iam.js';
import { listReadableDocuments } from './list.js';
export async function listWithFallback(request: Request, tenantId: string) {
let plan: ResourcePlan;
try {
plan = await iam.planResources({ headers: request.headers, tenantId, action: 'documents:read', type: 'document' });
} catch (error) {
if (!(error instanceof IamError) || error.code !== 'UNSUPPORTED_FILTER') throw error;
// The policy has no exact filter: check one page of candidates with authorizeMany (at most 50 checks).
const { rows } = await pool.query('SELECT id, title FROM documents WHERE tenant_id = $1 ORDER BY title LIMIT 50', [
tenantId,
]);
if (rows.length === 0) return [];
const { results } = await iam.authorizeMany({
headers: request.headers,
tenantId,
checks: rows.map((row) => ({ action: 'documents:read', resource: { type: 'document', id: row.id } })),
});
return rows.filter((_, index) => results[index].allowed);
}
try {
return await listReadableDocuments(tenantId, plan);
} catch (error) {
if (!(error instanceof IamError) || error.code !== 'UNSUPPORTED_FILTER') throw error;
// The plan exists but this query target cannot express it: filter candidate rows in memory, exactly.
if (plan.kind === 'never') return [];
const { rows } = await pool.query(
'SELECT id, title, owner_id, classification FROM documents WHERE tenant_id = $1 ORDER BY title LIMIT 500',
[tenantId],
);
return rows.filter((row) =>
filterMatches(plan.filter, { id: row.id, ownerId: row.owner_id ?? undefined, classification: row.classification ?? undefined }),
);
}
}filterMatches supports every filter with the engine's exact semantics. It reads fields by name as flat keys, and
a missing field (undefined) satisfies nothing but not exists, so pass undefined rather than null for empty
columns.
Verify it works
-
Print the plan for a member as an administrator, without their session.
filters.planForneedsiam:policies:simulateon the identity and is audited:scripts/print-plan.ts import { describeFilter } from 'better-iam/core'; import { iam } from '../src/iam.js'; const admin = { token: process.env.ADMIN_TOKEN! }; const plan = await iam.api.filters.planFor(admin, { tenantId: process.env.TENANT_ID!, identityId: process.env.MEMBER_ID!, action: 'documents:read', type: 'document', }); console.log(plan.kind, describeFilter(plan.filter)); // conditional not (classification = "secret") and (ownerId = "usr_alice" or id like "public-*")Route:
POST /filters/planFor -
Insert three rows for the member's tenant: one they own, one named
public-handbook, and one they own withclassification = 'secret'.GET /documentsas the member returns the first two only. -
Compare the list with single checks. For each returned row and a few excluded ones,
iam.authorizewithdocuments:readmust agree: allowed for every listed row, denied for every excluded row.
The same plan is available to browser and remote callers as filters.plan:
import { iam } from './iam.js';
const plan = await iam.api.filters.plan({ headers: request.headers }, { tenantId, action: 'documents:read', type: 'document' });Route: POST /filters/plan
Compile filters on the server only. The column mapping is trusted SQL and must not be built from anything the browser sends.
Troubleshooting
Next steps
Plans, the filter tree, every compiler, and what a plan takes into account.
Policy conditionsThe operators a plan translates, and how missing keys behave.
filters API referenceplan and planFor with permissions and errors.
authorizeMany and listAccessible for managed resources.
Better IAM is created by Sean Filimon
Last updated
AI agents that act for users
Register an AI agent with a sponsor and a ceiling, let a person delegate scoped and expiring access to it, have the agent act through short delegated sessions, require the person's confirmation for risky actions, and audit or stop the agent.
Just-in-time admin access
Replace standing administrator bindings with eligible bindings that members activate for a bounded time, with justification, MFA, approval by a named group, audit events, and revocation.