Skip to content
tsbouncerpreview
guide

A simple RBAC API

Hono, a JSON file, and one idea — a document never names a person.

You will build a small documents API with role-based access control: install the pieces, declare the model, seed the grants, wire three routes, run the server, and verify every decision twice — once with curl, once with assertions. About forty minutes, and you end with an API whose access list is a file you can read.

Install

Shell
mkdir docs-api && cd docs-api
npm init -y && npm pkg set type=module
npm i hono @hono/node-server tsbouncer
npm i -D tsx typescript @types/node

Node 22 or newer. Everything below assumes that directory.

The model — one idea

A document never names a person. It names a role, and the role names its holders, so onboarding someone is one write rather than one per document. src/model.ts:

TypeScript
import { createAuthz, defineModel, defineType, permission, relation, withCache } from '@tsbouncer/tsbouncer';
import { memoryCache } from '@tsbouncer/in-memory';
import { jsonStore } from '@tsbouncer/json-file';
import assert from 'node:assert/strict';
TypeScript
export const model = defineModel({
  types: {
    user: defineType({}),

    role: defineType({
      relations: { holder: relation('user') },
    }),

    document: defineType({
      relations: {
        owner: relation('user'),
        editor: relation('user').or(relation('role', { through: 'holder' })),
        viewer: relation('user').or(relation('role', { through: 'holder' })),
      },
      permissions: {
        read: permission.or('owner', 'editor', 'viewer'),
        write: permission.or('owner', 'editor'),
        manage: permission.or('owner'),
      },
    }),
  },
});

relation('role', { through: 'holder' }) turns a role into “everyone who holds it”. A userset edge must name a relation, never a permission — holder is one, which is why this works.

document.write is open to editors. document.manage is not — it is the owner alone, and it is what changes who has access. A system where an editor can edit the access list is a system where a compromised editor account exfiltrates the document.

Seed it and open the store

src/seed.ts. People go into roles, roles go onto documents — nothing names both a person and a document:

TypeScript
const authz = createAuthz({ model, store: jsonStore({ file: './authz.json' }) });

await authz.write([
  { subject: 'user:alice', relation: 'holder', resource: 'role:acme:admin' },
  { subject: 'user:bob', relation: 'holder', resource: 'role:acme:editor' },
  { subject: 'user:carol', relation: 'holder', resource: 'role:acme:viewer' },
  { subject: 'user:alice', relation: 'owner', resource: 'document:1' },
  { subject: 'role:acme:editor#holder', relation: 'editor', resource: 'document:1' },
  { subject: 'role:acme:viewer#holder', relation: 'viewer', resource: 'document:2' },
]);

The store needs one line of explanation: jsonStore reads the file while constructing and re-writes it atomically after every mutation, so by the time an await authz.write(...) resolves the data is on disk. A missing file is an empty store — commit authz.json and a fresh clone has the same access graph.

Who is calling: 401 versus 403

src/auth.ts. A real app reads a session or a verified token; either way it ends the same way — one string in a context variable. Authorization starts from that string and nothing else:

TypeScript
import type { Context, MiddlewareHandler } from 'hono';
import type { Authz } from '@tsbouncer/tsbouncer';

interface Env {
  readonly Variables: { readonly authz: Authz; readonly subject: string | undefined };
}

export function identify(authz: Authz): MiddlewareHandler<Env> {
  return async (c, next) => {
    c.set('authz', authz);
    const id = c.req.header('x-user-id');
    c.set('subject', id === undefined || id === '' ? undefined : `user:${id}`);
    await next();
  };
}

// The caller's reference, or a 401. Anonymous is 401;
// authenticated-but-refused is 403, and conflating them tells a user
// to log in when logging in will not help.
export function caller(c: Context<Env>): string {
  const subject = c.get('subject');
  if (subject === undefined) throw new HttpError(401, 'provide an x-user-id header');
  return subject;
}

export async function requirePermission(
  c: Context<Env>,
  permission: 'document.read' | 'document.write' | 'document.manage',
  resource: string,
): Promise<void> {
  const allowed = await c.get('authz').can(caller(c), permission, resource);
  if (!allowed) throw new HttpError(403, `${permission} on ${resource} is not yours`);
}

(HttpError is a three-line class carrying a status — nothing library-specific.)

Routes: authorize before you look

src/app.ts. Every route does the same three things in the same order: who is calling (401 if nobody), are they allowed (403 if not), and only then does the row get looked up:

TypeScript
// GET /api/documents/:id — a caller who may not read learns nothing,
// whether or not the document exists.
app.get('/api/documents/:id', async (c) => {
  const id = c.req.param('id');
  await requirePermission(c, 'document.read', `document:${id}`);
  const document = documents.find(id);
  if (document === undefined) throw new HttpError(404, `no document ${id}`);
  return c.json(document);
});

// GET /api/documents — what can this person read? Asked of the graph,
// not of a WHERE clause, so it stays correct for rules added next week.
app.get('/api/documents', async (c) => {
  const { resources, truncated } = await authz.listResources({
    subject: caller(c),
    permission: 'document.read',
  });
  const ids = resources.map((reference) => reference.slice('document:'.length));
  return c.json({ documents: documents.findMany(ids), truncated });
});

Authorize before the row lookup. Inverting those two lines hands out a free existence oracle: 403 for “not yours”, 404 for “not there”, and an attacker subtracts one from the other. The price is that a 404 only happens for someone who was allowed to ask — in practice “the grant outlived the row”.

Grants are routes too — creating access is a write through the model, guarded by manage:

TypeScript
// POST /api/documents/:id/roles { role: 'acme:editor' } — hand a document to a role.
app.post('/api/documents/:id/roles', async (c) => {
  const id = c.req.param('id');
  await requirePermission(c, 'document.manage', `document:${id}`);
  const body = (await c.req.json().catch(() => ({}))) as { role?: unknown };
  if (body.role !== 'acme:editor' && body.role !== 'acme:viewer') {
    throw new HttpError(400, `unknown role ${String(body.role)}`);
  }
  const relation = body.role === 'acme:editor' ? 'editor' : 'viewer';
  await authz.grant({
    subject: `role:${body.role}#holder`,
    relation,
    resource: `document:${id}`,
  });
  return c.json({ granted: true }, 201);
});

// GET /api/documents/:id/why — the whole decision tree, for the question
// support actually asks. It needs no debugging and no logging, because the
// answer is computed from the same evaluation that produced the refusal.
app.get('/api/documents/:id/why', async (c) => {
  const id = c.req.param('id');
  await requirePermission(c, 'document.read', `document:${id}`);
  return c.json(
    await authz.explain({
      subject: caller(c),
      permission: 'document.read',
      resource: `document:${id}`,
    }),
  );
});

truncated is part of the contract, not a footnote. The list walk is budgeted, a budget that runs out mid-enumeration produces a partial list, and a bare array could not tell you that — so the route returns the flag even when it is false.

Run it

Boot the server (serve from @hono/node-server, port 3000), then talk to it:

Shell
npx tsx src/server.ts &
BOB=" -H 'x-user-id: bob'"; CAROL=" -H 'x-user-id: carol'"
curl -s$BOB localhost:3000/api/documents/1 | head -c 60; echo
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH$CAROL localhost:3000/api/documents/1
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/documents/1
Text
{"id":"1","title":"Onboarding","body":"Day one is paperwork..."}
403
401

Bob reads what his role grants him. Carol — a viewer — may not write. And nobody at all is 401, not 403: anonymous callers are unauthenticated, not forbidden.

Verify it

curl proves the server answers; assertions prove the decisions hold. src/verify.ts, run with npx tsx src/verify.ts — silence is green:

TypeScript
// Ownership, roles, and the line between them.
assert.strictEqual(await authz.can('user:bob', 'document.read', 'document:1'), true);
assert.strictEqual(await authz.can('user:bob', 'document.write', 'document:1'), true);
assert.strictEqual(await authz.can('user:carol', 'document.write', 'document:1'), false);
assert.strictEqual(await authz.can('user:bob', 'document.manage', 'document:1'), false);
assert.strictEqual(await authz.can('user:alice', 'document.manage', 'document:1'), true);

// The list agrees with the checks, and says whether it is complete.
const listed = await authz.listResources({ subject: 'user:bob', permission: 'document.read' });
assert.ok(listed.resources.includes('document:1'));
assert.strictEqual(listed.truncated, false);

// A refusal explains itself.
const denied = await authz.explain({
  subject: 'user:carol',
  permission: 'document.write',
  resource: 'document:1',
});
assert.strictEqual(denied.allowed, false);

Keep both. The transcript shows the system working end to end; the assertions pin the decisions so a later model edit that silently moves them fails loudly instead of shipping.

Memoize the hot path

Reads usually outnumber writes by orders of magnitude, so wrap the client once at boot and stop paying full evaluations for repeated questions:

TypeScript
const cached = withCache(authz, memoryCache(), { namespace: 'docs-api-v1' });

assert.strictEqual(await cached.can('user:dave', 'document.read', 'document:1'), false);
await cached.grant({ subject: 'user:dave', relation: 'viewer', resource: 'document:1' });
assert.strictEqual(await cached.can('user:dave', 'document.read', 'document:1'), true);

The middle line is the whole point: the false was memoized, the grant invalidated it by resource, and the last check re-evaluated instead of serving the stale denial. The namespace is required and should be versioned with the model — two applications sharing one cache must never read each other’s decisions. Swap memoryCache() for redisCache when the memo needs to outlive the process.

When to stop reading

There is no folder tree, no teams, no inheritance, no attributes and no conditions here. That is deliberate — it is the shape to internalise first. When the requirements outgrow it, the real-world API is the same library over a real database, with ReBAC and ABAC on one accumulated model.