Client
Every method on the object createAuthz returns, and which one to reach for.
createAuthz({ model, store }) returns the only object you need to hold. Every
example on this page runs against this setup:
import { createAuthz, defineModel, defineType, isAuthorizationError, permission, relation } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
import type { EvaluationLimits, Model, TupleStore } from '@tsbouncer/tsbouncer';
const model = defineModel({
types: {
user: defineType({}),
document: defineType({
relations: { owner: relation(['user']) },
permissions: { read: permission.or('owner') },
}),
},
});
const store: TupleStore = memoryStore();
const authz = createAuthz({ model, store });interface CreateAuthzOptions {
model: Model;
store: TupleStore;
limits?: Partial<EvaluationLimits>;
validate?: boolean; // default true
}createAuthz
createAuthz({ model, store, limits, validate }): AuthzValidates the store’s shape immediately, so a malformed store fails at
construction rather than on the first read. limits caps depth, node count, and
wall-clock time per evaluation; validate turns write-time model validation off,
and the only good reason to is replaying data written under a previous model.
Deciding
can
can(subject, permission, resource, options?): Promise<boolean>The boolean form. options accepts the same context as everything else, so a
conditional permission is expressible here too.
await authz.can('user:dave', 'document.read', 'document:1', {
context: { callerRegion: 'eu' },
});check
check(request, options?): Promise<Decision>The same decision with the request echoed back, which is what you want in a log line or an audit record.
interface Decision {
allowed: boolean;
subject: string;
permission: string;
resource: string;
truncated: boolean;
}truncated says the budget ran out mid-evaluation, so allowed: false is a
lower bound rather than a denial from the data. explain() carries the same
flag alongside its tree.
assert
assert(request, options?): Promise<void>Throws AccessDeniedError when denied. Use it where a denial means the caller is
in an invalid state and the route must not continue.
async function requireWrite(subject: string, resource: string): Promise<Response | undefined> {
try {
await authz.assert({ subject, permission: 'document.write', resource });
} catch (error) {
if (isAuthorizationError(error) && error.code === 'access_denied') {
return new Response('forbidden', { status: 403 });
}
throw error;
}
}explain
explain(request, options?): Promise<ExplainResult>The decision and the reasoning. See The decision for a worked transcript.
Whole-graph reads
listResources
listResources({ subject, permission, context? }): Promise<ResourceList>Everything the subject holds permission on, including access inherited from a
folder or a group. On what that costs at scale, see
listResources is O(store size).
interface ResourceList {
resources: readonly string[];
truncated: boolean;
}listSubjects
listSubjects({ permission, resource, context? }): Promise<SubjectSet>Who holds a permission on a resource. Deliberately not always a concrete list: a grant that covers a class of subjects is reported symbolically, because inventing a member list would be a guess.
const { allOfTypes, members, excluded, truncated: subjectListTruncated } =
await authz.listSubjects({
permission: 'document.public',
resource: 'document:1',
});
// allOfTypes: ['user'] "any user", not a list of users
// members: ['user:alice', 'team:eng#member']
// excluded: ['user:mallory'] the `except` side, not flattened awayexcluded is populated when the base is symbolic and a named subject is carved
out of it — “every user except mallory”. A concrete set that subtracts to empty is
simply empty.
expand
expand({ subject }, options?): Promise<ExpandResult>Every concrete subject a userset contains, transitively. A wildcard expands to
itself rather than to an invented list. Conditional memberships are gated on
options context — the tuple’s bindings win and the request fills the gaps, the
same merge check applies — so the expansion never lists a member the decisions
would deny.
const { subjects, truncated } = await authz.expand({ subject: 'team:eng#member' });Writing
grant(input: GrantInput): Promise<void>
write(tuples: readonly Tuple[], mode?: 'insert' | 'upsert'): Promise<void>
revoke(input: GrantInput): Promise<void>
delete(input: DeleteInput): Promise<void>Scoping to a transaction
withStore(store: TupleStore): AuthzReturns a client bound to another store — typically a transaction handle. The per-request memo is keyed by store identity, so results are never computed against the outer store and returned as if they came from the transaction.
declare function inTransaction<T>(fn: (store: TupleStore) => Promise<T>): Promise<T>;
await inTransaction(async (store) => {
const scoped = authz.withStore(store);
await scoped.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });
// decisions made here see the write above, and nothing outside the transaction
});Memoizing decisions
withCache(authz: Authz, cache: Cache, options: CachedAuthzOptions): AuthzWraps a client so can and check consult a Cache first. Hits serve
validated values only; misses, corrupt entries, and backend failures all fall
through to evaluation. Every mutation invalidates by resource — a scope without
one concrete resource clears the whole namespace instead of guessing — and a
failed invalidation disables caching rather than risking a stale allow.
explain, expand, and the list queries stay uncached, and withStore drops
the layer, because transactional reads must never hit shared cache.
The namespace is required and should be versioned with the model. Two applications sharing one cache must never read each other’s decisions:
import { withCache } from '@tsbouncer/tsbouncer';
import { memoryCache } from '@tsbouncer/in-memory';
const cached = withCache(authz, memoryCache(), { namespace: 'docs-api-v1' });
await authz.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });
await cached.can('user:alice', 'document.read', 'document:1'); // true, evaluated once
await cached.can('user:alice', 'document.read', 'document:1'); // true, from the memoIntrospection
types(): string[]
relations(type: string): string[]
permissions(type: string): string[]For building admin UIs. A model built at runtime is not knowable from the types alone, and these are how you ask.
Next
- API: model builders
- The model — relations, permissions, and the shapes they derive.
- API: errors — codes, and what they mean.