@zudojs/permissions 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -31
- 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 +58 -5
- package/dist/evaluator/authorizationEngine.js +76 -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 +28 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.js +70 -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 +11 -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/permission/permissionRegistry.d.ts +14 -1
- package/dist/permission/permissionRegistry.js +15 -2
- 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,23 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
|
|
|
118
135
|
check:
|
|
119
136
|
|
|
120
137
|
```typescript
|
|
138
|
+
import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
|
|
139
|
+
|
|
140
|
+
const tenancy = createContextManager({ storage: getDefaultStorage() });
|
|
141
|
+
|
|
121
142
|
await engine.can(actor, "invoice:read", invoice, {
|
|
122
|
-
|
|
143
|
+
// The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
|
|
144
|
+
// `requireCurrentTenant()` is a method on the context manager; it throws
|
|
145
|
+
// when no tenant context is active, rather than returning undefined.
|
|
146
|
+
metadata: { tenantId: tenancy.requireCurrentTenant().id },
|
|
123
147
|
});
|
|
124
148
|
```
|
|
125
149
|
|
|
126
150
|
`tenantIsolation()` compares that value against the resource's tenant, and
|
|
127
|
-
denies when either is missing.
|
|
151
|
+
denies when either is missing. It is only as good as the value you pass: fill
|
|
152
|
+
`tenantId` from a source the client cannot choose (a verified token claim, or
|
|
153
|
+
the tenant `@zudojs/tenancy` resolved), **never from a request header** — a
|
|
154
|
+
caller would set the header to the resource's tenant and pass.
|
|
128
155
|
|
|
129
156
|
## Policies
|
|
130
157
|
|
|
@@ -176,9 +203,14 @@ const policies = createPolicyRegistry();
|
|
|
176
203
|
const engine = createPermissionEngine({ roles, policies });
|
|
177
204
|
|
|
178
205
|
roles.define({ name: "auditor", permissions: ["audit:read"] });
|
|
179
|
-
|
|
206
|
+
roles.remove("reader");
|
|
207
|
+
// both take effect on the next check: the engine subscribes to the registry
|
|
180
208
|
|
|
181
|
-
policies.define({
|
|
209
|
+
policies.define({
|
|
210
|
+
name: "lockdown",
|
|
211
|
+
permissions: ["*:*"],
|
|
212
|
+
evaluate: () => ({ allowed: false }),
|
|
213
|
+
});
|
|
182
214
|
// enforced by the next check — through the engine or an existing Ability
|
|
183
215
|
|
|
184
216
|
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
@@ -188,9 +220,17 @@ All three registries reject a duplicate name — re-registering a role or a
|
|
|
188
220
|
policy is an authorization rule disappearing without a trace. Pass
|
|
189
221
|
`{ allowOverride: true }` when replacement is what you mean.
|
|
190
222
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
223
|
+
The engine subscribes to a role or policy registry it is given. Every
|
|
224
|
+
`define`, `remove` and `clear` discards the memoized roles **and every cached
|
|
225
|
+
decision**, so revoking a role is not undone by a cache entry written before
|
|
226
|
+
the revocation. `engine.invalidateRoles()` does the same for a custom source.
|
|
227
|
+
|
|
228
|
+
`validateConfiguration` (default `true`) rejects a pattern that could never
|
|
229
|
+
match wherever it is written — a role grant, a role's rules, a static rule, or
|
|
230
|
+
a policy's `permissions` — because a malformed pattern in a deny or a lockdown
|
|
231
|
+
policy would fail open in silence. Arrays are checked at construction; a
|
|
232
|
+
registry checks on `define` (`InvalidRoleError` / `InvalidPermissionError`),
|
|
233
|
+
and a custom source is checked when the engine reads it.
|
|
194
234
|
|
|
195
235
|
A permission registry records descriptions and implications:
|
|
196
236
|
|
|
@@ -201,11 +241,17 @@ permissions.define("post:write", { implies: ["post:read"] });
|
|
|
201
241
|
|
|
202
242
|
const engine = createPermissionEngine({
|
|
203
243
|
roles,
|
|
204
|
-
expandImplied:
|
|
244
|
+
expandImplied: permissions,
|
|
205
245
|
});
|
|
206
246
|
// An actor granted post:admin now passes post:read.
|
|
207
247
|
```
|
|
208
248
|
|
|
249
|
+
Pass the registry itself rather than a closure over it. The engine subscribes
|
|
250
|
+
to it, so `permissions.remove("post:admin")` — or redefining it without the
|
|
251
|
+
implication — drops the decisions that were cached while it stood. A bare
|
|
252
|
+
`(permission) => permissions.expandImplied(permission)` still works, but it
|
|
253
|
+
cannot announce a change, so an engine given one caches no decisions at all.
|
|
254
|
+
|
|
209
255
|
## Caching
|
|
210
256
|
|
|
211
257
|
```typescript
|
|
@@ -226,10 +272,13 @@ await engine.can(actor, "post:read", post, { skipCache: true });
|
|
|
226
272
|
await engine.invalidateActor("user_1");
|
|
227
273
|
```
|
|
228
274
|
|
|
229
|
-
Keys include the actor,
|
|
230
|
-
`
|
|
231
|
-
|
|
232
|
-
|
|
275
|
+
Keys include the actor id, a digest of everything else the actor carries
|
|
276
|
+
(`roles`, `permissions`, `type`, any other field a condition may read), the
|
|
277
|
+
permission and the resource id (from `resource.id`, or `options.resourceId`).
|
|
278
|
+
The same user id with different roles — an admin token in one tenant and a
|
|
279
|
+
viewer token in another, or a demoted token — never shares a decision. A `|`
|
|
280
|
+
inside an actor or resource id is escaped, so two different checks can never
|
|
281
|
+
share a key either.
|
|
233
282
|
|
|
234
283
|
A check is cached only when the key can describe it completely:
|
|
235
284
|
|
|
@@ -239,7 +288,16 @@ A check is cached only when the key can describe it completely:
|
|
|
239
288
|
- a check carrying `metadata` is **not cached**, because conditions such as
|
|
240
289
|
`tenantIsolation()` read the tenant from there and it is not part of the
|
|
241
290
|
key;
|
|
242
|
-
-
|
|
291
|
+
- an actor carrying something the digest cannot describe (a function, a class
|
|
292
|
+
instance, a `Map`) is **not cached**;
|
|
293
|
+
- a decision produced by a policy marked `cacheable: false`, or forced by a
|
|
294
|
+
condition that threw, is not stored;
|
|
295
|
+
- an engine with a `roleResolver` or `permissionResolver` and no
|
|
296
|
+
`resolverCacheKey` caches **nothing**: the resolver reads state the key
|
|
297
|
+
cannot describe, so an entry would outlive a grant withdrawn upstream;
|
|
298
|
+
- an engine whose `expandImplied` is a bare function rather than a
|
|
299
|
+
`createPermissionRegistry()` caches **nothing**, for the same reason: a
|
|
300
|
+
revoked implication cannot announce itself;
|
|
243
301
|
- a TTL of `0` or less means "do not cache".
|
|
244
302
|
|
|
245
303
|
`deniedPermissions` is evaluated before the cache is consulted, so a deny
|
|
@@ -260,20 +318,40 @@ const engine = createPermissionEngine({
|
|
|
260
318
|
A resolver that fails is reported through `onError` and the check continues
|
|
261
319
|
fail-closed, rather than throwing out of the authorization path.
|
|
262
320
|
|
|
321
|
+
A resolver reads authorization state the engine does not own and cannot see
|
|
322
|
+
change, and none of it is in the decision-cache key. **A resolver-backed
|
|
323
|
+
engine therefore caches nothing** unless you describe that state with
|
|
324
|
+
`resolverCacheKey`:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const engine = createPermissionEngine({
|
|
328
|
+
roles,
|
|
329
|
+
cache: createMemoryPermissionCache(),
|
|
330
|
+
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
|
|
331
|
+
// Anything that changes when the resolver's answer could change.
|
|
332
|
+
resolverCacheKey: (actor) => db.grantsVersionFor(actor.id),
|
|
333
|
+
});
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Return `undefined` for an actor whose state you cannot describe, and that
|
|
337
|
+
actor's decisions stay uncached.
|
|
338
|
+
|
|
263
339
|
## Failure behaviour
|
|
264
340
|
|
|
265
341
|
Every failure denies:
|
|
266
342
|
|
|
267
|
-
| Situation
|
|
268
|
-
|
|
|
269
|
-
| Unknown role on the actor
|
|
270
|
-
| Malformed permission string
|
|
271
|
-
|
|
|
272
|
-
|
|
|
273
|
-
|
|
|
274
|
-
|
|
|
275
|
-
|
|
|
276
|
-
|
|
|
343
|
+
| Situation | Result |
|
|
344
|
+
| ----------------------------- | ------------------------------------------------------ |
|
|
345
|
+
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
346
|
+
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
347
|
+
| Allow condition throws | the allow does not apply |
|
|
348
|
+
| Deny condition throws | denied, `reason: "rule_deny"`; reported to `onError` |
|
|
349
|
+
| Malformed rule/policy pattern | rejected at construction or `define` |
|
|
350
|
+
| A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
|
|
351
|
+
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
352
|
+
| Role inheritance cycle | denied; reported to `onError` |
|
|
353
|
+
| Role source throws | denied; reported to `onError` |
|
|
354
|
+
| `signal` aborted | throws `AuthorizationAbortedError` |
|
|
277
355
|
|
|
278
356
|
A value that is not an `Error` — a policy or resolver that throws a string —
|
|
279
357
|
reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
|
|
@@ -324,8 +402,10 @@ emitter.on((event) => auditLog.write(event));
|
|
|
324
402
|
const engine = createPermissionEngine({ roles, emitter });
|
|
325
403
|
```
|
|
326
404
|
|
|
327
|
-
Every check emits — allowed, denied, and the ones that throw
|
|
328
|
-
|
|
405
|
+
Every check emits — allowed, denied, and the ones that throw — including
|
|
406
|
+
`explain()` on the engine and on an Ability, which make the same real
|
|
407
|
+
decision. A handler that throws cannot break authorization, but it is reported
|
|
408
|
+
rather than swallowed.
|
|
329
409
|
|
|
330
410
|
## HTTP middleware
|
|
331
411
|
|
|
@@ -334,10 +414,14 @@ import { authorize, createActorMiddleware } from "@zudojs/permissions";
|
|
|
334
414
|
|
|
335
415
|
const guard = authorize(engine, "post:update", {
|
|
336
416
|
extractActor: (context) => context.state.get("auth:user"),
|
|
337
|
-
|
|
417
|
+
// May be async: it is awaited, and a loader that rejects denies (403).
|
|
418
|
+
extractResource: (context) => loadPost(context.request.getParam?.("id")),
|
|
419
|
+
// The tenant @zudojs/tenancy resolved and trust-checked — never a header.
|
|
338
420
|
extractMetadata: (context) => ({
|
|
339
|
-
tenantId: context.
|
|
421
|
+
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
|
|
422
|
+
?.tenantId,
|
|
340
423
|
}),
|
|
424
|
+
onError: (error, source) => logger.warn({ error, source }, "guard denied"),
|
|
341
425
|
});
|
|
342
426
|
```
|
|
343
427
|
|
|
@@ -351,11 +435,16 @@ const guard = authorize(engine, "post:update", {
|
|
|
351
435
|
decision.
|
|
352
436
|
- `context.signal` is forwarded, so a client disconnect stops policy
|
|
353
437
|
evaluation.
|
|
438
|
+
- `extractResource` is awaited. A loader that throws or rejects answers
|
|
439
|
+
**403** (`reason: "resource_error"`) and reports through `onError`; it never
|
|
440
|
+
lets the request through.
|
|
354
441
|
- `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
|
|
355
442
|
several permissions, short-circuiting on the first that decides the outcome.
|
|
443
|
+
An empty list denies in either mode.
|
|
356
444
|
|
|
357
|
-
|
|
358
|
-
|
|
445
|
+
The middleware composes with the real `@zudojs/http` pipeline without
|
|
446
|
+
depending on it: the HTTP types are mirrored structurally (headers, params and
|
|
447
|
+
query may be plain objects, as `@zudojs/http` provides them, or maps).
|
|
359
448
|
|
|
360
449
|
## Errors
|
|
361
450
|
|
|
@@ -5,33 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { evaluate, evaluateWithTrace } from "../evaluator/evaluator.core.js";
|
|
7
7
|
import { PermissionDeniedError } from "../permissionErrors/index.js";
|
|
8
|
+
import { observed } from "../evaluator/engineSupport/index.js";
|
|
8
9
|
/**
|
|
9
10
|
* Create an Ability for an actor.
|
|
10
11
|
*/
|
|
11
12
|
export function createAbility(actor, evaluatorOptions, emitter) {
|
|
12
|
-
|
|
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,40 @@
|
|
|
6
6
|
import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PermissionCache, PermissionResolver, RoleResolver, RoleDefinition, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
7
7
|
import { type Ability } from "../ability/ability.core.js";
|
|
8
8
|
import type { PermissionEventEmitter } from "../observability/observability.core.js";
|
|
9
|
-
/**
|
|
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;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Anything the engine will accept as its source of permission implications.
|
|
33
|
+
*
|
|
34
|
+
* A source with `subscribe` (every `createPermissionRegistry()`) is watched
|
|
35
|
+
* like the role and policy registries: revoking an implication drops every
|
|
36
|
+
* decision that was cached while it stood. A bare function cannot announce a
|
|
37
|
+
* change, so an engine given one caches nothing — see
|
|
38
|
+
* {@link PermissionEngineOptions.expandImplied}.
|
|
39
|
+
*/
|
|
40
|
+
export interface ImpliedPermissionSource {
|
|
41
|
+
expandImplied(permission: string): readonly string[];
|
|
42
|
+
subscribe?(listener: () => void): () => void;
|
|
17
43
|
}
|
|
18
44
|
/** Configuration for the permission engine. */
|
|
19
45
|
export interface PermissionEngineOptions {
|
|
@@ -41,8 +67,31 @@ export interface PermissionEngineOptions {
|
|
|
41
67
|
readonly permissionResolver?: PermissionResolver;
|
|
42
68
|
/** Loads additional roles for an actor from an external source. */
|
|
43
69
|
readonly roleResolver?: RoleResolver;
|
|
44
|
-
/**
|
|
45
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Describes the state a resolver is answering from, so that decisions it
|
|
72
|
+
* influenced can be cached safely.
|
|
73
|
+
*
|
|
74
|
+
* A resolver reads authorization data the engine does not own and cannot
|
|
75
|
+
* see change — a grants table, another service. Nothing about it is in the
|
|
76
|
+
* decision-cache key, so an entry written while the resolver said "allow"
|
|
77
|
+
* kept answering after the grant was withdrawn upstream. Resolver-backed
|
|
78
|
+
* engines therefore **do not cache at all** unless this is supplied.
|
|
79
|
+
*
|
|
80
|
+
* Return a value that changes whenever the resolver's answer for this actor
|
|
81
|
+
* could change — a version column, an `updatedAt` stamp, a grants-table
|
|
82
|
+
* generation. Return `undefined` for an actor whose state cannot be
|
|
83
|
+
* described, and that actor's decisions stay uncached.
|
|
84
|
+
*/
|
|
85
|
+
readonly resolverCacheKey?: (actor: PermissionActor) => string | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Expands a permission into the permissions it implies.
|
|
88
|
+
*
|
|
89
|
+
* Prefer passing a `createPermissionRegistry()` — the engine subscribes to
|
|
90
|
+
* it, so revoking an implication invalidates the decisions cached under it.
|
|
91
|
+
* A bare function cannot announce a change, so an engine given one caches
|
|
92
|
+
* no decisions rather than serving one from a revoked implication.
|
|
93
|
+
*/
|
|
94
|
+
readonly expandImplied?: ((permission: string) => readonly string[]) | ImpliedPermissionSource;
|
|
46
95
|
/** Emits an event for every completed check, including failures. */
|
|
47
96
|
readonly emitter?: PermissionEventEmitter;
|
|
48
97
|
/** Reports a failure authorization swallowed to stay fail-closed. */
|
|
@@ -79,7 +128,11 @@ export interface PermissionEngine {
|
|
|
79
128
|
createAbility(actor: PermissionActor): Ability;
|
|
80
129
|
/** Drop cached decisions for one actor. */
|
|
81
130
|
invalidateActor(actorId: string): Promise<void>;
|
|
82
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Re-read the role source, discarding the memoized lookups and every
|
|
133
|
+
* cached decision. Registries created with `createRoleRegistry()` and
|
|
134
|
+
* `createPolicyRegistry()` trigger this themselves on every change.
|
|
135
|
+
*/
|
|
83
136
|
invalidateRoles(): void;
|
|
84
137
|
}
|
|
85
138
|
/**
|