Authorization for multi-tenant TypeScript

Decide who can do what. Then explain why.

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.

Version
4
Dependencies
None
Network calls
None

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 rolebook

Private preview. Not yet published to a public registry.

Identity provider→who is the subject?
Your application→which assignments, which resource?
Rolebook→is this allowed, and why?

What you get

01

Scoped roles.

A role lives at a scope: organization, workspace, project. An assignment says where it applies and whether it covers descendants.

02

Deny before allow.

Denies are evaluated first across every applicable assignment. One matching deny ends the request.

03

Trusted resources.

You pass a snapshot with its full ancestry. A type-correct lookalike or a missing ancestor is denied, never guessed.

04

Conditions on facts.

Compare subject, resource, and context facts with serializable expressions. Missing facts never grant and conservatively trigger denies.

05

Field-level rules.

An allow limited to fields cannot authorize a resource-wide request. A deny limited to fields does match one.

06

Explainable decisions.

Every answer carries a stable code, a reason, and a bounded trace of the rules that were evaluated.

07

Fails closed on bad input.

Malformed subjects, assignments, unknown roles, oversized structures: a closed decision. check() never throws.

08

Nothing in the way.

Plain ESM with no database, network, or framework in the evaluator. Optional Hono adapter and first-party sharing packages.

How it works

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.

1

Define a policy

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.

policy.ts
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" }],
    },
  },
});
2

Bind and check

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.

projects.ts
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"
3

Enforce at the edge

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.

routes.ts
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 }),
);
4

Explain a decision

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.

explain.ts
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 short

Try it

Make a decision

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.

Assignments for user_42
Permission
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" }),
  });
ALLOWEDALLOWED

An allow rule matched.

Matched allow rule on role workspace_editor for project:update.

  1. not matchedworkspace_viewer allow workspace:read Rule did not match all constraints.
  2. not matchedworkspace_viewer allow project:read Rule did not match all constraints.
  3. matchedworkspace_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

Sharing, built on the same decisions

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.

Per-resource membership

Membership, ownership transfer, invitations, revocation, audit events, and accessible-resource queries, per resource type, with one policy behind all of it.

Your identity provider stays the source

Adapters for Better Auth and Clerk read workspace membership from where it already lives. Membership is eligibility, never access: nothing is granted implicitly.

PostgreSQL with transactional fences

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.

→

Define once, share anything

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.

sharing.ts
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!),
});

Why a policy, not scattered checks

Most products start with if (user.role === "admin"). It works until the third role, the second tenant, or the first audit.

Checks drift apart

A route allows what a resource check forbids. One decision model for routes, resources, conditions, and fields keeps every layer in agreement.

Denials are implicit

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.

Nobody can explain a 403

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

Deterministic, every time

The same binding and the same resource always take the same path. There is no rule priority to tune and no ambiguity to debug.

  1. 01ValidateReject malformed subjects, resources, and the entire assignment collection before anything else.
  2. 02ScopeKeep only the assignments whose scope covers the resource, using the supplied ancestry.
  3. 03DeniesEvaluate deny rules across every applicable assignment. Any match ends the request.
  4. 04AllowsEvaluate allow rules with their conditions and field constraints.
  5. 05DefaultDeny with NOT_GRANTED when nothing matched.

Safety boundary

Closed by default

  • A subject never carries roles. Access comes only from explicit scoped assignments.
  • The whole assignment collection is validated first. One malformed entry denies the binding.
  • Denies are evaluated before allows across every applicable assignment.
  • An unknown condition never grants an allow and conservatively matches a deny.
  • Missing or malformed facts never grant access.
  • A field-restricted allow cannot authorize a resource-wide request.
  • Resource ancestry is validated. A right-typed ancestor id is not proof of ancestry.
  • Policies are validated, canonicalized, compiled, and frozen.
  • check() never throws. A fault becomes a closed INVALID_REQUEST decision.
  • Every decision carries a stable code and a reason.

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.

Add your first scoped check today.