Conditions
Attribute-gated access, the split between what the writer bound and what the caller knows.
A condition is a predicate in the model. Only its name and a few bound parameters live on the tuple, so tuples still serialize cleanly and a store never evaluates anything.
The split
A condition has two halves of input, and keeping them apart is the whole idea.
| carried by | example | |
|---|---|---|
| what the writer bound | the tuple | region: 'eu' — part of the grant, stored with it |
| what the caller knows now | the request | callerRegion: 'us' — not in the store at all |
import { createAuthz, defineCondition, defineModel, defineType, formatExplain, permission, relation } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
const model = defineModel({
types: {
user: defineType({}),
document: defineType({
relations: { viewer: relation('user') },
permissions: { read: permission.or('viewer') },
}),
},
conditions: {
sameRegion: defineCondition(
'sameRegion',
(ctx) => {
const { region, callerRegion } = ctx;
if (typeof region !== 'string' || typeof callerRegion !== 'string') return false;
return region === callerRegion;
},
{ params: { region: 'string' as const } },
),
},
});
const authz = createAuthz({ model, store: memoryStore() });
await authz.grant({
subject: 'user:dave',
relation: 'viewer',
resource: 'document:1',
condition: 'sameRegion',
context: { region: 'eu' },
});await authz.check(
{ subject: 'user:dave', permission: 'document.read', resource: 'document:1' },
{ context: { callerRegion: 'eu' } },
); // allowed — the caller's region matches what the writer bound
await authz.check(
{ subject: 'user:dave', permission: 'document.read', resource: 'document:1' },
{ context: { callerRegion: 'us' } },
); // deniedThe tuple is authoritative
The predicate sees { ...request, ...tuple } — the tuple’s bindings win on every
conflict. If the request could override region, a caller could rewrite the
constraint the grant was written with, and the condition would be theatre.
// Still denied: the request says region "us", but the tuple bound "eu", and
// the merged context is eu — so eu !== us.
await authz.check(
{ subject: 'user:dave', permission: 'document.read', resource: 'document:1' },
{ context: { callerRegion: 'us', region: 'eu' } },
);params is about detecting absence
The params schema governs what a tuple may bind, not everything the predicate
reads. Its real job is making a missing key detectable: a JavaScript predicate
reading an absent key just gets undefined and quietly returns false, which is
indistinguishable from a genuine denial. Declaring params turns that silence into a
reason in explain().
// `region` is declared in `params`, and this grant never bound it — so the
// reason is the missing key rather than "the predicate returned false".
await authz.grant({
subject: 'user:eve',
relation: 'viewer',
resource: 'document:2',
condition: 'sameRegion',
});
const absent = await authz.explain(
{ subject: 'user:eve', permission: 'document.read', resource: 'document:2' },
{ context: { callerRegion: 'eu' } },
);
if (!absent.allowed) console.log(formatExplain(absent));DENIED user:eve -> document:2#document.read
- union
- viewer
- direct
every matching tuple is conditional and none of its conditions hold
- condition
condition sameRegion needs "region", which no tuple or request supplied
(1 read)Unknown or mistyped bound parameters are rejected at grant time, where a typo is
still cheap to fix:
await authz.grant({
subject: 'user:dave',
relation: 'viewer',
resource: 'document:1',
condition: 'sameRegion',
context: { regoin: 'eu' }, // TupleValidationError
});Narrowing
A predicate’s context is { [key: string]: unknown }. params governs write-time
validation; it does not type the predicate. So a predicate narrows everything it
reads, including keys it declared itself.
// `ctx.seatsUsed` and `ctx.seatsTotal` are both `unknown` without this, because
// `seatsTotal` is declared but the declaration does not reach the predicate type.
const activeSeat = defineCondition(
'activeSeat',
(ctx) => {
const { plan, seatsUsed, minimumPlan, seatsTotal } = ctx;
if (typeof plan !== 'string' || typeof seatsUsed !== 'number') return false;
if (typeof minimumPlan !== 'string' || typeof seatsTotal !== 'number') return false;
return plan === minimumPlan && seatsUsed < seatsTotal;
},
{ params: { minimumPlan: 'string' as const, seatsTotal: 'number' as const } },
);
void activeSeat;Every failure path denies
In order: an undeclared condition, a missing declared param, a wrong param type, a predicate that threw, and a predicate that returned false. All five resolve to not allowed, and each carries a reason.
const denied = await authz.explain({
subject: 'user:dave',
permission: 'document.read',
resource: 'document:1',
});
if (!denied.allowed) console.log(formatExplain(denied));DENIED user:dave -> document:1#document.read
- union
- viewer
- direct
every matching tuple is conditional and none of its conditions hold
- condition
sameRegion returned false
(1 read)A condition that throws is never allowed through. The library is confused, so it says no.
A condition with no bound parameters
A condition can be decided entirely by request state, which is the right shape for “this account is suspended”:
const notSuspended = defineCondition('notSuspended', (ctx) => ctx.suspended === false, {
params: {},
});
void notSuspended;There is nothing for a writer to bind, so no grant context is needed — and a suspended account cannot be rescued by any grant.
Next
- API: model builders
- Recipe: ABAC — a worked example with two conditions.