Skip to content
Warlock.js v4.2.11

Define policies

A policy is the “…but only this one” rule that RBAC can’t express. Register one per permission, and keep each policy in the module that owns the resource.

Put policies in the owning module’s policies/ folder — an order policy lives with the orders module:

src/app/orders/policies/index.ts
import { definePolicy } from "@warlock.js/access";
definePolicy("orders.update", (user, order, ctx) =>
order.get("organization_id") === ctx.tenant &&
(order.get("customer_id") === user.id || ctx.hasRole("manager")),
);

Load it once from that module’s main.ts so it’s registered before any request (the module root stays just routes.ts + main.ts):

src/app/orders/main.ts
import "./policies";
await authorize(user, "orders.update"); // policy SKIPPED (grant only)
await authorize(user, "orders.update", { resource: order }); // policy runs

So a route gate (gate, no resource) verifies the grant, and the per-record condition runs in your service after you load the order. Class-level and instance-level stay cleanly separated.

  • No grant → denied; the policy never runs.
  • Grant, no policy registered → allowed.
  • Grant + policy → the policy decides.

Policies deny further — they can never let a user past a permission they don’t hold.

(user, resource, ctx). ctx carries the resolved tenant, the engine helpers hasRole(role) / hasPermission(perm), and any extra keys you passed on the check:

await authorize(user, "orders.refund", { resource: order, amount: 5000 });
definePolicy("orders.refund", (user, order, ctx) =>
ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
);
  • A policy that throws is treated as a denial (fail-closed) and logged.
  • Policies are keyed by permission name — define each once.
  • “Own resource” at graph scale (deep relationship chains) is ReBAC, which is out of scope; a policy covers the everyday ownership case.