Skip to content
tsbouncerpreview
recipe

ABAC — conditions on a grant

Gating a grant on state the graph cannot hold, and why the tuple's binding wins.

The situation

A document is readable by everyone in eu, but only during working hours. The graph can say who holds a relation; it cannot say when or from where, because those are not edges — they are facts about the request.

The wrong move is to sprinkle if (region === 'eu') into handlers. That puts the policy next to the transport, in every transport, and there is no explain() for a console.log.

The two halves

A condition reads two kinds 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: 'eu' — not in the store at all

The predicate is code in the model. Only its name and the parameters the writer bound live on the tuple, so tuples still serialize cleanly and a store never evaluates anything.

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: { owner: relation('user'), viewer: relation('user') },
      permissions: { read: permission.or('owner', 'viewer') },
    }),
  },
  conditions: {
    // The writer binds `region`; the caller supplies `callerRegion` at check time.
    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 } },
    ),
    // Nothing to bind. Decided entirely from request state, which is the right
    // shape for "is this the right time", not for "is this the right tenant".
    businessHours: defineCondition(
      'businessHours',
      (ctx) => typeof ctx.hour === 'number' && ctx.hour >= 9 && ctx.hour < 17,
      {},
    ),
  },
});

const authz = createAuthz({ model, store: memoryStore() });

The data

Two grants, each carrying the condition it was written with.

TypeScript
await authz.grant({
  subject: 'user:alice',
  relation: 'viewer',
  resource: 'document:1',
  condition: 'sameRegion',
  context: { region: 'eu' },
});

await authz.grant({
  subject: 'user:alice',
  relation: 'owner',
  resource: 'document:2',
  condition: 'businessHours',
});

The result

TypeScript
await authz.check(
  { subject: 'user:alice', permission: 'document.read', resource: 'document:1' },
  { context: { callerRegion: 'eu' } },
); // allowed  — the caller is in the region the writer bound

await authz.check(
  { subject: 'user:alice', permission: 'document.read', resource: 'document:1' },
  { context: { callerRegion: 'us' } },
); // denied   — the tuple bound `eu` and the request cannot rewrite it

await authz.check(
  { subject: 'user:alice', permission: 'document.read', resource: 'document:2' },
  { context: { hour: 10 } },
); // allowed

await authz.check(
  { subject: 'user:alice', permission: 'document.read', resource: 'document:2' },
  { context: { hour: 22 } },
); // denied

Why the tuple wins

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
const denied = await authz.explain(
  { subject: 'user:alice', permission: 'document.read', resource: 'document:1' },
  { context: { callerRegion: 'us' } },
);
if (!denied.allowed) console.log(formatExplain(denied));
Text
DENIED  user:alice -> document:1#document.read
  - union
    - owner
      - direct
        no matching tuples
    - viewer
      - direct
        every matching tuple is conditional and none of its conditions hold
        - condition
          sameRegion returned false
  (2 reads)

Everything fails closed

An undeclared condition, a missing declared parameter, a wrong parameter type, a predicate that threw, and a predicate that returned false all resolve to not allowed, each with a reason in explain(). If the library is confused, it says no.

What this is not

ABAC does not replace the graph. A condition narrows a grant that already exists; it cannot create one, and it cannot answer “who has access to this document?” — that is still listSubjects. Use the graph for structure and conditions for the facts about a single request.

Next