@zudojs/permissions 1.0.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 +76 -21
- 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 +9 -2
- package/dist/cache/cache.core.js +27 -5
- 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 +59 -73
- 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 +36 -41
- 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 +23 -2
- package/dist/role/roleRegistry.d.ts +18 -0
- package/dist/role/roleRegistry.js +38 -2
- 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 +6 -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
|
|
|
@@ -151,8 +174,8 @@ const engine = createPermissionEngine({
|
|
|
151
174
|
|
|
152
175
|
Policies run highest priority first and stop at the first denial. A policy
|
|
153
176
|
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins;
|
|
154
|
-
an allowing one can grant access the rules did not, but never overrides
|
|
155
|
-
denial.
|
|
177
|
+
an allowing one can grant access the rules did not decide, but never overrides
|
|
178
|
+
a denial — from another policy or from a deny rule that applied.
|
|
156
179
|
|
|
157
180
|
`policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
|
|
158
181
|
disable.
|
|
@@ -176,7 +199,11 @@ 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
|
|
204
|
+
|
|
205
|
+
policies.define({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
|
|
206
|
+
// enforced by the next check — through the engine or an existing Ability
|
|
180
207
|
|
|
181
208
|
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
182
209
|
```
|
|
@@ -185,9 +212,17 @@ All three registries reject a duplicate name — re-registering a role or a
|
|
|
185
212
|
policy is an authorization rule disappearing without a trace. Pass
|
|
186
213
|
`{ allowOverride: true }` when replacement is what you mean.
|
|
187
214
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
191
226
|
|
|
192
227
|
A permission registry records descriptions and implications:
|
|
193
228
|
|
|
@@ -223,9 +258,13 @@ await engine.can(actor, "post:read", post, { skipCache: true });
|
|
|
223
258
|
await engine.invalidateActor("user_1");
|
|
224
259
|
```
|
|
225
260
|
|
|
226
|
-
Keys include the actor,
|
|
227
|
-
`
|
|
228
|
-
|
|
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.
|
|
229
268
|
|
|
230
269
|
A check is cached only when the key can describe it completely:
|
|
231
270
|
|
|
@@ -235,7 +274,10 @@ A check is cached only when the key can describe it completely:
|
|
|
235
274
|
- a check carrying `metadata` is **not cached**, because conditions such as
|
|
236
275
|
`tenantIsolation()` read the tenant from there and it is not part of the
|
|
237
276
|
key;
|
|
238
|
-
-
|
|
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;
|
|
239
281
|
- a TTL of `0` or less means "do not cache".
|
|
240
282
|
|
|
241
283
|
`deniedPermissions` is evaluated before the cache is consulted, so a deny
|
|
@@ -264,7 +306,10 @@ Every failure denies:
|
|
|
264
306
|
| --------------------------- | ------------------------------------------------------ |
|
|
265
307
|
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
266
308
|
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
267
|
-
|
|
|
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 |
|
|
268
313
|
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
269
314
|
| Role inheritance cycle | denied; reported to `onError` |
|
|
270
315
|
| Role source throws | denied; reported to `onError` |
|
|
@@ -319,8 +364,10 @@ emitter.on((event) => auditLog.write(event));
|
|
|
319
364
|
const engine = createPermissionEngine({ roles, emitter });
|
|
320
365
|
```
|
|
321
366
|
|
|
322
|
-
Every check emits — allowed, denied, and the ones that throw
|
|
323
|
-
|
|
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.
|
|
324
371
|
|
|
325
372
|
## HTTP middleware
|
|
326
373
|
|
|
@@ -329,10 +376,13 @@ import { authorize, createActorMiddleware } from "@zudojs/permissions";
|
|
|
329
376
|
|
|
330
377
|
const guard = authorize(engine, "post:update", {
|
|
331
378
|
extractActor: (context) => context.state.get("auth:user"),
|
|
332
|
-
|
|
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.
|
|
333
382
|
extractMetadata: (context) => ({
|
|
334
|
-
tenantId: context.
|
|
383
|
+
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")?.tenantId,
|
|
335
384
|
}),
|
|
385
|
+
onError: (error, source) => logger.warn({ error, source }, "guard denied"),
|
|
336
386
|
});
|
|
337
387
|
```
|
|
338
388
|
|
|
@@ -346,11 +396,16 @@ const guard = authorize(engine, "post:update", {
|
|
|
346
396
|
decision.
|
|
347
397
|
- `context.signal` is forwarded, so a client disconnect stops policy
|
|
348
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.
|
|
349
402
|
- `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
|
|
350
403
|
several permissions, short-circuiting on the first that decides the outcome.
|
|
404
|
+
An empty list denies in either mode.
|
|
351
405
|
|
|
352
|
-
|
|
353
|
-
|
|
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).
|
|
354
409
|
|
|
355
410
|
## Errors
|
|
356
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
|
|
@@ -8,9 +8,16 @@ import type { PermissionCache } from "../permissionTypes/index.js";
|
|
|
8
8
|
* Generate a cache key for a permission check.
|
|
9
9
|
*
|
|
10
10
|
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
11
|
-
* actors `10` and `123
|
|
11
|
+
* actors `10` and `123`, and a delimiter inside an id is escaped so two
|
|
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.
|
|
12
19
|
*/
|
|
13
|
-
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;
|
|
14
21
|
/** Options for {@link createMemoryPermissionCache}. */
|
|
15
22
|
export interface MemoryPermissionCacheOptions {
|
|
16
23
|
/** Default TTL in milliseconds. Defaults to 60,000 (1 minute). */
|
package/dist/cache/cache.core.js
CHANGED
|
@@ -5,19 +5,41 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Separates the actor id from the rest of the key. */
|
|
7
7
|
const KEY_DELIMITER = "|";
|
|
8
|
+
/**
|
|
9
|
+
* Escape a caller-supplied segment so it cannot forge a delimiter.
|
|
10
|
+
*
|
|
11
|
+
* Actor and resource ids are opaque strings and may contain `|`. Without
|
|
12
|
+
* escaping, actor `u|post:read` checking `x:y` produced the same key as
|
|
13
|
+
* actor `u` checking `post:read` on resource `x:y` — one actor's cached
|
|
14
|
+
* decision answering for another. Ids without `|` or `\` are unchanged, so
|
|
15
|
+
* existing keys keep their shape.
|
|
16
|
+
*/
|
|
17
|
+
function escapeSegment(segment) {
|
|
18
|
+
return segment.replace(/[\\|]/g, (char) => `\\${char}`);
|
|
19
|
+
}
|
|
8
20
|
/** Prefix identifying an actor's cache entries. */
|
|
9
21
|
function actorPrefix(actorId) {
|
|
10
|
-
return `actor:${actorId}${KEY_DELIMITER}`;
|
|
22
|
+
return `actor:${escapeSegment(actorId)}${KEY_DELIMITER}`;
|
|
11
23
|
}
|
|
12
24
|
/**
|
|
13
25
|
* Generate a cache key for a permission check.
|
|
14
26
|
*
|
|
15
27
|
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
16
|
-
* actors `10` and `123
|
|
28
|
+
* actors `10` and `123`, and a delimiter inside an id is escaped so two
|
|
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.
|
|
17
36
|
*/
|
|
18
|
-
export function permissionCacheKey(actorId, permission, resourceId) {
|
|
19
|
-
const
|
|
20
|
-
|
|
37
|
+
export function permissionCacheKey(actorId, permission, resourceId, scope) {
|
|
38
|
+
const scoped = scope === undefined ? "" : `~${escapeSegment(scope)}${KEY_DELIMITER}`;
|
|
39
|
+
const base = `${actorPrefix(actorId)}${scoped}${permission}`;
|
|
40
|
+
return resourceId
|
|
41
|
+
? `${base}${KEY_DELIMITER}${escapeSegment(resourceId)}`
|
|
42
|
+
: base;
|
|
21
43
|
}
|
|
22
44
|
const DEFAULT_TTL_MS = 60_000;
|
|
23
45
|
const DEFAULT_MAX_ENTRIES = 10_000;
|
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
|
/**
|