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
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/nodeNode 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:
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';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:
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:
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:
// 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:
// 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:
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{"id":"1","title":"Onboarding","body":"Day one is paperwork..."}
403
401Bob 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:
// 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:
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.