@zudojs/permissions 0.0.1 → 1.0.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 +345 -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 +24 -5
  6. package/dist/cache/cache.core.js +62 -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 +146 -17
  14. package/dist/evaluator/evaluator.core.d.ts +28 -4
  15. package/dist/evaluator/evaluator.core.js +246 -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 +26 -23
  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 +16 -25
  58. package/dist/role/roleRegistry.js +27 -23
  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 +23 -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,358 @@ 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
32
+ ```
33
+
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, but never overrides a
155
+ denial.
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
+ roles.require("auditor"); // throws RoleNotFoundError when unregistered
182
+ ```
183
+
184
+ All three registries reject a duplicate name — re-registering a role or a
185
+ policy is an authorization rule disappearing without a trace. Pass
186
+ `{ allowOverride: true }` when replacement is what you mean.
187
+
188
+ `validateConfiguration` (default `true`) applies to a registry as well as to
189
+ an inline array: a role whose grant could never match is rejected when the
190
+ engine looks it up, and the check denies.
191
+
192
+ A permission registry records descriptions and implications:
193
+
194
+ ```typescript
195
+ const permissions = createPermissionRegistry();
196
+ permissions.define("post:admin", { implies: ["post:write"] });
197
+ permissions.define("post:write", { implies: ["post:read"] });
198
+
199
+ const engine = createPermissionEngine({
200
+ roles,
201
+ expandImplied: (permission) => permissions.expandImplied(permission),
202
+ });
203
+ // An actor granted post:admin now passes post:read.
204
+ ```
205
+
206
+ ## Caching
207
+
208
+ ```typescript
209
+ import { createMemoryPermissionCache } from "@zudojs/permissions";
210
+
211
+ const engine = createPermissionEngine({
212
+ roles,
213
+ cache: createMemoryPermissionCache({
214
+ defaultTtlMs: 30_000,
215
+ maxEntries: 5_000,
216
+ }),
217
+ cacheTtlMs: 30_000,
218
+ });
219
+
220
+ await engine.can(actor, "post:read", post); // evaluated
221
+ await engine.can(actor, "post:read", post); // cached
222
+ await engine.can(actor, "post:read", post, { skipCache: true });
223
+ await engine.invalidateActor("user_1");
224
+ ```
225
+
226
+ Keys include the actor, the permission and the resource id (from
227
+ `resource.id`, or `options.resourceId`), so two resources never share one
228
+ decision.
229
+
230
+ A check is cached only when the key can describe it completely:
231
+
232
+ - a resource with no `id` and no `options.resourceId` is **not cached**, since
233
+ the key would collapse to actor + permission and an allow for one object
234
+ would answer for the next;
235
+ - a check carrying `metadata` is **not cached**, because conditions such as
236
+ `tenantIsolation()` read the tenant from there and it is not part of the
237
+ key;
238
+ - a decision produced by a policy marked `cacheable: false` is not stored;
239
+ - a TTL of `0` or less means "do not cache".
240
+
241
+ `deniedPermissions` is evaluated before the cache is consulted, so a deny
242
+ added to the actor takes effect immediately rather than waiting for a cached
243
+ allow to expire. A malformed entry there can never match, so it is reported
244
+ through `onError` instead of being dropped in silence.
245
+
246
+ ## External sources
247
+
248
+ ```typescript
249
+ const engine = createPermissionEngine({
250
+ roles,
251
+ roleResolver: { resolveRoles: (actor) => db.rolesFor(actor.id) },
252
+ permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
253
+ });
254
+ ```
255
+
256
+ A resolver that fails is reported through `onError` and the check continues
257
+ fail-closed, rather than throwing out of the authorization path.
258
+
259
+ ## Failure behaviour
260
+
261
+ Every failure denies:
262
+
263
+ | Situation | Result |
264
+ | --------------------------- | ------------------------------------------------------ |
265
+ | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
266
+ | Malformed permission string | denied, `reason: "invalid_permission"` |
267
+ | Condition throws | rule does not apply |
268
+ | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
269
+ | Role inheritance cycle | denied; reported to `onError` |
270
+ | Role source throws | denied; reported to `onError` |
271
+ | `signal` aborted | throws `AuthorizationAbortedError` |
272
+
273
+ A value that is not an `Error` — a policy or resolver that throws a string —
274
+ reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
275
+ the original as `cause`.
276
+
277
+ Cancellation is the one case that throws, because the caller asked for the
278
+ work to stop.
279
+
280
+ ## Abilities
281
+
282
+ ```typescript
283
+ const ability = engine.createAbility(actor);
284
+
285
+ await ability.can("post:update", post);
286
+ await ability.cannot("post:delete");
287
+ await ability.check("post:update", post); // full decision
288
+ await ability.authorize("post:update", post); // throws PermissionDeniedError
289
+ await ability.explain("post:update", post);
290
+ ```
291
+
292
+ ## Explain
293
+
294
+ `explain` runs the same evaluation as `check` and collects the steps, so the
295
+ trace always describes the decision that was made:
296
+
297
+ ```typescript
298
+ const { allowed, steps, decision } = await engine.explain(
299
+ actor,
300
+ "post:update",
301
+ post,
302
+ );
303
+ steps.forEach((step) => console.log(step.type, step.detail, step.matched));
304
+ ```
305
+
306
+ The trace lists the grants that bear on this decision, not every permission
307
+ the actor holds.
308
+
309
+ ## Audit events
310
+
311
+ ```typescript
312
+ import { createPermissionEventEmitter } from "@zudojs/permissions";
313
+
314
+ const emitter = createPermissionEventEmitter({
315
+ onHandlerError: (error) => logger.error({ error }, "audit sink failed"),
316
+ });
317
+ emitter.on((event) => auditLog.write(event));
318
+
319
+ const engine = createPermissionEngine({ roles, emitter });
320
+ ```
321
+
322
+ Every check emits — allowed, denied, and the ones that throw. A handler that
323
+ throws cannot break authorization, but it is reported rather than swallowed.
324
+
325
+ ## HTTP middleware
326
+
327
+ ```typescript
328
+ import { authorize, createActorMiddleware } from "@zudojs/permissions";
329
+
330
+ const guard = authorize(engine, "post:update", {
331
+ extractActor: (context) => context.state.get("auth:user"),
332
+ extractResource: (context) => loadPost(context.request.params.get("id")),
333
+ extractMetadata: (context) => ({
334
+ tenantId: context.request.headers.get("x-tenant"),
335
+ }),
336
+ });
24
337
  ```
25
338
 
26
- ## Features
339
+ - The guard extracts the actor itself when one is not already in state, so it
340
+ works without a separate actor middleware.
341
+ - No actor → **401** with `WWW-Authenticate`. Actor but not permitted →
342
+ **403**.
343
+ - The 403 body carries `decision.publicReason`, never the internal reason:
344
+ `policy_error:<name>` names your policies and does not belong in a
345
+ response. Pass `deniedResponse` to shape the body — it receives the real
346
+ decision.
347
+ - `context.signal` is forwarded, so a client disconnect stops policy
348
+ evaluation.
349
+ - `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
350
+ several permissions, short-circuiting on the first that decides the outcome.
351
+
352
+ `@zudojs/http` is an optional peer dependency; the middleware types are
353
+ mirrored locally so this package works without it.
354
+
355
+ ## Errors
356
+
357
+ `PermissionError` is the base. `PermissionDeniedError` carries a caller-safe
358
+ message; the actor id, reason and policy live on `error.details` for the log,
359
+ not in the exposed metadata.
27
360
 
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
361
+ `PermissionNotFoundError` · `DuplicatePermissionError` · `RoleNotFoundError` ·
362
+ `DuplicateRoleError` · `DuplicatePolicyError` · `InvalidPermissionError` ·
363
+ `InvalidRoleError` ·
364
+ `CircularRoleInheritanceError` · `PolicyError` · `PolicyTimeoutError` ·
365
+ `PermissionResolverError` · `AuthorizationAbortedError`
35
366
 
36
367
  ## Use Cases
37
368
 
@@ -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,32 @@
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`.
11
12
  */
12
- export declare function createMemoryPermissionCache(defaultTtlMs?: number): PermissionCache;
13
+ export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
14
+ /** Options for {@link createMemoryPermissionCache}. */
15
+ export interface MemoryPermissionCacheOptions {
16
+ /** Default TTL in milliseconds. Defaults to 60,000 (1 minute). */
17
+ readonly defaultTtlMs?: number;
18
+ /**
19
+ * Maximum entries held. The oldest are evicted past it. Default: 10,000.
20
+ *
21
+ * Keys carry actor and resource ids, so an unbounded cache grows with
22
+ * traffic — and this one holds authorization decisions, which is the last
23
+ * place to want unbounded retention.
24
+ */
25
+ readonly maxEntries?: number;
26
+ }
13
27
  /**
14
- * Generate a cache key for a permission check.
28
+ * Create an in-memory permission cache.
29
+ *
30
+ * A TTL of `0` or less means "do not cache", not "cache forever" — the
31
+ * inverse is a decision that outlives the grant that produced it.
15
32
  */
16
- export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
33
+ export declare function createMemoryPermissionCache(optionsOrTtl?: MemoryPermissionCacheOptions | number): PermissionCache & {
34
+ size(): number;
35
+ };
17
36
  //# sourceMappingURL=cache.core.d.ts.map
@@ -3,49 +3,91 @@
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
+ /** Prefix identifying an actor's cache entries. */
9
+ function actorPrefix(actorId) {
10
+ return `actor:${actorId}${KEY_DELIMITER}`;
11
+ }
12
+ /**
13
+ * Generate a cache key for a permission check.
14
+ *
15
+ * The actor id is delimited, so invalidating actor `1` cannot also clear
16
+ * actors `10` and `123`.
17
+ */
18
+ export function permissionCacheKey(actorId, permission, resourceId) {
19
+ const base = `${actorPrefix(actorId)}${permission}`;
20
+ return resourceId ? `${base}${KEY_DELIMITER}${resourceId}` : base;
21
+ }
22
+ const DEFAULT_TTL_MS = 60_000;
23
+ const DEFAULT_MAX_ENTRIES = 10_000;
6
24
  /**
7
25
  * Create an in-memory permission cache.
8
26
  *
9
- * @param defaultTtlMs - Default TTL in milliseconds. Defaults to 60000 (1 minute).
27
+ * A TTL of `0` or less means "do not cache", not "cache forever" — the
28
+ * inverse is a decision that outlives the grant that produced it.
10
29
  */
11
- export function createMemoryPermissionCache(defaultTtlMs = 60000) {
30
+ export function createMemoryPermissionCache(optionsOrTtl = {}) {
31
+ const options = typeof optionsOrTtl === "number"
32
+ ? { defaultTtlMs: optionsOrTtl }
33
+ : optionsOrTtl;
34
+ const defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
35
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
12
36
  const store = new Map();
37
+ function evictIfNeeded() {
38
+ if (store.size <= maxEntries)
39
+ return;
40
+ const now = Date.now();
41
+ for (const [key, entry] of store) {
42
+ if (entry.expiresAt !== undefined && now > entry.expiresAt) {
43
+ store.delete(key);
44
+ }
45
+ }
46
+ // Map iterates in insertion order, so the head is the oldest entry.
47
+ while (store.size > maxEntries) {
48
+ const oldest = store.keys().next();
49
+ if (oldest.done === true)
50
+ break;
51
+ store.delete(oldest.value);
52
+ }
53
+ }
13
54
  return {
14
55
  async get(key) {
15
56
  const entry = store.get(key);
16
57
  if (!entry)
17
58
  return undefined;
18
- if (entry.expiresAt && Date.now() > entry.expiresAt) {
59
+ if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
19
60
  store.delete(key);
20
61
  return undefined;
21
62
  }
22
63
  return entry.value;
23
64
  },
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
- });
65
+ async set(key, value, setOptions) {
66
+ const ttl = setOptions?.ttl ?? defaultTtlMs;
67
+ if (ttl <= 0) {
68
+ store.delete(key);
69
+ return;
70
+ }
71
+ store.delete(key);
72
+ store.set(key, { value, expiresAt: Date.now() + ttl });
73
+ evictIfNeeded();
30
74
  },
31
75
  async delete(key) {
32
76
  store.delete(key);
33
77
  },
34
78
  async invalidateActor(actorId) {
35
- const prefix = `actor:${actorId}`;
36
- for (const key of store.keys()) {
37
- if (key.startsWith(prefix)) {
79
+ const prefix = actorPrefix(actorId);
80
+ for (const key of [...store.keys()]) {
81
+ if (key.startsWith(prefix))
38
82
  store.delete(key);
39
- }
40
83
  }
41
84
  },
85
+ async clear() {
86
+ store.clear();
87
+ },
88
+ size() {
89
+ return store.size;
90
+ },
42
91
  };
43
92
  }
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
93
  //# 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
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Condition combinators for composing authorization policies.
3
3
  *
4
+ * Conditions are evaluated by the rule engine: attach one to a
5
+ * `PermissionRule` and the rule applies only when it returns `true`.
6
+ *
4
7
  * @module conditions/conditions
5
8
  */
6
9
  import type { PermissionConditionFn } from "../permissionTypes/index.js";
@@ -33,8 +36,21 @@ export declare function isOwner(ownerField?: string): PermissionConditionFn;
33
36
  /**
34
37
  * Enforce tenant isolation — actor and resource must share the same tenant.
35
38
  *
36
- * @param actorTenantField - Field on actor metadata holding tenant ID. Defaults to "tenantId".
37
- * @param resourceTenantField - Field on resource holding tenant ID. Defaults to "tenantId".
39
+ * The actor's tenant is read from `context.metadata`, which is supplied per
40
+ * check through `AuthorizationOptions.metadata`:
41
+ *
42
+ * ```ts
43
+ * engine.check(actor, "invoice:read", invoice, {
44
+ * metadata: { tenantId: request.tenantId },
45
+ * });
46
+ * ```
47
+ *
48
+ * @param actorTenantField - Key in the context metadata holding the actor's tenant. Defaults to "tenantId".
49
+ * @param resourceTenantField - Field on the resource holding the tenant ID. Defaults to "tenantId".
38
50
  */
39
51
  export declare function tenantIsolation(actorTenantField?: string, resourceTenantField?: string): PermissionConditionFn;
52
+ /** Check that a value in the context metadata equals an expected value. */
53
+ export declare function metadataEquals(key: string, expected: unknown): PermissionConditionFn;
54
+ /** Check that the resource field matches the expected value. */
55
+ export declare function resourceEquals(field: string, expected: unknown): PermissionConditionFn;
40
56
  //# sourceMappingURL=conditions.core.d.ts.map