Skip to content
Warlock.js v4.16.0

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 (RBAC-only) — and logs a one-time warning naming the permission. A typo’d permission name, or a forgotten import "./policies", silently degrades an instance check to a class-level one; the warning is the runtime signal that a permission you meant to scope per-resource isn’t actually scoped. Set strictPolicies: true in your access config to throw AccessConfigError instead of warning — see configuration.
  • 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.