@zudojs/permissions 0.1.0 → 1.1.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -14
  3. package/dist/ability/ability.core.d.ts +2 -1
  4. package/dist/ability/ability.core.js +34 -9
  5. package/dist/cache/cache.core.d.ts +25 -5
  6. package/dist/cache/cache.core.js +77 -20
  7. package/dist/cache/index.d.ts +1 -1
  8. package/dist/conditions/conditions.core.d.ts +18 -2
  9. package/dist/conditions/conditions.core.js +46 -6
  10. package/dist/conditions/index.d.ts +1 -1
  11. package/dist/conditions/index.js +1 -1
  12. package/dist/evaluator/authorizationEngine.d.ts +48 -6
  13. package/dist/evaluator/authorizationEngine.js +156 -16
  14. package/dist/evaluator/evaluator.core.d.ts +28 -4
  15. package/dist/evaluator/evaluator.core.js +259 -39
  16. package/dist/evaluator/evaluator.explain.d.ts +6 -3
  17. package/dist/evaluator/evaluator.explain.js +8 -71
  18. package/dist/evaluator/evaluator.pipeline.d.ts +70 -9
  19. package/dist/evaluator/evaluator.pipeline.js +194 -49
  20. package/dist/evaluator/index.d.ts +4 -2
  21. package/dist/evaluator/index.js +4 -1
  22. package/dist/http/httpHelpers.d.ts +28 -12
  23. package/dist/http/httpHelpers.js +33 -6
  24. package/dist/http/httpMiddleware.core.d.ts +54 -13
  25. package/dist/http/httpMiddleware.core.js +72 -28
  26. package/dist/http/index.d.ts +7 -3
  27. package/dist/http/index.js +7 -3
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -2
  30. package/dist/observability/index.d.ts +1 -1
  31. package/dist/observability/observability.core.d.ts +32 -12
  32. package/dist/observability/observability.core.js +41 -20
  33. package/dist/permission/index.d.ts +2 -2
  34. package/dist/permission/index.js +1 -1
  35. package/dist/permission/permission.core.d.ts +13 -2
  36. package/dist/permission/permission.core.js +29 -21
  37. package/dist/permission/permissionRegistry.d.ts +27 -33
  38. package/dist/permission/permissionRegistry.js +59 -41
  39. package/dist/permissionErrors/index.d.ts +1 -1
  40. package/dist/permissionErrors/index.js +1 -1
  41. package/dist/permissionErrors/permissionError.access.d.ts +24 -0
  42. package/dist/permissionErrors/permissionError.access.js +25 -5
  43. package/dist/permissionErrors/permissionError.types.d.ts +1 -1
  44. package/dist/permissionErrors/permissionError.types.js +1 -1
  45. package/dist/permissionTypes/index.d.ts +2 -2
  46. package/dist/permissionTypes/index.js +1 -1
  47. package/dist/permissionTypes/policyTypes.d.ts +35 -2
  48. package/dist/permissionTypes/ruleTypes.d.ts +38 -5
  49. package/dist/policy/index.d.ts +1 -1
  50. package/dist/policy/index.js +1 -1
  51. package/dist/policy/policyRegistry.d.ts +16 -23
  52. package/dist/policy/policyRegistry.js +32 -24
  53. package/dist/role/index.d.ts +2 -2
  54. package/dist/role/index.js +1 -1
  55. package/dist/role/roleHierarchy.d.ts +34 -4
  56. package/dist/role/roleHierarchy.js +48 -23
  57. package/dist/role/roleRegistry.d.ts +25 -25
  58. package/dist/role/roleRegistry.js +46 -24
  59. package/dist/rule/index.d.ts +2 -2
  60. package/dist/rule/index.js +2 -2
  61. package/dist/rule/rule.core.d.ts +39 -10
  62. package/dist/rule/rule.core.js +95 -38
  63. package/dist/rule/ruleCompiler.d.ts +14 -5
  64. package/dist/rule/ruleCompiler.js +46 -30
  65. package/dist/utils/utils.helper.d.ts +8 -0
  66. package/dist/utils/utils.helper.js +16 -14
  67. package/package.json +27 -16
  68. package/dist/.tsbuildinfo +0 -1
  69. package/dist/ability/ability.core.d.ts.map +0 -1
  70. package/dist/ability/ability.core.js.map +0 -1
  71. package/dist/ability/index.d.ts.map +0 -1
  72. package/dist/ability/index.js.map +0 -1
  73. package/dist/actor/actor.core.d.ts.map +0 -1
  74. package/dist/actor/actor.core.js.map +0 -1
  75. package/dist/actor/index.d.ts.map +0 -1
  76. package/dist/actor/index.js.map +0 -1
  77. package/dist/cache/cache.core.d.ts.map +0 -1
  78. package/dist/cache/cache.core.js.map +0 -1
  79. package/dist/cache/index.d.ts.map +0 -1
  80. package/dist/cache/index.js.map +0 -1
  81. package/dist/conditions/conditions.core.d.ts.map +0 -1
  82. package/dist/conditions/conditions.core.js.map +0 -1
  83. package/dist/conditions/index.d.ts.map +0 -1
  84. package/dist/conditions/index.js.map +0 -1
  85. package/dist/evaluator/authorizationEngine.d.ts.map +0 -1
  86. package/dist/evaluator/authorizationEngine.js.map +0 -1
  87. package/dist/evaluator/evaluator.core.d.ts.map +0 -1
  88. package/dist/evaluator/evaluator.core.js.map +0 -1
  89. package/dist/evaluator/evaluator.explain.d.ts.map +0 -1
  90. package/dist/evaluator/evaluator.explain.js.map +0 -1
  91. package/dist/evaluator/evaluator.pipeline.d.ts.map +0 -1
  92. package/dist/evaluator/evaluator.pipeline.js.map +0 -1
  93. package/dist/evaluator/index.d.ts.map +0 -1
  94. package/dist/evaluator/index.js.map +0 -1
  95. package/dist/http/httpHelpers.d.ts.map +0 -1
  96. package/dist/http/httpHelpers.js.map +0 -1
  97. package/dist/http/httpMiddleware.core.d.ts.map +0 -1
  98. package/dist/http/httpMiddleware.core.js.map +0 -1
  99. package/dist/http/httpTypes.d.ts.map +0 -1
  100. package/dist/http/httpTypes.js.map +0 -1
  101. package/dist/http/index.d.ts.map +0 -1
  102. package/dist/http/index.js.map +0 -1
  103. package/dist/index.d.ts.map +0 -1
  104. package/dist/index.js.map +0 -1
  105. package/dist/observability/index.d.ts.map +0 -1
  106. package/dist/observability/index.js.map +0 -1
  107. package/dist/observability/observability.core.d.ts.map +0 -1
  108. package/dist/observability/observability.core.js.map +0 -1
  109. package/dist/permission/index.d.ts.map +0 -1
  110. package/dist/permission/index.js.map +0 -1
  111. package/dist/permission/permission.core.d.ts.map +0 -1
  112. package/dist/permission/permission.core.js.map +0 -1
  113. package/dist/permission/permissionRegistry.d.ts.map +0 -1
  114. package/dist/permission/permissionRegistry.js.map +0 -1
  115. package/dist/permissionErrors/index.d.ts.map +0 -1
  116. package/dist/permissionErrors/index.js.map +0 -1
  117. package/dist/permissionErrors/permissionError.access.d.ts.map +0 -1
  118. package/dist/permissionErrors/permissionError.access.js.map +0 -1
  119. package/dist/permissionErrors/permissionError.base.d.ts.map +0 -1
  120. package/dist/permissionErrors/permissionError.base.js.map +0 -1
  121. package/dist/permissionErrors/permissionError.types.d.ts.map +0 -1
  122. package/dist/permissionErrors/permissionError.types.js.map +0 -1
  123. package/dist/permissionErrors/permissionError.validation.d.ts.map +0 -1
  124. package/dist/permissionErrors/permissionError.validation.js.map +0 -1
  125. package/dist/permissionTypes/index.d.ts.map +0 -1
  126. package/dist/permissionTypes/index.js.map +0 -1
  127. package/dist/permissionTypes/permissionActor.d.ts.map +0 -1
  128. package/dist/permissionTypes/permissionActor.js.map +0 -1
  129. package/dist/permissionTypes/policyTypes.d.ts.map +0 -1
  130. package/dist/permissionTypes/policyTypes.js.map +0 -1
  131. package/dist/permissionTypes/ruleTypes.d.ts.map +0 -1
  132. package/dist/permissionTypes/ruleTypes.js.map +0 -1
  133. package/dist/policy/index.d.ts.map +0 -1
  134. package/dist/policy/index.js.map +0 -1
  135. package/dist/policy/policyRegistry.d.ts.map +0 -1
  136. package/dist/policy/policyRegistry.js.map +0 -1
  137. package/dist/resolvers/index.d.ts.map +0 -1
  138. package/dist/resolvers/index.js.map +0 -1
  139. package/dist/resolvers/resolvers.core.d.ts.map +0 -1
  140. package/dist/resolvers/resolvers.core.js.map +0 -1
  141. package/dist/role/index.d.ts.map +0 -1
  142. package/dist/role/index.js.map +0 -1
  143. package/dist/role/roleHierarchy.d.ts.map +0 -1
  144. package/dist/role/roleHierarchy.js.map +0 -1
  145. package/dist/role/roleRegistry.d.ts.map +0 -1
  146. package/dist/role/roleRegistry.js.map +0 -1
  147. package/dist/rule/index.d.ts.map +0 -1
  148. package/dist/rule/index.js.map +0 -1
  149. package/dist/rule/rule.core.d.ts.map +0 -1
  150. package/dist/rule/rule.core.js.map +0 -1
  151. package/dist/rule/ruleCompiler.d.ts.map +0 -1
  152. package/dist/rule/ruleCompiler.js.map +0 -1
  153. package/dist/utils/index.d.ts.map +0 -1
  154. package/dist/utils/index.js.map +0 -1
  155. package/dist/utils/utils.helper.d.ts.map +0 -1
  156. package/dist/utils/utils.helper.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -11,27 +11,363 @@ npm install @zudojs/permissions
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { createPermissionEngine } from "@zudojs/permissions";
14
+ import {
15
+ createPermissionEngine,
16
+ createPermissionActor,
17
+ } from "@zudojs/permissions";
15
18
 
16
19
  const engine = createPermissionEngine({
17
- roles: {
18
- admin: { permissions: ["user:*", "post:*"] },
19
- editor: { permissions: ["post:read", "post:write"] },
20
- },
20
+ roles: [
21
+ { name: "reader", permissions: ["post:read"] },
22
+ { name: "editor", permissions: ["post:update"], inherits: ["reader"] },
23
+ { name: "admin", permissions: ["*:*"] },
24
+ ],
21
25
  });
22
26
 
23
- const allowed = await engine.check("admin", "user", "delete");
27
+ const actor = createPermissionActor("user_1", { roles: ["editor"] });
28
+
29
+ await engine.can(actor, "post:update"); // true
30
+ await engine.can(actor, "post:read"); // true — inherited from "reader"
31
+ await engine.can(actor, "user:delete"); // false
24
32
  ```
25
33
 
26
- ## Features
34
+ `roles` is an array of `RoleDefinition`, and every check takes the **actor**,
35
+ not a role name. Malformed configuration is rejected at construction: a grant
36
+ like `"post"` (no action) could never match, so it is an error rather than a
37
+ silent no-op.
38
+
39
+ ## Permissions
40
+
41
+ Permissions are `resource:action`. Two wildcard forms are supported, and they
42
+ mean the same thing everywhere — in a grant, a deny, a rule, or a policy:
43
+
44
+ | Pattern | Matches |
45
+ | ---------------- | ----------------------------------------- |
46
+ | `post:read` | exactly that |
47
+ | `post:*` | every action on `post` |
48
+ | `*:read` | `read` on every resource |
49
+ | `*:*` | everything |
50
+ | `billing.*:read` | `billing:read`, `billing.invoice:read`, … |
51
+
52
+ A partial wildcard such as `post*:read` is rejected: the matcher has no
53
+ meaning for it, so accepting one would register a grant that never matches.
54
+
55
+ ## Denies
56
+
57
+ `deniedPermissions` is matched with the same rules as grants, so a wildcard
58
+ deny works:
59
+
60
+ ```typescript
61
+ const restricted = createPermissionActor("user_2", {
62
+ roles: ["admin"],
63
+ deniedPermissions: ["*:delete", "billing.*:read"],
64
+ });
65
+
66
+ await engine.can(restricted, "post:delete"); // false
67
+ await engine.can(restricted, "post:read"); // true
68
+ ```
69
+
70
+ An explicit deny short-circuits before any rule or policy runs.
71
+
72
+ ## Rules and ABAC
73
+
74
+ A `PermissionRule` carries an optional **condition**, and the rule applies
75
+ only when that condition holds. This is what makes "owners may edit their own
76
+ posts" expressible:
77
+
78
+ ```typescript
79
+ import { isOwner, tenantIsolation, allOf } from "@zudojs/permissions";
80
+
81
+ const engine = createPermissionEngine({
82
+ roles,
83
+ rules: [
84
+ {
85
+ name: "own-posts",
86
+ effect: "allow",
87
+ resource: "post",
88
+ action: "update",
89
+ condition: isOwner(), // compares resource.ownerId to actor.id
90
+ },
91
+ {
92
+ name: "locked-posts",
93
+ effect: "deny",
94
+ resource: "post",
95
+ action: "update",
96
+ condition: (context) => Boolean((context.resource as Post).locked),
97
+ },
98
+ ],
99
+ });
100
+
101
+ await engine.can(actor, "post:update", { ownerId: "user_1" }); // true
102
+ await engine.can(actor, "post:update", { ownerId: "user_9" }); // false
103
+ ```
104
+
105
+ Conditions may be async. A condition that throws is treated as unmet — an
106
+ authorization check fails closed.
107
+
108
+ **Combining.** The default is `deny-overrides`: any applicable deny wins,
109
+ whatever its priority. Pass `algorithm: "priority"` for highest-priority-wins,
110
+ where a tie still goes to deny.
111
+
112
+ Combinators: `allOf`, `anyOf`, `not`, `always`, `never`, `isOwner`,
113
+ `tenantIsolation`, `metadataEquals`, `resourceEquals`.
114
+
115
+ ### Request metadata
116
+
117
+ Conditions read request-scoped facts from `context.metadata`, supplied per
118
+ check:
119
+
120
+ ```typescript
121
+ await engine.can(actor, "invoice:read", invoice, {
122
+ metadata: { tenantId: request.tenantId },
123
+ });
124
+ ```
125
+
126
+ `tenantIsolation()` compares that value against the resource's tenant, and
127
+ denies when either is missing.
128
+
129
+ ## Policies
130
+
131
+ A policy is a named, prioritised hook that runs alongside the rules.
132
+
133
+ ```typescript
134
+ const engine = createPermissionEngine({
135
+ roles,
136
+ policyTimeout: 250,
137
+ policies: [
138
+ {
139
+ name: "business-hours",
140
+ permissions: ["post:*"], // wildcards work here too
141
+ priority: 10, // higher runs first
142
+ cacheable: false, // depends on the clock, so never cache it
143
+ evaluate: (context) =>
144
+ isBusinessHours()
145
+ ? { allowed: true }
146
+ : { allowed: false, reason: "outside_business_hours" },
147
+ },
148
+ ],
149
+ });
150
+ ```
151
+
152
+ Policies run highest priority first and stop at the first denial. A policy
153
+ that throws, or exceeds `policyTimeout`, denies. A denying policy always wins;
154
+ an allowing one can grant access the rules did not decide, but never overrides
155
+ a denial — from another policy or from a deny rule that applied.
156
+
157
+ `policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
158
+ disable.
159
+
160
+ ## Registries
161
+
162
+ Registries can be handed straight to the engine, and stay live:
163
+
164
+ ```typescript
165
+ import {
166
+ createRoleRegistry,
167
+ createPolicyRegistry,
168
+ createPermissionRegistry,
169
+ } from "@zudojs/permissions";
170
+
171
+ const roles = createRoleRegistry();
172
+ roles.define({ name: "reader", permissions: ["post:read"] });
173
+ roles.define({ name: "staff", permissions: [], inherits: ["reader"] });
174
+
175
+ const policies = createPolicyRegistry();
176
+ const engine = createPermissionEngine({ roles, policies });
177
+
178
+ roles.define({ name: "auditor", permissions: ["audit:read"] });
179
+ engine.invalidateRoles(); // pick up the change
180
+
181
+ policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
182
+ // enforced by the next check — through the engine or an existing Ability
183
+
184
+ roles.require("auditor"); // throws RoleNotFoundError when unregistered
185
+ ```
186
+
187
+ All three registries reject a duplicate name — re-registering a role or a
188
+ policy is an authorization rule disappearing without a trace. Pass
189
+ `{ allowOverride: true }` when replacement is what you mean.
190
+
191
+ `validateConfiguration` (default `true`) applies to a registry as well as to
192
+ an inline array: a role whose grant could never match is rejected when the
193
+ engine looks it up, and the check denies.
194
+
195
+ A permission registry records descriptions and implications:
196
+
197
+ ```typescript
198
+ const permissions = createPermissionRegistry();
199
+ permissions.define("post:admin", { implies: ["post:write"] });
200
+ permissions.define("post:write", { implies: ["post:read"] });
201
+
202
+ const engine = createPermissionEngine({
203
+ roles,
204
+ expandImplied: (permission) => permissions.expandImplied(permission),
205
+ });
206
+ // An actor granted post:admin now passes post:read.
207
+ ```
208
+
209
+ ## Caching
210
+
211
+ ```typescript
212
+ import { createMemoryPermissionCache } from "@zudojs/permissions";
213
+
214
+ const engine = createPermissionEngine({
215
+ roles,
216
+ cache: createMemoryPermissionCache({
217
+ defaultTtlMs: 30_000,
218
+ maxEntries: 5_000,
219
+ }),
220
+ cacheTtlMs: 30_000,
221
+ });
222
+
223
+ await engine.can(actor, "post:read", post); // evaluated
224
+ await engine.can(actor, "post:read", post); // cached
225
+ await engine.can(actor, "post:read", post, { skipCache: true });
226
+ await engine.invalidateActor("user_1");
227
+ ```
228
+
229
+ Keys include the actor, the permission and the resource id (from
230
+ `resource.id`, or `options.resourceId`), so two resources never share one
231
+ decision. A `|` inside an actor or resource id is escaped, so two different
232
+ (actor, permission, resource) triples can never share a key either.
233
+
234
+ A check is cached only when the key can describe it completely:
235
+
236
+ - a resource with no `id` and no `options.resourceId` is **not cached**, since
237
+ the key would collapse to actor + permission and an allow for one object
238
+ would answer for the next;
239
+ - a check carrying `metadata` is **not cached**, because conditions such as
240
+ `tenantIsolation()` read the tenant from there and it is not part of the
241
+ key;
242
+ - a decision produced by a policy marked `cacheable: false` is not stored;
243
+ - a TTL of `0` or less means "do not cache".
244
+
245
+ `deniedPermissions` is evaluated before the cache is consulted, so a deny
246
+ added to the actor takes effect immediately rather than waiting for a cached
247
+ allow to expire. A malformed entry there can never match, so it is reported
248
+ through `onError` instead of being dropped in silence.
249
+
250
+ ## External sources
251
+
252
+ ```typescript
253
+ const engine = createPermissionEngine({
254
+ roles,
255
+ roleResolver: { resolveRoles: (actor) => db.rolesFor(actor.id) },
256
+ permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
257
+ });
258
+ ```
259
+
260
+ A resolver that fails is reported through `onError` and the check continues
261
+ fail-closed, rather than throwing out of the authorization path.
262
+
263
+ ## Failure behaviour
264
+
265
+ Every failure denies:
266
+
267
+ | Situation | Result |
268
+ | --------------------------- | ------------------------------------------------------ |
269
+ | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
270
+ | Malformed permission string | denied, `reason: "invalid_permission"` |
271
+ | Condition throws | rule does not apply |
272
+ | A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
273
+ | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
274
+ | Role inheritance cycle | denied; reported to `onError` |
275
+ | Role source throws | denied; reported to `onError` |
276
+ | `signal` aborted | throws `AuthorizationAbortedError` |
277
+
278
+ A value that is not an `Error` — a policy or resolver that throws a string —
279
+ reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
280
+ the original as `cause`.
281
+
282
+ Cancellation is the one case that throws, because the caller asked for the
283
+ work to stop.
284
+
285
+ ## Abilities
286
+
287
+ ```typescript
288
+ const ability = engine.createAbility(actor);
289
+
290
+ await ability.can("post:update", post);
291
+ await ability.cannot("post:delete");
292
+ await ability.check("post:update", post); // full decision
293
+ await ability.authorize("post:update", post); // throws PermissionDeniedError
294
+ await ability.explain("post:update", post);
295
+ ```
296
+
297
+ ## Explain
298
+
299
+ `explain` runs the same evaluation as `check` and collects the steps, so the
300
+ trace always describes the decision that was made:
301
+
302
+ ```typescript
303
+ const { allowed, steps, decision } = await engine.explain(
304
+ actor,
305
+ "post:update",
306
+ post,
307
+ );
308
+ steps.forEach((step) => console.log(step.type, step.detail, step.matched));
309
+ ```
310
+
311
+ The trace lists the grants that bear on this decision, not every permission
312
+ the actor holds.
313
+
314
+ ## Audit events
315
+
316
+ ```typescript
317
+ import { createPermissionEventEmitter } from "@zudojs/permissions";
318
+
319
+ const emitter = createPermissionEventEmitter({
320
+ onHandlerError: (error) => logger.error({ error }, "audit sink failed"),
321
+ });
322
+ emitter.on((event) => auditLog.write(event));
323
+
324
+ const engine = createPermissionEngine({ roles, emitter });
325
+ ```
326
+
327
+ Every check emits — allowed, denied, and the ones that throw. A handler that
328
+ throws cannot break authorization, but it is reported rather than swallowed.
329
+
330
+ ## HTTP middleware
331
+
332
+ ```typescript
333
+ import { authorize, createActorMiddleware } from "@zudojs/permissions";
334
+
335
+ const guard = authorize(engine, "post:update", {
336
+ extractActor: (context) => context.state.get("auth:user"),
337
+ extractResource: (context) => loadPost(context.request.params.get("id")),
338
+ extractMetadata: (context) => ({
339
+ tenantId: context.request.headers.get("x-tenant"),
340
+ }),
341
+ });
342
+ ```
343
+
344
+ - The guard extracts the actor itself when one is not already in state, so it
345
+ works without a separate actor middleware.
346
+ - No actor → **401** with `WWW-Authenticate`. Actor but not permitted →
347
+ **403**.
348
+ - The 403 body carries `decision.publicReason`, never the internal reason:
349
+ `policy_error:<name>` names your policies and does not belong in a
350
+ response. Pass `deniedResponse` to shape the body — it receives the real
351
+ decision.
352
+ - `context.signal` is forwarded, so a client disconnect stops policy
353
+ evaluation.
354
+ - `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
355
+ several permissions, short-circuiting on the first that decides the outcome.
356
+
357
+ `@zudojs/http` is an optional peer dependency; the middleware types are
358
+ mirrored locally so this package works without it.
359
+
360
+ ## Errors
361
+
362
+ `PermissionError` is the base. `PermissionDeniedError` carries a caller-safe
363
+ message; the actor id, reason and policy live on `error.details` for the log,
364
+ not in the exposed metadata.
27
365
 
28
- - Role-Based Access Control (RBAC)
29
- - Attribute-Based Access Control (ABAC)
30
- - Resource authorization with wildcards
31
- - Role hierarchy and inheritance
32
- - Policy engine
33
- - Ability-based checks
34
- - Explain mode for debugging
366
+ `PermissionNotFoundError` · `DuplicatePermissionError` · `RoleNotFoundError` ·
367
+ `DuplicateRoleError` · `DuplicatePolicyError` · `InvalidPermissionError` ·
368
+ `InvalidRoleError` ·
369
+ `CircularRoleInheritanceError` · `PolicyError` · `PolicyTimeoutError` ·
370
+ `PermissionResolverError` · `AuthorizationAbortedError`
35
371
 
36
372
  ## Use Cases
37
373
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { PermissionActor, PermissionDecision, ExplainResult, AuthorizationOptions } from "../permissionTypes/index.js";
7
7
  import type { EvaluatorOptions } from "../evaluator/evaluator.pipeline.js";
8
+ import type { PermissionEventEmitter } from "../observability/observability.core.js";
8
9
  /**
9
10
  * An Ability provides fast permission checks for a pre-resolved actor.
10
11
  */
@@ -25,5 +26,5 @@ export interface Ability {
25
26
  /**
26
27
  * Create an Ability for an actor.
27
28
  */
28
- export declare function createAbility(actor: PermissionActor, evaluatorOptions: EvaluatorOptions): Ability;
29
+ export declare function createAbility(actor: PermissionActor, evaluatorOptions: EvaluatorOptions, emitter?: PermissionEventEmitter): Ability;
29
30
  //# sourceMappingURL=ability.core.d.ts.map
@@ -3,33 +3,58 @@
3
3
  *
4
4
  * @module ability/ability
5
5
  */
6
- import { evaluate, evaluateWithExplain } from "../evaluator/evaluator.core.js";
6
+ import { evaluate, evaluateWithTrace } from "../evaluator/evaluator.core.js";
7
7
  import { PermissionDeniedError } from "../permissionErrors/index.js";
8
8
  /**
9
9
  * Create an Ability for an actor.
10
10
  */
11
- export function createAbility(actor, evaluatorOptions) {
11
+ export function createAbility(actor, evaluatorOptions, emitter) {
12
+ async function run(permission, resource, options) {
13
+ const start = performance.now();
14
+ let decision;
15
+ let failure;
16
+ try {
17
+ decision = await evaluate(actor, permission, resource, evaluatorOptions, options);
18
+ return decision;
19
+ }
20
+ catch (error) {
21
+ failure = error;
22
+ throw error;
23
+ }
24
+ finally {
25
+ emitter?.emit({
26
+ actorId: actor.id,
27
+ permission,
28
+ allowed: decision?.allowed ?? false,
29
+ reason: decision?.reason ??
30
+ (failure instanceof Error ? `error:${failure.name}` : undefined),
31
+ durationMs: performance.now() - start,
32
+ errored: failure !== undefined,
33
+ });
34
+ }
35
+ }
12
36
  return {
13
37
  actor,
14
38
  async can(permission, resource, options) {
15
- const decision = await evaluate(actor, permission, resource, evaluatorOptions, options);
16
- return decision.allowed;
39
+ return (await run(permission, resource, options)).allowed;
17
40
  },
18
41
  async cannot(permission, resource, options) {
19
- return !(await this.can(permission, resource, options));
42
+ return !(await run(permission, resource, options)).allowed;
20
43
  },
21
44
  async check(permission, resource, options) {
22
- return evaluate(actor, permission, resource, evaluatorOptions, options);
45
+ return run(permission, resource, options);
23
46
  },
24
47
  async explain(permission, resource, options) {
25
- return evaluateWithExplain(actor, permission, resource, evaluatorOptions, options);
48
+ return evaluateWithTrace(actor, permission, resource, evaluatorOptions, options);
26
49
  },
27
50
  async authorize(permission, resource, options) {
28
- const decision = await evaluate(actor, permission, resource, evaluatorOptions, options);
51
+ const decision = await run(permission, resource, options);
29
52
  if (!decision.allowed) {
30
- throw new PermissionDeniedError(decision.reason ?? "Access denied", {
53
+ throw new PermissionDeniedError(decision.publicReason ?? "Access denied", {
31
54
  actorId: actor.id,
32
55
  permission,
56
+ reason: decision.reason,
57
+ policy: decision.policy,
33
58
  });
34
59
  }
35
60
  },
@@ -5,13 +5,33 @@
5
5
  */
6
6
  import type { PermissionCache } from "../permissionTypes/index.js";
7
7
  /**
8
- * Create an in-memory permission cache.
8
+ * Generate a cache key for a permission check.
9
9
  *
10
- * @param defaultTtlMs - Default TTL in milliseconds. Defaults to 60000 (1 minute).
10
+ * The actor id is delimited, so invalidating actor `1` cannot also clear
11
+ * actors `10` and `123`, and a delimiter inside an id is escaped so two
12
+ * different (actor, permission, resource) triples can never share a key.
11
13
  */
12
- export declare function createMemoryPermissionCache(defaultTtlMs?: number): PermissionCache;
14
+ export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
15
+ /** Options for {@link createMemoryPermissionCache}. */
16
+ export interface MemoryPermissionCacheOptions {
17
+ /** Default TTL in milliseconds. Defaults to 60,000 (1 minute). */
18
+ readonly defaultTtlMs?: number;
19
+ /**
20
+ * Maximum entries held. The oldest are evicted past it. Default: 10,000.
21
+ *
22
+ * Keys carry actor and resource ids, so an unbounded cache grows with
23
+ * traffic — and this one holds authorization decisions, which is the last
24
+ * place to want unbounded retention.
25
+ */
26
+ readonly maxEntries?: number;
27
+ }
13
28
  /**
14
- * Generate a cache key for a permission check.
29
+ * Create an in-memory permission cache.
30
+ *
31
+ * A TTL of `0` or less means "do not cache", not "cache forever" — the
32
+ * inverse is a decision that outlives the grant that produced it.
15
33
  */
16
- export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
34
+ export declare function createMemoryPermissionCache(optionsOrTtl?: MemoryPermissionCacheOptions | number): PermissionCache & {
35
+ size(): number;
36
+ };
17
37
  //# sourceMappingURL=cache.core.d.ts.map
@@ -3,49 +3,106 @@
3
3
  *
4
4
  * @module cache/cache
5
5
  */
6
+ /** Separates the actor id from the rest of the key. */
7
+ const KEY_DELIMITER = "|";
8
+ /**
9
+ * Escape a caller-supplied segment so it cannot forge a delimiter.
10
+ *
11
+ * Actor and resource ids are opaque strings and may contain `|`. Without
12
+ * escaping, actor `u|post:read` checking `x:y` produced the same key as
13
+ * actor `u` checking `post:read` on resource `x:y` — one actor's cached
14
+ * decision answering for another. Ids without `|` or `\` are unchanged, so
15
+ * existing keys keep their shape.
16
+ */
17
+ function escapeSegment(segment) {
18
+ return segment.replace(/[\\|]/g, (char) => `\\${char}`);
19
+ }
20
+ /** Prefix identifying an actor's cache entries. */
21
+ function actorPrefix(actorId) {
22
+ return `actor:${escapeSegment(actorId)}${KEY_DELIMITER}`;
23
+ }
24
+ /**
25
+ * Generate a cache key for a permission check.
26
+ *
27
+ * The actor id is delimited, so invalidating actor `1` cannot also clear
28
+ * actors `10` and `123`, and a delimiter inside an id is escaped so two
29
+ * different (actor, permission, resource) triples can never share a key.
30
+ */
31
+ export function permissionCacheKey(actorId, permission, resourceId) {
32
+ const base = `${actorPrefix(actorId)}${permission}`;
33
+ return resourceId
34
+ ? `${base}${KEY_DELIMITER}${escapeSegment(resourceId)}`
35
+ : base;
36
+ }
37
+ const DEFAULT_TTL_MS = 60_000;
38
+ const DEFAULT_MAX_ENTRIES = 10_000;
6
39
  /**
7
40
  * Create an in-memory permission cache.
8
41
  *
9
- * @param defaultTtlMs - Default TTL in milliseconds. Defaults to 60000 (1 minute).
42
+ * A TTL of `0` or less means "do not cache", not "cache forever" — the
43
+ * inverse is a decision that outlives the grant that produced it.
10
44
  */
11
- export function createMemoryPermissionCache(defaultTtlMs = 60000) {
45
+ export function createMemoryPermissionCache(optionsOrTtl = {}) {
46
+ const options = typeof optionsOrTtl === "number"
47
+ ? { defaultTtlMs: optionsOrTtl }
48
+ : optionsOrTtl;
49
+ const defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
50
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
12
51
  const store = new Map();
52
+ function evictIfNeeded() {
53
+ if (store.size <= maxEntries)
54
+ return;
55
+ const now = Date.now();
56
+ for (const [key, entry] of store) {
57
+ if (entry.expiresAt !== undefined && now > entry.expiresAt) {
58
+ store.delete(key);
59
+ }
60
+ }
61
+ // Map iterates in insertion order, so the head is the oldest entry.
62
+ while (store.size > maxEntries) {
63
+ const oldest = store.keys().next();
64
+ if (oldest.done === true)
65
+ break;
66
+ store.delete(oldest.value);
67
+ }
68
+ }
13
69
  return {
14
70
  async get(key) {
15
71
  const entry = store.get(key);
16
72
  if (!entry)
17
73
  return undefined;
18
- if (entry.expiresAt && Date.now() > entry.expiresAt) {
74
+ if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
19
75
  store.delete(key);
20
76
  return undefined;
21
77
  }
22
78
  return entry.value;
23
79
  },
24
- async set(key, value, options) {
25
- const ttl = options?.ttl ?? defaultTtlMs;
26
- store.set(key, {
27
- value,
28
- expiresAt: ttl > 0 ? Date.now() + ttl : undefined,
29
- });
80
+ async set(key, value, setOptions) {
81
+ const ttl = setOptions?.ttl ?? defaultTtlMs;
82
+ if (ttl <= 0) {
83
+ store.delete(key);
84
+ return;
85
+ }
86
+ store.delete(key);
87
+ store.set(key, { value, expiresAt: Date.now() + ttl });
88
+ evictIfNeeded();
30
89
  },
31
90
  async delete(key) {
32
91
  store.delete(key);
33
92
  },
34
93
  async invalidateActor(actorId) {
35
- const prefix = `actor:${actorId}`;
36
- for (const key of store.keys()) {
37
- if (key.startsWith(prefix)) {
94
+ const prefix = actorPrefix(actorId);
95
+ for (const key of [...store.keys()]) {
96
+ if (key.startsWith(prefix))
38
97
  store.delete(key);
39
- }
40
98
  }
41
99
  },
100
+ async clear() {
101
+ store.clear();
102
+ },
103
+ size() {
104
+ return store.size;
105
+ },
42
106
  };
43
107
  }
44
- /**
45
- * Generate a cache key for a permission check.
46
- */
47
- export function permissionCacheKey(actorId, permission, resourceId) {
48
- const base = `actor:${actorId}:${permission}`;
49
- return resourceId ? `${base}:${resourceId}` : base;
50
- }
51
108
  //# sourceMappingURL=cache.core.js.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * @module cache
5
5
  */
6
- export { createMemoryPermissionCache, permissionCacheKey, } from "./cache.core.js";
6
+ export { createMemoryPermissionCache, permissionCacheKey, type MemoryPermissionCacheOptions, } from "./cache.core.js";
7
7
  //# sourceMappingURL=index.d.ts.map