Scoped roles.
A role lives at a scope: organization, workspace, project. An assignment says where it applies and whether it covers descendants.
Authorization for multi-tenant TypeScript
Rolebook is a scoped authorization engine. Bind a subject's assignments, hand it a trusted resource, and get a decision that fails closed and explains itself.
README
Authentication tells you who someone is. Rolebook decides what they may do to one specific resource inside an organization, workspace, or project. Subjects carry no roles: your application loads scoped assignments and passes the resource with its ancestry, and every decision returns a stable code, a reason, and a trace.
bun add rolebookPrivate preview. Not yet published to a public registry.
A role lives at a scope: organization, workspace, project. An assignment says where it applies and whether it covers descendants.
Denies are evaluated first across every applicable assignment. One matching deny ends the request.
You pass a snapshot with its full ancestry. A type-correct lookalike or a missing ancestor is denied, never guessed.
Compare subject, resource, and context facts with serializable expressions. Missing facts never grant and conservatively trigger denies.
An allow limited to fields cannot authorize a resource-wide request. A deny limited to fields does match one.
Every answer carries a stable code, a reason, and a bounded trace of the rules that were evaluated.
Malformed subjects, assignments, unknown roles, oversized structures: a closed decision. check() never throws.
Plain ESM with no database, network, or framework in the evaluator. Optional Hono adapter and first-party sharing packages.
Four calls cover most applications. The policy is plain data, so it lives in a file, is reviewed in a pull request, and is versioned like any other code.
Declare your resources, their actions, and their parents. Give each role a scope and a list of allow and deny rules, with optional conditions and fields. Rolebook validates, compiles, and freezes the whole thing up front.
import { definePolicy } from "rolebook";
export const policy = definePolicy({
version: "4.0.0",
resources: {
organization: { actions: ["read"] },
workspace: { parent: "organization", actions: ["read", "update"] },
project: { parent: "workspace", actions: ["read", "update", "delete"] },
},
roles: {
workspace_editor: {
scope: "workspace",
rules: [
{ effect: "allow", permission: "project:read" },
{
effect: "allow",
permission: "project:update",
where: {
fact: "resource.ownerId",
operator: "equals",
value: { fact: "subject.id" },
},
},
],
},
contractor: {
scope: "workspace",
rules: [{ effect: "deny", permission: "project:delete" }],
},
},
});Bind the subject with the assignments your application loaded for this request. Pass a trusted resource with its ancestry. You get back a decision object, never a bare boolean, so the reason travels with the answer.
import { createAuthorizer, trustedResource } from "rolebook";
const authorization = createAuthorizer(policy).bind({
subject: { type: "user", id: "user_123" },
assignments: [
{
subject: { type: "user", id: "user_123" },
role: "workspace_editor",
scope: { resource: { type: "workspace", id: "ws_456" }, appliesTo: "descendants" },
},
],
});
const project = trustedResource(
{ type: "project", id: "proj_1" },
[{ type: "workspace", id: "ws_456" }, { type: "organization", id: "org_1" }],
{ ownerId: "user_123", status: "draft" },
);
const decision = authorization.check({ permission: "project:update", resource: project });
decision.allowed; // true
decision.code; // "ALLOWED"
decision.matched?.role; // "workspace_editor"Guard routes with the Hono adapter, or call enforce() anywhere to throw anAuthorizationError that carries the denial. The adapter resolves a binding and a resource per route and contains no policy logic.
import { Hono } from "hono";
import { createRolebookMiddleware, type RolebookEnv } from "rolebook/middleware/hono";
const app = new Hono<RolebookEnv<typeof policy>>();
const middleware = createRolebookMiddleware({
policy,
getBinding: async (c) => loadAuthorizationContext(c), // { subject, assignments }
});
app.onError(middleware.onError);
app.delete(
"/projects/:id",
middleware.requirePermission(async (c) => ({
permission: "project:delete",
resource: await loadTrustedProject(c.req.param("id")),
})),
(c) => c.json({ ok: true }),
);When support asks why a user got a 403, the answer is already there: a stable code, a sentence, and the ordered trace of every rule that was considered.
const explanation = authorization.explain({
permission: "project:delete",
resource: project,
});
explanation.allowed; // false
explanation.code; // "EXPLICIT_DENY"
explanation.reason; // which rule, on which role, matched
explanation.trace; // every rule evaluated, in order
explanation.traceTruncated; // true if the bounded trace was cut shortTry it
Toggle assignments, pick a permission, move the resource. The result below is computed by the same engine that ships in the package, running in your browser.
createAuthorizer(policy)
.bind({
subject: { type: "user", id: "user_42" },
assignments: [workspace_editor@ws_1],
})
.explain({
permission: "project:update",
resource: trustedResource(proj_1, [ws_1, org_1], { ownerId: "user_42" }),
});ALLOWEDAn allow rule matched.
Matched allow rule on role workspace_editor for project:update.
workspace_viewer allow workspace:read Rule did not match all constraints.workspace_viewer allow project:read Rule did not match all constraints.workspace_editor allow project:update Rule matched all constraints.policy 4.0.0 · evaluated in the browser with the real engine · illustrative only, enforcement happens on your server
First-party packages
Most products need more than workspace roles: a project shared with three people, a folder handed to a new owner, an invitation that expires. Rolebook Sharing owns that layer and asks the engine for every final decision.
Membership, ownership transfer, invitations, revocation, audit events, and accessible-resource queries, per resource type, with one policy behind all of it.
Adapters for Better Auth and Clerk read workspace membership from where it already lives. Membership is eligibility, never access: nothing is granted implicitly.
An ORM-neutral adapter with migrations. Fences lock the authoritative rows inside each mutation, so a concurrent removal cannot leave a stale grant. One security contract runs against every adapter.
Map your policy's roles and permissions onto a resource type, plug in a storage adapter and an identity adapter, and the sharing service does the rest.
import { createHmacInvitationTokens, createSharing, defineSharing } from "@rolebook/sharing";
import { createBetterAuthIdentityAdapter } from "@rolebook/sharing-better-auth";
const definition = defineSharing({
policy,
resources: {
project: {
memberRoles: { editor: "project_editor" },
ownershipRole: "project_owner",
actions: { read: "project:read" },
management: {
members: "project:manage_members",
invitations: "project:manage_invitations",
ownership: "project:transfer_ownership",
},
adapter: projectSharingAdapter, // PostgreSQL, from @rolebook/sharing-postgres
},
},
});
export const sharing = createSharing({
definition,
identity: createBetterAuthIdentityAdapter({ directory }),
invitationTokens: createHmacInvitationTokens(process.env.SHARING_INVITATION_KEY!),
});Most products start with if (user.role === "admin"). It works until the third role, the second tenant, or the first audit.
A route allows what a resource check forbids. One decision model for routes, resources, conditions, and fields keeps every layer in agreement.
Ad hoc checks fail open when an assignment is missing or a role is misspelled. Rolebook fails closed and makes every denial explicit and deterministic.
A boolean says no. A decision says which role, which rule, and which fact. That is what support, security review, and your future self need.
Evaluation order
The same binding and the same resource always take the same path. There is no rule priority to tune and no ambiguity to debug.
Safety boundary
Where Rolebook stops
Rolebook evaluates policy. It deliberately does not store assignments, authenticate users, deliver audit events, or run a service in your request path. If your access model is dominated by recursive sharing graphs and cross-service traversal, use a relationship-based authorization database instead. Rolebook will not pretend to be one.