@zudojs/permissions 1.1.0 → 1.3.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 (45) hide show
  1. package/README.md +120 -31
  2. package/dist/ability/ability.core.js +4 -24
  3. package/dist/cache/cache.actorDigest.d.ts +26 -0
  4. package/dist/cache/cache.actorDigest.js +90 -0
  5. package/dist/cache/cache.core.d.ts +7 -1
  6. package/dist/cache/cache.core.js +9 -2
  7. package/dist/cache/index.d.ts +1 -0
  8. package/dist/cache/index.js +1 -0
  9. package/dist/evaluator/authorizationEngine.d.ts +58 -5
  10. package/dist/evaluator/authorizationEngine.js +76 -68
  11. package/dist/evaluator/engineSupport/authorizationEngine.validation.d.ts +37 -0
  12. package/dist/evaluator/engineSupport/authorizationEngine.validation.js +90 -0
  13. package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +28 -0
  14. package/dist/evaluator/engineSupport/evaluator.cacheKey.js +70 -0
  15. package/dist/evaluator/engineSupport/evaluator.observed.d.ts +18 -0
  16. package/dist/evaluator/engineSupport/evaluator.observed.js +49 -0
  17. package/dist/evaluator/engineSupport/index.d.ts +10 -0
  18. package/dist/evaluator/engineSupport/index.js +10 -0
  19. package/dist/evaluator/evaluator.core.js +19 -37
  20. package/dist/evaluator/evaluator.pipeline.d.ts +11 -0
  21. package/dist/evaluator/evaluator.pipeline.js +25 -3
  22. package/dist/http/httpMiddleware.core.d.ts +15 -5
  23. package/dist/http/httpMiddleware.core.js +18 -4
  24. package/dist/http/httpResource.helper.d.ts +30 -0
  25. package/dist/http/httpResource.helper.js +32 -0
  26. package/dist/http/httpTypes.d.ts +17 -4
  27. package/dist/http/index.d.ts +7 -5
  28. package/dist/http/index.js +6 -4
  29. package/dist/permission/index.d.ts +1 -1
  30. package/dist/permission/index.js +1 -1
  31. package/dist/permission/permission.core.d.ts +12 -0
  32. package/dist/permission/permission.core.js +20 -1
  33. package/dist/permission/permissionRegistry.d.ts +14 -1
  34. package/dist/permission/permissionRegistry.js +15 -2
  35. package/dist/policy/policyRegistry.d.ts +12 -0
  36. package/dist/policy/policyRegistry.js +17 -1
  37. package/dist/role/roleRegistry.d.ts +9 -0
  38. package/dist/role/roleRegistry.js +19 -1
  39. package/dist/rule/rule.core.d.ts +14 -22
  40. package/dist/rule/rule.core.js +49 -86
  41. package/dist/rule/rule.pattern.d.ts +44 -0
  42. package/dist/rule/rule.pattern.js +72 -0
  43. package/dist/utils/utils.notifier.d.ts +19 -0
  44. package/dist/utils/utils.notifier.js +31 -0
  45. package/package.json +2 -10
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-permissions](https://zudojs.oyinlola.site/docs/packages-permissions) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-permissions.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -102,8 +108,19 @@ await engine.can(actor, "post:update", { ownerId: "user_1" }); // true
102
108
  await engine.can(actor, "post:update", { ownerId: "user_9" }); // false
103
109
  ```
104
110
 
105
- Conditions may be async. A condition that throws is treated as unmet — an
106
- authorization check fails closed.
111
+ Conditions may be async. A condition that cannot be evaluated fails closed,
112
+ and what "closed" means depends on the rule's effect:
113
+
114
+ - an **allow** whose condition throws (or that has a condition but no
115
+ context) does not apply;
116
+ - a **deny** whose condition throws **applies**. In the example above,
117
+ `locked-posts` throws when no resource is passed, and the check is denied —
118
+ treating the deny as unmet would let the role's allow win. The error is
119
+ reported through `onError`, and the decision is not cached.
120
+
121
+ A deny also bears on a wildcard check: `can(actor, "post:*")` is refused by a
122
+ deny on `post:delete`, whether it comes from `deniedPermissions`, a deny rule,
123
+ or a policy registered for `post:delete`.
107
124
 
108
125
  **Combining.** The default is `deny-overrides`: any applicable deny wins,
109
126
  whatever its priority. Pass `algorithm: "priority"` for highest-priority-wins,
@@ -118,13 +135,23 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
118
135
  check:
119
136
 
120
137
  ```typescript
138
+ import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
139
+
140
+ const tenancy = createContextManager({ storage: getDefaultStorage() });
141
+
121
142
  await engine.can(actor, "invoice:read", invoice, {
122
- metadata: { tenantId: request.tenantId },
143
+ // The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
144
+ // `requireCurrentTenant()` is a method on the context manager; it throws
145
+ // when no tenant context is active, rather than returning undefined.
146
+ metadata: { tenantId: tenancy.requireCurrentTenant().id },
123
147
  });
124
148
  ```
125
149
 
126
150
  `tenantIsolation()` compares that value against the resource's tenant, and
127
- denies when either is missing.
151
+ denies when either is missing. It is only as good as the value you pass: fill
152
+ `tenantId` from a source the client cannot choose (a verified token claim, or
153
+ the tenant `@zudojs/tenancy` resolved), **never from a request header** — a
154
+ caller would set the header to the resource's tenant and pass.
128
155
 
129
156
  ## Policies
130
157
 
@@ -176,9 +203,14 @@ const policies = createPolicyRegistry();
176
203
  const engine = createPermissionEngine({ roles, policies });
177
204
 
178
205
  roles.define({ name: "auditor", permissions: ["audit:read"] });
179
- engine.invalidateRoles(); // pick up the change
206
+ roles.remove("reader");
207
+ // both take effect on the next check: the engine subscribes to the registry
180
208
 
181
- policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
209
+ policies.define({
210
+ name: "lockdown",
211
+ permissions: ["*:*"],
212
+ evaluate: () => ({ allowed: false }),
213
+ });
182
214
  // enforced by the next check — through the engine or an existing Ability
183
215
 
184
216
  roles.require("auditor"); // throws RoleNotFoundError when unregistered
@@ -188,9 +220,17 @@ All three registries reject a duplicate name — re-registering a role or a
188
220
  policy is an authorization rule disappearing without a trace. Pass
189
221
  `{ allowOverride: true }` when replacement is what you mean.
190
222
 
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.
223
+ The engine subscribes to a role or policy registry it is given. Every
224
+ `define`, `remove` and `clear` discards the memoized roles **and every cached
225
+ decision**, so revoking a role is not undone by a cache entry written before
226
+ the revocation. `engine.invalidateRoles()` does the same for a custom source.
227
+
228
+ `validateConfiguration` (default `true`) rejects a pattern that could never
229
+ match wherever it is written — a role grant, a role's rules, a static rule, or
230
+ a policy's `permissions` — because a malformed pattern in a deny or a lockdown
231
+ policy would fail open in silence. Arrays are checked at construction; a
232
+ registry checks on `define` (`InvalidRoleError` / `InvalidPermissionError`),
233
+ and a custom source is checked when the engine reads it.
194
234
 
195
235
  A permission registry records descriptions and implications:
196
236
 
@@ -201,11 +241,17 @@ permissions.define("post:write", { implies: ["post:read"] });
201
241
 
202
242
  const engine = createPermissionEngine({
203
243
  roles,
204
- expandImplied: (permission) => permissions.expandImplied(permission),
244
+ expandImplied: permissions,
205
245
  });
206
246
  // An actor granted post:admin now passes post:read.
207
247
  ```
208
248
 
249
+ Pass the registry itself rather than a closure over it. The engine subscribes
250
+ to it, so `permissions.remove("post:admin")` — or redefining it without the
251
+ implication — drops the decisions that were cached while it stood. A bare
252
+ `(permission) => permissions.expandImplied(permission)` still works, but it
253
+ cannot announce a change, so an engine given one caches no decisions at all.
254
+
209
255
  ## Caching
210
256
 
211
257
  ```typescript
@@ -226,10 +272,13 @@ await engine.can(actor, "post:read", post, { skipCache: true });
226
272
  await engine.invalidateActor("user_1");
227
273
  ```
228
274
 
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.
275
+ Keys include the actor id, a digest of everything else the actor carries
276
+ (`roles`, `permissions`, `type`, any other field a condition may read), the
277
+ permission and the resource id (from `resource.id`, or `options.resourceId`).
278
+ The same user id with different roles — an admin token in one tenant and a
279
+ viewer token in another, or a demoted token — never shares a decision. A `|`
280
+ inside an actor or resource id is escaped, so two different checks can never
281
+ share a key either.
233
282
 
234
283
  A check is cached only when the key can describe it completely:
235
284
 
@@ -239,7 +288,16 @@ A check is cached only when the key can describe it completely:
239
288
  - a check carrying `metadata` is **not cached**, because conditions such as
240
289
  `tenantIsolation()` read the tenant from there and it is not part of the
241
290
  key;
242
- - a decision produced by a policy marked `cacheable: false` is not stored;
291
+ - an actor carrying something the digest cannot describe (a function, a class
292
+ instance, a `Map`) is **not cached**;
293
+ - a decision produced by a policy marked `cacheable: false`, or forced by a
294
+ condition that threw, is not stored;
295
+ - an engine with a `roleResolver` or `permissionResolver` and no
296
+ `resolverCacheKey` caches **nothing**: the resolver reads state the key
297
+ cannot describe, so an entry would outlive a grant withdrawn upstream;
298
+ - an engine whose `expandImplied` is a bare function rather than a
299
+ `createPermissionRegistry()` caches **nothing**, for the same reason: a
300
+ revoked implication cannot announce itself;
243
301
  - a TTL of `0` or less means "do not cache".
244
302
 
245
303
  `deniedPermissions` is evaluated before the cache is consulted, so a deny
@@ -260,20 +318,40 @@ const engine = createPermissionEngine({
260
318
  A resolver that fails is reported through `onError` and the check continues
261
319
  fail-closed, rather than throwing out of the authorization path.
262
320
 
321
+ A resolver reads authorization state the engine does not own and cannot see
322
+ change, and none of it is in the decision-cache key. **A resolver-backed
323
+ engine therefore caches nothing** unless you describe that state with
324
+ `resolverCacheKey`:
325
+
326
+ ```typescript
327
+ const engine = createPermissionEngine({
328
+ roles,
329
+ cache: createMemoryPermissionCache(),
330
+ permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
331
+ // Anything that changes when the resolver's answer could change.
332
+ resolverCacheKey: (actor) => db.grantsVersionFor(actor.id),
333
+ });
334
+ ```
335
+
336
+ Return `undefined` for an actor whose state you cannot describe, and that
337
+ actor's decisions stay uncached.
338
+
263
339
  ## Failure behaviour
264
340
 
265
341
  Every failure denies:
266
342
 
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` |
343
+ | Situation | Result |
344
+ | ----------------------------- | ------------------------------------------------------ |
345
+ | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
346
+ | Malformed permission string | denied, `reason: "invalid_permission"` |
347
+ | Allow condition throws | the allow does not apply |
348
+ | Deny condition throws | denied, `reason: "rule_deny"`; reported to `onError` |
349
+ | Malformed rule/policy pattern | rejected at construction or `define` |
350
+ | A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
351
+ | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
352
+ | Role inheritance cycle | denied; reported to `onError` |
353
+ | Role source throws | denied; reported to `onError` |
354
+ | `signal` aborted | throws `AuthorizationAbortedError` |
277
355
 
278
356
  A value that is not an `Error` — a policy or resolver that throws a string —
279
357
  reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
@@ -324,8 +402,10 @@ emitter.on((event) => auditLog.write(event));
324
402
  const engine = createPermissionEngine({ roles, emitter });
325
403
  ```
326
404
 
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.
405
+ Every check emits — allowed, denied, and the ones that throw — including
406
+ `explain()` on the engine and on an Ability, which make the same real
407
+ decision. A handler that throws cannot break authorization, but it is reported
408
+ rather than swallowed.
329
409
 
330
410
  ## HTTP middleware
331
411
 
@@ -334,10 +414,14 @@ import { authorize, createActorMiddleware } from "@zudojs/permissions";
334
414
 
335
415
  const guard = authorize(engine, "post:update", {
336
416
  extractActor: (context) => context.state.get("auth:user"),
337
- extractResource: (context) => loadPost(context.request.params.get("id")),
417
+ // May be async: it is awaited, and a loader that rejects denies (403).
418
+ extractResource: (context) => loadPost(context.request.getParam?.("id")),
419
+ // The tenant @zudojs/tenancy resolved and trust-checked — never a header.
338
420
  extractMetadata: (context) => ({
339
- tenantId: context.request.headers.get("x-tenant"),
421
+ tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
422
+ ?.tenantId,
340
423
  }),
424
+ onError: (error, source) => logger.warn({ error, source }, "guard denied"),
341
425
  });
342
426
  ```
343
427
 
@@ -351,11 +435,16 @@ const guard = authorize(engine, "post:update", {
351
435
  decision.
352
436
  - `context.signal` is forwarded, so a client disconnect stops policy
353
437
  evaluation.
438
+ - `extractResource` is awaited. A loader that throws or rejects answers
439
+ **403** (`reason: "resource_error"`) and reports through `onError`; it never
440
+ lets the request through.
354
441
  - `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
355
442
  several permissions, short-circuiting on the first that decides the outcome.
443
+ An empty list denies in either mode.
356
444
 
357
- `@zudojs/http` is an optional peer dependency; the middleware types are
358
- mirrored locally so this package works without it.
445
+ The middleware composes with the real `@zudojs/http` pipeline without
446
+ depending on it: the HTTP types are mirrored structurally (headers, params and
447
+ query may be plain objects, as `@zudojs/http` provides them, or maps).
359
448
 
360
449
  ## Errors
361
450
 
@@ -5,33 +5,13 @@
5
5
  */
6
6
  import { evaluate, evaluateWithTrace } from "../evaluator/evaluator.core.js";
7
7
  import { PermissionDeniedError } from "../permissionErrors/index.js";
8
+ import { observed } from "../evaluator/engineSupport/index.js";
8
9
  /**
9
10
  * Create an Ability for an actor.
10
11
  */
11
12
  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
- }
13
+ function run(permission, resource, options) {
14
+ return observed(emitter, actor, permission, resource, () => evaluate(actor, permission, resource, evaluatorOptions, options), (decision) => decision);
35
15
  }
36
16
  return {
37
17
  actor,
@@ -45,7 +25,7 @@ export function createAbility(actor, evaluatorOptions, emitter) {
45
25
  return run(permission, resource, options);
46
26
  },
47
27
  async explain(permission, resource, options) {
48
- return evaluateWithTrace(actor, permission, resource, evaluatorOptions, options);
28
+ return observed(emitter, actor, permission, resource, () => evaluateWithTrace(actor, permission, resource, evaluatorOptions, options), (result) => result.decision);
49
29
  },
50
30
  async authorize(permission, resource, options) {
51
31
  const decision = await run(permission, resource, options);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Actor digest for the decision cache key.
3
+ *
4
+ * A decision depends on everything the actor carries, not only its id:
5
+ * `roles`, `permissions` and `type` travel on the actor object for each
6
+ * request (a tenant-scoped token, `auth.checkAccess`), and a condition may
7
+ * read any other field on it. Keying on the id alone let an admin decision
8
+ * made in one tenant answer for the same user as a viewer in another, and a
9
+ * demoted token keep its old grants until the entry expired.
10
+ *
11
+ * @module cache/cache.actorDigest
12
+ */
13
+ import type { PermissionActor } from "../permissionTypes/index.js";
14
+ /** Longest digest worth keying on; a longer one is not cached at all. */
15
+ export declare const MAX_ACTOR_DIGEST_LENGTH = 4096;
16
+ /**
17
+ * A stable description of everything an actor carries besides its id.
18
+ *
19
+ * Returns `undefined` when the actor holds something the digest cannot
20
+ * describe faithfully — a function, a class instance, a `Map`, nesting past
21
+ * six levels, or a digest longer than {@link MAX_ACTOR_DIGEST_LENGTH}. The
22
+ * engine does not cache such a decision rather than key it on a partial
23
+ * description.
24
+ */
25
+ export declare function actorCacheDigest(actor: PermissionActor): string | undefined;
26
+ //# sourceMappingURL=cache.actorDigest.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Actor digest for the decision cache key.
3
+ *
4
+ * A decision depends on everything the actor carries, not only its id:
5
+ * `roles`, `permissions` and `type` travel on the actor object for each
6
+ * request (a tenant-scoped token, `auth.checkAccess`), and a condition may
7
+ * read any other field on it. Keying on the id alone let an admin decision
8
+ * made in one tenant answer for the same user as a viewer in another, and a
9
+ * demoted token keep its old grants until the entry expired.
10
+ *
11
+ * @module cache/cache.actorDigest
12
+ */
13
+ /** Longest digest worth keying on; a longer one is not cached at all. */
14
+ export const MAX_ACTOR_DIGEST_LENGTH = 4096;
15
+ /** Deepest nesting the digest will describe. */
16
+ const MAX_DEPTH = 6;
17
+ /** Marks a value the digest cannot describe faithfully. */
18
+ const UNDESCRIBABLE = Symbol("undescribable");
19
+ function isPlainRecord(value) {
20
+ const proto = Object.getPrototypeOf(value);
21
+ return proto === Object.prototype || proto === null;
22
+ }
23
+ function canonical(value, depth) {
24
+ if (depth > MAX_DEPTH)
25
+ return UNDESCRIBABLE;
26
+ if (value === null)
27
+ return "null";
28
+ switch (typeof value) {
29
+ case "string":
30
+ case "boolean":
31
+ return JSON.stringify(value);
32
+ case "number":
33
+ return Number.isFinite(value) ? JSON.stringify(value) : UNDESCRIBABLE;
34
+ case "object":
35
+ break;
36
+ default:
37
+ return UNDESCRIBABLE;
38
+ }
39
+ const object = value;
40
+ if (object instanceof Date) {
41
+ const time = object.getTime();
42
+ return Number.isNaN(time) ? UNDESCRIBABLE : `D${JSON.stringify(time)}`;
43
+ }
44
+ if (Array.isArray(object)) {
45
+ const parts = [];
46
+ for (const entry of object) {
47
+ const part = canonical(entry, depth + 1);
48
+ if (part === UNDESCRIBABLE)
49
+ return UNDESCRIBABLE;
50
+ parts.push(part);
51
+ }
52
+ return `[${parts.join(",")}]`;
53
+ }
54
+ if (!isPlainRecord(object))
55
+ return UNDESCRIBABLE;
56
+ return canonicalRecord(object, depth, []);
57
+ }
58
+ function canonicalRecord(record, depth, skip) {
59
+ const parts = [];
60
+ for (const key of Object.keys(record).sort()) {
61
+ if (skip.includes(key))
62
+ continue;
63
+ const entry = record[key];
64
+ if (entry === undefined)
65
+ continue;
66
+ const part = canonical(entry, depth + 1);
67
+ if (part === UNDESCRIBABLE)
68
+ return UNDESCRIBABLE;
69
+ parts.push(`${JSON.stringify(key)}:${part}`);
70
+ }
71
+ return `{${parts.join(",")}}`;
72
+ }
73
+ /**
74
+ * A stable description of everything an actor carries besides its id.
75
+ *
76
+ * Returns `undefined` when the actor holds something the digest cannot
77
+ * describe faithfully — a function, a class instance, a `Map`, nesting past
78
+ * six levels, or a digest longer than {@link MAX_ACTOR_DIGEST_LENGTH}. The
79
+ * engine does not cache such a decision rather than key it on a partial
80
+ * description.
81
+ */
82
+ export function actorCacheDigest(actor) {
83
+ if (typeof actor !== "object" || actor === null)
84
+ return undefined;
85
+ const digest = canonicalRecord(actor, 0, ["id"]);
86
+ if (digest === UNDESCRIBABLE)
87
+ return undefined;
88
+ return digest.length > MAX_ACTOR_DIGEST_LENGTH ? undefined : digest;
89
+ }
90
+ //# sourceMappingURL=cache.actorDigest.js.map
@@ -10,8 +10,14 @@ import type { PermissionCache } from "../permissionTypes/index.js";
10
10
  * The actor id is delimited, so invalidating actor `1` cannot also clear
11
11
  * actors `10` and `123`, and a delimiter inside an id is escaped so two
12
12
  * different (actor, permission, resource) triples can never share a key.
13
+ *
14
+ * `scope` carries whatever else the decision depends on — the engine passes
15
+ * a digest of the actor's roles, permissions and attributes, so the same id
16
+ * holding different grants never shares a key. It sits after the actor
17
+ * prefix behind a `~`, which no permission string can start with, so
18
+ * `invalidateActor` still finds every entry for the actor.
13
19
  */
14
- export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
20
+ export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string, scope?: string): string;
15
21
  /** Options for {@link createMemoryPermissionCache}. */
16
22
  export interface MemoryPermissionCacheOptions {
17
23
  /** Default TTL in milliseconds. Defaults to 60,000 (1 minute). */
@@ -27,9 +27,16 @@ function actorPrefix(actorId) {
27
27
  * The actor id is delimited, so invalidating actor `1` cannot also clear
28
28
  * actors `10` and `123`, and a delimiter inside an id is escaped so two
29
29
  * different (actor, permission, resource) triples can never share a key.
30
+ *
31
+ * `scope` carries whatever else the decision depends on — the engine passes
32
+ * a digest of the actor's roles, permissions and attributes, so the same id
33
+ * holding different grants never shares a key. It sits after the actor
34
+ * prefix behind a `~`, which no permission string can start with, so
35
+ * `invalidateActor` still finds every entry for the actor.
30
36
  */
31
- export function permissionCacheKey(actorId, permission, resourceId) {
32
- const base = `${actorPrefix(actorId)}${permission}`;
37
+ export function permissionCacheKey(actorId, permission, resourceId, scope) {
38
+ const scoped = scope === undefined ? "" : `~${escapeSegment(scope)}${KEY_DELIMITER}`;
39
+ const base = `${actorPrefix(actorId)}${scoped}${permission}`;
33
40
  return resourceId
34
41
  ? `${base}${KEY_DELIMITER}${escapeSegment(resourceId)}`
35
42
  : base;
@@ -4,4 +4,5 @@
4
4
  * @module cache
5
5
  */
6
6
  export { createMemoryPermissionCache, permissionCacheKey, type MemoryPermissionCacheOptions, } from "./cache.core.js";
7
+ export { actorCacheDigest, MAX_ACTOR_DIGEST_LENGTH, } from "./cache.actorDigest.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * @module cache
5
5
  */
6
6
  export { createMemoryPermissionCache, permissionCacheKey, } from "./cache.core.js";
7
+ export { actorCacheDigest, MAX_ACTOR_DIGEST_LENGTH, } from "./cache.actorDigest.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -6,14 +6,40 @@
6
6
  import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PermissionCache, PermissionResolver, RoleResolver, RoleDefinition, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
7
7
  import { type Ability } from "../ability/ability.core.js";
8
8
  import type { PermissionEventEmitter } from "../observability/observability.core.js";
9
- /** Anything the engine will accept as its source of roles. */
9
+ /**
10
+ * Anything the engine will accept as its source of roles.
11
+ *
12
+ * A source with `subscribe` (every `createRoleRegistry()`) is watched: a
13
+ * `define`, `remove` or `clear` discards the engine's memoized roles and
14
+ * every cached decision, so revoking a role takes effect on the next check.
15
+ */
10
16
  export interface RoleSource {
11
17
  get(name: string): RoleDefinition | undefined;
18
+ subscribe?(listener: () => void): () => void;
12
19
  }
13
- /** Anything the engine will accept as its source of policies. */
20
+ /**
21
+ * Anything the engine will accept as its source of policies.
22
+ *
23
+ * A source with `subscribe` (every `createPolicyRegistry()`) is watched the
24
+ * same way, so a policy added or removed is not bypassed by a cached decision.
25
+ */
14
26
  export interface PolicySource {
15
27
  names(): readonly string[];
16
28
  get(name: string): PermissionPolicyDefinition | undefined;
29
+ subscribe?(listener: () => void): () => void;
30
+ }
31
+ /**
32
+ * Anything the engine will accept as its source of permission implications.
33
+ *
34
+ * A source with `subscribe` (every `createPermissionRegistry()`) is watched
35
+ * like the role and policy registries: revoking an implication drops every
36
+ * decision that was cached while it stood. A bare function cannot announce a
37
+ * change, so an engine given one caches nothing — see
38
+ * {@link PermissionEngineOptions.expandImplied}.
39
+ */
40
+ export interface ImpliedPermissionSource {
41
+ expandImplied(permission: string): readonly string[];
42
+ subscribe?(listener: () => void): () => void;
17
43
  }
18
44
  /** Configuration for the permission engine. */
19
45
  export interface PermissionEngineOptions {
@@ -41,8 +67,31 @@ export interface PermissionEngineOptions {
41
67
  readonly permissionResolver?: PermissionResolver;
42
68
  /** Loads additional roles for an actor from an external source. */
43
69
  readonly roleResolver?: RoleResolver;
44
- /** Expands a permission into the permissions it implies. */
45
- readonly expandImplied?: (permission: string) => readonly string[];
70
+ /**
71
+ * Describes the state a resolver is answering from, so that decisions it
72
+ * influenced can be cached safely.
73
+ *
74
+ * A resolver reads authorization data the engine does not own and cannot
75
+ * see change — a grants table, another service. Nothing about it is in the
76
+ * decision-cache key, so an entry written while the resolver said "allow"
77
+ * kept answering after the grant was withdrawn upstream. Resolver-backed
78
+ * engines therefore **do not cache at all** unless this is supplied.
79
+ *
80
+ * Return a value that changes whenever the resolver's answer for this actor
81
+ * could change — a version column, an `updatedAt` stamp, a grants-table
82
+ * generation. Return `undefined` for an actor whose state cannot be
83
+ * described, and that actor's decisions stay uncached.
84
+ */
85
+ readonly resolverCacheKey?: (actor: PermissionActor) => string | undefined;
86
+ /**
87
+ * Expands a permission into the permissions it implies.
88
+ *
89
+ * Prefer passing a `createPermissionRegistry()` — the engine subscribes to
90
+ * it, so revoking an implication invalidates the decisions cached under it.
91
+ * A bare function cannot announce a change, so an engine given one caches
92
+ * no decisions rather than serving one from a revoked implication.
93
+ */
94
+ readonly expandImplied?: ((permission: string) => readonly string[]) | ImpliedPermissionSource;
46
95
  /** Emits an event for every completed check, including failures. */
47
96
  readonly emitter?: PermissionEventEmitter;
48
97
  /** Reports a failure authorization swallowed to stay fail-closed. */
@@ -79,7 +128,11 @@ export interface PermissionEngine {
79
128
  createAbility(actor: PermissionActor): Ability;
80
129
  /** Drop cached decisions for one actor. */
81
130
  invalidateActor(actorId: string): Promise<void>;
82
- /** Re-read the role source, discarding the memoized lookups. */
131
+ /**
132
+ * Re-read the role source, discarding the memoized lookups and every
133
+ * cached decision. Registries created with `createRoleRegistry()` and
134
+ * `createPolicyRegistry()` trigger this themselves on every change.
135
+ */
83
136
  invalidateRoles(): void;
84
137
  }
85
138
  /**