Skip to content
tsbouncerpreview
guide

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 byexample
what the writer boundthe tupleregion: 'eu' — part of the grant, stored with it
what the caller knows nowthe requestcallerRegion: 'us' — not in the store at all
TypeScript
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' },
});
TypeScript
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' } },
); // denied

The 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.

TypeScript
// 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().

TypeScript
// `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));
Text
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:

TypeScript
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.

TypeScript
// `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.

TypeScript
const denied = await authz.explain({
  subject: 'user:dave',
  permission: 'document.read',
  resource: 'document:1',
});
if (!denied.allowed) console.log(formatExplain(denied));
Text
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”:

TypeScript
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