Skip to content
tsbouncerpreview
guide

The model

Every shape the evaluator understands, and the two that surprise people.

A model is a set of types, each with relations and permissions. It is code, and it is validated at defineModel time. Relations are where tuples may be written; permissions are what you ask about.

TypeScript
import { createAuthz, defineCondition, defineModel, defineType, permission, relation, ttu, wildcard } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';

const model = defineModel({
  types: {
    user: defineType({}),
    team: defineType({ relations: { member: relation(['user']) } }),
    folder: defineType({
      relations: {
        viewer: relation('user').or(relation('team', { through: 'member' })),
        parent: relation('folder'),
      },
      permissions: { read: permission.or('viewer', ttu('parent', 'read')) },
    }),
    document: defineType({
      relations: {
        owner: relation(['user']),
        editor: relation('user').or(relation('team', { through: 'member' })),
        parent: relation('folder'),
        banned: relation('user').or(wildcard('user')),
        anyone: relation('user').or(wildcard('user')),
      },
      permissions: {
        read: permission.or('owner', 'editor', 'anyone', ttu('parent', 'read')),
        write: permission.allOf('owner', 'editor').except('banned'),
        public: permission.or('anyone'),
      },
    }),
  },
});

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

Relations

A relation says which subject types may be written against a resource of this type.

TypeScript
document: defineType({
  relations: {
    owner: relation(['user']), // a user, named
    editor: relation('user'), // also a user
  },
});

A string and a one-element array are the same thing. An array is clearer when a relation accepts more than one type.

Userset edges

relation(type, { through }) is the shape that lets a group grant to its members without naming them.

TypeScript
editor: relation('user').or(relation('team', { through: 'member' })),

Two things about this that are easy to get wrong:

The tuple’s subject is a userset, not the object. The subject is the group qualified by the relation the edge walks.

TypeScript
await authz.write([
  { subject: 'user:alice', relation: 'member', resource: 'team:eng' },
  { subject: 'team:eng#member', relation: 'editor', resource: 'document:2' },
]);

Writing { subject: 'team:eng', relation: 'editor', … } is rejected at write time, which is a useful error rather than a silent deny.

through must name a relation, not a permission. Inheriting write from a folder is a ttu, not a userset edge — see below.

Wildcards

wildcard(type) declares that a relation accepts a user:* subject, which is how you write a “public to everyone, including people who do not exist yet” grant.

TypeScript
anyone: relation('user').or(wildcard('user')),
TypeScript
await authz.grant({ subject: 'user:*', relation: 'anyone', resource: 'document:3' });

Permissions

Permissions are what you ask about. They are built from relation names and from each other.

TypeScript
permissions: {
  read: permission.or('owner', 'editor', 'anyone', ttu('parent', 'read')),
  write: permission.allOf('owner', 'editor').except('banned'),
}

permission.or is a union, permission.allOf is an intersection, and .except is an exclusion. All three chain, and you can use the fluent form too:

TypeScript
viewer: relation('user')
  .or(relation('team', { through: 'member' }))
  .or(relation('role', { through: 'holder' }))
  .or(wildcard('user'));

Union

Any branch grants it. This is the common case.

Intersection

Every branch must hold. Useful for “this document is editable, and only by someone who can also edit the workspace”.

TypeScript
write: permission.allOf('owner', 'editor'),

Exclusion

Removes an access the base granted. Both sides are always evaluated — returning early on a satisfied base is the bug this shape exists to prevent.

TypeScript
write: permission.allOf('owner', 'editor').except('banned'),

Tuple-to-userset

Follow a relation to another object and take a member there. This is inheritance, and it is how permissions propagate down a tree.

TypeScript
folder: defineType({
  relations: { viewer: relation('user'), parent: relation('folder') },
  permissions: { read: permission.or('viewer', ttu('parent', 'read')) },
});

ttu('parent', 'read') means “follow parent, then take read over there”. The direction is the trap: it recurses into the tuple’s subject, so the object the walk started from is not re-tested. Getting it backwards walks back up the edge and denies everything.

Conditions

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.

TypeScript
const conditionalModel = defineModel({
  types: { user: defineType({}), doc: defineType({}) },
  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 } },
    ),
  },
});

Conditions are covered in depth on Conditions.

Putting the shapes together

The three styles compose, which is what real systems need:

TypeScript
document: defineType({
  relations: {
    owner: relation('user'),
    editor: relation('user')
      .or(relation('role', { through: 'holder' })) // RBAC: a role attached here
      .or(relation('team', { through: 'member' })) // ReBAC: a group
      .or(wildcard('user')), // public
    parent: relation('folder'),
    banned: relation('user').or(wildcard('user')),
  },
  permissions: {
    read: permission.or('owner', 'editor', ttu('parent', 'read')),
    write: permission
      .or(permission.allOf('owner', 'editor'), ttu('parent', 'write'))
      .except('banned'),
  },
});

write has two ways in, and the difference is the point. Directly it needs owner and editor, so attaching an editor role to a document does not hand out write to everyone holding it. Inherited, it follows the parent folder’s write. The exclusion wraps both, and neither branch may short-circuit it.

Next