@warlock.js/access 5.19.0 → 5.20.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/llms-full.txt CHANGED
@@ -6,427 +6,427 @@
6
6
 
7
7
  ## check-permissions `@warlock.js/access/check-permissions/SKILL.md`
8
8
 
9
- ---
10
- name: check-permissions
11
- description: "Check permissions in @warlock.js/access; use when you need to check permissions."
12
- ---
13
-
14
- # Check permissions
15
-
16
- ## In a route — class-level gate
17
-
18
- Stack it AFTER `authMiddleware` (which sets `request.locals.user`):
19
-
20
- ```ts
21
- import { authMiddleware } from "@warlock.js/auth";
22
- import { gate } from "@warlock.js/access";
23
-
24
- router.post("/orders", createOrder, {
25
- middleware: [authMiddleware([]), gate("orders.create")],
26
- });
27
- ```
28
-
29
- A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.
30
-
31
- ## In code — boolean
32
-
33
- ```ts
34
- import { can, cannot, canAll, canAny } from "@warlock.js/access";
35
-
36
- if (await can(user, "orders.update")) { /* … */ }
37
-
38
- await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
39
- await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
40
- ```
41
-
42
- > **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.
43
-
44
- ## In a service — throw
45
-
46
- ```ts
47
- import { authorize, authorizeAll } from "@warlock.js/access";
48
-
49
- await authorize(user, "orders.update"); // throws ForbiddenError (403, EC100) on deny
50
- await authorizeAll(user, ["orders.update", "orders.viewCost"]);
51
- ```
52
-
53
- ## Instance-level — "this resource"
54
-
55
- Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):
56
-
57
- ```ts
58
- const order = await Order.find(orderId);
59
-
60
- await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
61
- ```
62
-
63
- - `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
64
- - `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").
65
-
66
- ## Wildcards
67
-
68
- `*` 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.
69
-
70
- ## Gotchas
71
-
72
- - **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).
73
- - Stack `gate` **after** `authMiddleware` — it reads `request.locals.user`; without an authenticated user it `403`s.
74
- - The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).
75
-
76
- ## See also
77
-
78
- - [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
79
- - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.
9
+ ---
10
+ name: check-permissions
11
+ 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`.'
12
+ ---
13
+
14
+ # Check permissions
15
+
16
+ ## In a route — class-level gate
17
+
18
+ Stack it AFTER `authMiddleware` (which sets `request.locals.user`):
19
+
20
+ ```ts
21
+ import { authMiddleware } from "@warlock.js/auth";
22
+ import { gate } from "@warlock.js/access";
23
+
24
+ router.post("/orders", createOrder, {
25
+ middleware: [authMiddleware([]), gate("orders.create")],
26
+ });
27
+ ```
28
+
29
+ A missing permission → `403` before your controller runs. Use `gateAny([...])` / `gateAll([...])` for sets.
30
+
31
+ ## In code — boolean
32
+
33
+ ```ts
34
+ import { can, cannot, canAll, canAny } from "@warlock.js/access";
35
+
36
+ if (await can(user, "orders.update")) { /* … */ }
37
+
38
+ await canAll(user, ["orders.update", "orders.viewCost"]); // needs BOTH
39
+ await canAny(user, ["orders.update", "orders.updateStatus"]); // needs EITHER
40
+ ```
41
+
42
+ > **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.
43
+
44
+ ## In a service — throw
45
+
46
+ ```ts
47
+ import { authorize, authorizeAll } from "@warlock.js/access";
48
+
49
+ await authorize(user, "orders.update"); // throws ForbiddenError (403, EC100) on deny
50
+ await authorizeAll(user, ["orders.update", "orders.viewCost"]);
51
+ ```
52
+
53
+ ## Instance-level — "this resource"
54
+
55
+ Pass a `resource` and the permission's policy runs on top of the grant (see [`define-policies`](@warlock.js/access/define-policies/SKILL.md)):
56
+
57
+ ```ts
58
+ const order = await Order.find(orderId);
59
+
60
+ await authorize(user, "orders.update", { resource: order, tenant }); // grant AND policy
61
+ ```
62
+
63
+ - `gate` is **class-level only** — no resource, runs before the controller ("can they update orders at all?").
64
+ - `authorize(…, { resource })` is **instance-level** — after you load the row ("can they update THIS order?").
65
+
66
+ ## Wildcards
67
+
68
+ `*` 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.
69
+
70
+ ## Gotchas
71
+
72
+ - **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).
73
+ - Stack `gate` **after** `authMiddleware` — it reads `request.locals.user`; without an authenticated user it `403`s.
74
+ - The boolean `can*` family never throws; the `authorize*` family throws `ForbiddenError`. Pick per layer (controllers gate with middleware, services assert with `authorize`).
75
+
76
+ ## See also
77
+
78
+ - [`@warlock.js/access/define-policies/SKILL.md`](@warlock.js/access/define-policies/SKILL.md) — the `{ resource }` conditions.
79
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`, which runs first.
80
80
 
81
81
 
82
82
  ## configure-access `@warlock.js/access/configure-access/SKILL.md`
83
83
 
84
- ---
85
- name: configure-access
86
- description: "Configure access in @warlock.js/access; use when you need to configure access."
87
- ---
88
-
89
- # Configure access
90
-
91
- 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.
92
-
93
- ## Quickstart — a fixed, code-defined catalog
94
-
95
- `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.
96
-
97
- ```ts title="src/config/access.ts"
98
- import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";
99
-
100
- const access: AccessConfigurations = {
101
- resolver: new DefaultAccessResolver({
102
- owner: ["*"], // `*` is the super-grant
103
- editor: ["orders.*", "posts.create"], // `orders.*` covers orders.update, orders.view, …
104
- viewer: ["orders.view"],
105
- }),
106
- };
107
-
108
- export default access;
109
- ```
110
-
111
- `can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.
112
-
113
- > `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.
114
-
115
- ## Real apps — dynamic, DB-backed roles
116
-
117
- `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.
118
-
119
- ```ts title="src/config/access.ts"
120
- import { type AccessConfigurations } from "@warlock.js/access";
121
- import { DatabaseAccessResolver } from "app/access/services/access-resolver";
122
-
123
- const access: AccessConfigurations = {
124
- resolver: new DatabaseAccessResolver(),
125
- };
126
-
127
- export default access;
128
- ```
129
-
130
- 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)).
131
-
132
- ## The full shape
133
-
134
- ```ts
135
- type AccessConfigurations = {
136
- resolver: AccessResolver; // REQUIRED — reads roles + permissions (+ optional tenant)
137
- cache?: {
138
- ttl?: string | number; // resolved-set TTL, default "10m"
139
- };
140
- strictPolicies?: boolean; // default true — see below
141
- };
142
- ```
143
-
144
- ### `strictPolicies`
145
-
146
- 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.
147
-
148
- 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.
149
-
150
- > **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.
151
-
152
- 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.
153
-
154
- ## Tenant
155
-
156
- 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:
157
-
158
- ```ts
159
- public resolveTenant(user: Auth): string | undefined {
160
- return user.get("organization_id");
161
- }
162
- ```
163
-
164
- ## Caching
165
-
166
- 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.
167
-
168
- ## See also
169
-
170
- - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
171
- - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
172
- ```
84
+ ---
85
+ name: configure-access
86
+ 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`.'
87
+ ---
88
+
89
+ # Configure access
90
+
91
+ 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.
92
+
93
+ ## Quickstart — a fixed, code-defined catalog
94
+
95
+ `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.
96
+
97
+ ```ts title="src/config/access.ts"
98
+ import { type AccessConfigurations, DefaultAccessResolver } from "@warlock.js/access";
99
+
100
+ const access: AccessConfigurations = {
101
+ resolver: new DefaultAccessResolver({
102
+ owner: ["*"], // `*` is the super-grant
103
+ editor: ["orders.*", "posts.create"], // `orders.*` covers orders.update, orders.view, …
104
+ viewer: ["orders.view"],
105
+ }),
106
+ };
107
+
108
+ export default access;
109
+ ```
110
+
111
+ `can(user, "orders.update")` now works for any user whose `roles` column includes `editor`.
112
+
113
+ > `DefaultAccessResolver` is **not tenant-aware** — it returns the same roles in every tenant. For per-tenant roles, use the dynamic resolver below.
114
+
115
+ ## Real apps — dynamic, DB-backed roles
116
+
117
+ `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.
118
+
119
+ ```ts title="src/config/access.ts"
120
+ import { type AccessConfigurations } from "@warlock.js/access";
121
+ import { DatabaseAccessResolver } from "app/access/services/access-resolver";
122
+
123
+ const access: AccessConfigurations = {
124
+ resolver: new DatabaseAccessResolver(),
125
+ };
126
+
127
+ export default access;
128
+ ```
129
+
130
+ 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)).
131
+
132
+ ## The full shape
133
+
134
+ ```ts
135
+ type AccessConfigurations = {
136
+ resolver: AccessResolver; // REQUIRED — reads roles + permissions (+ optional tenant)
137
+ cache?: {
138
+ ttl?: string | number; // resolved-set TTL, default "10m"
139
+ };
140
+ strictPolicies?: boolean; // default true — see below
141
+ };
142
+ ```
143
+
144
+ ### `strictPolicies`
145
+
146
+ 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.
147
+
148
+ 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.
149
+
150
+ > **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.
151
+
152
+ 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.
153
+
154
+ ## Tenant
155
+
156
+ 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:
157
+
158
+ ```ts
159
+ public resolveTenant(user: Auth): string | undefined {
160
+ return user.get("organization_id");
161
+ }
162
+ ```
163
+
164
+ ## Caching
165
+
166
+ 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.
167
+
168
+ ## See also
169
+
170
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
171
+ - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
172
+ ```
173
173
 
174
174
 
175
175
  ## define-policies `@warlock.js/access/define-policies/SKILL.md`
176
176
 
177
- ---
178
- name: define-policies
179
- description: "Define policies (ABAC) in @warlock.js/access; use when you need to define policies."
180
- ---
181
-
182
- # Define policies (ABAC)
183
-
184
- RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
185
-
186
- ## Register a condition
187
-
188
- 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.
189
-
190
- ```ts title="src/app/orders/policies/index.ts"
191
- import { definePolicy } from "@warlock.js/access";
192
-
193
- definePolicy("orders.update", (user, order, ctx) =>
194
- order.get("organization_id") === ctx.tenant &&
195
- (order.get("customer_id") === user.id || ctx.hasRole("manager")),
196
- );
197
- ```
198
-
199
- ```ts title="src/app/orders/main.ts"
200
- import "./policies"; // register this module's policies at boot
201
- ```
202
-
203
- ## It runs ONLY on an instance check
204
-
205
- ```ts
206
- await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
207
- await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
208
- ```
209
-
210
- 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.
211
-
212
- ## The decision = grant AND policy
213
-
214
- - No grant → denied (the policy never runs).
215
- - 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).
216
- - Grant + policy → the policy decides.
217
-
218
- Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
219
-
220
- ## The policy context
221
-
222
- `(user, resource, ctx)`, where `ctx` carries:
223
-
224
- - `tenant` — the resolved tenant.
225
- - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
226
- - any extra keys you passed on the check:
227
-
228
- ```ts
229
- await authorize(user, "orders.refund", { resource: order, amount: 5000 });
230
-
231
- definePolicy("orders.refund", (user, order, ctx) =>
232
- ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
233
- );
234
- ```
235
-
236
- ## Gotchas
237
-
238
- - Policies are global by permission name — define each once at boot.
239
- - A throwing policy is treated as a **denial** (fail-closed) and logged.
240
- - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
241
-
242
- ## See also
243
-
244
- - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.
177
+ ---
178
+ name: define-policies
179
+ 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`.'
180
+ ---
181
+
182
+ # Define policies (ABAC)
183
+
184
+ RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
185
+
186
+ ## Register a condition
187
+
188
+ 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.
189
+
190
+ ```ts title="src/app/orders/policies/index.ts"
191
+ import { definePolicy } from "@warlock.js/access";
192
+
193
+ definePolicy("orders.update", (user, order, ctx) =>
194
+ order.get("organization_id") === ctx.tenant &&
195
+ (order.get("customer_id") === user.id || ctx.hasRole("manager")),
196
+ );
197
+ ```
198
+
199
+ ```ts title="src/app/orders/main.ts"
200
+ import "./policies"; // register this module's policies at boot
201
+ ```
202
+
203
+ ## It runs ONLY on an instance check
204
+
205
+ ```ts
206
+ await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
207
+ await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
208
+ ```
209
+
210
+ 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.
211
+
212
+ ## The decision = grant AND policy
213
+
214
+ - No grant → denied (the policy never runs).
215
+ - 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).
216
+ - Grant + policy → the policy decides.
217
+
218
+ Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
219
+
220
+ ## The policy context
221
+
222
+ `(user, resource, ctx)`, where `ctx` carries:
223
+
224
+ - `tenant` — the resolved tenant.
225
+ - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
226
+ - any extra keys you passed on the check:
227
+
228
+ ```ts
229
+ await authorize(user, "orders.refund", { resource: order, amount: 5000 });
230
+
231
+ definePolicy("orders.refund", (user, order, ctx) =>
232
+ ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
233
+ );
234
+ ```
235
+
236
+ ## Gotchas
237
+
238
+ - Policies are global by permission name — define each once at boot.
239
+ - A throwing policy is treated as a **denial** (fail-closed) and logged.
240
+ - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
241
+
242
+ ## See also
243
+
244
+ - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.
245
245
 
246
246
 
247
247
  ## implement-resolver `@warlock.js/access/implement-resolver/SKILL.md`
248
248
 
249
- ---
250
- name: implement-resolver
251
- description: "Implement a resolver in @warlock.js/access; use when you need to implement resolver."
252
- ---
253
-
254
- # Implement a resolver
255
-
256
- The resolver is the ONE thing only you can write — your schema — and it's just a function over your own data. The engine does matching, caching, policies, and fail-closed for you.
257
-
258
- ```ts
259
- export interface AccessResolver {
260
- resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
261
- resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
262
- resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
263
- }
264
- ```
265
-
266
- Register it: `access: { resolver: new MyResolver() }`.
267
-
268
- ## The default: dynamic, DB-backed (ejected)
269
-
270
- `npx warlock add access` ejects this `DatabaseAccessResolver` — roles come from the `user_roles` table, permissions are joined through the `roles` catalog table. This is what most apps run, because admins manage roles + permissions at runtime.
271
-
272
- ```ts
273
- import type { AccessResolver } from "@warlock.js/access";
274
- import type { Auth } from "@warlock.js/auth";
275
- import { Role } from "app/access/models/role";
276
- import { UserRole } from "app/access/models/user-role";
277
-
278
- export class DatabaseAccessResolver implements AccessResolver {
279
- public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
280
- return UserRole.rolesFor(user, tenant);
281
- }
282
-
283
- public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
284
- const names = await this.resolveRoles(user, tenant);
285
-
286
- if (names.length === 0) return [];
287
-
288
- const roles = await Role.query().whereIn("name", names).get();
289
-
290
- return [...new Set(roles.flatMap((role) => role.permissions))];
291
- }
292
-
293
- // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
294
- // the user — never trust client-supplied request input for the tenant boundary:
295
- // public resolveTenant(user: Auth): string | undefined {
296
- // return user.get("organization_id");
297
- // }
298
- }
299
- ```
300
-
301
- ## Other recipes
302
-
303
- **A single role column, permissions mapped in code**
304
-
305
- ```ts
306
- class ColumnResolver implements AccessResolver {
307
- public constructor(private readonly roles: Record<string, string[]>) {}
308
- public async resolveRoles(user: Auth): Promise<string[]> {
309
- return [user.get("role")].filter(Boolean);
310
- }
311
- public async resolvePermissions(user: Auth): Promise<string[]> {
312
- return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
313
- }
314
- }
315
- ```
316
-
317
- **Roles from a token claim (external IdP — no DB)**
318
-
319
- ```ts
320
- class ClaimResolver implements AccessResolver {
321
- public async resolveRoles(user: Auth): Promise<string[]> {
322
- return user.get("decodedAccessToken")?.roles ?? [];
323
- }
324
- public async resolvePermissions(user: Auth): Promise<string[]> {
325
- return user.get("decodedAccessToken")?.permissions ?? [];
326
- }
327
- }
328
- ```
329
-
330
- **Direct permissions per user (role is just a label)**
331
-
332
- ```ts
333
- class DirectResolver implements AccessResolver {
334
- public async resolveRoles(user: Auth): Promise<string[]> {
335
- return [user.get("role")];
336
- }
337
- public async resolvePermissions(user: Auth): Promise<string[]> {
338
- const rows = await UserPermission.query().where({ user_id: user.id }).get();
339
- return rows.map((row) => row.get("name") as string);
340
- }
341
- }
342
- ```
343
-
344
- ## Rules
345
-
346
- - **Only fetch — never cache inside the resolver.** The engine caches per `(user, tenant)`; caching twice causes stale grants. Invalidate with `access.flush(user, tenant)` when your data changes.
347
- - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
348
- - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
349
- - Throwing from the resolver fails the check **closed** (denied + logged).
350
-
351
- ## See also
352
-
353
- - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
354
- ```
249
+ ---
250
+ name: implement-resolver
251
+ description: 'Connect `@warlock.js/access` to your role/permission storage by implementing the `AccessResolver` contract (`resolveRoles` / `resolvePermissions`, optional `resolveTenant`) — for a DB-backed catalog, a user column, a pivot table, a token claim, or an external directory. The engine owns matching / caching / policies; the resolver only fetches. TRIGGER: `AccessResolver`, `DatabaseAccessResolver`, `resolveTenant`, "custom resolver", "where do roles come from", "roles in a token claim", "permissions from an external API", "implement resolver". Skip: the quickstart `DefaultAccessResolver` — `@warlock.js/access/configure-access/SKILL.md`.'
252
+ ---
253
+
254
+ # Implement a resolver
255
+
256
+ The resolver is the ONE thing only you can write — your schema — and it's just a function over your own data. The engine does matching, caching, policies, and fail-closed for you.
257
+
258
+ ```ts
259
+ export interface AccessResolver {
260
+ resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
261
+ resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
262
+ resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
263
+ }
264
+ ```
265
+
266
+ Register it: `access: { resolver: new MyResolver() }`.
267
+
268
+ ## The default: dynamic, DB-backed (ejected)
269
+
270
+ `npx warlock add access` ejects this `DatabaseAccessResolver` — roles come from the `user_roles` table, permissions are joined through the `roles` catalog table. This is what most apps run, because admins manage roles + permissions at runtime.
271
+
272
+ ```ts
273
+ import type { AccessResolver } from "@warlock.js/access";
274
+ import type { Auth } from "@warlock.js/auth";
275
+ import { Role } from "app/access/models/role";
276
+ import { UserRole } from "app/access/models/user-role";
277
+
278
+ export class DatabaseAccessResolver implements AccessResolver {
279
+ public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
280
+ return UserRole.rolesFor(user, tenant);
281
+ }
282
+
283
+ public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
284
+ const names = await this.resolveRoles(user, tenant);
285
+
286
+ if (names.length === 0) return [];
287
+
288
+ const roles = await Role.query().whereIn("name", names).get();
289
+
290
+ return [...new Set(roles.flatMap((role) => role.permissions))];
291
+ }
292
+
293
+ // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
294
+ // the user — never trust client-supplied request input for the tenant boundary:
295
+ // public resolveTenant(user: Auth): string | undefined {
296
+ // return user.get("organization_id");
297
+ // }
298
+ }
299
+ ```
300
+
301
+ ## Other recipes
302
+
303
+ **A single role column, permissions mapped in code**
304
+
305
+ ```ts
306
+ class ColumnResolver implements AccessResolver {
307
+ public constructor(private readonly roles: Record<string, string[]>) {}
308
+ public async resolveRoles(user: Auth): Promise<string[]> {
309
+ return [user.get("role")].filter(Boolean);
310
+ }
311
+ public async resolvePermissions(user: Auth): Promise<string[]> {
312
+ return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
313
+ }
314
+ }
315
+ ```
316
+
317
+ **Roles from a token claim (external IdP — no DB)**
318
+
319
+ ```ts
320
+ class ClaimResolver implements AccessResolver {
321
+ public async resolveRoles(user: Auth): Promise<string[]> {
322
+ return user.get("decodedAccessToken")?.roles ?? [];
323
+ }
324
+ public async resolvePermissions(user: Auth): Promise<string[]> {
325
+ return user.get("decodedAccessToken")?.permissions ?? [];
326
+ }
327
+ }
328
+ ```
329
+
330
+ **Direct permissions per user (role is just a label)**
331
+
332
+ ```ts
333
+ class DirectResolver implements AccessResolver {
334
+ public async resolveRoles(user: Auth): Promise<string[]> {
335
+ return [user.get("role")];
336
+ }
337
+ public async resolvePermissions(user: Auth): Promise<string[]> {
338
+ const rows = await UserPermission.query().where({ user_id: user.id }).get();
339
+ return rows.map((row) => row.get("name") as string);
340
+ }
341
+ }
342
+ ```
343
+
344
+ ## Rules
345
+
346
+ - **Only fetch — never cache inside the resolver.** The engine caches per `(user, tenant)`; caching twice causes stale grants. Invalidate with `access.flush(user, tenant)` when your data changes.
347
+ - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
348
+ - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
349
+ - Throwing from the resolver fails the check **closed** (denied + logged).
350
+
351
+ ## See also
352
+
353
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
354
+ ```
355
355
 
356
356
 
357
357
  ## manage-roles `@warlock.js/access/manage-roles/SKILL.md`
358
358
 
359
- ---
360
- name: manage-roles
361
- description: "Manage roles in @warlock.js/access; use when you need to manage roles."
362
- ---
363
-
364
- # Manage roles
365
-
366
- Role storage is **ejected into your app** (`npx warlock add access`): a `UserRole` table (who holds which role) and a `Role` catalog table (role → permissions). Both are dynamic — admins manage them at runtime. The package itself only reads them through the `DatabaseAccessResolver`.
367
-
368
- ## Assign / revoke
369
-
370
- Assign and revoke with the ejected `UserRole` model. Both **flush the affected user's cached set for you** — no manual `access.flush` after a role change:
371
-
372
- ```ts
373
- import { UserRole } from "app/access/models/user-role";
374
-
375
- await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
376
- await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
377
-
378
- await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
379
- ```
380
-
381
- The `tenant` arg is optional — omit it for single-tenant apps (roles are stored globally). In a **multi-tenant** app, always pass the tenant: an omitted or unresolved tenant scopes to *global* roles only, never the union across tenants.
382
-
383
- ### When you still need `access.flush`
384
-
385
- `assign` / `revoke` cover the common case. The cache keys off `(user, tenant)`, so flush manually only for **out-of-band** changes those two methods can't see:
386
-
387
- ```ts
388
- import { access } from "@warlock.js/access";
389
-
390
- // A role's permission set changed in the `Role` catalog — every holder's cached
391
- // permissions are now stale, so flush each affected user:
392
- await access.flush(user, "tenant-1");
393
-
394
- // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
395
- // `role` column directly, or a bulk SQL update):
396
- await access.flush(user, "tenant-1");
397
- ```
398
-
399
- `access.flush(user, tenant)` is per-user — it has no "drop everything" form. For a sweeping catalog change with many holders, flush each affected user, or let the entries expire on their own (`cache.ttl`, default `10m`).
400
-
401
- ## Read roles
402
-
403
- ```ts
404
- import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
405
-
406
- await hasRole(user, "editor"); // tenant is an optional 3rd arg
407
- await hasAnyRole(user, ["admin", "manager"]);
408
- await hasAllRoles(user, ["staff", "verified"]);
409
- ```
410
-
411
- > **Prefer permission checks over role checks.** Role checks couple your code to the role taxonomy; `can(user, "orders.update")` survives a role rename. Reach for `hasRole` only for coarse UI gating.
412
-
413
- ## The catalog (the `Role` table)
414
-
415
- A role's permissions live in the ejected `Role` table — `{ name, permissions }`. Add a role, or change what it grants, by writing rows; the `DatabaseAccessResolver` joins `user_roles` through it. No redeploy needed.
416
-
417
- ## The model (overridable)
418
-
419
- The ejected `UserRole` is a thin cascade model — `{ user_id, user_type, role, tenant }`. `user_id` defaults to `uuid` — edit the ejected migration in `src/app/access/models/user-role/migrations/` if your user ids are integers.
420
-
421
- ## Roles stored elsewhere?
422
-
423
- If roles live on the user (a `roles` column) or in a token claim, you don't use `UserRole` — you manage assignment your way and a resolver reads them. See [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md).
424
-
425
- ## See also
426
-
427
- - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
428
- - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
429
- ```
359
+ ---
360
+ name: manage-roles
361
+ description: 'Assign and read roles in `@warlock.js/access` — the ejected `UserRole.assign` / `UserRole.revoke` (the `user_roles` table) followed by `access.flush`, plus `hasRole` / `hasAnyRole` / `hasAllRoles`. The role→permission catalog is the ejected `Role` table (dynamic). TRIGGER: `UserRole.assign`, `UserRole.revoke`, `access.flush`, `hasRole`, `hasAnyRole`, `hasAllRoles`, `Role` table, "give a user a role", "assign role", "check a user role", "roles per tenant". Skip: permission checks — `@warlock.js/access/check-permissions/SKILL.md`; resolver choice — `@warlock.js/access/configure-access/SKILL.md`.'
362
+ ---
363
+
364
+ # Manage roles
365
+
366
+ Role storage is **ejected into your app** (`npx warlock add access`): a `UserRole` table (who holds which role) and a `Role` catalog table (role → permissions). Both are dynamic — admins manage them at runtime. The package itself only reads them through the `DatabaseAccessResolver`.
367
+
368
+ ## Assign / revoke
369
+
370
+ Assign and revoke with the ejected `UserRole` model. Both **flush the affected user's cached set for you** — no manual `access.flush` after a role change:
371
+
372
+ ```ts
373
+ import { UserRole } from "app/access/models/user-role";
374
+
375
+ await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
376
+ await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
377
+
378
+ await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
379
+ ```
380
+
381
+ The `tenant` arg is optional — omit it for single-tenant apps (roles are stored globally). In a **multi-tenant** app, always pass the tenant: an omitted or unresolved tenant scopes to *global* roles only, never the union across tenants.
382
+
383
+ ### When you still need `access.flush`
384
+
385
+ `assign` / `revoke` cover the common case. The cache keys off `(user, tenant)`, so flush manually only for **out-of-band** changes those two methods can't see:
386
+
387
+ ```ts
388
+ import { access } from "@warlock.js/access";
389
+
390
+ // A role's permission set changed in the `Role` catalog — every holder's cached
391
+ // permissions are now stale, so flush each affected user:
392
+ await access.flush(user, "tenant-1");
393
+
394
+ // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
395
+ // `role` column directly, or a bulk SQL update):
396
+ await access.flush(user, "tenant-1");
397
+ ```
398
+
399
+ `access.flush(user, tenant)` is per-user — it has no "drop everything" form. For a sweeping catalog change with many holders, flush each affected user, or let the entries expire on their own (`cache.ttl`, default `10m`).
400
+
401
+ ## Read roles
402
+
403
+ ```ts
404
+ import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
405
+
406
+ await hasRole(user, "editor"); // tenant is an optional 3rd arg
407
+ await hasAnyRole(user, ["admin", "manager"]);
408
+ await hasAllRoles(user, ["staff", "verified"]);
409
+ ```
410
+
411
+ > **Prefer permission checks over role checks.** Role checks couple your code to the role taxonomy; `can(user, "orders.update")` survives a role rename. Reach for `hasRole` only for coarse UI gating.
412
+
413
+ ## The catalog (the `Role` table)
414
+
415
+ A role's permissions live in the ejected `Role` table — `{ name, permissions }`. Add a role, or change what it grants, by writing rows; the `DatabaseAccessResolver` joins `user_roles` through it. No redeploy needed.
416
+
417
+ ## The model (overridable)
418
+
419
+ The ejected `UserRole` is a thin cascade model — `{ user_id, user_type, role, tenant }`. `user_id` defaults to `uuid` — edit the ejected migration in `src/app/access/models/user-role/migrations/` if your user ids are integers.
420
+
421
+ ## Roles stored elsewhere?
422
+
423
+ If roles live on the user (a `roles` column) or in a token claim, you don't use `UserRole` — you manage assignment your way and a resolver reads them. See [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md).
424
+
425
+ ## See also
426
+
427
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
428
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
429
+ ```
430
430
 
431
431
 
432
432
  ## overview `@warlock.js/access/overview/SKILL.md`