Skip to content
tsbouncerpreview
guide

Data

The tuple format, opaque references, and what write-time validation catches for you.

Tuples

A tuple is one edge: this subject holds this relation on this resource.

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

const model = defineModel({
  types: {
    user: defineType({}),
    team: defineType({ relations: { member: relation(['user']) } }),
    document: defineType({
      relations: {
        owner: relation(['user']),
        editor: relation('user').or(relation('team', { through: 'member' })),
      },
      permissions: { read: permission.or('owner', 'editor') },
    }),
  },
});

const authz = createAuthz({ model, store: memoryStore() });
TypeScript
await authz.write([
  { subject: 'user:alice', relation: 'owner', resource: 'document:1' },
  { subject: 'user:alice', relation: 'member', resource: 'team:eng' },
  { subject: 'team:eng#member', relation: 'editor', resource: 'document:2' },
]);

They are plain objects with no methods and no class instances, so they serialize to JSON, diff in a pull request, and seed from a fixture without any special handling.

References

A reference is an opaque string with three forms:

formmeaning
user:alicethe object alice of type user
team:eng#memberthe set of members of team:eng — a userset
user:*every subject of type user

tsbouncer never resolves user:alice to a row in your users table, and never needs a foreign key into your domain schema. What a reference means is entirely your application’s business.

Writing

TypeScript
await authz.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });

await authz.write([
  { subject: 'user:bob', relation: 'viewer', resource: 'document:1' },
  { subject: 'user:carol', relation: 'viewer', resource: 'document:1' },
]);

write takes a second argument:

TypeScript
const seed = [{ subject: 'user:bob', relation: 'owner', resource: 'document:1' }];

await authz.write(seed, 'insert'); // the default: reject duplicates
await authz.write(seed, 'upsert'); // replace tuples that share a key

Use insert in a seed script. A fixture that silently overwrites passes on a machine where the data already existed and fails everywhere else.

One edge holds one param-set per condition. The tuple’s identity is its subject, relation, resource, and condition name — not the bound params — so writing the same edge and condition with different params is a rejected duplicate, not a second row. Write with 'upsert' to replace the binding.

Removing

TypeScript
await authz.revoke({ subject: 'user:bob', relation: 'viewer', resource: 'document:1' });

await authz.delete({
  kind: 'tuples',
  tuples: [{ subject: 'user:bob', relation: 'viewer', resource: 'document:1' }],
});
await authz.delete({ kind: 'filter', query: { resource: 'document:1' } });
await authz.delete({
  kind: 'replace',
  query: { resource: 'document:1' },
  tuples: [{ subject: 'user:dave', relation: 'owner', resource: 'document:1' }],
});

replace swaps everything scoped by the query for the new tuples, and stores that support it apply it as one all-or-nothing unit.

Write-time validation

Every write is checked against the model before it is stored. This is the highest value-per-line behaviour in the library: a typo fails where it is still cheap, rather than silently denying forever.

TypeScript
await authz.write([{ subject: 'user:alice', relation: 'viewe', resource: 'document:1' }]);
// TupleValidationError: relation "viewe" on "document" does not exist
TypeScript
await authz.write([{ subject: 'user:carol', relation: 'owner', resource: 'document:1' }]);
// TupleValidationError: relation "owner" on "document" does not accept a "team" subject

Validation can be turned off, and there is exactly one good reason to: replaying data that a previous version of the model accepted, before you migrate it.

TypeScript
const lenient = createAuthz({ model, store: memoryStore(), validate: false });
await lenient.write([{ subject: 'user:alice', relation: 'owner', resource: 'document:1' }]);

Reading tuples back

The store is readable, and one filtered read is the whole primitive.

TypeScript
const { items, cursor } = await authz.store.read({ resource: 'document:1', limit: 100 });
const mine = await authz.store.read({ subject: 'user:alice' });

'user:*' is a legal filter, so “everything anyone can see” is a query rather than a scan:

TypeScript
const publicDocs = await authz.store.read({ subject: 'user:*' });

Filters are conjunctive: any subset of subject, relation, and resource. A reference spans several columns, so subject: ['user:alice', 'user:bob'] is implemented as an OR of per-reference ANDs — it can never match a userset row by pairing one reference’s type with another’s id.

Next