@zudojs/permissions 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +70 -20
  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 +20 -3
  10. package/dist/evaluator/authorizationEngine.js +43 -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 +24 -0
  14. package/dist/evaluator/engineSupport/evaluator.cacheKey.js +56 -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 +6 -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/policy/policyRegistry.d.ts +12 -0
  34. package/dist/policy/policyRegistry.js +17 -1
  35. package/dist/role/roleRegistry.d.ts +9 -0
  36. package/dist/role/roleRegistry.js +19 -1
  37. package/dist/rule/rule.core.d.ts +14 -22
  38. package/dist/rule/rule.core.js +49 -86
  39. package/dist/rule/rule.pattern.d.ts +44 -0
  40. package/dist/rule/rule.pattern.js +72 -0
  41. package/dist/utils/utils.notifier.d.ts +19 -0
  42. package/dist/utils/utils.notifier.js +31 -0
  43. 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,19 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
118
135
  check:
119
136
 
120
137
  ```typescript
138
+ import { requireCurrentTenant } from "@zudojs/tenancy";
139
+
121
140
  await engine.can(actor, "invoice:read", invoice, {
122
- metadata: { tenantId: request.tenantId },
141
+ // The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
142
+ metadata: { tenantId: requireCurrentTenant().id },
123
143
  });
124
144
  ```
125
145
 
126
146
  `tenantIsolation()` compares that value against the resource's tenant, and
127
- denies when either is missing.
147
+ denies when either is missing. It is only as good as the value you pass: fill
148
+ `tenantId` from a source the client cannot choose (a verified token claim, or
149
+ the tenant `@zudojs/tenancy` resolved), **never from a request header** — a
150
+ caller would set the header to the resource's tenant and pass.
128
151
 
129
152
  ## Policies
130
153
 
@@ -176,7 +199,8 @@ const policies = createPolicyRegistry();
176
199
  const engine = createPermissionEngine({ roles, policies });
177
200
 
178
201
  roles.define({ name: "auditor", permissions: ["audit:read"] });
179
- engine.invalidateRoles(); // pick up the change
202
+ roles.remove("reader");
203
+ // both take effect on the next check: the engine subscribes to the registry
180
204
 
181
205
  policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
182
206
  // enforced by the next check — through the engine or an existing Ability
@@ -188,9 +212,17 @@ All three registries reject a duplicate name — re-registering a role or a
188
212
  policy is an authorization rule disappearing without a trace. Pass
189
213
  `{ allowOverride: true }` when replacement is what you mean.
190
214
 
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.
215
+ The engine subscribes to a role or policy registry it is given. Every
216
+ `define`, `remove` and `clear` discards the memoized roles **and every cached
217
+ decision**, so revoking a role is not undone by a cache entry written before
218
+ the revocation. `engine.invalidateRoles()` does the same for a custom source.
219
+
220
+ `validateConfiguration` (default `true`) rejects a pattern that could never
221
+ match wherever it is written — a role grant, a role's rules, a static rule, or
222
+ a policy's `permissions` — because a malformed pattern in a deny or a lockdown
223
+ policy would fail open in silence. Arrays are checked at construction; a
224
+ registry checks on `define` (`InvalidRoleError` / `InvalidPermissionError`),
225
+ and a custom source is checked when the engine reads it.
194
226
 
195
227
  A permission registry records descriptions and implications:
196
228
 
@@ -226,10 +258,13 @@ await engine.can(actor, "post:read", post, { skipCache: true });
226
258
  await engine.invalidateActor("user_1");
227
259
  ```
228
260
 
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.
261
+ Keys include the actor id, a digest of everything else the actor carries
262
+ (`roles`, `permissions`, `type`, any other field a condition may read), the
263
+ permission and the resource id (from `resource.id`, or `options.resourceId`).
264
+ The same user id with different roles — an admin token in one tenant and a
265
+ viewer token in another, or a demoted token — never shares a decision. A `|`
266
+ inside an actor or resource id is escaped, so two different checks can never
267
+ share a key either.
233
268
 
234
269
  A check is cached only when the key can describe it completely:
235
270
 
@@ -239,7 +274,10 @@ A check is cached only when the key can describe it completely:
239
274
  - a check carrying `metadata` is **not cached**, because conditions such as
240
275
  `tenantIsolation()` read the tenant from there and it is not part of the
241
276
  key;
242
- - a decision produced by a policy marked `cacheable: false` is not stored;
277
+ - an actor carrying something the digest cannot describe (a function, a class
278
+ instance, a `Map`) is **not cached**;
279
+ - a decision produced by a policy marked `cacheable: false`, or forced by a
280
+ condition that threw, is not stored;
243
281
  - a TTL of `0` or less means "do not cache".
244
282
 
245
283
  `deniedPermissions` is evaluated before the cache is consulted, so a deny
@@ -268,7 +306,9 @@ Every failure denies:
268
306
  | --------------------------- | ------------------------------------------------------ |
269
307
  | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
270
308
  | Malformed permission string | denied, `reason: "invalid_permission"` |
271
- | Condition throws | rule does not apply |
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` |
272
312
  | A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
273
313
  | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
274
314
  | Role inheritance cycle | denied; reported to `onError` |
@@ -324,8 +364,10 @@ emitter.on((event) => auditLog.write(event));
324
364
  const engine = createPermissionEngine({ roles, emitter });
325
365
  ```
326
366
 
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.
367
+ Every check emits — allowed, denied, and the ones that throw — including
368
+ `explain()` on the engine and on an Ability, which make the same real
369
+ decision. A handler that throws cannot break authorization, but it is reported
370
+ rather than swallowed.
329
371
 
330
372
  ## HTTP middleware
331
373
 
@@ -334,10 +376,13 @@ import { authorize, createActorMiddleware } from "@zudojs/permissions";
334
376
 
335
377
  const guard = authorize(engine, "post:update", {
336
378
  extractActor: (context) => context.state.get("auth:user"),
337
- extractResource: (context) => loadPost(context.request.params.get("id")),
379
+ // May be async: it is awaited, and a loader that rejects denies (403).
380
+ extractResource: (context) => loadPost(context.request.getParam?.("id")),
381
+ // The tenant @zudojs/tenancy resolved and trust-checked — never a header.
338
382
  extractMetadata: (context) => ({
339
- tenantId: context.request.headers.get("x-tenant"),
383
+ tenantId: context.state.get<{ tenantId: string }>("tenancy:context")?.tenantId,
340
384
  }),
385
+ onError: (error, source) => logger.warn({ error, source }, "guard denied"),
341
386
  });
342
387
  ```
343
388
 
@@ -351,11 +396,16 @@ const guard = authorize(engine, "post:update", {
351
396
  decision.
352
397
  - `context.signal` is forwarded, so a client disconnect stops policy
353
398
  evaluation.
399
+ - `extractResource` is awaited. A loader that throws or rejects answers
400
+ **403** (`reason: "resource_error"`) and reports through `onError`; it never
401
+ lets the request through.
354
402
  - `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
355
403
  several permissions, short-circuiting on the first that decides the outcome.
404
+ An empty list denies in either mode.
356
405
 
357
- `@zudojs/http` is an optional peer dependency; the middleware types are
358
- mirrored locally so this package works without it.
406
+ The middleware composes with the real `@zudojs/http` pipeline without
407
+ depending on it: the HTTP types are mirrored structurally (headers, params and
408
+ query may be plain objects, as `@zudojs/http` provides them, or maps).
359
409
 
360
410
  ## Errors
361
411
 
@@ -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,27 @@
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;
17
30
  }
18
31
  /** Configuration for the permission engine. */
19
32
  export interface PermissionEngineOptions {
@@ -79,7 +92,11 @@ export interface PermissionEngine {
79
92
  createAbility(actor: PermissionActor): Ability;
80
93
  /** Drop cached decisions for one actor. */
81
94
  invalidateActor(actorId: string): Promise<void>;
82
- /** Re-read the role source, discarding the memoized lookups. */
95
+ /**
96
+ * Re-read the role source, discarding the memoized lookups and every
97
+ * cached decision. Registries created with `createRoleRegistry()` and
98
+ * `createPolicyRegistry()` trigger this themselves on every change.
99
+ */
83
100
  invalidateRoles(): void;
84
101
  }
85
102
  /**
@@ -5,10 +5,11 @@
5
5
  */
6
6
  import { evaluate, evaluateWithTrace } from "./evaluator.core.js";
7
7
  import { createAbility } from "../ability/ability.core.js";
8
- import { PermissionDeniedError, InvalidRoleError, } from "../permissionErrors/index.js";
9
- import { isValidPermission } from "../permission/permission.core.js";
8
+ import { PermissionDeniedError } from "../permissionErrors/index.js";
10
9
  import { memoizeRoleLookup } from "../role/roleHierarchy.js";
11
10
  import { freezeRoleDefinition } from "../role/roleRegistry.js";
11
+ import { validatePolicy, validateRole, validateRoles, validateRule, } from "./engineSupport/index.js";
12
+ import { observed } from "./engineSupport/index.js";
12
13
  function isRoleSource(roles) {
13
14
  return (roles !== undefined &&
14
15
  !Array.isArray(roles) &&
@@ -19,35 +20,15 @@ function isPolicySource(policies) {
19
20
  !Array.isArray(policies) &&
20
21
  typeof policies.names === "function");
21
22
  }
22
- /** Rejects a role whose grants could never match. */
23
- function validateRole(role) {
24
- if (!role.name || role.name.trim() === "") {
25
- throw new InvalidRoleError("Role name cannot be empty");
26
- }
27
- for (const permission of role.permissions) {
28
- if (!isValidPermission(permission)) {
29
- // A malformed grant can never match, so it is a silent no-op unless
30
- // it is rejected here.
31
- throw new InvalidRoleError(`Role "${role.name}" grants "${permission}", which is not a valid ` +
32
- `"resource:action" permission`);
33
- }
34
- }
35
- }
36
- function validateRoles(roles) {
37
- const seen = new Set();
38
- for (const role of roles) {
39
- validateRole(role);
40
- if (seen.has(role.name)) {
41
- throw new InvalidRoleError(`Role "${role.name}" is defined more than once`);
42
- }
43
- seen.add(role.name);
44
- }
45
- }
46
23
  /**
47
24
  * Create a permission engine.
48
25
  */
49
26
  export function createPermissionEngine(options) {
50
27
  const validateConfiguration = options?.validateConfiguration ?? true;
28
+ if (validateConfiguration) {
29
+ for (const rule of options?.rules ?? [])
30
+ validateRule(rule);
31
+ }
51
32
  let roleLookup;
52
33
  let invalidateRoleCache;
53
34
  if (isRoleSource(options?.roles)) {
@@ -81,14 +62,43 @@ export function createPermissionEngine(options) {
81
62
  const policyList = policySource
82
63
  ? undefined
83
64
  : (options?.policies ?? []);
65
+ if (validateConfiguration)
66
+ policyList?.forEach(validatePolicy);
67
+ // A policy source is read lazily, so its validation is lazy too. Each
68
+ // definition is checked once; a malformed one makes the check throw, which
69
+ // never reads as an allow.
70
+ const validatedPolicies = new WeakSet();
84
71
  const resolvePolicies = () => {
85
72
  if (!policySource)
86
73
  return policyList ?? [];
87
74
  return policySource
88
75
  .names()
89
76
  .map((name) => policySource.get(name))
90
- .filter((policy) => policy !== undefined);
77
+ .filter((policy) => policy !== undefined)
78
+ .map((policy) => {
79
+ if (validateConfiguration && !validatedPolicies.has(policy)) {
80
+ validatePolicy(policy);
81
+ validatedPolicies.add(policy);
82
+ }
83
+ return policy;
84
+ });
85
+ };
86
+ // Every change to the configuration bumps the generation, which is part
87
+ // of every decision-cache key: an entry written under an older role or
88
+ // policy set can no longer be found, whatever cache adapter is in use.
89
+ let generation = 0;
90
+ const invalidateConfiguration = () => {
91
+ generation += 1;
92
+ invalidateRoleCache();
93
+ const cleared = options?.cache?.clear?.();
94
+ cleared?.catch((error) => {
95
+ options?.onError?.(error, "PermissionCache.clear");
96
+ });
91
97
  };
98
+ if (isRoleSource(options?.roles)) {
99
+ options.roles.subscribe?.(invalidateConfiguration);
100
+ }
101
+ policySource?.subscribe?.(invalidateConfiguration);
92
102
  // One live view over the configuration. `policies` is a getter so a
93
103
  // registry-backed engine re-reads the registry on every evaluation — an
94
104
  // Ability used to capture a snapshot of the policy list when it was
@@ -108,38 +118,13 @@ export function createPermissionEngine(options) {
108
118
  roleResolver: options?.roleResolver,
109
119
  expandImplied: options?.expandImplied,
110
120
  onError: options?.onError,
121
+ cacheScope: () => `g${generation}`,
111
122
  };
112
123
  const evaluatorOptions = () => liveOptions;
113
124
  const emitter = options?.emitter;
114
- /**
115
- * Runs a check and emits an audit event whichever way it ends — including
116
- * when it throws. An authorization trail that records only the successful
117
- * paths is not a trail.
118
- */
119
- async function runCheck(actor, permission, resource, authOptions) {
120
- const start = performance.now();
121
- let decision;
122
- let failure;
123
- try {
124
- decision = await evaluate(actor, permission, resource, evaluatorOptions(), authOptions);
125
- return decision;
126
- }
127
- catch (error) {
128
- failure = error;
129
- throw error;
130
- }
131
- finally {
132
- emitter?.emit({
133
- actorId: actor.id,
134
- permission,
135
- resourceType: resourceTypeOf(resource),
136
- allowed: decision?.allowed ?? false,
137
- reason: decision?.reason ??
138
- (failure instanceof Error ? `error:${failure.name}` : undefined),
139
- durationMs: performance.now() - start,
140
- errored: failure !== undefined,
141
- });
142
- }
125
+ /** Runs a check and emits an audit event whichever way it ends. */
126
+ function runCheck(actor, permission, resource, authOptions) {
127
+ return observed(emitter, actor, permission, resource, () => evaluate(actor, permission, resource, evaluatorOptions(), authOptions), (decision) => decision);
143
128
  }
144
129
  return {
145
130
  async can(actor, permission, resource, authOptions) {
@@ -163,7 +148,7 @@ export function createPermissionEngine(options) {
163
148
  }
164
149
  },
165
150
  async explain(actor, permission, resource, authOptions) {
166
- return evaluateWithTrace(actor, permission, resource, evaluatorOptions(), authOptions);
151
+ return observed(emitter, actor, permission, resource, () => evaluateWithTrace(actor, permission, resource, evaluatorOptions(), authOptions), (result) => result.decision);
167
152
  },
168
153
  createAbility(actor) {
169
154
  return createAbility(actor, evaluatorOptions(), emitter);
@@ -172,18 +157,8 @@ export function createPermissionEngine(options) {
172
157
  await options?.cache?.invalidateActor(actorId);
173
158
  },
174
159
  invalidateRoles() {
175
- invalidateRoleCache();
160
+ invalidateConfiguration();
176
161
  },
177
162
  };
178
163
  }
179
- /** Best-effort resource type for an audit event. */
180
- function resourceTypeOf(resource) {
181
- if (typeof resource !== "object" || resource === null)
182
- return undefined;
183
- const record = resource;
184
- if (typeof record.type === "string")
185
- return record.type;
186
- const name = record.constructor?.name;
187
- return name && name !== "Object" ? name : undefined;
188
- }
189
164
  //# sourceMappingURL=authorizationEngine.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Configuration validation for the authorization engine.
3
+ *
4
+ * A malformed pattern can never match. In a grant that is a silent no-op; in
5
+ * a denying policy or a deny rule it is a restriction that fails open. The
6
+ * same pattern therefore has to be rejected wherever it can be written, not
7
+ * only in a role's grant list.
8
+ *
9
+ * @module evaluator/authorizationEngine.validation
10
+ */
11
+ import type { PermissionPolicyDefinition, PermissionRule, RoleDefinition } from "../../permissionTypes/index.js";
12
+ /**
13
+ * The first `resource:action` pair a rule names that is not a valid
14
+ * permission pattern, or `undefined` when every pair is valid.
15
+ */
16
+ export declare function invalidRulePattern(rule: PermissionRule): string | undefined;
17
+ /**
18
+ * Reject a rule whose resource or action could never match.
19
+ *
20
+ * @throws {InvalidPermissionError} naming the offending pair.
21
+ */
22
+ export declare function validateRule(rule: PermissionRule): void;
23
+ /**
24
+ * Reject a policy scoped to a pattern that could never match.
25
+ *
26
+ * @throws {InvalidPermissionError} naming the offending pattern.
27
+ */
28
+ export declare function validatePolicy(policy: PermissionPolicyDefinition): void;
29
+ /**
30
+ * Reject a role whose grants or rules could never match.
31
+ *
32
+ * @throws {InvalidRoleError} naming the role and the offending pattern.
33
+ */
34
+ export declare function validateRole(role: RoleDefinition): void;
35
+ /** Validate a role list, rejecting duplicates as well. */
36
+ export declare function validateRoles(roles: readonly RoleDefinition[]): void;
37
+ //# sourceMappingURL=authorizationEngine.validation.d.ts.map