Skip to content

Core Engine

This page answers: what does the Arbac class actually expose, what does evaluate() do step by step, and what are the type shapes you can rely on? It documents @aooth/arbac-core — the zero-dependency engine.

@aooth/arbac re-exports everything here verbatim, so you can import Arbac from either package.

class Arbac<TUserAttrs, TScope>

The evaluator. Holds a registry of roles and resources, pre-compiles per-resource allow/deny lists for each role, and answers evaluate() queries.

ts
class Arbac<TUserAttrs extends object, TScope extends object> {
  constructor();
  registerRole(role: TArbacRole<TUserAttrs, TScope>): this;
  registerResource(resource: string): this;
  evaluate<T extends string | undefined>(
    res: { resource: string; action: string },
    user: {
      id: T;
      roles: string[];
      attrs: TUserAttrs | ((id: T) => TUserAttrs | Promise<TUserAttrs>);
    },
  ): Promise<TArbacEvalResult<TScope>>;
}

Two generics:

  • TUserAttrs — the shape of the per-user attribute bag passed to scope functions. Constrained to object.
  • TScope — the shape of the scope objects rules emit. Constrained to object. Choose one stable shape across all rules so the union in scopes: TScope[] is homogeneous.

Constructor

new Arbac() — takes no arguments. There is nothing to configure.

ts
import { Arbac } from "@aooth/arbac";

type Attrs = { dept: string };
type Scope = { dept: string };

const arbac = new Arbac<Attrs, Scope>();

registerRole(role): this

Stores the role in the internal roles[id] map and re-evaluates the role against every already-registered resource. Idempotent: re-registering the same id overwrites.

ts
arbac.registerRole({
  id: "editor",
  name: "Editor",
  description: "Can read and update articles in their department.",
  rules: [
    { resource: "articles", action: "read" },
    { resource: "articles", action: "update", scope: (a) => ({ dept: a.dept }) },
    { resource: "articles", action: "publish", effect: "deny" },
  ],
});

Returns this, so calls chain: arbac.registerRole(a).registerRole(b).

registerResource(resource): this

Idempotent — no-op if the resource is already registered. Otherwise it re-evaluates every known role against the resource, caching the matching allow/deny lists.

evaluate() auto-calls this for the resource it's asked about, so explicit pre-registration is rarely needed. The one reason to call it is to pre-warm caches at startup.

Long-lived processes with unbounded resource IDs

Because evaluate() auto-registers the resource it sees, the internal resources map grows unboundedly if you keep evaluating against new resource IDs. In short-lived workers this is fine; in long-running services with high-cardinality resources, register a finite set of resource patterns via roles (using **) and avoid synthesising per-entity resource IDs.

evaluate(res, user): Promise<TArbacEvalResult<TScope>>

Async because user.attrs may be a lazy (userId) => Promise<TUserAttrs> resolver.

ts
const result = await arbac.evaluate(
  { resource: "articles", action: "update" },
  {
    id: "u1",
    roles: ["editor"],
    attrs: { dept: "sales" },
  },
);
// → { allowed: true, scopes: [{ dept: 'sales' }] }

With a lazy attrs resolver:

ts
await arbac.evaluate(
  { resource: "articles", action: "read" },
  {
    id: "u1",
    roles: ["editor"],
    attrs: async (id) => loadUserAttrs(id), // called at most once per evaluate()
  },
);

The resolver is invoked the first time any matching scope function needs userAttrs. Subsequent scope functions in the same evaluate() call reuse the cached value.

Evaluation algorithm

The implementation runs these steps, in order:

  1. Ensure the resource is registered (auto-register if new) so per-role allow/deny lists exist.
  2. Resolve the user's roles by lookup in resources[resource][roleId]. Unknown role IDs emit console.warn once per role ID (per process).
  3. Empty-roles short-circuit. If no resolved roles, return { allowed: false } with no scopes key.
  4. Deny pass — first. Iterate every resolved role's deny list; if any pre-compiled _actionRegex matches the requested action, return { allowed: false } immediately.
  5. Allow pass. Iterate every resolved role's allow list. For each match:
    • If the rule has a scope fn — lazily resolve userAttrs, then push rule.scope(userAttrs, String(user.id)).
    • Otherwise push {} (the universe sentinel).
  6. Return allowed ? { allowed: true, scopes } : { allowed: false } (branches on the allowed flag — at least one allow rule matched — not on scopes.length; a universe-sentinel {} still counts as a match).

A few invariants follow directly:

  • Deny precedes allow. There is no specificity weighting.
  • No precedence among roles. Allow scopes from multiple roles are concatenated.
  • user.id is stringified before being passed to scope fns: rule.scope(userAttrs, String(user.id)).

Implementation notes

The engine mutates rule objects during pre-compilation to attach internal regex caches. Don't Object.freeze() rule literals before registration.

Type reference

TArbacEvalResult<TScope>

ts
interface TArbacEvalResult<TScope> {
  allowed: boolean;
  scopes?: TScope[]; // present only when allowed === true
}

Practical pattern for callers:

ts
const r = await arbac.evaluate(res, user);
if (!r.allowed) throw new ForbiddenError();
applyToQuery(r.scopes); // TS narrows scopes to TScope[]

TArbacRole<TUserAttrs, TScope>

ts
interface TArbacRole<TUserAttrs, TScope> {
  id: string;
  name?: string;
  description?: string;
  rules: Array<TArbacRule<TUserAttrs, TScope>>;
}

id is the lookup key. name and description are for UIs and audit logs — the engine doesn't read them.

TArbacRule<TUserAttrs, TScope>

A discriminated union, distinguished by the presence of effect:

ts
type TArbacRule<TUserAttrs, TScope> =
  | {
      resource: string;
      action: string;
      scope?: (userAttrs: TUserAttrs, userId: string) => TScope;
      effect?: never; // allow (implicit)
    }
  | {
      resource: string;
      action: string;
      effect: "deny";
      scope?: never; // deny can never carry a scope
    };

Two consequences:

  • A deny rule cannot carry a scope. A deny is total — it answers { allowed: false } end of story.
  • An allow rule cannot set effect: 'allow' explicitly. The implementation falls back to 'allow' when effect is missing; the type forbids the literal.

arbacPatternToRegex(input: string): RegExp

ts
function arbacPatternToRegex(input: string): RegExp;

The wildcard matcher exposed for inspection or reuse. * matches a single dot-separated segment; ** matches across segments. The . separator is hard-coded.

ts
import { arbacPatternToRegex } from "@aooth/arbac";

arbacPatternToRegex("com.resource.db.*");
// → /^com\.resource\.db\.[^.]*$/

Worked example — two roles, three scopes

A user has both an editor role bounded to their department and a regional role bounded to their region. They request update on articles.

ts
type Attrs = { dept: string; region: string };
type Scope = { dept?: string; region?: string };

const arbac = new Arbac<Attrs, Scope>();

arbac.registerRole({
  id: "editor",
  rules: [
    { resource: "articles", action: "read" },
    { resource: "articles", action: "update", scope: (a) => ({ dept: a.dept }) },
    { resource: "articles", action: "publish", effect: "deny" },
  ],
});

arbac.registerRole({
  id: "regional",
  rules: [
    { resource: "articles", action: "*", scope: (a) => ({ region: a.region }) },
    { resource: "articles", action: "delete", effect: "deny" },
  ],
});

await arbac.evaluate(
  { resource: "articles", action: "update" },
  {
    id: "u1",
    roles: ["editor", "regional"],
    attrs: { dept: "sales", region: "EMEA" },
  },
);
// → {
//     allowed: true,
//     scopes: [
//       { dept:   'sales' },     // from editor.update
//       { region: 'EMEA'  },     // from regional.*
//     ],
//   }

The caller now decides how to apply both scopes to a query. The UNION interpretation says: articles in sales dept OR articles in EMEA region. That's typically not what you want — you want the intersection ("must be both"). That intersection is the application layer's job, often expressed by emitting { dept, region } from a single scope fn rather than two rules. See Scope Merging for the multi-role union utilities.

Calling evaluate() with action: 'publish' returns { allowed: false } (editor's explicit deny wins). Calling with action: 'delete' also returns { allowed: false } (regional's deny wins, even though no allow rule matched anyway).

Gotchas

  • Unknown roles warn once. First time an unknown role ID is requested, the engine logs once. Useful in dev — confirm role IDs at boot.
  • Scopes array can grow unbounded. N matching allow rules across roles produce N scope entries. Deduplicate (or merge) at the caller — see Scope Merging.
  • No scopes key when allowed is false. Don't write r.scopes?.length to mean "denied"; write !r.allowed.
  • {} is not "no access". It's the universe sentinel — "no restriction". Treat it as a wildcard widener inside a union.
  • Stringified userId. Scope fns get String(user.id). Pass id already as a string if your store uses string keys.

Next

  • Builder API — replace hand-rolled TArbacRole literals with a chainable, generic-aware API.
  • Privilege Factories — bundle related rules into reusable named units.

Released under the MIT License.