@warlock.js/access 4.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/cjs/index.cjs +367 -0
  5. package/cjs/index.cjs.map +1 -0
  6. package/esm/contracts/access-resolver.d.mts +40 -0
  7. package/esm/contracts/access-resolver.d.mts.map +1 -0
  8. package/esm/contracts/index.d.mts +2 -0
  9. package/esm/contracts/types.d.mts +38 -0
  10. package/esm/contracts/types.d.mts.map +1 -0
  11. package/esm/index.d.mts +11 -0
  12. package/esm/index.mjs +12 -0
  13. package/esm/middleware/gate.middleware.d.mts +20 -0
  14. package/esm/middleware/gate.middleware.d.mts.map +1 -0
  15. package/esm/middleware/gate.middleware.mjs +44 -0
  16. package/esm/middleware/gate.middleware.mjs.map +1 -0
  17. package/esm/middleware/index.d.mts +1 -0
  18. package/esm/middleware/index.mjs +3 -0
  19. package/esm/services/access-config.d.mts +19 -0
  20. package/esm/services/access-config.d.mts.map +1 -0
  21. package/esm/services/access-config.mjs +50 -0
  22. package/esm/services/access-config.mjs.map +1 -0
  23. package/esm/services/access.d.mts +51 -0
  24. package/esm/services/access.d.mts.map +1 -0
  25. package/esm/services/access.mjs +80 -0
  26. package/esm/services/access.mjs.map +1 -0
  27. package/esm/services/default-resolver.d.mts +27 -0
  28. package/esm/services/default-resolver.d.mts.map +1 -0
  29. package/esm/services/default-resolver.mjs +35 -0
  30. package/esm/services/default-resolver.mjs.map +1 -0
  31. package/esm/services/engine.d.mts +8 -0
  32. package/esm/services/engine.d.mts.map +1 -0
  33. package/esm/services/engine.mjs +87 -0
  34. package/esm/services/engine.mjs.map +1 -0
  35. package/esm/services/index.d.mts +5 -0
  36. package/esm/services/index.mjs +7 -0
  37. package/esm/services/matcher.mjs +22 -0
  38. package/esm/services/matcher.mjs.map +1 -0
  39. package/esm/services/policies.d.mts +17 -0
  40. package/esm/services/policies.d.mts.map +1 -0
  41. package/esm/services/policies.mjs +25 -0
  42. package/esm/services/policies.mjs.map +1 -0
  43. package/esm/utils/access-config-error.d.mts +12 -0
  44. package/esm/utils/access-config-error.d.mts.map +1 -0
  45. package/esm/utils/access-config-error.mjs +16 -0
  46. package/esm/utils/access-config-error.mjs.map +1 -0
  47. package/esm/utils/access-error-codes.d.mts +13 -0
  48. package/esm/utils/access-error-codes.d.mts.map +1 -0
  49. package/esm/utils/access-error-codes.mjs +15 -0
  50. package/esm/utils/access-error-codes.mjs.map +1 -0
  51. package/llms-full.txt +470 -0
  52. package/llms.txt +14 -0
  53. package/package.json +43 -0
  54. package/skills/check-permissions/SKILL.md +71 -0
  55. package/skills/configure-access/SKILL.md +80 -0
  56. package/skills/define-policies/SKILL.md +68 -0
  57. package/skills/implement-resolver/SKILL.md +106 -0
  58. package/skills/manage-roles/SKILL.md +71 -0
  59. package/skills/overview/SKILL.md +44 -0
@@ -0,0 +1,16 @@
1
+ //#region ../@warlock.js/access/src/utils/access-config-error.ts
2
+ /**
3
+ * Thrown when `@warlock.js/access` is misconfigured (e.g. no resolver). Unlike a
4
+ * runtime resolution failure — which fails CLOSED (denies) — a config error is a
5
+ * developer mistake, so the engine re-throws it LOUD instead of silently denying.
6
+ */
7
+ var AccessConfigError = class extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "AccessConfigError";
11
+ }
12
+ };
13
+
14
+ //#endregion
15
+ export { AccessConfigError };
16
+ //# sourceMappingURL=access-config-error.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-config-error.mjs","names":[],"sources":["../../../../../../../@warlock.js/access/src/utils/access-config-error.ts"],"sourcesContent":["/**\n * Thrown when `@warlock.js/access` is misconfigured (e.g. no resolver). Unlike a\n * runtime resolution failure — which fails CLOSED (denies) — a config error is a\n * developer mistake, so the engine re-throws it LOUD instead of silently denying.\n */\nexport class AccessConfigError extends Error {\n public constructor(message: string) {\n super(message);\n this.name = \"AccessConfigError\";\n }\n}\n"],"mappings":";;;;;;AAKA,IAAa,oBAAb,cAAuC,MAAM;CAC3C,AAAO,YAAY,SAAiB;EAClC,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF"}
@@ -0,0 +1,13 @@
1
+ //#region ../@warlock.js/access/src/utils/access-error-codes.d.ts
2
+ /**
3
+ * Wire-stable error codes emitted by `@warlock.js/access`. Map these in your
4
+ * error transformer the way you map `AuthErrorCodes`. A value change here is a
5
+ * breaking change for any client that reacts to the code.
6
+ */
7
+ declare enum AccessErrorCodes {
8
+ /** EC100 — authenticated, but missing the required permission. */
9
+ Forbidden = "EC100"
10
+ }
11
+ //#endregion
12
+ export { AccessErrorCodes };
13
+ //# sourceMappingURL=access-error-codes.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-error-codes.d.mts","names":[],"sources":["../../../../../../../@warlock.js/access/src/utils/access-error-codes.ts"],"mappings":";;AAKA;;;;aAAY,gBAAA;;EAEV,SAAS;AAAA"}
@@ -0,0 +1,15 @@
1
+ //#region ../@warlock.js/access/src/utils/access-error-codes.ts
2
+ /**
3
+ * Wire-stable error codes emitted by `@warlock.js/access`. Map these in your
4
+ * error transformer the way you map `AuthErrorCodes`. A value change here is a
5
+ * breaking change for any client that reacts to the code.
6
+ */
7
+ let AccessErrorCodes = /* @__PURE__ */ function(AccessErrorCodes) {
8
+ /** EC100 — authenticated, but missing the required permission. */
9
+ AccessErrorCodes["Forbidden"] = "EC100";
10
+ return AccessErrorCodes;
11
+ }({});
12
+
13
+ //#endregion
14
+ export { AccessErrorCodes };
15
+ //# sourceMappingURL=access-error-codes.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-error-codes.mjs","names":[],"sources":["../../../../../../../@warlock.js/access/src/utils/access-error-codes.ts"],"sourcesContent":["/**\n * Wire-stable error codes emitted by `@warlock.js/access`. Map these in your\n * error transformer the way you map `AuthErrorCodes`. A value change here is a\n * breaking change for any client that reacts to the code.\n */\nexport enum AccessErrorCodes {\n /** EC100 — authenticated, but missing the required permission. */\n Forbidden = \"EC100\",\n}\n"],"mappings":";;;;;;AAKA,IAAY,mBAAL;;CAEL;;AACF"}
package/llms-full.txt ADDED
@@ -0,0 +1,470 @@
1
+ # Warlock Access — full skills
2
+
3
+ > Package: `@warlock.js/access`
4
+
5
+ > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/access/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
6
+
7
+ ## check-permissions `@warlock.js/access/check-permissions/SKILL.md`
8
+
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.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.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
+
81
+
82
+ ## configure-access `@warlock.js/access/configure-access/SKILL.md`
83
+
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
+ };
141
+ ```
142
+
143
+ 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.
144
+
145
+ ## Tenant
146
+
147
+ 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:
148
+
149
+ ```ts
150
+ public resolveTenant(user: Auth): string | undefined {
151
+ return user.get("organization_id");
152
+ }
153
+ ```
154
+
155
+ ## Caching
156
+
157
+ 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.
158
+
159
+ ## See also
160
+
161
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — write a resolver for your own storage.
162
+ - [`@warlock.js/access/manage-roles/SKILL.md`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke with the ejected table.
163
+ ```
164
+
165
+
166
+ ## define-policies `@warlock.js/access/define-policies/SKILL.md`
167
+
168
+ ---
169
+ name: define-policies
170
+ 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`.'
171
+ ---
172
+
173
+ # Define policies (ABAC)
174
+
175
+ RBAC says "can update orders". A **policy** adds "...but only THIS order" — the instance-level condition.
176
+
177
+ ## Register a condition
178
+
179
+ 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.
180
+
181
+ ```ts title="src/app/orders/policies/index.ts"
182
+ import { definePolicy } from "@warlock.js/access";
183
+
184
+ definePolicy("orders.update", (user, order, ctx) =>
185
+ order.get("organization_id") === ctx.tenant &&
186
+ (order.get("customer_id") === user.id || ctx.hasRole("manager")),
187
+ );
188
+ ```
189
+
190
+ ```ts title="src/app/orders/main.ts"
191
+ import "./policies"; // register this module's policies at boot
192
+ ```
193
+
194
+ ## It runs ONLY on an instance check
195
+
196
+ ```ts
197
+ await authorize(user, "orders.update"); // class-level → grant only, policy SKIPPED
198
+ await authorize(user, "orders.update", { resource: order }); // instance → grant AND policy
199
+ ```
200
+
201
+ 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.
202
+
203
+ ## The decision = grant AND policy
204
+
205
+ - No grant → denied (the policy never runs).
206
+ - Grant + no policy registered → allowed.
207
+ - Grant + policy → the policy decides.
208
+
209
+ Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
210
+
211
+ ## The policy context
212
+
213
+ `(user, resource, ctx)`, where `ctx` carries:
214
+
215
+ - `tenant` — the resolved tenant.
216
+ - `hasRole(role)` / `hasPermission(perm)` — engine-injected helpers.
217
+ - any extra keys you passed on the check:
218
+
219
+ ```ts
220
+ await authorize(user, "orders.refund", { resource: order, amount: 5000 });
221
+
222
+ definePolicy("orders.refund", (user, order, ctx) =>
223
+ ctx.hasRole("manager") || (ctx.amount as number) <= 1000,
224
+ );
225
+ ```
226
+
227
+ ## Gotchas
228
+
229
+ - Policies are global by permission name — define each once at boot.
230
+ - A throwing policy is treated as a **denial** (fail-closed) and logged.
231
+ - Need "own resource" at graph scale (deep relationships)? That's ReBAC — out of scope; a policy covers the common ownership case.
232
+
233
+ ## See also
234
+
235
+ - [`@warlock.js/access/check-permissions/SKILL.md`](@warlock.js/access/check-permissions/SKILL.md) — `authorize(…, { resource })`.
236
+
237
+
238
+ ## implement-resolver `@warlock.js/access/implement-resolver/SKILL.md`
239
+
240
+ ---
241
+ name: implement-resolver
242
+ 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`.'
243
+ ---
244
+
245
+ # Implement a resolver
246
+
247
+ 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.
248
+
249
+ ```ts
250
+ export interface AccessResolver {
251
+ resolveRoles(user: Auth, tenant?: string): Promise<string[]>; // powers hasRole
252
+ resolvePermissions(user: Auth, tenant?: string): Promise<string[]>; // powers can / authorize
253
+ resolveTenant?(user: Auth): string | undefined; // optional ambient tenant
254
+ }
255
+ ```
256
+
257
+ Register it: `access: { resolver: new MyResolver() }`.
258
+
259
+ ## The default: dynamic, DB-backed (ejected)
260
+
261
+ `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.
262
+
263
+ ```ts
264
+ import type { AccessResolver } from "@warlock.js/access";
265
+ import type { Auth } from "@warlock.js/auth";
266
+ import { Role } from "app/access/models/role";
267
+ import { UserRole } from "app/access/models/user-role";
268
+
269
+ export class DatabaseAccessResolver implements AccessResolver {
270
+ public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {
271
+ return UserRole.rolesFor(user, tenant);
272
+ }
273
+
274
+ public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {
275
+ const names = await this.resolveRoles(user, tenant);
276
+
277
+ if (names.length === 0) return [];
278
+
279
+ const roles = await Role.query().whereIn("name", names).get();
280
+
281
+ return [...new Set(roles.flatMap((role) => role.permissions))];
282
+ }
283
+
284
+ // Multi-tenant? Uncomment to scope checks to the user's tenant. Derive it from
285
+ // the user — never trust client-supplied request input for the tenant boundary:
286
+ // public resolveTenant(user: Auth): string | undefined {
287
+ // return user.get("organization_id");
288
+ // }
289
+ }
290
+ ```
291
+
292
+ ## Other recipes
293
+
294
+ **A single role column, permissions mapped in code**
295
+
296
+ ```ts
297
+ class ColumnResolver implements AccessResolver {
298
+ public constructor(private readonly roles: Record<string, string[]>) {}
299
+ public async resolveRoles(user: Auth): Promise<string[]> {
300
+ return [user.get("role")].filter(Boolean);
301
+ }
302
+ public async resolvePermissions(user: Auth): Promise<string[]> {
303
+ return (await this.resolveRoles(user)).flatMap((role) => this.roles[role] ?? []);
304
+ }
305
+ }
306
+ ```
307
+
308
+ **Roles from a token claim (external IdP — no DB)**
309
+
310
+ ```ts
311
+ class ClaimResolver implements AccessResolver {
312
+ public async resolveRoles(user: Auth): Promise<string[]> {
313
+ return user.get("decodedAccessToken")?.roles ?? [];
314
+ }
315
+ public async resolvePermissions(user: Auth): Promise<string[]> {
316
+ return user.get("decodedAccessToken")?.permissions ?? [];
317
+ }
318
+ }
319
+ ```
320
+
321
+ **Direct permissions per user (role is just a label)**
322
+
323
+ ```ts
324
+ class DirectResolver implements AccessResolver {
325
+ public async resolveRoles(user: Auth): Promise<string[]> {
326
+ return [user.get("role")];
327
+ }
328
+ public async resolvePermissions(user: Auth): Promise<string[]> {
329
+ const rows = await UserPermission.query().where({ user_id: user.id }).get();
330
+ return rows.map((row) => row.get("name") as string);
331
+ }
332
+ }
333
+ ```
334
+
335
+ ## Rules
336
+
337
+ - **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.
338
+ - Return plain `string[]`. Wildcards (`orders.*`, `*`) in the returned permissions are honored by the engine.
339
+ - `resolveRoles` and `resolvePermissions` may read **different** sources — roles and permissions are independent axes (a role can be a pure label with permissions granted directly).
340
+ - Throwing from the resolver fails the check **closed** (denied + logged).
341
+
342
+ ## See also
343
+
344
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — the quickstart `DefaultAccessResolver`.
345
+ ```
346
+
347
+
348
+ ## manage-roles `@warlock.js/access/manage-roles/SKILL.md`
349
+
350
+ ---
351
+ name: manage-roles
352
+ 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`.'
353
+ ---
354
+
355
+ # Manage roles
356
+
357
+ 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`.
358
+
359
+ ## Assign / revoke
360
+
361
+ 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:
362
+
363
+ ```ts
364
+ import { UserRole } from "app/access/models/user-role";
365
+
366
+ await UserRole.assign(user, "editor", "tenant-1"); // editor in tenant-1 (auto-flushes that user)
367
+ await UserRole.assign(user, "viewer", "tenant-2"); // viewer in tenant-2 — same user, different role
368
+
369
+ await UserRole.revoke(user, "editor", "tenant-1"); // auto-flushes too
370
+ ```
371
+
372
+ 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.
373
+
374
+ ### When you still need `access.flush`
375
+
376
+ `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:
377
+
378
+ ```ts
379
+ import { access } from "@warlock.js/access";
380
+
381
+ // A role's permission set changed in the `Role` catalog — every holder's cached
382
+ // permissions are now stale, so flush each affected user:
383
+ await access.flush(user, "tenant-1");
384
+
385
+ // You changed a user's role membership WITHOUT UserRole (e.g. wrote the user's
386
+ // `role` column directly, or a bulk SQL update):
387
+ await access.flush(user, "tenant-1");
388
+ ```
389
+
390
+ `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`).
391
+
392
+ ## Read roles
393
+
394
+ ```ts
395
+ import { hasAllRoles, hasAnyRole, hasRole } from "@warlock.js/access";
396
+
397
+ await hasRole(user, "editor"); // tenant is an optional 3rd arg
398
+ await hasAnyRole(user, ["admin", "manager"]);
399
+ await hasAllRoles(user, ["staff", "verified"]);
400
+ ```
401
+
402
+ > **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.
403
+
404
+ ## The catalog (the `Role` table)
405
+
406
+ 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.
407
+
408
+ ## The model (overridable)
409
+
410
+ 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.
411
+
412
+ ## Roles stored elsewhere?
413
+
414
+ 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).
415
+
416
+ ## See also
417
+
418
+ - [`@warlock.js/access/configure-access/SKILL.md`](@warlock.js/access/configure-access/SKILL.md) — choosing the resolver.
419
+ - [`@warlock.js/access/implement-resolver/SKILL.md`](@warlock.js/access/implement-resolver/SKILL.md) — non-table role storage.
420
+ ```
421
+
422
+
423
+ ## overview `@warlock.js/access/overview/SKILL.md`
424
+
425
+ ---
426
+ name: overview
427
+ description: 'Front-door for `@warlock.js/access` — authorization (RBAC + ABAC) for Warlock apps: `can` / `authorize` / `gate` permission checks, `definePolicy` attribute conditions, role management, and a pluggable `AccessResolver` that connects the engine to however you store roles. Depends on `@warlock.js/auth` (reads `request.user`). TRIGGER when: importing from `@warlock.js/access`; "permissions in Warlock", "RBAC", "can this user do X", "protect a route by permission", "role-based access", "ownership / policy check". Skip: authentication / login (that is `@warlock.js/auth`); a known task — load the matching skill (`check-permissions`, `define-policies`, `manage-roles`, `implement-resolver`, `configure-access`).'
428
+ ---
429
+
430
+ # `@warlock.js/access` — overview
431
+
432
+ Authorization for Warlock apps. `@warlock.js/auth` answers _who you are_; `access` answers _what you can do_.
433
+
434
+ ## The mental model in one paragraph
435
+
436
+ The package owns the **engine** — wildcard matching, caching, ABAC policies, fail-closed decisions. You hand it **one required adapter**, an `AccessResolver`, that reads a user's roles + permissions from however YOUR app stores them (the role→permission catalog lives inside the resolver). Then `can(user, "orders.update")` and `gate("orders.update")` just work. The two concepts inside are **permissions** (RBAC grants) and **policies** (ABAC conditions) — which is why the package is called `access`, not either half.
437
+
438
+ ## When to reach for it
439
+
440
+ - A Warlock app (already on `@warlock.js/auth`) that needs per-action / per-resource authorization beyond auth's user-type gate.
441
+ - **Multi-tenant** role scoping — `editor` in tenant A, `viewer` in tenant B.
442
+ - "Only their own / only in their tenant / only while pending" conditions → ABAC policies.
443
+
444
+ Skip if you only need "is the user an admin" — `authMiddleware("admin")` from `@warlock.js/auth` already covers coarse type gating.
445
+
446
+ ## The two-stage model (important)
447
+
448
+ - **Class-level** (no resource): _"can this user update orders at all?"_ → `gate("orders.update")` in middleware, `can(user, "orders.update")` in code. Cheap, cached, runs before the controller.
449
+ - **Instance-level** (a resource): _"can they update THIS order?"_ → `authorize(user, "orders.update", { resource: order })` in the service, after you load the row. Runs the registered policy on top of the grant.
450
+
451
+ ## Skills index
452
+
453
+ - [`configure-access`](@warlock.js/access/configure-access/SKILL.md) — the required resolver (`DefaultAccessResolver` vs the ejected `DatabaseAccessResolver`), cache + tenant.
454
+ - [`check-permissions`](@warlock.js/access/check-permissions/SKILL.md) — `can` / `cannot` / `canAll` / `canAny` / `authorize*` + the `gate*` middleware.
455
+ - [`define-policies`](@warlock.js/access/define-policies/SKILL.md) — `definePolicy` for ownership / tenant / state conditions (ABAC).
456
+ - [`manage-roles`](@warlock.js/access/manage-roles/SKILL.md) — assign / revoke via the ejected `UserRole` + `access.flush`, `hasRole` / `hasAnyRole` / `hasAllRoles`.
457
+ - [`implement-resolver`](@warlock.js/access/implement-resolver/SKILL.md) — connect the engine to your storage (a DB catalog, a column, a pivot, a token claim).
458
+
459
+ ## What it deliberately doesn't do
460
+
461
+ - **Authentication.** Use `@warlock.js/auth`; `access` reads `request.user`.
462
+ - **Ship a permission admin UI.** The engine reads permission strings; whether the catalog is code-defined (`DefaultAccessResolver`) or DB-managed (the ejected `DatabaseAccessResolver`) is your resolver's choice — the package ships no admin screens.
463
+ - **ReBAC graphs / row-level query scoping.** Use a policy for "own resource"; graph-scale relationships are out of scope.
464
+
465
+ ## See also
466
+
467
+ - [`@warlock.js/auth/overview/SKILL.md`](@warlock.js/auth/overview/SKILL.md) — authentication, the layer below.
468
+ - [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — `authMiddleware`; stack `gate` after it.
469
+
470
+
package/llms.txt ADDED
@@ -0,0 +1,14 @@
1
+ # Warlock Access
2
+
3
+ > Package: `@warlock.js/access`
4
+
5
+ > Authorization (RBAC + ABAC) for Warlock.js applications
6
+
7
+ ## Skills
8
+
9
+ - [check-permissions](@warlock.js/access/check-permissions/SKILL.md): 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`.
10
+ - [configure-access](@warlock.js/access/configure-access/SKILL.md): 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`.
11
+ - [define-policies](@warlock.js/access/define-policies/SKILL.md): 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`.
12
+ - [implement-resolver](@warlock.js/access/implement-resolver/SKILL.md): 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`.
13
+ - [manage-roles](@warlock.js/access/manage-roles/SKILL.md): 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`.
14
+ - [overview](@warlock.js/access/overview/SKILL.md): Front-door for `@warlock.js/access` — authorization (RBAC + ABAC) for Warlock apps: `can` / `authorize` / `gate` permission checks, `definePolicy` attribute conditions, role management, and a pluggable `AccessResolver` that connects the engine to however you store roles. Depends on `@warlock.js/auth` (reads `request.user`). TRIGGER when: importing from `@warlock.js/access`; "permissions in Warlock", "RBAC", "can this user do X", "protect a route by permission", "role-based access", "ownership / policy check". Skip: authentication / login (that is `@warlock.js/auth`); a known task — load the matching skill (`check-permissions`, `define-policies`, `manage-roles`, `implement-resolver`, `configure-access`).
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@warlock.js/access",
3
+ "description": "Authorization (RBAC + ABAC) for Warlock.js applications",
4
+ "keywords": [
5
+ "nodejs",
6
+ "authorization",
7
+ "rbac",
8
+ "abac",
9
+ "permissions",
10
+ "roles",
11
+ "access-control"
12
+ ],
13
+ "author": "hassanzohdy",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/warlockjs/access"
18
+ },
19
+ "peerDependencies": {
20
+ "@warlock.js/auth": "4.2.0",
21
+ "@warlock.js/cache": "4.2.0",
22
+ "@warlock.js/cascade": "4.2.0",
23
+ "@warlock.js/core": "4.2.0",
24
+ "@warlock.js/logger": "4.2.0",
25
+ "@warlock.js/seal": "4.2.0"
26
+ },
27
+ "version": "4.2.0",
28
+ "main": "./cjs/index.cjs",
29
+ "module": "./esm/index.mjs",
30
+ "types": "./esm/index.d.mts",
31
+ "exports": {
32
+ ".": {
33
+ "import": {
34
+ "types": "./esm/index.d.mts",
35
+ "default": "./esm/index.mjs"
36
+ },
37
+ "require": {
38
+ "types": "./esm/index.d.mts",
39
+ "default": "./cjs/index.cjs"
40
+ }
41
+ }
42
+ }
43
+ }