@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 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 { requireCurrentTenant } from "@zudojs/tenancy";
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
- metadata: { tenantId: requireCurrentTenant().id },
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: ["post:*"], // wildcards work here too
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: (context) =>
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
- 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.
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({ name: "lockdown", permissions: ["*:*"], evaluate: () => ({ allowed: false }) });
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: (permission) => permissions.expandImplied(permission),
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 | Result |
306
- | --------------------------- | ------------------------------------------------------ |
307
- | Unknown role on the actor | denied; reported to `onError`; other roles still apply |
308
- | Malformed permission string | denied, `reason: "invalid_permission"` |
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 |
313
- | Policy throws or times out | denied, `reason: "policy_error:<name>"` |
314
- | Role inheritance cycle | denied; reported to `onError` |
315
- | Role source throws | denied; reported to `onError` |
316
- | `signal` aborted | throws `AuthorizationAbortedError` |
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")?.tenantId,
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
- /** Expands a permission into the permissions it implies. */
58
- readonly expandImplied?: (permission: string) => readonly string[];
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: options?.expandImplied,
153
+ expandImplied,
120
154
  onError: options?.onError,
121
- cacheScope: () => `g${generation}`,
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
- const generation = options.cacheScope?.() ?? "";
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 can grant access the rules
208
- * did not, which is what makes a policy an ABAC escape hatch rather than a
209
- * filter — but it can never override a denial, and that includes a denial
210
- * the *rules* produced. "The rules did not allow" covers two cases: no rule
211
- * matched, and a deny rule matched. Treating them alike let an allowing
212
- * policy for `post:*` cancel a `deny post:update` rule — the exact inversion
213
- * of `deny-overrides`.
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
- /** A framework-agnostic response. */
20
- export interface PermissionHttpResponse {
21
- readonly status: number;
22
- readonly body: unknown;
23
- readonly headers: Readonly<Record<string, string>>;
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
  *
@@ -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 Object.freeze({
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 Object.freeze({
34
+ return createGuardResponse({
38
35
  status: 401,
39
36
  body,
40
- headers: Object.freeze({
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 Object.freeze({
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
@@ -6,8 +6,23 @@
6
6
  *
7
7
  * @module http/httpTypes
8
8
  */
9
- /** HTTP middleware signature from @zudojs/http. */
10
- export type HttpMiddleware = (context: HttpMiddlewareContext, next: () => Promise<HttpResponseContext>) => void | Response | HttpResponseContext | Promise<void | Response | HttpResponseContext>;
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;
@@ -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
- * expandImplied: (permission) => permissions.expandImplied(permission),
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
- * expandImplied: (permission) => permissions.expandImplied(permission),
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
- return permissions.delete(permission);
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.
@@ -4,4 +4,5 @@
4
4
  * @module policy
5
5
  */
6
6
  export { createPolicyRegistry, type PolicyRegistry, type PolicyRegistryOptions, } from "./policyRegistry.js";
7
+ export { DEFAULT_POLICY_EFFECT, policyGrants } from "./policyEffect.core.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * @module policy
5
5
  */
6
6
  export { createPolicyRegistry, } from "./policyRegistry.js";
7
+ export { DEFAULT_POLICY_EFFECT, policyGrants } from "./policyEffect.core.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -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.2.0",
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.1.0"
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": "^4.1.11"
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://github.com/oyinlola-tech/zudo#readme",
49
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-permissions",
49
50
  "bugs": {
50
51
  "url": "https://github.com/oyinlola-tech/zudo/issues"
51
52
  },