@zudojs/permissions 1.2.0 → 1.4.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 +143 -27
- package/dist/evaluator/authorizationEngine.d.ts +49 -3
- package/dist/evaluator/authorizationEngine.js +36 -2
- package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +4 -0
- package/dist/evaluator/engineSupport/evaluator.cacheKey.js +15 -1
- package/dist/evaluator/evaluator.core.js +13 -10
- package/dist/evaluator/evaluator.pipeline.d.ts +16 -2
- package/dist/evaluator/evaluator.pipeline.js +9 -4
- package/dist/http/httpHelpers.d.ts +7 -6
- package/dist/http/httpHelpers.js +6 -13
- package/dist/http/httpTypes.d.ts +17 -2
- package/dist/http/index.d.ts +1 -1
- package/dist/permission/permissionRegistry.d.ts +14 -1
- package/dist/permission/permissionRegistry.js +15 -2
- package/dist/permissionTypes/index.d.ts +1 -1
- package/dist/permissionTypes/policyTypes.d.ts +18 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/policyEffect.core.d.ts +21 -0
- package/dist/policy/policyEffect.core.js +22 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -135,11 +135,15 @@ Conditions read request-scoped facts from `context.metadata`, supplied per
|
|
|
135
135
|
check:
|
|
136
136
|
|
|
137
137
|
```typescript
|
|
138
|
-
import {
|
|
138
|
+
import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
|
|
139
|
+
|
|
140
|
+
const tenancy = createContextManager({ storage: getDefaultStorage() });
|
|
139
141
|
|
|
140
142
|
await engine.can(actor, "invoice:read", invoice, {
|
|
141
143
|
// The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
|
|
142
|
-
|
|
144
|
+
// `requireCurrentTenant()` is a method on the context manager; it throws
|
|
145
|
+
// when no tenant context is active, rather than returning undefined.
|
|
146
|
+
metadata: { tenantId: tenancy.requireCurrentTenant().id },
|
|
143
147
|
});
|
|
144
148
|
```
|
|
145
149
|
|
|
@@ -149,33 +153,95 @@ denies when either is missing. It is only as good as the value you pass: fill
|
|
|
149
153
|
the tenant `@zudojs/tenancy` resolved), **never from a request header** — a
|
|
150
154
|
caller would set the header to the resource's tenant and pass.
|
|
151
155
|
|
|
156
|
+
Enforce it as a **deny** rule, `not(tenantIsolation())`:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { createPermissionEngine, not, tenantIsolation } from "@zudojs/permissions";
|
|
160
|
+
|
|
161
|
+
const engine = createPermissionEngine({
|
|
162
|
+
roles: [{ name: "accountant", permissions: ["invoice:read"] }],
|
|
163
|
+
rules: [
|
|
164
|
+
{
|
|
165
|
+
name: "tenant-isolation",
|
|
166
|
+
effect: "deny",
|
|
167
|
+
resource: "invoice",
|
|
168
|
+
action: "*",
|
|
169
|
+
condition: not(tenantIsolation()), // other tenant, or no tenant → deny
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A conditional **allow** rule with `tenantIsolation()` only adds a way in: a
|
|
176
|
+
role that grants `invoice:read` allows the check whatever the condition says,
|
|
177
|
+
so that role reads every tenant's invoices. The deny form refuses cross-tenant
|
|
178
|
+
access even for roles that hold the permission, and no policy can override it.
|
|
179
|
+
|
|
152
180
|
## Policies
|
|
153
181
|
|
|
154
|
-
A policy is a named, prioritised hook that runs alongside the rules.
|
|
182
|
+
A policy is a named, prioritised hook that runs alongside the rules. By
|
|
183
|
+
default it is an **extra condition on top of RBAC/ABAC**: it can take access
|
|
184
|
+
away, never hand it out.
|
|
155
185
|
|
|
156
186
|
```typescript
|
|
157
187
|
const engine = createPermissionEngine({
|
|
158
|
-
roles,
|
|
188
|
+
roles: [{ name: "staff", permissions: ["task:*"] }],
|
|
159
189
|
policyTimeout: 250,
|
|
160
190
|
policies: [
|
|
161
191
|
{
|
|
162
192
|
name: "business-hours",
|
|
163
|
-
permissions: ["
|
|
193
|
+
permissions: ["task:*"], // wildcards work here too
|
|
164
194
|
priority: 10, // higher runs first
|
|
165
195
|
cacheable: false, // depends on the clock, so never cache it
|
|
166
|
-
evaluate: (
|
|
196
|
+
evaluate: () =>
|
|
167
197
|
isBusinessHours()
|
|
168
|
-
? { allowed: true }
|
|
198
|
+
? { allowed: true } // "no objection" — the role still has to grant
|
|
169
199
|
: { allowed: false, reason: "outside_business_hours" },
|
|
170
200
|
},
|
|
171
201
|
],
|
|
172
202
|
});
|
|
203
|
+
|
|
204
|
+
await engine.can(staff, "task:delete"); // true in hours, false after
|
|
205
|
+
await engine.can(guest, "task:delete"); // false: no role grants it
|
|
173
206
|
```
|
|
174
207
|
|
|
208
|
+
> **Warning — a policy's `allowed: true` is not a grant.** Before 1.4 it was:
|
|
209
|
+
> an allowing policy granted the permission even to an actor with no roles, so
|
|
210
|
+
> the "business-hours" policy above handed `task:delete` to everyone during
|
|
211
|
+
> office hours. A policy now only constrains, unless it opts in with
|
|
212
|
+
> `effect: "grant"`.
|
|
213
|
+
|
|
175
214
|
Policies run highest priority first and stop at the first denial. A policy
|
|
176
|
-
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins
|
|
177
|
-
|
|
178
|
-
|
|
215
|
+
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins.
|
|
216
|
+
An allowing policy grants nothing by itself: the actor's roles, direct
|
|
217
|
+
permissions or rules must still grant the permission.
|
|
218
|
+
|
|
219
|
+
### Policies that grant
|
|
220
|
+
|
|
221
|
+
A policy that establishes the right on its own — ownership is the usual one —
|
|
222
|
+
says so with `effect: "grant"`:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
const engine = createPermissionEngine({
|
|
226
|
+
roles,
|
|
227
|
+
policies: [
|
|
228
|
+
{
|
|
229
|
+
name: "author-can-edit",
|
|
230
|
+
permissions: ["post:update"],
|
|
231
|
+
effect: "grant", // an allow here grants, even with no role
|
|
232
|
+
evaluate: ({ actor, resource }) => ({
|
|
233
|
+
allowed: (resource as { authorId?: string })?.authorId === actor.id,
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A granting policy still never overrides a denial — from another policy, an
|
|
241
|
+
explicit deny, or a deny rule that applied. Only the exact value `"grant"`
|
|
242
|
+
grants; a typo constrains. `createPermissionEngine({ defaultPolicyEffect:
|
|
243
|
+
"grant" })` restores the pre-1.4 behaviour for every policy that sets no
|
|
244
|
+
`effect` — prefer marking the individual policies.
|
|
179
245
|
|
|
180
246
|
`policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
|
|
181
247
|
disable.
|
|
@@ -202,7 +268,11 @@ roles.define({ name: "auditor", permissions: ["audit:read"] });
|
|
|
202
268
|
roles.remove("reader");
|
|
203
269
|
// both take effect on the next check: the engine subscribes to the registry
|
|
204
270
|
|
|
205
|
-
policies.define({
|
|
271
|
+
policies.define({
|
|
272
|
+
name: "lockdown",
|
|
273
|
+
permissions: ["*:*"],
|
|
274
|
+
evaluate: () => ({ allowed: false }),
|
|
275
|
+
});
|
|
206
276
|
// enforced by the next check — through the engine or an existing Ability
|
|
207
277
|
|
|
208
278
|
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
@@ -233,11 +303,17 @@ permissions.define("post:write", { implies: ["post:read"] });
|
|
|
233
303
|
|
|
234
304
|
const engine = createPermissionEngine({
|
|
235
305
|
roles,
|
|
236
|
-
expandImplied:
|
|
306
|
+
expandImplied: permissions,
|
|
237
307
|
});
|
|
238
308
|
// An actor granted post:admin now passes post:read.
|
|
239
309
|
```
|
|
240
310
|
|
|
311
|
+
Pass the registry itself rather than a closure over it. The engine subscribes
|
|
312
|
+
to it, so `permissions.remove("post:admin")` — or redefining it without the
|
|
313
|
+
implication — drops the decisions that were cached while it stood. A bare
|
|
314
|
+
`(permission) => permissions.expandImplied(permission)` still works, but it
|
|
315
|
+
cannot announce a change, so an engine given one caches no decisions at all.
|
|
316
|
+
|
|
241
317
|
## Caching
|
|
242
318
|
|
|
243
319
|
```typescript
|
|
@@ -278,6 +354,12 @@ A check is cached only when the key can describe it completely:
|
|
|
278
354
|
instance, a `Map`) is **not cached**;
|
|
279
355
|
- a decision produced by a policy marked `cacheable: false`, or forced by a
|
|
280
356
|
condition that threw, is not stored;
|
|
357
|
+
- an engine with a `roleResolver` or `permissionResolver` and no
|
|
358
|
+
`resolverCacheKey` caches **nothing**: the resolver reads state the key
|
|
359
|
+
cannot describe, so an entry would outlive a grant withdrawn upstream;
|
|
360
|
+
- an engine whose `expandImplied` is a bare function rather than a
|
|
361
|
+
`createPermissionRegistry()` caches **nothing**, for the same reason: a
|
|
362
|
+
revoked implication cannot announce itself;
|
|
281
363
|
- a TTL of `0` or less means "do not cache".
|
|
282
364
|
|
|
283
365
|
`deniedPermissions` is evaluated before the cache is consulted, so a deny
|
|
@@ -298,22 +380,40 @@ const engine = createPermissionEngine({
|
|
|
298
380
|
A resolver that fails is reported through `onError` and the check continues
|
|
299
381
|
fail-closed, rather than throwing out of the authorization path.
|
|
300
382
|
|
|
383
|
+
A resolver reads authorization state the engine does not own and cannot see
|
|
384
|
+
change, and none of it is in the decision-cache key. **A resolver-backed
|
|
385
|
+
engine therefore caches nothing** unless you describe that state with
|
|
386
|
+
`resolverCacheKey`:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
const engine = createPermissionEngine({
|
|
390
|
+
roles,
|
|
391
|
+
cache: createMemoryPermissionCache(),
|
|
392
|
+
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
|
|
393
|
+
// Anything that changes when the resolver's answer could change.
|
|
394
|
+
resolverCacheKey: (actor) => db.grantsVersionFor(actor.id),
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Return `undefined` for an actor whose state you cannot describe, and that
|
|
399
|
+
actor's decisions stay uncached.
|
|
400
|
+
|
|
301
401
|
## Failure behaviour
|
|
302
402
|
|
|
303
403
|
Every failure denies:
|
|
304
404
|
|
|
305
|
-
| Situation
|
|
306
|
-
|
|
|
307
|
-
| Unknown role on the actor
|
|
308
|
-
| Malformed permission string
|
|
309
|
-
| Allow condition throws
|
|
310
|
-
| Deny condition throws
|
|
311
|
-
| Malformed rule/policy pattern | rejected at construction or `define`
|
|
312
|
-
| A deny rule applies
|
|
313
|
-
| Policy throws or times out
|
|
314
|
-
| Role inheritance cycle
|
|
315
|
-
| Role source throws
|
|
316
|
-
| `signal` aborted
|
|
405
|
+
| Situation | Result |
|
|
406
|
+
| ----------------------------- | ------------------------------------------------------ |
|
|
407
|
+
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
408
|
+
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
409
|
+
| Allow condition throws | the allow does not apply |
|
|
410
|
+
| Deny condition throws | denied, `reason: "rule_deny"`; reported to `onError` |
|
|
411
|
+
| Malformed rule/policy pattern | rejected at construction or `define` |
|
|
412
|
+
| A deny rule applies | denied, `reason: "rule_deny"`, even if a policy allows |
|
|
413
|
+
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
414
|
+
| Role inheritance cycle | denied; reported to `onError` |
|
|
415
|
+
| Role source throws | denied; reported to `onError` |
|
|
416
|
+
| `signal` aborted | throws `AuthorizationAbortedError` |
|
|
317
417
|
|
|
318
418
|
A value that is not an `Error` — a policy or resolver that throws a string —
|
|
319
419
|
reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
|
|
@@ -380,7 +480,8 @@ const guard = authorize(engine, "post:update", {
|
|
|
380
480
|
extractResource: (context) => loadPost(context.request.getParam?.("id")),
|
|
381
481
|
// The tenant @zudojs/tenancy resolved and trust-checked — never a header.
|
|
382
482
|
extractMetadata: (context) => ({
|
|
383
|
-
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
|
|
483
|
+
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
|
|
484
|
+
?.tenantId,
|
|
384
485
|
}),
|
|
385
486
|
onError: (error, source) => logger.warn({ error, source }, "guard denied"),
|
|
386
487
|
});
|
|
@@ -389,7 +490,11 @@ const guard = authorize(engine, "post:update", {
|
|
|
389
490
|
- The guard extracts the actor itself when one is not already in state, so it
|
|
390
491
|
works without a separate actor middleware.
|
|
391
492
|
- No actor → **401** with `WWW-Authenticate`. Actor but not permitted →
|
|
392
|
-
**403**.
|
|
493
|
+
**403**. The refusal is a `GuardResponse` (`createGuardResponse` from
|
|
494
|
+
`@zudojs/middleware`), which `@zudojs/http` sends with that status, body and
|
|
495
|
+
headers. It used to be a plain `{ status, body, headers }` object, which a
|
|
496
|
+
route middleware's return ignored: the handler did not run, but the client
|
|
497
|
+
got `200`.
|
|
393
498
|
- The 403 body carries `decision.publicReason`, never the internal reason:
|
|
394
499
|
`policy_error:<name>` names your policies and does not belong in a
|
|
395
500
|
response. Pass `deniedResponse` to shape the body — it receives the real
|
|
@@ -405,7 +510,18 @@ const guard = authorize(engine, "post:update", {
|
|
|
405
510
|
|
|
406
511
|
The middleware composes with the real `@zudojs/http` pipeline without
|
|
407
512
|
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)
|
|
513
|
+
query may be plain objects, as `@zudojs/http` provides them, or maps), and
|
|
514
|
+
`HttpMiddleware` is assignable to `@zudojs/http`'s own `HttpMiddleware` — pass
|
|
515
|
+
a guard straight to a route, no `as never`:
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
router.put("/posts/:id", updatePost, { middleware: [guard] });
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
A middleware of your own typed as this package's `HttpMiddleware` answers a
|
|
522
|
+
refusal with `createGuardResponse({ status, body })`; returning a plain
|
|
523
|
+
`{ status, body, headers }` object is no longer typed, because
|
|
524
|
+
`@zudojs/http` never sent it as a response.
|
|
409
525
|
|
|
410
526
|
## Errors
|
|
411
527
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/authorizationEngine
|
|
5
5
|
*/
|
|
6
|
-
import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PermissionCache, PermissionResolver, RoleResolver, RoleDefinition, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
6
|
+
import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PolicyEffect, 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
|
/**
|
|
@@ -28,6 +28,19 @@ export interface PolicySource {
|
|
|
28
28
|
get(name: string): PermissionPolicyDefinition | undefined;
|
|
29
29
|
subscribe?(listener: () => void): () => void;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Anything the engine will accept as its source of permission implications.
|
|
33
|
+
*
|
|
34
|
+
* A source with `subscribe` (every `createPermissionRegistry()`) is watched
|
|
35
|
+
* like the role and policy registries: revoking an implication drops every
|
|
36
|
+
* decision that was cached while it stood. A bare function cannot announce a
|
|
37
|
+
* change, so an engine given one caches nothing — see
|
|
38
|
+
* {@link PermissionEngineOptions.expandImplied}.
|
|
39
|
+
*/
|
|
40
|
+
export interface ImpliedPermissionSource {
|
|
41
|
+
expandImplied(permission: string): readonly string[];
|
|
42
|
+
subscribe?(listener: () => void): () => void;
|
|
43
|
+
}
|
|
31
44
|
/** Configuration for the permission engine. */
|
|
32
45
|
export interface PermissionEngineOptions {
|
|
33
46
|
/**
|
|
@@ -44,6 +57,16 @@ export interface PermissionEngineOptions {
|
|
|
44
57
|
readonly rules?: readonly PermissionRule[];
|
|
45
58
|
/** Default timeout for async policy evaluation (ms). */
|
|
46
59
|
readonly policyTimeout?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Effect of a policy that sets no `effect` of its own. Default:
|
|
62
|
+
* `"constrain"` — an allowing policy is an extra condition and cannot
|
|
63
|
+
* grant a permission the actor's roles, permissions or rules do not.
|
|
64
|
+
*
|
|
65
|
+
* `"grant"` restores the behaviour before 1.4, where every allowing policy
|
|
66
|
+
* was an independent grant. Prefer `effect: "grant"` on the individual
|
|
67
|
+
* policies that really establish the right on their own.
|
|
68
|
+
*/
|
|
69
|
+
readonly defaultPolicyEffect?: PolicyEffect;
|
|
47
70
|
/** How competing rules combine. Default: `"deny-overrides"`. */
|
|
48
71
|
readonly algorithm?: RuleCombiningAlgorithm;
|
|
49
72
|
/** Caches decisions. Create one with `createMemoryPermissionCache()`. */
|
|
@@ -54,8 +77,31 @@ export interface PermissionEngineOptions {
|
|
|
54
77
|
readonly permissionResolver?: PermissionResolver;
|
|
55
78
|
/** Loads additional roles for an actor from an external source. */
|
|
56
79
|
readonly roleResolver?: RoleResolver;
|
|
57
|
-
/**
|
|
58
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Describes the state a resolver is answering from, so that decisions it
|
|
82
|
+
* influenced can be cached safely.
|
|
83
|
+
*
|
|
84
|
+
* A resolver reads authorization data the engine does not own and cannot
|
|
85
|
+
* see change — a grants table, another service. Nothing about it is in the
|
|
86
|
+
* decision-cache key, so an entry written while the resolver said "allow"
|
|
87
|
+
* kept answering after the grant was withdrawn upstream. Resolver-backed
|
|
88
|
+
* engines therefore **do not cache at all** unless this is supplied.
|
|
89
|
+
*
|
|
90
|
+
* Return a value that changes whenever the resolver's answer for this actor
|
|
91
|
+
* could change — a version column, an `updatedAt` stamp, a grants-table
|
|
92
|
+
* generation. Return `undefined` for an actor whose state cannot be
|
|
93
|
+
* described, and that actor's decisions stay uncached.
|
|
94
|
+
*/
|
|
95
|
+
readonly resolverCacheKey?: (actor: PermissionActor) => string | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Expands a permission into the permissions it implies.
|
|
98
|
+
*
|
|
99
|
+
* Prefer passing a `createPermissionRegistry()` — the engine subscribes to
|
|
100
|
+
* it, so revoking an implication invalidates the decisions cached under it.
|
|
101
|
+
* A bare function cannot announce a change, so an engine given one caches
|
|
102
|
+
* no decisions rather than serving one from a revoked implication.
|
|
103
|
+
*/
|
|
104
|
+
readonly expandImplied?: ((permission: string) => readonly string[]) | ImpliedPermissionSource;
|
|
59
105
|
/** Emits an event for every completed check, including failures. */
|
|
60
106
|
readonly emitter?: PermissionEventEmitter;
|
|
61
107
|
/** Reports a failure authorization swallowed to stay fail-closed. */
|
|
@@ -20,6 +20,11 @@ function isPolicySource(policies) {
|
|
|
20
20
|
!Array.isArray(policies) &&
|
|
21
21
|
typeof policies.names === "function");
|
|
22
22
|
}
|
|
23
|
+
function isImpliedPermissionSource(source) {
|
|
24
|
+
return (source !== undefined &&
|
|
25
|
+
typeof source !== "function" &&
|
|
26
|
+
typeof source.expandImplied === "function");
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* Create a permission engine.
|
|
25
30
|
*/
|
|
@@ -99,6 +104,34 @@ export function createPermissionEngine(options) {
|
|
|
99
104
|
options.roles.subscribe?.(invalidateConfiguration);
|
|
100
105
|
}
|
|
101
106
|
policySource?.subscribe?.(invalidateConfiguration);
|
|
107
|
+
// The third registry the README wires in. Without this, revoking an
|
|
108
|
+
// implication left every decision it granted in the cache for the full TTL
|
|
109
|
+
// — `skipCache: true` said "deny" while `can()` kept saying "allow".
|
|
110
|
+
const impliedSource = isImpliedPermissionSource(options?.expandImplied)
|
|
111
|
+
? options.expandImplied
|
|
112
|
+
: undefined;
|
|
113
|
+
const expandImplied = impliedSource
|
|
114
|
+
? (permission) => impliedSource.expandImplied(permission)
|
|
115
|
+
: options?.expandImplied;
|
|
116
|
+
const impliedWatched = impliedSource?.subscribe?.(invalidateConfiguration) !== undefined;
|
|
117
|
+
// Two inputs the cache key cannot describe. An implication source that
|
|
118
|
+
// cannot announce a change, and a resolver reading state the engine does
|
|
119
|
+
// not own, both make a cached allow outlive the grant behind it — so the
|
|
120
|
+
// decision is not cached at all unless the caller closes the gap.
|
|
121
|
+
const impliedUnwatched = expandImplied !== undefined && !impliedWatched;
|
|
122
|
+
const resolverConfigured = options?.permissionResolver !== undefined ||
|
|
123
|
+
options?.roleResolver !== undefined;
|
|
124
|
+
const resolverCacheKey = options?.resolverCacheKey;
|
|
125
|
+
const cacheScope = (actor) => {
|
|
126
|
+
if (impliedUnwatched)
|
|
127
|
+
return undefined;
|
|
128
|
+
if (!resolverConfigured)
|
|
129
|
+
return `g${generation}`;
|
|
130
|
+
if (!resolverCacheKey)
|
|
131
|
+
return undefined;
|
|
132
|
+
const scope = resolverCacheKey(actor);
|
|
133
|
+
return scope === undefined ? undefined : `g${generation}|r${scope}`;
|
|
134
|
+
};
|
|
102
135
|
// One live view over the configuration. `policies` is a getter so a
|
|
103
136
|
// registry-backed engine re-reads the registry on every evaluation — an
|
|
104
137
|
// Ability used to capture a snapshot of the policy list when it was
|
|
@@ -111,14 +144,15 @@ export function createPermissionEngine(options) {
|
|
|
111
144
|
},
|
|
112
145
|
rules: options?.rules,
|
|
113
146
|
policyTimeout: options?.policyTimeout,
|
|
147
|
+
defaultPolicyEffect: options?.defaultPolicyEffect,
|
|
114
148
|
algorithm: options?.algorithm,
|
|
115
149
|
cache: options?.cache,
|
|
116
150
|
cacheTtlMs: options?.cacheTtlMs,
|
|
117
151
|
permissionResolver: options?.permissionResolver,
|
|
118
152
|
roleResolver: options?.roleResolver,
|
|
119
|
-
expandImplied
|
|
153
|
+
expandImplied,
|
|
120
154
|
onError: options?.onError,
|
|
121
|
-
cacheScope
|
|
155
|
+
cacheScope,
|
|
122
156
|
};
|
|
123
157
|
const evaluatorOptions = () => liveOptions;
|
|
124
158
|
const emitter = options?.emitter;
|
|
@@ -19,6 +19,10 @@ import type { EvaluatorOptions } from "../evaluator.pipeline.js";
|
|
|
19
19
|
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
20
20
|
* - The engine's configuration generation is in the key, so a role removed
|
|
21
21
|
* from a live registry cannot be served from an entry written before.
|
|
22
|
+
* - An external resolver reads state the key knows nothing about, so a
|
|
23
|
+
* resolver-backed engine is uncacheable unless it supplies
|
|
24
|
+
* `resolverCacheKey`. So is one whose implication source cannot announce a
|
|
25
|
+
* change.
|
|
22
26
|
*/
|
|
23
27
|
export declare function decisionCacheKey(actor: PermissionActor, permissionStr: string, resource: unknown, options: EvaluatorOptions, authOptions?: AuthorizationOptions): string | undefined;
|
|
24
28
|
//# sourceMappingURL=evaluator.cacheKey.d.ts.map
|
|
@@ -38,6 +38,10 @@ function resourceIdOf(resource) {
|
|
|
38
38
|
* in the key as a digest; an actor the digest cannot describe is not cached.
|
|
39
39
|
* - The engine's configuration generation is in the key, so a role removed
|
|
40
40
|
* from a live registry cannot be served from an entry written before.
|
|
41
|
+
* - An external resolver reads state the key knows nothing about, so a
|
|
42
|
+
* resolver-backed engine is uncacheable unless it supplies
|
|
43
|
+
* `resolverCacheKey`. So is one whose implication source cannot announce a
|
|
44
|
+
* change.
|
|
41
45
|
*/
|
|
42
46
|
export function decisionCacheKey(actor, permissionStr, resource, options, authOptions) {
|
|
43
47
|
if (!options.cache || authOptions?.skipCache === true)
|
|
@@ -50,7 +54,17 @@ export function decisionCacheKey(actor, permissionStr, resource, options, authOp
|
|
|
50
54
|
const digest = actorCacheDigest(actor);
|
|
51
55
|
if (digest === undefined)
|
|
52
56
|
return undefined;
|
|
53
|
-
|
|
57
|
+
// An engine that cannot describe its own configuration for this actor
|
|
58
|
+
// returns `undefined` here — an external resolver with no
|
|
59
|
+
// `resolverCacheKey`, or an implication source that cannot announce a
|
|
60
|
+
// change. Both would otherwise keep answering from a revoked grant.
|
|
61
|
+
let generation = "";
|
|
62
|
+
if (options.cacheScope !== undefined) {
|
|
63
|
+
const scope = options.cacheScope(actor);
|
|
64
|
+
if (scope === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
generation = scope;
|
|
67
|
+
}
|
|
54
68
|
return permissionCacheKey(actor.id, permissionStr, resourceId, `${generation}${digest}`);
|
|
55
69
|
}
|
|
56
70
|
//# sourceMappingURL=evaluator.cacheKey.js.map
|
|
@@ -185,7 +185,7 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
const decision = combine(ruleResult, outcome.decision, permissionStr);
|
|
188
|
+
const decision = combine(ruleResult, outcome.decision, outcome.grants, permissionStr);
|
|
189
189
|
/* ── Cache write ─────────────────────────────────────────────────────── */
|
|
190
190
|
// A decision forced by a condition that threw describes the failure, not
|
|
191
191
|
// the actor, and must not outlive it.
|
|
@@ -204,15 +204,18 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
204
204
|
/**
|
|
205
205
|
* Combine the rule outcome with the policy outcome.
|
|
206
206
|
*
|
|
207
|
-
* A denying policy always wins. An allowing policy
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
207
|
+
* A denying policy always wins. An allowing policy is, by default, only an
|
|
208
|
+
* extra condition: the actor's roles, permissions or rules must still grant
|
|
209
|
+
* the permission. It used to grant on its own, so a "business-hours" policy
|
|
210
|
+
* handed `task:delete` to an actor with no roles at all. Only a policy with
|
|
211
|
+
* `effect: "grant"` (or an engine with `defaultPolicyEffect: "grant"`) can
|
|
212
|
+
* grant access the rules did not — and even then it never overrides a
|
|
213
|
+
* denial, including one the *rules* produced. "The rules did not allow"
|
|
214
|
+
* covers two cases: no rule matched, and a deny rule matched. Treating them
|
|
215
|
+
* alike let an allowing policy for `post:*` cancel a `deny post:update` rule
|
|
216
|
+
* — the exact inversion of `deny-overrides`.
|
|
214
217
|
*/
|
|
215
|
-
function combine(ruleResult, policyDecision, permissionStr) {
|
|
218
|
+
function combine(ruleResult, policyDecision, policyGrants, permissionStr) {
|
|
216
219
|
if (policyDecision && !policyDecision.allowed)
|
|
217
220
|
return policyDecision;
|
|
218
221
|
const denyRule = !ruleResult.allowed && ruleResult.matchedRule?.effect === "deny"
|
|
@@ -232,7 +235,7 @@ function combine(ruleResult, policyDecision, permissionStr) {
|
|
|
232
235
|
...(policyDecision?.policy ? { policy: policyDecision.policy } : {}),
|
|
233
236
|
});
|
|
234
237
|
}
|
|
235
|
-
if (policyDecision?.allowed)
|
|
238
|
+
if (policyDecision?.allowed && policyGrants)
|
|
236
239
|
return policyDecision;
|
|
237
240
|
return denied("no_matching_rule");
|
|
238
241
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/evaluator.pipeline
|
|
5
5
|
*/
|
|
6
|
-
import type { PermissionActor, PermissionContext, PermissionDecision, PermissionPolicyDefinition, PermissionRule, PermissionCache, PermissionResolver, RoleDefinition, RoleResolver, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
6
|
+
import type { PermissionActor, PermissionContext, PermissionDecision, PermissionPolicyDefinition, PermissionRule, PermissionCache, PermissionResolver, RoleDefinition, RoleResolver, RuleCombiningAlgorithm, AuthorizationOptions, PolicyEffect } from "../permissionTypes/index.js";
|
|
7
7
|
/** Configuration for the evaluator. */
|
|
8
8
|
export interface EvaluatorOptions {
|
|
9
9
|
/** Function to look up a role definition by name. */
|
|
@@ -14,6 +14,8 @@ export interface EvaluatorOptions {
|
|
|
14
14
|
readonly rules?: readonly PermissionRule[];
|
|
15
15
|
/** Default timeout for async policy evaluation (ms). */
|
|
16
16
|
readonly policyTimeout?: number;
|
|
17
|
+
/** Effect of a policy that sets none. Default: `"constrain"`. */
|
|
18
|
+
readonly defaultPolicyEffect?: PolicyEffect;
|
|
17
19
|
/** How competing rules combine. Default: `"deny-overrides"`. */
|
|
18
20
|
readonly algorithm?: RuleCombiningAlgorithm;
|
|
19
21
|
/** Decision cache. */
|
|
@@ -35,8 +37,13 @@ export interface EvaluatorOptions {
|
|
|
35
37
|
* Extra decision-cache key scope, read on every evaluation. The engine
|
|
36
38
|
* passes its configuration generation, so a role change invalidates every
|
|
37
39
|
* entry written before it.
|
|
40
|
+
*
|
|
41
|
+
* Returning `undefined` means this decision must not be cached: the engine
|
|
42
|
+
* uses that for the inputs the key cannot describe — an implication source
|
|
43
|
+
* that cannot announce a change, and an external resolver with no
|
|
44
|
+
* `resolverCacheKey`.
|
|
38
45
|
*/
|
|
39
|
-
readonly cacheScope?: () => string;
|
|
46
|
+
readonly cacheScope?: (actor: PermissionActor) => string | undefined;
|
|
40
47
|
}
|
|
41
48
|
/** The permissions and rules an actor holds, once everything is resolved. */
|
|
42
49
|
export interface ResolvedGrants {
|
|
@@ -75,6 +82,13 @@ export interface PolicyOutcome {
|
|
|
75
82
|
/** Whether every policy that ran allows the result to be cached. */
|
|
76
83
|
readonly cacheable: boolean;
|
|
77
84
|
readonly evaluated: readonly string[];
|
|
85
|
+
/**
|
|
86
|
+
* Whether the allow may grant on its own: every applicable policy allowed
|
|
87
|
+
* and at least one of them has the `"grant"` effect. `false` for a denial,
|
|
88
|
+
* and for an allow from constraining policies only, which then needs a
|
|
89
|
+
* role, permission or rule to grant.
|
|
90
|
+
*/
|
|
91
|
+
readonly grants: boolean;
|
|
78
92
|
}
|
|
79
93
|
/**
|
|
80
94
|
* Evaluate policies for a context.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/evaluator.pipeline
|
|
5
5
|
*/
|
|
6
|
+
import { policyGrants } from "../policy/policyEffect.core.js";
|
|
6
7
|
import { resolveRolePermissions } from "../role/roleHierarchy.js";
|
|
7
8
|
import { matches, permissionsOverlap, parsePermissionSafe, } from "../permission/permission.core.js";
|
|
8
9
|
import { isWildcardTarget } from "../rule/rule.pattern.js";
|
|
@@ -153,13 +154,13 @@ function narrowerPolicies(policies, applicable, permissionStr) {
|
|
|
153
154
|
*/
|
|
154
155
|
export async function evaluatePolicies(context, policies, options, authOptions) {
|
|
155
156
|
if (policies.length === 0) {
|
|
156
|
-
return { decision: null, cacheable: true, evaluated: [] };
|
|
157
|
+
return { decision: null, cacheable: true, evaluated: [], grants: false };
|
|
157
158
|
}
|
|
158
159
|
const permissionStr = `${context.permission.resource}:${context.permission.action}`;
|
|
159
160
|
const applicable = selectPolicies(policies, permissionStr);
|
|
160
161
|
const denyOnly = narrowerPolicies(policies, applicable, permissionStr);
|
|
161
162
|
if (applicable.length === 0 && denyOnly.length === 0) {
|
|
162
|
-
return { decision: null, cacheable: true, evaluated: [] };
|
|
163
|
+
return { decision: null, cacheable: true, evaluated: [], grants: false };
|
|
163
164
|
}
|
|
164
165
|
// The per-call timeout wins, but the engine-level default is what makes a
|
|
165
166
|
// configured timeout mean anything at all.
|
|
@@ -183,6 +184,7 @@ export async function evaluatePolicies(context, policies, options, authOptions)
|
|
|
183
184
|
}),
|
|
184
185
|
cacheable,
|
|
185
186
|
evaluated,
|
|
187
|
+
grants: false,
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
190
|
}
|
|
@@ -200,20 +202,23 @@ export async function evaluatePolicies(context, policies, options, authOptions)
|
|
|
200
202
|
}),
|
|
201
203
|
cacheable: false,
|
|
202
204
|
evaluated,
|
|
205
|
+
grants: false,
|
|
203
206
|
};
|
|
204
207
|
}
|
|
205
208
|
}
|
|
206
209
|
if (applicable.length === 0) {
|
|
207
|
-
return { decision: null, cacheable, evaluated };
|
|
210
|
+
return { decision: null, cacheable, evaluated, grants: false };
|
|
208
211
|
}
|
|
212
|
+
const grants = applicable.some((policy) => policyGrants(policy, options.defaultPolicyEffect));
|
|
209
213
|
return {
|
|
210
214
|
decision: Object.freeze({
|
|
211
215
|
allowed: true,
|
|
212
|
-
reason: "policy_allow",
|
|
216
|
+
reason: grants ? "policy_allow" : "policy_pass",
|
|
213
217
|
policy: applicable.map((policy) => policy.name).join(","),
|
|
214
218
|
}),
|
|
215
219
|
cacheable,
|
|
216
220
|
evaluated,
|
|
221
|
+
grants,
|
|
217
222
|
};
|
|
218
223
|
}
|
|
219
224
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module http/httpHelpers
|
|
5
5
|
*/
|
|
6
|
+
import { type GuardResponse } from "@zudojs/middleware";
|
|
6
7
|
import type { PermissionDecision } from "../permissionTypes/index.js";
|
|
7
8
|
/** Options for creating denied responses. */
|
|
8
9
|
export interface DeniedResponseOptions {
|
|
@@ -16,12 +17,12 @@ export interface DeniedResponseOptions {
|
|
|
16
17
|
/** Value for the `WWW-Authenticate` header on a 401. */
|
|
17
18
|
readonly authenticateChallenge?: string;
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A framework-agnostic response: a `GuardResponse` from `@zudojs/middleware`,
|
|
22
|
+
* which `@zudojs/http` sends with its own status. It still has the
|
|
23
|
+
* `status`, `body` and `headers` fields this type always had.
|
|
24
|
+
*/
|
|
25
|
+
export type PermissionHttpResponse = GuardResponse;
|
|
25
26
|
/**
|
|
26
27
|
* Create a 403 Forbidden JSON response for a decision.
|
|
27
28
|
*
|
package/dist/http/httpHelpers.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module http/httpHelpers
|
|
5
5
|
*/
|
|
6
|
+
import { createGuardResponse } from "@zudojs/middleware";
|
|
6
7
|
const JSON_HEADERS = { "content-type": "application/json" };
|
|
7
8
|
/**
|
|
8
9
|
* Create a 403 Forbidden JSON response for a decision.
|
|
@@ -18,11 +19,7 @@ export function createForbiddenResponse(decision, options) {
|
|
|
18
19
|
error: "Forbidden",
|
|
19
20
|
message: decision.publicReason ?? "Access denied",
|
|
20
21
|
};
|
|
21
|
-
return
|
|
22
|
-
status: 403,
|
|
23
|
-
body,
|
|
24
|
-
headers: JSON_HEADERS,
|
|
25
|
-
});
|
|
22
|
+
return createGuardResponse({ status: 403, body, headers: JSON_HEADERS });
|
|
26
23
|
}
|
|
27
24
|
/**
|
|
28
25
|
* Create a 401 Unauthorized JSON response.
|
|
@@ -34,23 +31,19 @@ export function createUnauthorizedResponse(options) {
|
|
|
34
31
|
const body = options?.unauthenticatedResponse
|
|
35
32
|
? options.unauthenticatedResponse()
|
|
36
33
|
: { error: "Unauthorized", message: "Authentication required" };
|
|
37
|
-
return
|
|
34
|
+
return createGuardResponse({
|
|
38
35
|
status: 401,
|
|
39
36
|
body,
|
|
40
|
-
headers:
|
|
37
|
+
headers: {
|
|
41
38
|
...JSON_HEADERS,
|
|
42
39
|
"www-authenticate": options?.authenticateChallenge ?? "Bearer",
|
|
43
|
-
}
|
|
40
|
+
},
|
|
44
41
|
});
|
|
45
42
|
}
|
|
46
43
|
/**
|
|
47
44
|
* Create a JSON response.
|
|
48
45
|
*/
|
|
49
46
|
export function createJsonResponse(status, body) {
|
|
50
|
-
return
|
|
51
|
-
status,
|
|
52
|
-
body,
|
|
53
|
-
headers: JSON_HEADERS,
|
|
54
|
-
});
|
|
47
|
+
return createGuardResponse({ status, body, headers: JSON_HEADERS });
|
|
55
48
|
}
|
|
56
49
|
//# sourceMappingURL=httpHelpers.js.map
|
package/dist/http/httpTypes.d.ts
CHANGED
|
@@ -6,8 +6,23 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module http/httpTypes
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import type { GuardResponse } from "@zudojs/middleware";
|
|
10
|
+
/**
|
|
11
|
+
* What a permission middleware returns: nothing, a web `Response`, a
|
|
12
|
+
* `GuardResponse` refusing the request, or whatever `next()` produced.
|
|
13
|
+
*/
|
|
14
|
+
export type HttpMiddlewareOutcome<Downstream> = void | Response | GuardResponse | Downstream;
|
|
15
|
+
/**
|
|
16
|
+
* HTTP middleware signature, structurally assignable to `@zudojs/http`'s
|
|
17
|
+
* `HttpMiddleware` without a cast.
|
|
18
|
+
*
|
|
19
|
+
* Generic over what `next()` resolves to, so a middleware hands back the
|
|
20
|
+
* real pipeline's response unchanged. A refusal is a `GuardResponse`
|
|
21
|
+
* (`createGuardResponse` from `@zudojs/middleware`), which `@zudojs/http`
|
|
22
|
+
* sends with its own status; a plain `{ status, body, headers }` object is
|
|
23
|
+
* not a response.
|
|
24
|
+
*/
|
|
25
|
+
export type HttpMiddleware = <Downstream extends HttpResponseContext>(context: HttpMiddlewareContext, next: () => Promise<Downstream>) => HttpMiddlewareOutcome<Downstream> | Promise<HttpMiddlewareOutcome<Downstream>>;
|
|
11
26
|
/** HTTP middleware context from @zudojs/http. */
|
|
12
27
|
export interface HttpMiddlewareContext {
|
|
13
28
|
readonly request: HttpRequestContext;
|
package/dist/http/index.d.ts
CHANGED
|
@@ -13,5 +13,5 @@
|
|
|
13
13
|
export { createActorMiddleware, createRequirePermissionMiddleware, authorize, createRequirePermissionsMiddleware, ACTOR_STATE_KEY, DECISION_STATE_KEY, DECISIONS_STATE_KEY, type AuthorizeMiddlewareOptions, type ActorMiddlewareOptions, type RequirePermissionMiddlewareOptions, type RequirePermissionsMiddlewareOptions, } from "./httpMiddleware.core.js";
|
|
14
14
|
export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, type DeniedResponseOptions, type PermissionHttpResponse, } from "./httpHelpers.js";
|
|
15
15
|
export { loadResource, RESOURCE_ERROR_DECISION, type ResourceExtractor, type ResourceOutcome, } from "./httpResource.helper.js";
|
|
16
|
-
export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.js";
|
|
16
|
+
export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareOutcome, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.js";
|
|
17
17
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -39,6 +39,16 @@ export interface PermissionRegistry {
|
|
|
39
39
|
expandImplied(permission: string): readonly string[];
|
|
40
40
|
remove(permission: string): boolean;
|
|
41
41
|
clear(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Be told whenever the permission set changes (`define`, a `remove` that
|
|
44
|
+
* removed something, `clear`). Returns an unsubscribe function.
|
|
45
|
+
*
|
|
46
|
+
* An engine given this registry as its `expandImplied` source subscribes
|
|
47
|
+
* itself, which is what makes revoking an implication take effect
|
|
48
|
+
* immediately: the engine's cached decisions were written under the old
|
|
49
|
+
* implication and would otherwise keep granting for the whole TTL.
|
|
50
|
+
*/
|
|
51
|
+
subscribe(listener: () => void): () => void;
|
|
42
52
|
}
|
|
43
53
|
/**
|
|
44
54
|
* Create a permission registry.
|
|
@@ -49,7 +59,10 @@ export interface PermissionRegistry {
|
|
|
49
59
|
*
|
|
50
60
|
* const engine = createPermissionEngine({
|
|
51
61
|
* roles,
|
|
52
|
-
*
|
|
62
|
+
* // Pass the registry itself, not a closure over it: the engine
|
|
63
|
+
* // subscribes, so revoking an implication drops the cached decisions
|
|
64
|
+
* // that were made under it.
|
|
65
|
+
* expandImplied: permissions,
|
|
53
66
|
* });
|
|
54
67
|
* ```
|
|
55
68
|
*/
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module permission/permissionRegistry
|
|
5
5
|
*/
|
|
6
6
|
import { DuplicatePermissionError, InvalidPermissionError, PermissionNotFoundError, } from "../permissionErrors/index.js";
|
|
7
|
+
import { createChangeNotifier } from "../utils/utils.notifier.js";
|
|
7
8
|
import { formatPermission, isValidPermission, matchesPermission, parsePermission, } from "./permission.core.js";
|
|
8
9
|
/**
|
|
9
10
|
* Create a permission registry.
|
|
@@ -14,13 +15,17 @@ import { formatPermission, isValidPermission, matchesPermission, parsePermission
|
|
|
14
15
|
*
|
|
15
16
|
* const engine = createPermissionEngine({
|
|
16
17
|
* roles,
|
|
17
|
-
*
|
|
18
|
+
* // Pass the registry itself, not a closure over it: the engine
|
|
19
|
+
* // subscribes, so revoking an implication drops the cached decisions
|
|
20
|
+
* // that were made under it.
|
|
21
|
+
* expandImplied: permissions,
|
|
18
22
|
* });
|
|
19
23
|
* ```
|
|
20
24
|
*/
|
|
21
25
|
export function createPermissionRegistry(options) {
|
|
22
26
|
const permissions = new Map();
|
|
23
27
|
const allowOverride = options?.allowOverride ?? false;
|
|
28
|
+
const changes = createChangeNotifier();
|
|
24
29
|
function keyOf(permission) {
|
|
25
30
|
if (typeof permission === "string") {
|
|
26
31
|
// Validate strings and structured values alike; a structured value
|
|
@@ -53,6 +58,7 @@ export function createPermissionRegistry(options) {
|
|
|
53
58
|
? Object.freeze([...defineOptions.implies])
|
|
54
59
|
: undefined,
|
|
55
60
|
});
|
|
61
|
+
changes.notify();
|
|
56
62
|
},
|
|
57
63
|
get(permission) {
|
|
58
64
|
return permissions.get(permission)?.parsed;
|
|
@@ -96,10 +102,17 @@ export function createPermissionRegistry(options) {
|
|
|
96
102
|
return [...expanded];
|
|
97
103
|
},
|
|
98
104
|
remove(permission) {
|
|
99
|
-
|
|
105
|
+
const removed = permissions.delete(permission);
|
|
106
|
+
if (removed)
|
|
107
|
+
changes.notify();
|
|
108
|
+
return removed;
|
|
100
109
|
},
|
|
101
110
|
clear() {
|
|
102
111
|
permissions.clear();
|
|
112
|
+
changes.notify();
|
|
113
|
+
},
|
|
114
|
+
subscribe(listener) {
|
|
115
|
+
return changes.subscribe(listener);
|
|
103
116
|
},
|
|
104
117
|
};
|
|
105
118
|
}
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { type PermissionActor, type Permission, type PermissionString, } from "./permissionActor.js";
|
|
7
7
|
export { type RuleEffect, type RuleCombiningAlgorithm, type RuleEvaluation, type PermissionRule, type PermissionConditionFn, type PermissionContext, type PermissionDecision, } from "./ruleTypes.js";
|
|
8
|
-
export { type RoleDefinition, type PermissionResolver, type RoleResolver, type PermissionCache, type PermissionPolicyDefinition, type ExplainStep, type ExplainResult, type AuthorizationOptions, } from "./policyTypes.js";
|
|
8
|
+
export { type RoleDefinition, type PermissionResolver, type RoleResolver, type PermissionCache, type PermissionPolicyDefinition, type PolicyEffect, type ExplainStep, type ExplainResult, type AuthorizationOptions, } from "./policyTypes.js";
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -42,9 +42,27 @@ export interface PermissionCache {
|
|
|
42
42
|
/** Drops every entry. */
|
|
43
43
|
clear?(): Promise<void>;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What an allowing policy means.
|
|
47
|
+
*
|
|
48
|
+
* - `"constrain"` (the default): the policy is an extra condition on top of
|
|
49
|
+
* RBAC/ABAC. Its allow means "no objection"; the actor's roles, direct
|
|
50
|
+
* permissions or rules must still grant the permission. Its deny denies.
|
|
51
|
+
* - `"grant"`: the policy is an independent grant. Its allow grants the
|
|
52
|
+
* permission even when no role or rule does — an ownership check, say.
|
|
53
|
+
* Use it only for a policy that establishes the right on its own.
|
|
54
|
+
*/
|
|
55
|
+
export type PolicyEffect = "constrain" | "grant";
|
|
45
56
|
/** A named authorization policy. */
|
|
46
57
|
export interface PermissionPolicyDefinition {
|
|
47
58
|
readonly name: string;
|
|
59
|
+
/**
|
|
60
|
+
* Whether this policy's allow can grant a permission the actor's roles do
|
|
61
|
+
* not include. Default: the engine's `defaultPolicyEffect`, which is
|
|
62
|
+
* `"constrain"` — a policy only ever narrows access. Any value other than
|
|
63
|
+
* `"grant"` constrains.
|
|
64
|
+
*/
|
|
65
|
+
readonly effect?: PolicyEffect;
|
|
48
66
|
/**
|
|
49
67
|
* Permissions this policy applies to. Wildcards are honoured, so
|
|
50
68
|
* `["post:*"]` covers `post:update` — the same matching grants use.
|
package/dist/policy/index.d.ts
CHANGED
package/dist/policy/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy effect resolution: whether an allowing policy may grant access on
|
|
3
|
+
* its own or only constrains what RBAC/ABAC already granted.
|
|
4
|
+
*
|
|
5
|
+
* @module policy/policyEffect.core
|
|
6
|
+
*/
|
|
7
|
+
import type { PermissionPolicyDefinition, PolicyEffect } from "../permissionTypes/index.js";
|
|
8
|
+
/** The effect a policy without its own `effect` has. */
|
|
9
|
+
export declare const DEFAULT_POLICY_EFFECT: PolicyEffect;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a policy's allow is an independent grant.
|
|
12
|
+
*
|
|
13
|
+
* Only the exact value `"grant"` grants. A typo, a missing value or anything
|
|
14
|
+
* else falls back to constraining, so a mistake narrows access instead of
|
|
15
|
+
* widening it.
|
|
16
|
+
*
|
|
17
|
+
* @param policy - The policy that allowed.
|
|
18
|
+
* @param defaultEffect - The engine's `defaultPolicyEffect`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function policyGrants(policy: PermissionPolicyDefinition, defaultEffect?: PolicyEffect): boolean;
|
|
21
|
+
//# sourceMappingURL=policyEffect.core.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy effect resolution: whether an allowing policy may grant access on
|
|
3
|
+
* its own or only constrains what RBAC/ABAC already granted.
|
|
4
|
+
*
|
|
5
|
+
* @module policy/policyEffect.core
|
|
6
|
+
*/
|
|
7
|
+
/** The effect a policy without its own `effect` has. */
|
|
8
|
+
export const DEFAULT_POLICY_EFFECT = "constrain";
|
|
9
|
+
/**
|
|
10
|
+
* Whether a policy's allow is an independent grant.
|
|
11
|
+
*
|
|
12
|
+
* Only the exact value `"grant"` grants. A typo, a missing value or anything
|
|
13
|
+
* else falls back to constraining, so a mistake narrows access instead of
|
|
14
|
+
* widening it.
|
|
15
|
+
*
|
|
16
|
+
* @param policy - The policy that allowed.
|
|
17
|
+
* @param defaultEffect - The engine's `defaultPolicyEffect`.
|
|
18
|
+
*/
|
|
19
|
+
export function policyGrants(policy, defaultEffect = DEFAULT_POLICY_EFFECT) {
|
|
20
|
+
return (policy.effect ?? defaultEffect) === "grant";
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=policyEffect.core.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/permissions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -26,14 +26,15 @@
|
|
|
26
26
|
"!dist/.tsbuildinfo"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@zudojs/errors": "1.
|
|
29
|
+
"@zudojs/errors": "1.3.0",
|
|
30
|
+
"@zudojs/middleware": "1.1.0"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=24.0.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"typescript": "7.0.2",
|
|
36
|
-
"vitest": "^
|
|
37
|
+
"vitest": "^5.0.1"
|
|
37
38
|
},
|
|
38
39
|
"publishConfig": {
|
|
39
40
|
"access": "public"
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"authorization",
|
|
46
47
|
"permissions"
|
|
47
48
|
],
|
|
48
|
-
"homepage": "https://
|
|
49
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-permissions",
|
|
49
50
|
"bugs": {
|
|
50
51
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
51
52
|
},
|