@warlock.js/access 5.23.2 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -5,12 +5,12 @@
5
5
  "environment": "server"
6
6
  },
7
7
  "peerDependencies": {
8
- "@warlock.js/auth": "5.23.2",
9
- "@warlock.js/cache": "5.23.2",
10
- "@warlock.js/cascade": "5.23.2",
11
- "@warlock.js/core": "5.23.2",
12
- "@warlock.js/logger": "5.23.2",
13
- "@warlock.js/seal": "5.23.2"
8
+ "@warlock.js/auth": "5.25.0",
9
+ "@warlock.js/cache": "5.25.0",
10
+ "@warlock.js/cascade": "5.25.0",
11
+ "@warlock.js/core": "5.25.0",
12
+ "@warlock.js/logger": "5.25.0",
13
+ "@warlock.js/seal": "5.25.0"
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "author": "hassanzohdy",
29
29
  "license": "MIT",
30
- "version": "5.23.2",
30
+ "version": "5.25.0",
31
31
  "main": "./cjs/index.cjs",
32
32
  "module": "./esm/index.mjs",
33
33
  "types": "./esm/index.d.mts",
@@ -1,71 +1,71 @@
1
- ---
2
- name: check-permissions
3
- description: 'Check permissions in `@warlock.js/access` — `can` / `cannot` / `canAll` / `canAny` (boolean), `authorize` / `authorizeAll` / `authorizeAny` (throw 403), and the `gate` / `gateAny` / `gateAll` route middleware. Class-level vs instance-level (pass a `resource` to run its policy). TRIGGER: `can(`, `authorize(`, `canAll`, `canAny`, `gate`, `gateAny`, `gateAll`, "check a permission", "protect route by permission", "403 forbidden", "any vs all permissions". Skip: ownership / tenant conditions — `@warlock.js/access/define-policies/SKILL.md`; role checks — `@warlock.js/access/manage-roles/SKILL.md`.'
4
- ---
5
-
6
- # Check permissions
7
-
8
- ## In a route — class-level gate
9
-
10
- Stack it AFTER `authMiddleware` (which sets `request.locals.user`):
11
-
12
- ```ts
13
- import { authMiddleware } from "@warlock.js/auth";
14
- import { gate } from "@warlock.js/access";
15
-
16
- router.post("/orders", createOrder, {
17
- middleware: [authMiddleware([]), gate("orders.create")],
18
- });
19
- ```
20
-
21
- A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.
22
-
23
- ## In code — boolean
24
-
25
- ```ts
26
- import { can, cannot, canAll, canAny } from "@warlock.js/access";
27
-
28
- if (await can(user, "orders.update")) { /* … */ }
29
-
30
- await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
31
- await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
32
- ```
33
-
34
- > **Multi-permission checks are named, never defaulted.** Use `canAll` / `canAny` (and `gateAll` / `gateAny`). There is no `can(user, [array])` — a wrong implicit any/all default is a silent privilege-escalation or a lockout.
35
-
36
- ## In a service — throw
37
-
38
- ```ts
39
- import { authorize, authorizeAll } from "@warlock.js/access";
40
-
41
- await authorize(user, "orders.update"); // throws ForbiddenError (403, EC100) on deny
42
- await authorizeAll(user, ["orders.update", "orders.viewCost"]);
43
- ```
44
-
45
- ## Instance-level — "this resource"
46
-
47
- Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):
48
-
49
- ```ts
50
- const order = await Order.find(orderId);
51
-
52
- await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
53
- ```
54
-
55
- - `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
56
- - `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").
57
-
58
- ## Wildcards
59
-
60
- `*` grants everything; `orders.*` covers `orders.update` and any nested `orders.update.status` — but **not** the bare `orders` (the prefix needs a trailing `.`, so granting `orders.*` and checking `orders` is silently denied). Give a role `["*"]` to make it a super-admin.
61
-
62
- ## Gotchas
63
-
64
- - **Fails closed, but config errors are loud.** A resolver or policy that throws → denied and logged (a user with no roles is denied, never allowed by accident). The one exception: `AccessConfigError` (e.g. no resolver configured) is **re-thrown**, not denied — a misconfig must surface, not hide. A cache outage is different again: it degrades to the resolver (the cache fails *open*; the decision still fails closed).
65
- - Stack `gate` **after** `authMiddleware` — it reads `request.locals.user`; without an authenticated user it `403`s.
66
- - The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).
67
-
68
- ## See also
69
-
70
- - [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
71
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.
1
+ ---
2
+ name: check-permissions
3
+ description: 'Check permissions in `@warlock.js/access` — `can` / `cannot` / `canAll` / `canAny` (boolean), `authorize` / `authorizeAll` / `authorizeAny` (throw 403), and the `gate` / `gateAny` / `gateAll` route middleware. Class-level vs instance-level (pass a `resource` to run its policy). TRIGGER: `can(`, `authorize(`, `canAll`, `canAny`, `gate`, `gateAny`, `gateAll`, "check a permission", "protect route by permission", "403 forbidden", "any vs all permissions". Skip: ownership / tenant conditions — `@warlock.js/access/define-policies/SKILL.md`; role checks — `@warlock.js/access/manage-roles/SKILL.md`.'
4
+ ---
5
+
6
+ # Check permissions
7
+
8
+ ## In a route — class-level gate
9
+
10
+ Stack it AFTER `authMiddleware` (which sets `request.locals.user`):
11
+
12
+ ```ts
13
+ import { authMiddleware } from "@warlock.js/auth";
14
+ import { gate } from "@warlock.js/access";
15
+
16
+ router.post("/orders", createOrder, {
17
+ middleware: [authMiddleware([]), gate("orders.create")],
18
+ });
19
+ ```
20
+
21
+ A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.
22
+
23
+ ## In code — boolean
24
+
25
+ ```ts
26
+ import { can, cannot, canAll, canAny } from "@warlock.js/access";
27
+
28
+ if (await can(user, "orders.update")) { /* … */ }
29
+
30
+ await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
31
+ await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
32
+ ```
33
+
34
+ > **Multi-permission checks are named, never defaulted.** Use `canAll` / `canAny` (and `gateAll` / `gateAny`). There is no `can(user, [array])` — a wrong implicit any/all default is a silent privilege-escalation or a lockout.
35
+
36
+ ## In a service — throw
37
+
38
+ ```ts
39
+ import { authorize, authorizeAll } from "@warlock.js/access";
40
+
41
+ await authorize(user, "orders.update"); // throws ForbiddenError (403, EC100) on deny
42
+ await authorizeAll(user, ["orders.update", "orders.viewCost"]);
43
+ ```
44
+
45
+ ## Instance-level — "this resource"
46
+
47
+ Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):
48
+
49
+ ```ts
50
+ const order = await Order.find(orderId);
51
+
52
+ await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
53
+ ```
54
+
55
+ - `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
56
+ - `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").
57
+
58
+ ## Wildcards
59
+
60
+ `*` grants everything; `orders.*` covers `orders.update` and any nested `orders.update.status` — but **not** the bare `orders` (the prefix needs a trailing `.`, so granting `orders.*` and checking `orders` is silently denied). Give a role `["*"]` to make it a super-admin.
61
+
62
+ ## Gotchas
63
+
64
+ - **Fails closed, but config errors are loud.** A resolver or policy that throws → denied and logged (a user with no roles is denied, never allowed by accident). The one exception: `AccessConfigError` (e.g. no resolver configured) is **re-thrown**, not denied — a misconfig must surface, not hide. A cache outage is different again: it degrades to the resolver (the cache fails *open*; the decision still fails closed).
65
+ - Stack `gate` **after** `authMiddleware` — it reads `request.locals.user`; without an authenticated user it `403`s.
66
+ - The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).
67
+
68
+ ## See also
69
+
70
+ - [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
71
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.
@@ -1,89 +1,89 @@
1
- ---
2
- name: configure-access
3
- description: 'Configure `@warlock.js/access` — the REQUIRED `resolver` (the quickstart `DefaultAccessResolver` over a fixed code map vs the ejected, DB-backed `DatabaseAccessResolver`), `cache: { ttl }`, and the ambient tenant via the resolver''s optional `resolveTenant()`. TRIGGER: `AccessConfigurations`, `config.access`, `src/config/access.ts`, "set up permissions", "DefaultAccessResolver", "DatabaseAccessResolver", "access cache ttl", "access tenant". Skip: checking permissions — `@warlock.js/access/check-permissions/SKILL.md`; custom storage — `@warlock.js/access/implement-resolver/SKILL.md`.'
4
- ---
5
-
6
- # Configure access
7
-
8
- The resolver is the **one required piece**. It tells the engine how to read a user's roles + permissions. There is no separate `roles` config map — the role→permission catalog lives INSIDE the resolver.
9
-
10
- ## Quickstart — a fixed, code-defined catalog
11
-
12
- `DefaultAccessResolver` reads a user's roles from `user.get("roles")` (or a single `user.get("role")`) and maps them to permissions through a code map. No tables, no migrations.
13
-
14
- ```ts title="src/config/access.ts"
15
- import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";
16
-
17
- const access: AccessConfigurations = {
18
- resolver: new DefaultAccessResolver({
19
- owner: ["*"], // `*` is the super-grant
20
- editor: ["orders.*", "posts.create"], // `orders.*` covers orders.update, orders.view, …
21
- viewer: ["orders.view"],
22
- }),
23
- };
24
-
25
- export default access;
26
- ```
27
-
28
- `can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.
29
-
30
- > `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.
31
-
32
- ## Real apps — dynamic, DB-backed roles
33
-
34
- `npx warlock add access` ejects a `DatabaseAccessResolver` (plus the `Role` + `UserRole` tables) into `src/app/access/`. Roles come from the `user_roles` table (multi-role, tenant-aware); permissions are joined through the `roles` catalog table — so admins manage both at runtime, in the DB.
35
-
36
- ```ts title="src/config/access.ts"
37
- import { type AccessConfigurations } from "@warlock.js/access";
38
- import { DatabaseAccessResolver } from "app/access/services/access-resolver";
39
-
40
- const access: AccessConfigurations = {
41
- resolver: new DatabaseAccessResolver(),
42
- };
43
-
44
- export default access;
45
- ```
46
-
47
- Assign roles via the ejected `UserRole.assign(user, "editor", tenant)` — it flushes that user's cached set for you (see [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md)).
48
-
49
- ## The full shape
50
-
51
- ```ts
52
- type AccessConfigurations = {
53
- resolver: AccessResolver; // REQUIRED — reads roles + permissions (+ optional tenant)
54
- cache?: {
55
- ttl?: string | number; // resolved-set TTL, default "10m"
56
- };
57
- strictPolicies?: boolean; // default true — see below
58
- };
59
- ```
60
-
61
- ### `strictPolicies`
62
-
63
- An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
64
-
65
- Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
66
-
67
- > **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
68
-
69
- A missing resolver throws `AccessConfigError` **at boot** — the framework's access connector wires `src/config/access.ts` (the same way the notifications connector wires `config/notifications.ts`), so a misconfig fails at startup, never a silent deny.
70
-
71
- ## Tenant
72
-
73
- Checks accept an explicit tenant (`can(user, "x", { tenant })`), but add a `resolveTenant(user)` to your resolver so you never pass it on the happy path. Derive the tenant from the **user**, not from request input — a client-supplied `organizationId` is attacker-controlled and would let a user check (or act) across tenant boundaries:
74
-
75
- ```ts
76
- public resolveTenant(user: Auth): string | undefined {
77
- return user.get("organization_id");
78
- }
79
- ```
80
-
81
- ## Caching
82
-
83
- The engine caches each user's resolved set (`cache.ttl`, default `10m`). Roles changed out of band? Call `access.flush(user, tenant)`. The cache is **best-effort**: an outage degrades to the resolver, it never denies.
84
-
85
- ## See also
86
-
87
- - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
88
- - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
89
- ```
1
+ ---
2
+ name: configure-access
3
+ description: 'Configure `@warlock.js/access` — the REQUIRED `resolver` (the quickstart `DefaultAccessResolver` over a fixed code map vs the ejected, DB-backed `DatabaseAccessResolver`), `cache: { ttl }`, and the ambient tenant via the resolver''s optional `resolveTenant()`. TRIGGER: `AccessConfigurations`, `config.access`, `src/config/access.ts`, "set up permissions", "DefaultAccessResolver", "DatabaseAccessResolver", "access cache ttl", "access tenant". Skip: checking permissions — `@warlock.js/access/check-permissions/SKILL.md`; custom storage — `@warlock.js/access/implement-resolver/SKILL.md`.'
4
+ ---
5
+
6
+ # Configure access
7
+
8
+ The resolver is the **one required piece**. It tells the engine how to read a user's roles + permissions. There is no separate `roles` config map — the role→permission catalog lives INSIDE the resolver.
9
+
10
+ ## Quickstart — a fixed, code-defined catalog
11
+
12
+ `DefaultAccessResolver` reads a user's roles from `user.get("roles")` (or a single `user.get("role")`) and maps them to permissions through a code map. No tables, no migrations.
13
+
14
+ ```ts title="src/config/access.ts"
15
+ import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";
16
+
17
+ const access: AccessConfigurations = {
18
+ resolver: new DefaultAccessResolver({
19
+ owner: ["*"], // `*` is the super-grant
20
+ editor: ["orders.*", "posts.create"], // `orders.*` covers orders.update, orders.view, …
21
+ viewer: ["orders.view"],
22
+ }),
23
+ };
24
+
25
+ export default access;
26
+ ```
27
+
28
+ `can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.
29
+
30
+ > `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.
31
+
32
+ ## Real apps — dynamic, DB-backed roles
33
+
34
+ `npx warlock add access` ejects a `DatabaseAccessResolver` (plus the `Role` + `UserRole` tables) into `src/app/access/`. Roles come from the `user_roles` table (multi-role, tenant-aware); permissions are joined through the `roles` catalog table — so admins manage both at runtime, in the DB.
35
+
36
+ ```ts title="src/config/access.ts"
37
+ import { type AccessConfigurations } from "@warlock.js/access";
38
+ import { DatabaseAccessResolver } from "app/access/services/access-resolver";
39
+
40
+ const access: AccessConfigurations = {
41
+ resolver: new DatabaseAccessResolver(),
42
+ };
43
+
44
+ export default access;
45
+ ```
46
+
47
+ Assign roles via the ejected `UserRole.assign(user, "editor", tenant)` — it flushes that user's cached set for you (see [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md)).
48
+
49
+ ## The full shape
50
+
51
+ ```ts
52
+ type AccessConfigurations = {
53
+ resolver: AccessResolver; // REQUIRED — reads roles + permissions (+ optional tenant)
54
+ cache?: {
55
+ ttl?: string | number; // resolved-set TTL, default "10m"
56
+ };
57
+ strictPolicies?: boolean; // default true — see below
58
+ };
59
+ ```
60
+
61
+ ### `strictPolicies`
62
+
63
+ An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
64
+
65
+ Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
66
+
67
+ > **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
68
+
69
+ A missing resolver throws `AccessConfigError` **at boot** — the framework's access connector wires `src/config/access.ts` (the same way the notifications connector wires `config/notifications.ts`), so a misconfig fails at startup, never a silent deny.
70
+
71
+ ## Tenant
72
+
73
+ Checks accept an explicit tenant (`can(user, "x", { tenant })`), but add a `resolveTenant(user)` to your resolver so you never pass it on the happy path. Derive the tenant from the **user**, not from request input — a client-supplied `organizationId` is attacker-controlled and would let a user check (or act) across tenant boundaries:
74
+
75
+ ```ts
76
+ public resolveTenant(user: Auth): string | undefined {
77
+ return user.get("organization_id");
78
+ }
79
+ ```
80
+
81
+ ## Caching
82
+
83
+ The engine caches each user's resolved set (`cache.ttl`, default `10m`). Roles changed out of band? Call `access.flush(user, tenant)`. The cache is **best-effort**: an outage degrades to the resolver, it never denies.
84
+
85
+ ## See also
86
+
87
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
88
+ - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
89
+ ```
@@ -1,68 +1,68 @@
1
- ---
2
- name: define-policies
3
- description: 'ABAC conditions in `@warlock.js/access` — `definePolicy(permission, (user, resource, ctx) => boolean)` adds an instance-level rule (ownership / tenant / state) on top of the RBAC grant, evaluated when an authorization check carries a `resource`. TRIGGER: `definePolicy`, "ownership check", "only their own", "can edit this specific record", "ABAC", "policy", "resource-level permission", `authorize(user, perm, { resource })`. Skip: plain grant checks — `@warlock.js/access/check-permissions/SKILL.md`.'
4
- ---
5
-
6
- # Define policies (ABAC)
7
-
8
- RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
9
-
10
- ## Register a condition
11
-
12
- Policies live **per module**, in `src/app/<module>/policies/`, and are loaded by an `import "./policies";` side-effect in that module's `main.ts` — a policy defined in an unimported file silently never registers.
13
-
14
- ```ts title="src/app/orders/policies/index.ts"
15
- import { definePolicy } from "@warlock.js/access";
16
-
17
- definePolicy("orders.update", (user, order, ctx) =>
18
- order.get("organization_id") === ctx.tenant &&
19
- (order.get("customer_id") === user.id || ctx.hasRole("manager")),
20
- );
21
- ```
22
-
23
- ```ts title="src/app/orders/main.ts"
24
- import "./policies"; // register this module's policies at boot
25
- ```
26
-
27
- ## It runs ONLY on an instance check
28
-
29
- ```ts
30
- await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
31
- await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
32
- ```
33
-
34
- So `gate("orders.update")` (a route gate, no resource) checks the grant; the per-order rule runs in your service, after you load the order.
35
-
36
- ## The decision = grant AND policy
37
-
38
- - No grant → denied (the policy never runs).
39
- - Grant + no policy registered → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (allowed).
40
- - Grant + policy → the policy decides.
41
-
42
- Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
43
-
44
- ## The policy context
45
-
46
- `(user, resource, ctx)`, where `ctx` carries:
47
-
48
- - `tenant` — the resolved tenant.
49
- - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
50
- - any extra keys you passed on the check:
51
-
52
- ```ts
53
- await authorize(user, "orders.refund", { resource: order, amount: 5000 });
54
-
55
- definePolicy("orders.refund", (user, order, ctx) =>
56
- ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
57
- );
58
- ```
59
-
60
- ## Gotchas
61
-
62
- - Policies are global by permission name — define each once at boot.
63
- - A throwing policy is treated as a **denial** (fail-closed) and logged.
64
- - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
65
-
66
- ## See also
67
-
68
- - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.
1
+ ---
2
+ name: define-policies
3
+ description: 'ABAC conditions in `@warlock.js/access` — `definePolicy(permission, (user, resource, ctx) => boolean)` adds an instance-level rule (ownership / tenant / state) on top of the RBAC grant, evaluated when an authorization check carries a `resource`. TRIGGER: `definePolicy`, "ownership check", "only their own", "can edit this specific record", "ABAC", "policy", "resource-level permission", `authorize(user, perm, { resource })`. Skip: plain grant checks — `@warlock.js/access/check-permissions/SKILL.md`.'
4
+ ---
5
+
6
+ # Define policies (ABAC)
7
+
8
+ RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
9
+
10
+ ## Register a condition
11
+
12
+ Policies live **per module**, in `src/app/<module>/policies/`, and are loaded by an `import "./policies";` side-effect in that module's `main.ts` — a policy defined in an unimported file silently never registers.
13
+
14
+ ```ts title="src/app/orders/policies/index.ts"
15
+ import { definePolicy } from "@warlock.js/access";
16
+
17
+ definePolicy("orders.update", (user, order, ctx) =>
18
+ order.get("organization_id") === ctx.tenant &&
19
+ (order.get("customer_id") === user.id || ctx.hasRole("manager")),
20
+ );
21
+ ```
22
+
23
+ ```ts title="src/app/orders/main.ts"
24
+ import "./policies"; // register this module's policies at boot
25
+ ```
26
+
27
+ ## It runs ONLY on an instance check
28
+
29
+ ```ts
30
+ await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
31
+ await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
32
+ ```
33
+
34
+ So `gate("orders.update")` (a route gate, no resource) checks the grant; the per-order rule runs in your service, after you load the order.
35
+
36
+ ## The decision = grant AND policy
37
+
38
+ - No grant → denied (the policy never runs).
39
+ - Grant + no policy registered → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (allowed).
40
+ - Grant + policy → the policy decides.
41
+
42
+ Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
43
+
44
+ ## The policy context
45
+
46
+ `(user, resource, ctx)`, where `ctx` carries:
47
+
48
+ - `tenant` — the resolved tenant.
49
+ - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
50
+ - any extra keys you passed on the check:
51
+
52
+ ```ts
53
+ await authorize(user, "orders.refund", { resource: order, amount: 5000 });
54
+
55
+ definePolicy("orders.refund", (user, order, ctx) =>
56
+ ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
57
+ );
58
+ ```
59
+
60
+ ## Gotchas
61
+
62
+ - Policies are global by permission name — define each once at boot.
63
+ - A throwing policy is treated as a **denial** (fail-closed) and logged.
64
+ - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
65
+
66
+ ## See also
67
+
68
+ - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.