@zudojs/permissions 1.2.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.
package/README.md CHANGED
@@ -135,11 +135,15 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
135
135
  check:
136
136
 
137
137
  ```typescript
138
- import { requireCurrentTenant } from "@zudojs/tenancy";
138
+ import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
139
+
140
+ const tenancy = createContextManager({ storage: getDefaultStorage() });
139
141
 
140
142
  await engine.can(actor, "invoice:read", invoice, {
141
143
  // The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
142
- metadata: { tenantId: requireCurrentTenant().id },
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 },
143
147
  });
144
148
  ```
145
149
 
@@ -202,7 +206,11 @@ roles.define({ name: "auditor", permissions: ["audit:read"] });
202
206
  roles.remove("reader");
203
207
  // both take effect on the next check: the engine subscribes to the registry
204
208
 
205
- policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
209
+ policies.define({
210
+ name: "lockdown",
211
+ permissions: ["*:*"],
212
+ evaluate: () => ({ allowed: false }),
213
+ });
206
214
  // enforced by the next check — through the engine or an existing Ability
207
215
 
208
216
  roles.require("auditor"); // throws RoleNotFoundError when unregistered
@@ -233,11 +241,17 @@ permissions.define("post:write", { implies: ["post:read"] });
233
241
 
234
242
  const engine = createPermissionEngine({
235
243
  roles,
236
- expandImplied: (permission) => permissions.expandImplied(permission),
244
+ expandImplied: permissions,
237
245
  });
238
246
  // An actor granted post:admin now passes post:read.
239
247
  ```
240
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
+
241
255
  ## Caching
242
256
 
243
257
  ```typescript
@@ -278,6 +292,12 @@ A check is cached only when the key can describe it completely:
278
292
  instance, a `Map`) is **not cached**;
279
293
  - a decision produced by a policy marked `cacheable: false`, or forced by a
280
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;
281
301
  - a TTL of `0` or less means "do not cache".
282
302
 
283
303
  `deniedPermissions` is evaluated before the cache is consulted, so a deny
@@ -298,22 +318,40 @@ const engine = createPermissionEngine({
298
318
  A resolver that fails is reported through `onError` and the check continues
299
319
  fail-closed, rather than throwing out of the authorization path.
300
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
+
301
339
  ## Failure behaviour
302
340
 
303
341
  Every failure denies:
304
342
 
305
- | Situation | Result |
306
- | --------------------------- | ------------------------------------------------------ |
307
- | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
308
- | Malformed permission string | denied, `reason: "invalid_permission"` |
309
- | Allow condition throws | the allow does not apply |
310
- | Deny condition throws | denied, `reason: "rule_deny"`; reported to `onError` |
311
- | Malformed rule/policy pattern | rejected at construction or `define` |
312
- | A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
313
- | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
314
- | Role inheritance cycle | denied; reported to `onError` |
315
- | Role source throws | denied; reported to `onError` |
316
- | `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` |
317
355
 
318
356
  A value that is not an `Error` — a policy or resolver that throws a string —
319
357
  reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
@@ -380,7 +418,8 @@ const guard = authorize(engine, "post:update", {
380
418
  extractResource: (context) => loadPost(context.request.getParam?.("id")),
381
419
  // The tenant @zudojs/tenancy resolved and trust-checked — never a header.
382
420
  extractMetadata: (context) => ({
383
- tenantId: context.state.get<{ tenantId: string }>("tenancy:context")?.tenantId,
421
+ tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
422
+ ?.tenantId,
384
423
  }),
385
424
  onError: (error, source) => logger.warn({ error, source }, "guard denied"),
386
425
  });
@@ -28,6 +28,19 @@ export interface PolicySource {
28
28
  get(name: string): PermissionPolicyDefinition | undefined;
29
29
  subscribe?(listener: () => void): () => void;
30
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;
43
+ }
31
44
  /** Configuration for the permission engine. */
32
45
  export interface PermissionEngineOptions {
33
46
  /**
@@ -54,8 +67,31 @@ export interface PermissionEngineOptions {
54
67
  readonly permissionResolver?: PermissionResolver;
55
68
  /** Loads additional roles for an actor from an external source. */
56
69
  readonly roleResolver?: RoleResolver;
57
- /** Expands a permission into the permissions it implies. */
58
- 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;
59
95
  /** Emits an event for every completed check, including failures. */
60
96
  readonly emitter?: PermissionEventEmitter;
61
97
  /** Reports a failure authorization swallowed to stay fail-closed. */
@@ -20,6 +20,11 @@ function isPolicySource(policies) {
20
20
  !Array.isArray(policies) &&
21
21
  typeof policies.names === "function");
22
22
  }
23
+ function isImpliedPermissionSource(source) {
24
+ return (source !== undefined &&
25
+ typeof source !== "function" &&
26
+ typeof source.expandImplied === "function");
27
+ }
23
28
  /**
24
29
  * Create a permission engine.
25
30
  */
@@ -99,6 +104,34 @@ export function createPermissionEngine(options) {
99
104
  options.roles.subscribe?.(invalidateConfiguration);
100
105
  }
101
106
  policySource?.subscribe?.(invalidateConfiguration);
107
+ // The third registry the README wires in. Without this, revoking an
108
+ // implication left every decision it granted in the cache for the full TTL
109
+ // — `skipCache: true` said "deny" while `can()` kept saying "allow".
110
+ const impliedSource = isImpliedPermissionSource(options?.expandImplied)
111
+ ? options.expandImplied
112
+ : undefined;
113
+ const expandImplied = impliedSource
114
+ ? (permission) => impliedSource.expandImplied(permission)
115
+ : options?.expandImplied;
116
+ const impliedWatched = impliedSource?.subscribe?.(invalidateConfiguration) !== undefined;
117
+ // Two inputs the cache key cannot describe. An implication source that
118
+ // cannot announce a change, and a resolver reading state the engine does
119
+ // not own, both make a cached allow outlive the grant behind it — so the
120
+ // decision is not cached at all unless the caller closes the gap.
121
+ const impliedUnwatched = expandImplied !== undefined && !impliedWatched;
122
+ const resolverConfigured = options?.permissionResolver !== undefined ||
123
+ options?.roleResolver !== undefined;
124
+ const resolverCacheKey = options?.resolverCacheKey;
125
+ const cacheScope = (actor) => {
126
+ if (impliedUnwatched)
127
+ return undefined;
128
+ if (!resolverConfigured)
129
+ return `g${generation}`;
130
+ if (!resolverCacheKey)
131
+ return undefined;
132
+ const scope = resolverCacheKey(actor);
133
+ return scope === undefined ? undefined : `g${generation}|r${scope}`;
134
+ };
102
135
  // One live view over the configuration. `policies` is a getter so a
103
136
  // registry-backed engine re-reads the registry on every evaluation — an
104
137
  // Ability used to capture a snapshot of the policy list when it was
@@ -116,9 +149,9 @@ export function createPermissionEngine(options) {
116
149
  cacheTtlMs: options?.cacheTtlMs,
117
150
  permissionResolver: options?.permissionResolver,
118
151
  roleResolver: options?.roleResolver,
119
- expandImplied: options?.expandImplied,
152
+ expandImplied,
120
153
  onError: options?.onError,
121
- cacheScope: () => `g${generation}`,
154
+ cacheScope,
122
155
  };
123
156
  const evaluatorOptions = () => liveOptions;
124
157
  const emitter = options?.emitter;
@@ -19,6 +19,10 @@ import type { EvaluatorOptions } from "../evaluator.pipeline.js";
19
19
  * in the key as a digest; an actor the digest cannot describe is not cached.
20
20
  * - The engine's configuration generation is in the key, so a role removed
21
21
  * from a live registry cannot be served from an entry written before.
22
+ * - An external resolver reads state the key knows nothing about, so a
23
+ * resolver-backed engine is uncacheable unless it supplies
24
+ * `resolverCacheKey`. So is one whose implication source cannot announce a
25
+ * change.
22
26
  */
23
27
  export declare function decisionCacheKey(actor: PermissionActor, permissionStr: string, resource: unknown, options: EvaluatorOptions, authOptions?: AuthorizationOptions): string | undefined;
24
28
  //# sourceMappingURL=evaluator.cacheKey.d.ts.map
@@ -38,6 +38,10 @@ function resourceIdOf(resource) {
38
38
  * in the key as a digest; an actor the digest cannot describe is not cached.
39
39
  * - The engine's configuration generation is in the key, so a role removed
40
40
  * from a live registry cannot be served from an entry written before.
41
+ * - An external resolver reads state the key knows nothing about, so a
42
+ * resolver-backed engine is uncacheable unless it supplies
43
+ * `resolverCacheKey`. So is one whose implication source cannot announce a
44
+ * change.
41
45
  */
42
46
  export function decisionCacheKey(actor, permissionStr, resource, options, authOptions) {
43
47
  if (!options.cache || authOptions?.skipCache === true)
@@ -50,7 +54,17 @@ export function decisionCacheKey(actor, permissionStr, resource, options, authOp
50
54
  const digest = actorCacheDigest(actor);
51
55
  if (digest === undefined)
52
56
  return undefined;
53
- const generation = options.cacheScope?.() ?? "";
57
+ // An engine that cannot describe its own configuration for this actor
58
+ // returns `undefined` here — an external resolver with no
59
+ // `resolverCacheKey`, or an implication source that cannot announce a
60
+ // change. Both would otherwise keep answering from a revoked grant.
61
+ let generation = "";
62
+ if (options.cacheScope !== undefined) {
63
+ const scope = options.cacheScope(actor);
64
+ if (scope === undefined)
65
+ return undefined;
66
+ generation = scope;
67
+ }
54
68
  return permissionCacheKey(actor.id, permissionStr, resourceId, `${generation}${digest}`);
55
69
  }
56
70
  //# sourceMappingURL=evaluator.cacheKey.js.map
@@ -35,8 +35,13 @@ export interface EvaluatorOptions {
35
35
  * Extra decision-cache key scope, read on every evaluation. The engine
36
36
  * passes its configuration generation, so a role change invalidates every
37
37
  * entry written before it.
38
+ *
39
+ * Returning `undefined` means this decision must not be cached: the engine
40
+ * uses that for the inputs the key cannot describe — an implication source
41
+ * that cannot announce a change, and an external resolver with no
42
+ * `resolverCacheKey`.
38
43
  */
39
- readonly cacheScope?: () => string;
44
+ readonly cacheScope?: (actor: PermissionActor) => string | undefined;
40
45
  }
41
46
  /** The permissions and rules an actor holds, once everything is resolved. */
42
47
  export interface ResolvedGrants {
@@ -39,6 +39,16 @@ export interface PermissionRegistry {
39
39
  expandImplied(permission: string): readonly string[];
40
40
  remove(permission: string): boolean;
41
41
  clear(): void;
42
+ /**
43
+ * Be told whenever the permission set changes (`define`, a `remove` that
44
+ * removed something, `clear`). Returns an unsubscribe function.
45
+ *
46
+ * An engine given this registry as its `expandImplied` source subscribes
47
+ * itself, which is what makes revoking an implication take effect
48
+ * immediately: the engine's cached decisions were written under the old
49
+ * implication and would otherwise keep granting for the whole TTL.
50
+ */
51
+ subscribe(listener: () => void): () => void;
42
52
  }
43
53
  /**
44
54
  * Create a permission registry.
@@ -49,7 +59,10 @@ export interface PermissionRegistry {
49
59
  *
50
60
  * const engine = createPermissionEngine({
51
61
  * roles,
52
- * expandImplied: (permission) => permissions.expandImplied(permission),
62
+ * // Pass the registry itself, not a closure over it: the engine
63
+ * // subscribes, so revoking an implication drops the cached decisions
64
+ * // that were made under it.
65
+ * expandImplied: permissions,
53
66
  * });
54
67
  * ```
55
68
  */
@@ -4,6 +4,7 @@
4
4
  * @module permission/permissionRegistry
5
5
  */
6
6
  import { DuplicatePermissionError, InvalidPermissionError, PermissionNotFoundError, } from "../permissionErrors/index.js";
7
+ import { createChangeNotifier } from "../utils/utils.notifier.js";
7
8
  import { formatPermission, isValidPermission, matchesPermission, parsePermission, } from "./permission.core.js";
8
9
  /**
9
10
  * Create a permission registry.
@@ -14,13 +15,17 @@ import { formatPermission, isValidPermission, matchesPermission, parsePermission
14
15
  *
15
16
  * const engine = createPermissionEngine({
16
17
  * roles,
17
- * expandImplied: (permission) => permissions.expandImplied(permission),
18
+ * // Pass the registry itself, not a closure over it: the engine
19
+ * // subscribes, so revoking an implication drops the cached decisions
20
+ * // that were made under it.
21
+ * expandImplied: permissions,
18
22
  * });
19
23
  * ```
20
24
  */
21
25
  export function createPermissionRegistry(options) {
22
26
  const permissions = new Map();
23
27
  const allowOverride = options?.allowOverride ?? false;
28
+ const changes = createChangeNotifier();
24
29
  function keyOf(permission) {
25
30
  if (typeof permission === "string") {
26
31
  // Validate strings and structured values alike; a structured value
@@ -53,6 +58,7 @@ export function createPermissionRegistry(options) {
53
58
  ? Object.freeze([...defineOptions.implies])
54
59
  : undefined,
55
60
  });
61
+ changes.notify();
56
62
  },
57
63
  get(permission) {
58
64
  return permissions.get(permission)?.parsed;
@@ -96,10 +102,17 @@ export function createPermissionRegistry(options) {
96
102
  return [...expanded];
97
103
  },
98
104
  remove(permission) {
99
- return permissions.delete(permission);
105
+ const removed = permissions.delete(permission);
106
+ if (removed)
107
+ changes.notify();
108
+ return removed;
100
109
  },
101
110
  clear() {
102
111
  permissions.clear();
112
+ changes.notify();
113
+ },
114
+ subscribe(listener) {
115
+ return changes.subscribe(listener);
103
116
  },
104
117
  };
105
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/permissions",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -26,7 +26,7 @@
26
26
  "!dist/.tsbuildinfo"
27
27
  ],
28
28
  "dependencies": {
29
- "@zudojs/errors": "1.1.0"
29
+ "@zudojs/errors": "1.2.0"
30
30
  },
31
31
  "engines": {
32
32
  "node": ">=24.0.0"