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.
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.
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.
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.
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.
anyone: relation('user').or(wildcard('user')),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.
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:
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”.
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.
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.
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.
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:
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
- Data — the reference format and write-time validation.
- Conditions — attribute-gated access.
- API: model builders — every builder, with signatures.