CYART // SCHEMA · RBAC-ENGINE7 NODES · 6 LINKSDECISIONACTOR · Request — subject, action, resourceACTOR01Requestsubject, action, resourceSERVICE · Decision point — evaluate(…)SERVICE02Decision pointevaluate(…)DATA · Role store — user → roles → permsDATA03Role storeuser → roles → permsDATA · Attribute source — tenant, owner, timeDATA04Attribute sourcetenant, owner, timeDATA · Policy set — ordered, deny winsDATA05Policy setordered, deny winsDATA · Decision cache — TTL 60 s, keyed by hashDATA06Decision cacheTTL 60 s, keyed by hashSTREAM · Allow / Deny — + reason, + obligationsSTREAM07Allow / Deny+ reason, + obligations

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.

● Must know for interviews● MediumFREEDesign problem·55 min read·Intermediate → Advanced
RBACABACpolicyTypeScript

01Understand the problem

✓What you will learn
  • 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
◎Interview radar

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.

  1. isAllowed(subject, action, resource) returns allow or deny with reasons.
  2. Policies grant permissions to roles; subjects have roles per tenant.
  3. Policies may carry conditions over attributes of subject, resource and environment (resource.ownerId == subject.id, env.time within business hours).
  4. Explicit deny overrides any allow. No matching policy means deny.
  5. 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

The contractts
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 }
iWhy conditions are objects, not strings

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

FigEvaluation flow
CYART // SCHEMA · RBAC-ENGINE7 NODES · 6 LINKSDECISIONACTOR · Request — subject, action, resourceACTOR01Requestsubject, action, resourceSERVICE · Decision point — evaluate(…)SERVICE02Decision pointevaluate(…)DATA · Role store — user → roles → permsDATA03Role storeuser → roles → permsDATA · Attribute source — tenant, owner, timeDATA04Attribute sourcetenant, owner, timeDATA · Policy set — ordered, deny winsDATA05Policy setordered, deny winsDATA · Decision cache — TTL 60 s, keyed by hashDATA06Decision cacheTTL 60 s, keyed by hashSTREAM · Allow / Deny — + reason, + obligationsSTREAM07Allow / Deny+ reason, + obligations
  • ACTOR
  • SERVICE
  • DATA
  • STREAM
  • Live flow
  • Lookup / async
//The engine receives a request, filters policies by role, action and resource type, evaluates conditions, applies deny-overrides, returns a decision with the matched policy IDs and a reason, and notifies listeners.
The enginets
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))));
A safe condition libraryts
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(", ")}]`; }
}
▭On the whiteboard · Design choices3 points
01
deny wins
cross-tenant → deny first; any deny → deny; no match → deny
02
pure
no I/O in evaluate; attributes arrive in the request; engine is immutable
03
explain
matched policy IDs + reason in every Decision; listeners get both

04Tests that prove the property

vitestts
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[]>), so applies runs on a handful, not all fifty.
  • Policy loading: a PolicyParser turns JSON into Policy objects with a registry of condition types; unknown condition types fail loading, never evaluation.
  • ReBAC: add a RelationCondition backed by a pre-fetched relationship set; the engine stays pure because the fetch happens before isAllowed.
  • Obligations: extend Decision with obligations: ["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.

“How to say it in the room

"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."

Test yourself

A new requirement: admins may read any lesson in their tenant, but never lessons flagged `legalHold`. Minimal change?

Finished this one?
Tick it and it shows as done on the roadmap and the course.