@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.
- package/README.md +70 -20
- package/dist/ability/ability.core.js +4 -24
- package/dist/cache/cache.actorDigest.d.ts +26 -0
- package/dist/cache/cache.actorDigest.js +90 -0
- package/dist/cache/cache.core.d.ts +7 -1
- package/dist/cache/cache.core.js +9 -2
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/evaluator/authorizationEngine.d.ts +20 -3
- package/dist/evaluator/authorizationEngine.js +43 -68
- package/dist/evaluator/engineSupport/authorizationEngine.validation.d.ts +37 -0
- package/dist/evaluator/engineSupport/authorizationEngine.validation.js +90 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +24 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.js +56 -0
- package/dist/evaluator/engineSupport/evaluator.observed.d.ts +18 -0
- package/dist/evaluator/engineSupport/evaluator.observed.js +49 -0
- package/dist/evaluator/engineSupport/index.d.ts +10 -0
- package/dist/evaluator/engineSupport/index.js +10 -0
- package/dist/evaluator/evaluator.core.js +19 -37
- package/dist/evaluator/evaluator.pipeline.d.ts +6 -0
- package/dist/evaluator/evaluator.pipeline.js +25 -3
- package/dist/http/httpMiddleware.core.d.ts +15 -5
- package/dist/http/httpMiddleware.core.js +18 -4
- package/dist/http/httpResource.helper.d.ts +30 -0
- package/dist/http/httpResource.helper.js +32 -0
- package/dist/http/httpTypes.d.ts +17 -4
- package/dist/http/index.d.ts +7 -5
- package/dist/http/index.js +6 -4
- package/dist/permission/index.d.ts +1 -1
- package/dist/permission/index.js +1 -1
- package/dist/permission/permission.core.d.ts +12 -0
- package/dist/permission/permission.core.js +20 -1
- package/dist/policy/policyRegistry.d.ts +12 -0
- package/dist/policy/policyRegistry.js +17 -1
- package/dist/role/roleRegistry.d.ts +9 -0
- package/dist/role/roleRegistry.js +19 -1
- package/dist/rule/rule.core.d.ts +14 -22
- package/dist/rule/rule.core.js +49 -86
- package/dist/rule/rule.pattern.d.ts +44 -0
- package/dist/rule/rule.pattern.js +72 -0
- package/dist/utils/utils.notifier.d.ts +19 -0
- package/dist/utils/utils.notifier.js +31 -0
- 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
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
230
|
-
`
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
|
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
|
|
328
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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). */
|
package/dist/cache/cache.core.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/cache/index.d.ts
CHANGED
package/dist/cache/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|