Design an authorisation engine (RBAC + ABAC)
Design the in-process policy engine a service calls on every request: roles and permissions, attribute conditions, explicit deny, deterministic evaluation, an audit event per decision and a test suite that proves deny-by-default. In TypeScript, from interfaces to working code.
01Understand the problem
- Model subjects, resources, actions and policies cleanly
- Evaluate with deny overrides and default deny
- Keep evaluation pure and deterministic so it is testable
- Emit audit events without coupling the engine to a logger
What they probe: the public interface, how deny wins, how conditions get their data, how you avoid a policy that accidentally grants everything, whether the engine can explain a decision. Where candidates slip: boolean soup in a single function, mutable global policy, attributes fetched inside evaluation, no reason in the result.
isAllowed(subject, action, resource)returns allow or deny with reasons.- Policies grant permissions to roles; subjects have roles per tenant.
- Policies may carry conditions over attributes of subject, resource and environment (
resource.ownerId == subject.id,env.time within business hours). - Explicit deny overrides any allow. No matching policy means deny.
- Every decision produces an audit event.
Non-functional: p99 under 1 ms for 50 policies; policy set replaceable at runtime atomically; no I/O during evaluation.
02Interfaces first
export type Effect = "allow" | "deny";
export interface Subject { id: string; tenantId: string; roles: readonly string[]; attrs: Readonly<Record<string, unknown>> }
export interface Resource { type: string; id: string; tenantId: string; attrs: Readonly<Record<string, unknown>> }
export interface Environment { now: Date; ip?: string; attrs: Readonly<Record<string, unknown>> }
export interface Request { subject: Subject; action: string; resource: Resource; env: Environment }
export interface Condition { evaluate(req: Request): boolean; describe(): string }
export interface Policy {
id: string;
effect: Effect;
roles: readonly string[]; // any of; "*" = everyone
actions: readonly string[]; // "lesson:read", "lesson:*"
resourceTypes: readonly string[]; // "lesson", "*"
conditions: readonly Condition[]; // all must hold
}
export interface Decision { effect: Effect; matched: readonly string[]; reason: string }
export interface AuthorizationEngine {
isAllowed(req: Request): Decision;
}
export interface DecisionListener { onDecision(req: Request, d: Decision): void }A Condition interface lets you ship a safe set (equality, membership, time window) and unit test each one, instead of evaluating expressions from a config file with a mini-language you now have to secure.
03Core model and evaluation
- ACTOR
- SERVICE
- DATA
- STREAM
- Live flow
- Lookup / async
export class PolicyEngine implements AuthorizationEngine {
private policies: readonly Policy[];
constructor(policies: readonly Policy[], private readonly listeners: readonly DecisionListener[] = []) {
this.policies = Object.freeze([...policies]);
}
/** Atomic replacement: callers holding the old engine keep a consistent snapshot. */
withPolicies(policies: readonly Policy[]): PolicyEngine {
return new PolicyEngine(policies, this.listeners);
}
isAllowed(req: Request): Decision {
const d = this.evaluate(req);
for (const l of this.listeners) l.onDecision(req, d);
return d;
}
private evaluate(req: Request): Decision {
if (req.subject.tenantId !== req.resource.tenantId) {
return { effect: "deny", matched: [], reason: "cross-tenant access" };
}
const matched = this.policies.filter((p) => this.applies(p, req));
const denies = matched.filter((p) => p.effect === "deny");
if (denies.length) return { effect: "deny", matched: denies.map((p) => p.id), reason: "explicit deny" };
const allows = matched.filter((p) => p.effect === "allow");
if (allows.length) return { effect: "allow", matched: allows.map((p) => p.id), reason: "allowed by policy" };
return { effect: "deny", matched: [], reason: "no matching policy" };
}
private applies(p: Policy, req: Request): boolean {
return (
matchesAny(p.roles, req.subject.roles) &&
matchesPattern(p.actions, req.action) &&
matchesPattern(p.resourceTypes, req.resource.type) &&
p.conditions.every((c) => c.evaluate(req))
);
}
}
const matchesAny = (allowed: readonly string[], have: readonly string[]) =>
allowed.includes("*") || have.some((r) => allowed.includes(r));
const matchesPattern = (patterns: readonly string[], value: string) =>
patterns.some((pat) => pat === "*" || pat === value || (pat.endsWith(":*") && value.startsWith(pat.slice(0, -1))));export class OwnerCondition implements Condition {
evaluate(r: Request) { return r.resource.attrs["ownerId"] === r.subject.id; }
describe() { return "resource.ownerId == subject.id"; }
}
export class TimeWindowCondition implements Condition {
constructor(private readonly startHour: number, private readonly endHour: number) {}
evaluate(r: Request) { const h = r.env.now.getUTCHours(); return h >= this.startHour && h < this.endHour; }
describe() { return `utc hour in [${this.startHour}, ${this.endHour})`; }
}
export class AttrInCondition implements Condition {
constructor(private readonly path: "subject" | "resource", private readonly key: string, private readonly values: readonly unknown[]) {}
evaluate(r: Request) { return this.values.includes(r[this.path].attrs[this.key]); }
describe() { return `${this.path}.${this.key} in [${this.values.join(", ")}]`; }
}04Tests that prove the property
import { describe, it, expect } from "vitest";
const base = (over: Partial<Request> = {}): Request => ({
subject: { id: "u1", tenantId: "t1", roles: ["learner"], attrs: {} },
action: "lesson:read",
resource: { type: "lesson", id: "l1", tenantId: "t1", attrs: { ownerId: "u9" } },
env: { now: new Date("2026-11-13T10:00:00Z"), attrs: {} },
...over,
});
describe("PolicyEngine", () => {
it("denies by default", () => {
expect(new PolicyEngine([]).isAllowed(base()).effect).toBe("deny");
});
it("allows a role with a matching policy", () => {
const e = new PolicyEngine([{ id: "p1", effect: "allow", roles: ["learner"], actions: ["lesson:read"], resourceTypes: ["lesson"], conditions: [] }]);
expect(e.isAllowed(base()).effect).toBe("allow");
});
it("explicit deny overrides allow", () => {
const e = new PolicyEngine([
{ id: "p1", effect: "allow", roles: ["*"], actions: ["*"], resourceTypes: ["*"], conditions: [] },
{ id: "d1", effect: "deny", roles: ["learner"], actions: ["lesson:*"], resourceTypes: ["lesson"], conditions: [] },
]);
const d = e.isAllowed(base());
expect(d.effect).toBe("deny");
expect(d.matched).toEqual(["d1"]);
});
it("never crosses tenants, even with a wildcard allow", () => {
const e = new PolicyEngine([{ id: "p1", effect: "allow", roles: ["*"], actions: ["*"], resourceTypes: ["*"], conditions: [] }]);
const d = e.isAllowed(base({ resource: { type: "lesson", id: "l2", tenantId: "t2", attrs: {} } }));
expect(d.effect).toBe("deny");
expect(d.reason).toBe("cross-tenant access");
});
it("conditions gate the allow", () => {
const e = new PolicyEngine([{ id: "own", effect: "allow", roles: ["learner"], actions: ["lesson:edit"], resourceTypes: ["lesson"], conditions: [new OwnerCondition()] }]);
expect(e.isAllowed(base({ action: "lesson:edit" })).effect).toBe("deny");
expect(e.isAllowed(base({ action: "lesson:edit", resource: { type: "lesson", id: "l1", tenantId: "t1", attrs: { ownerId: "u1" } } })).effect).toBe("allow");
});
});Notice which tests matter: default deny, deny overrides, tenant isolation with a wildcard allow. Those are the security properties. The happy path is one test among five.
05Extensions, follow-ups, how to say it
- Performance: index policies by action prefix and resource type at construction (
Map<string, Policy[]>), soappliesruns on a handful, not all fifty. - Policy loading: a
PolicyParserturns JSON intoPolicyobjects with a registry of condition types; unknown condition types fail loading, never evaluation. - ReBAC: add a
RelationConditionbacked by a pre-fetched relationship set; the engine stays pure because the fetch happens beforeisAllowed. - Obligations: extend
Decisionwithobligations: ["mask:ssn"]so an allow can carry constraints.
Q1Why is cross-tenant checked before policies?+
It is an invariant, not a policy. A misconfigured wildcard policy must not be able to break tenant isolation.
Q2How would you make policy changes safe?+
Policies are data: version them, load into a new engine instance, run the golden decision test suite against it, swap the reference atomically.
Q3Where does caching go?+
Outside the engine: a decorator that caches Decision by a hash of (subject roles+attrs, action, resource type+attrs, policy version) with a short TTL. The engine stays pure.
"The interface is one call: isAllowed(request) returning a decision with reasons. Inside, invariants first: cross-tenant is always deny. Then matching policies by role, action and type, conditions as small tested objects, deny overrides, default deny. It's pure and immutable so it's trivially testable and atomically swappable, and listeners handle audit so the engine never knows about logging."
A new requirement: admins may read any lesson in their tenant, but never lessons flagged `legalHold`. Minimal change?