@zudojs/permissions 1.4.0 → 1.4.1

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
@@ -235,13 +235,29 @@ const engine = createPermissionEngine({
235
235
  },
236
236
  ],
237
237
  });
238
+
239
+ await engine.can(author, "post:update", post); // true: the policy grants
240
+ await engine.can(editor, "post:update", post); // true: the role grants
241
+ await engine.can(viewer, "post:update", post); // false: neither does
238
242
  ```
239
243
 
244
+ A granting policy **grants when it allows and abstains when it does not**
245
+ (including when it throws or times out): it adds access, and never takes away
246
+ what roles, direct permissions or rules grant. So the policy returns only the
247
+ ownership test — no `|| actorHasRole(actor, "editor")` is needed to keep
248
+ editors working. In 1.4.0 a granting policy's `allowed: false` denied, so an
249
+ ownership policy locked out every editor who was not the author. To deny, use
250
+ an explicit deny rule, `deniedPermissions`, or a constraining policy.
251
+
240
252
  A granting policy still never overrides a denial — from another policy, an
241
253
  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.
254
+ grants; a typo constrains.
255
+
256
+ `createPermissionEngine({ defaultPolicyEffect: "grant" })` restores the
257
+ pre-1.4 behaviour, unchanged, for every policy that sets no `effect`: an
258
+ allowing policy grants and a denying one denies. It exists for backward
259
+ compatibility — prefer marking the individual policies with `effect: "grant"`,
260
+ which abstain instead of denying even under that engine default.
245
261
 
246
262
  `policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
247
263
  disable.
@@ -508,6 +524,46 @@ const guard = authorize(engine, "post:update", {
508
524
  several permissions, short-circuiting on the first that decides the outcome.
509
525
  An empty list denies in either mode.
510
526
 
527
+ ### A resource that does not exist
528
+
529
+ By default a guard checks the permission even when `extractResource` returns
530
+ `undefined` or `null`, with no resource. Rules and policies that read the
531
+ resource see nothing, so the answer comes from the rest of the model. A role
532
+ that grants `post:update` lets the request through, and the handler still has
533
+ to answer 404. A resource-owner rule denies with 403. `onMissingResource`
534
+ moves that answer into the guard:
535
+
536
+ ```typescript
537
+ const guard = authorize(engine, "post:update", {
538
+ extractActor: (context) => context.state.get("auth:user"),
539
+ extractResource: (context) => posts.find(context.request.getParam?.("id")),
540
+ onMissingResource: "notFound", // 404 when the loader returns undefined/null
541
+ notFoundResponse: () => ({ error: "Not Found" }), // optional body
542
+ });
543
+ ```
544
+
545
+ | `onMissingResource` | A missing resource answers |
546
+ | ------------------- | -------------------------- |
547
+ | `"check"` (default) | whatever the engine decides with no resource, as before |
548
+ | `"forbid"` | **403**, without evaluating |
549
+ | `"notFound"` | **404**, without evaluating |
550
+
551
+ The option applies only to a guard that has an `extractResource`. An
552
+ unauthenticated request still gets 401 first, and a loader that throws still
553
+ gets 403. The guard records `RESOURCE_NOT_FOUND_DECISION`
554
+ (`reason: "resource_not_found"`) under `permissions:decision`.
555
+ `createRequirePermissionsMiddleware` takes the same two options. The 404 body
556
+ is built by `createNotFoundResponse`.
557
+
558
+ **The trade-off.** A 404 hides whether a resource exists only when every
559
+ answer is consistent. With `"notFound"`, an authenticated caller who lacks the
560
+ permission gets 404 for an id that does not exist and 403 for one that does,
561
+ so the two statuses confirm which ids exist. To conceal existence from callers
562
+ who are not authorised, every route over the resource must answer the same
563
+ way, and a denial on an existing resource must also answer 404. These guards
564
+ do not do that for you. Use `"notFound"` to take the lookup-and-404 out of the
565
+ handler. On its own it does not conceal anything.
566
+
511
567
  The middleware composes with the real `@zudojs/http` pipeline without
512
568
  depending on it: the HTTP types are mirrored structurally (headers, params and
513
569
  query may be plain objects, as `@zudojs/http` provides them, or maps), and
@@ -62,9 +62,11 @@ export interface PermissionEngineOptions {
62
62
  * `"constrain"` — an allowing policy is an extra condition and cannot
63
63
  * grant a permission the actor's roles, permissions or rules do not.
64
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.
65
+ * `"grant"` restores the behaviour before 1.4 for every policy with no
66
+ * `effect`: an allowing policy is an independent grant and a denying one
67
+ * denies. Prefer `effect: "grant"` on the individual policies that really
68
+ * establish the right on their own — those grant on allow and abstain on
69
+ * deny, so they cannot take away what roles grant.
68
70
  */
69
71
  readonly defaultPolicyEffect?: PolicyEffect;
70
72
  /** How competing rules combine. Default: `"deny-overrides"`. */
@@ -209,7 +209,9 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
209
209
  * the permission. It used to grant on its own, so a "business-hours" policy
210
210
  * handed `task:delete` to an actor with no roles at all. Only a policy with
211
211
  * `effect: "grant"` (or an engine with `defaultPolicyEffect: "grant"`) can
212
- * grant access the rules did not — and even then it never overrides a
212
+ * grant access the rules did not. A per-policy `effect: "grant"` policy that
213
+ * does not allow abstains, so it never reaches here as a denial and cannot
214
+ * take away what the rules granted. A granting policy never overrides a
213
215
  * denial, including one the *rules* produced. "The rules did not allow"
214
216
  * covers two cases: no rule matched, and a deny rule matched. Treating them
215
217
  * alike let an allowing policy for `post:*` cancel a `deny post:update` rule
@@ -83,10 +83,10 @@ export interface PolicyOutcome {
83
83
  readonly cacheable: boolean;
84
84
  readonly evaluated: readonly string[];
85
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.
86
+ * Whether the allow may grant on its own: no policy denied and at least
87
+ * one granting policy allowed. `false` for a denial, and for an allow from
88
+ * constraining policies only, which then needs a role, permission or rule
89
+ * to grant.
90
90
  */
91
91
  readonly grants: boolean;
92
92
  }
@@ -96,6 +96,12 @@ export interface PolicyOutcome {
96
96
  * Policies are evaluated highest priority first and short-circuit on the
97
97
  * first denial. A policy that throws or times out denies — an authorization
98
98
  * check that cannot complete must not fall through to "allowed".
99
+ *
100
+ * A policy with its own `effect: "grant"` only ever adds access: when it
101
+ * returns `allowed: false`, throws or times out it abstains, as if it had
102
+ * not applied, so an ownership policy cannot take away what the actor's
103
+ * roles grant. A constraining policy's denial, and a `"legacyGrant"`
104
+ * policy's (no `effect`, engine `defaultPolicyEffect: "grant"`), still deny.
99
105
  */
100
106
  export declare function evaluatePolicies(context: PermissionContext, policies: readonly PermissionPolicyDefinition[], options: EvaluatorOptions, authOptions?: AuthorizationOptions): Promise<PolicyOutcome>;
101
107
  /**
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module evaluator/evaluator.pipeline
5
5
  */
6
- import { policyGrants } from "../policy/policyEffect.core.js";
6
+ import { resolvePolicyMode } from "../policy/policyEffect.core.js";
7
7
  import { resolveRolePermissions } from "../role/roleHierarchy.js";
8
8
  import { matches, permissionsOverlap, parsePermissionSafe, } from "../permission/permission.core.js";
9
9
  import { isWildcardTarget } from "../rule/rule.pattern.js";
@@ -145,12 +145,27 @@ function narrowerPolicies(policies, applicable, permissionStr) {
145
145
  policy.permissions.some((pattern) => permissionsOverlap(pattern, permissionStr)))
146
146
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
147
147
  }
148
+ /** A policy denial as the decision it produces. */
149
+ function policyDenial(policy, reason, publicReason) {
150
+ return Object.freeze({
151
+ allowed: false,
152
+ reason,
153
+ policy: policy.name,
154
+ publicReason,
155
+ });
156
+ }
148
157
  /**
149
158
  * Evaluate policies for a context.
150
159
  *
151
160
  * Policies are evaluated highest priority first and short-circuit on the
152
161
  * first denial. A policy that throws or times out denies — an authorization
153
162
  * check that cannot complete must not fall through to "allowed".
163
+ *
164
+ * A policy with its own `effect: "grant"` only ever adds access: when it
165
+ * returns `allowed: false`, throws or times out it abstains, as if it had
166
+ * not applied, so an ownership policy cannot take away what the actor's
167
+ * roles grant. A constraining policy's denial, and a `"legacyGrant"`
168
+ * policy's (no `effect`, engine `defaultPolicyEffect: "grant"`), still deny.
154
169
  */
155
170
  export async function evaluatePolicies(context, policies, options, authOptions) {
156
171
  if (policies.length === 0) {
@@ -166,55 +181,57 @@ export async function evaluatePolicies(context, policies, options, authOptions)
166
181
  // configured timeout mean anything at all.
167
182
  const timeoutMs = authOptions?.policyTimeout ?? options.policyTimeout;
168
183
  const evaluated = [];
184
+ const allowedBy = [];
169
185
  let cacheable = true;
186
+ let grants = false;
170
187
  for (const policy of [...applicable, ...denyOnly]) {
171
188
  assertNotAborted(context.signal ?? authOptions?.signal);
172
189
  evaluated.push(policy.name);
173
190
  if (policy.cacheable === false)
174
191
  cacheable = false;
192
+ const mode = resolvePolicyMode(policy, options.defaultPolicyEffect);
175
193
  try {
176
194
  const result = await withTimeout(Promise.resolve(policy.evaluate(context)), timeoutMs, policy.name);
177
195
  if (!result.allowed) {
196
+ if (mode === "grant")
197
+ continue;
178
198
  return {
179
- decision: Object.freeze({
180
- allowed: false,
181
- reason: result.reason ?? `policy:${policy.name}`,
182
- policy: policy.name,
183
- publicReason: result.publicReason ?? "Access denied",
184
- }),
199
+ decision: policyDenial(policy, result.reason ?? `policy:${policy.name}`, result.publicReason ?? "Access denied"),
185
200
  cacheable,
186
201
  evaluated,
187
202
  grants: false,
188
203
  };
189
204
  }
205
+ if (applicable.includes(policy)) {
206
+ allowedBy.push(policy);
207
+ if (mode !== "constrain")
208
+ grants = true;
209
+ }
190
210
  }
191
211
  catch (error) {
192
212
  if (error instanceof AuthorizationAbortedError)
193
213
  throw error;
194
214
  options.onError?.(reportable(error, (cause) => new PolicyError(policy.name, cause)), `Policy.${policy.name}`);
215
+ cacheable = false;
216
+ if (mode === "grant")
217
+ continue;
195
218
  // Policy error — fail closed.
196
219
  return {
197
- decision: Object.freeze({
198
- allowed: false,
199
- reason: `policy_error:${policy.name}`,
200
- policy: policy.name,
201
- publicReason: "Access denied",
202
- }),
220
+ decision: policyDenial(policy, `policy_error:${policy.name}`, "Access denied"),
203
221
  cacheable: false,
204
222
  evaluated,
205
223
  grants: false,
206
224
  };
207
225
  }
208
226
  }
209
- if (applicable.length === 0) {
227
+ if (allowedBy.length === 0) {
210
228
  return { decision: null, cacheable, evaluated, grants: false };
211
229
  }
212
- const grants = applicable.some((policy) => policyGrants(policy, options.defaultPolicyEffect));
213
230
  return {
214
231
  decision: Object.freeze({
215
232
  allowed: true,
216
233
  reason: grants ? "policy_allow" : "policy_pass",
217
- policy: applicable.map((policy) => policy.name).join(","),
234
+ policy: allowedBy.map((policy) => policy.name).join(","),
218
235
  }),
219
236
  cacheable,
220
237
  evaluated,
@@ -38,6 +38,16 @@ export declare function createForbiddenResponse(decision: PermissionDecision, op
38
38
  * leaves a client unable to tell "log in" from "you may not do this".
39
39
  */
40
40
  export declare function createUnauthorizedResponse(options?: DeniedResponseOptions): PermissionHttpResponse;
41
+ /** Options for {@link createNotFoundResponse}. */
42
+ export interface NotFoundResponseOptions {
43
+ /** Builds the 404 body for a resource that does not exist. */
44
+ readonly notFoundResponse?: () => unknown;
45
+ }
46
+ /**
47
+ * Create a 404 Not Found JSON response, for a guard whose resource loader
48
+ * found nothing (`onMissingResource: "notFound"`).
49
+ */
50
+ export declare function createNotFoundResponse(options?: NotFoundResponseOptions): PermissionHttpResponse;
41
51
  /**
42
52
  * Create a JSON response.
43
53
  */
@@ -40,6 +40,16 @@ export function createUnauthorizedResponse(options) {
40
40
  },
41
41
  });
42
42
  }
43
+ /**
44
+ * Create a 404 Not Found JSON response, for a guard whose resource loader
45
+ * found nothing (`onMissingResource: "notFound"`).
46
+ */
47
+ export function createNotFoundResponse(options) {
48
+ const body = options?.notFoundResponse
49
+ ? options.notFoundResponse()
50
+ : { error: "Not Found", message: "Resource not found" };
51
+ return createGuardResponse({ status: 404, body, headers: JSON_HEADERS });
52
+ }
43
53
  /**
44
54
  * Create a JSON response.
45
55
  */
@@ -10,7 +10,7 @@ import type { PermissionActor, AuthorizationOptions } from "../permissionTypes/i
10
10
  import type { PermissionEngine } from "../evaluator/authorizationEngine.js";
11
11
  import type { HttpMiddleware, HttpMiddlewareContext } from "./httpTypes.js";
12
12
  import { type DeniedResponseOptions } from "./httpHelpers.js";
13
- import { type ResourceExtractor } from "./httpResource.helper.js";
13
+ import { type MissingResourceOptions, type ResourceExtractor } from "./httpResource.helper.js";
14
14
  /** Options shared by the permission middleware. */
15
15
  export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
16
16
  /**
@@ -35,12 +35,13 @@ export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
35
35
  readonly onError?: (error: unknown, source: string) => void;
36
36
  }
37
37
  /** Options for the requirePermission middleware. */
38
- export interface RequirePermissionMiddlewareOptions extends AuthorizeMiddlewareOptions {
38
+ export interface RequirePermissionMiddlewareOptions extends AuthorizeMiddlewareOptions, MissingResourceOptions {
39
39
  /** The permission to check (e.g. "post:update"). */
40
40
  readonly permission: string;
41
41
  /**
42
42
  * Loads the resource the permission is checked against (optional). May be
43
43
  * async; it is awaited, and a loader that throws or rejects denies (403).
44
+ * What `undefined` or `null` answers is set by `onMissingResource`.
44
45
  */
45
46
  readonly extractResource?: ResourceExtractor;
46
47
  }
@@ -77,10 +78,11 @@ export declare function createRequirePermissionMiddleware(engine: PermissionEngi
77
78
  */
78
79
  export declare function authorize(engine: PermissionEngine, permission: string, options?: Omit<RequirePermissionMiddlewareOptions, "permission">): HttpMiddleware;
79
80
  /** Options for {@link createRequirePermissionsMiddleware}. */
80
- export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddlewareOptions {
81
+ export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddlewareOptions, MissingResourceOptions {
81
82
  /**
82
83
  * Loads the resource checked for every permission (optional). May be
83
84
  * async; it is awaited, and a loader that throws or rejects denies (403).
85
+ * What `undefined` or `null` answers is set by `onMissingResource`.
84
86
  */
85
87
  readonly extractResource?: ResourceExtractor;
86
88
  /**
@@ -7,7 +7,7 @@
7
7
  * @module http/httpMiddleware
8
8
  */
9
9
  import { createForbiddenResponse, createUnauthorizedResponse, } from "./httpHelpers.js";
10
- import { loadResource } from "./httpResource.helper.js";
10
+ import { loadResource, refuseMissingResource, } from "./httpResource.helper.js";
11
11
  // ─── State Keys ───────────────────────────────────────────────────────────
12
12
  /** State key for the current actor. */
13
13
  export const ACTOR_STATE_KEY = "permissions:actor";
@@ -70,6 +70,11 @@ export function createRequirePermissionMiddleware(engine, options) {
70
70
  context.state.set(DECISION_STATE_KEY, loaded.decision);
71
71
  return createForbiddenResponse(loaded.decision, options);
72
72
  }
73
+ const missing = refuseMissingResource(options.extractResource, loaded.resource, options);
74
+ if (missing) {
75
+ context.state.set(DECISION_STATE_KEY, missing.decision);
76
+ return missing.response;
77
+ }
73
78
  const decision = await engine.check(actor, options.permission, loaded.resource, buildAuthorization(context, options));
74
79
  context.state.set(DECISION_STATE_KEY, decision);
75
80
  if (!decision.allowed)
@@ -107,6 +112,9 @@ export function createRequirePermissionsMiddleware(engine, permissions, options
107
112
  const loaded = await loadResource(context, options.extractResource, options.onError);
108
113
  if (!loaded.ok)
109
114
  return createForbiddenResponse(loaded.decision, options);
115
+ const missing = refuseMissingResource(options.extractResource, loaded.resource, options);
116
+ if (missing)
117
+ return missing.response;
110
118
  const resource = loaded.resource;
111
119
  const authorization = buildAuthorization(context, options);
112
120
  const results = new Map();
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { PermissionDecision } from "../permissionTypes/index.js";
7
7
  import type { HttpMiddlewareContext } from "./httpTypes.js";
8
+ import { type DeniedResponseOptions, type NotFoundResponseOptions, type PermissionHttpResponse } from "./httpHelpers.js";
8
9
  /** Loads the resource a permission is checked against. May be async. */
9
10
  export type ResourceExtractor = (context: HttpMiddlewareContext) => unknown | Promise<unknown>;
10
11
  /** The resource, or the denial to answer with when it could not be loaded. */
@@ -27,4 +28,46 @@ export declare const RESOURCE_ERROR_DECISION: PermissionDecision;
27
28
  * loader that throws or rejects now denies; the error goes to `onError`.
28
29
  */
29
30
  export declare function loadResource(context: HttpMiddlewareContext, extract: ResourceExtractor | undefined, onError?: (error: unknown, source: string) => void): Promise<ResourceOutcome>;
31
+ /**
32
+ * What a guard does when `extractResource` returns `undefined` or `null`.
33
+ *
34
+ * - `"check"` (default): evaluate the permission with no resource, as the
35
+ * guards always have. Rules and policies that read the resource see
36
+ * nothing, so the answer depends on the rest of the model: a role grant
37
+ * alone lets the request through to the handler.
38
+ * - `"forbid"`: answer 403 without evaluating.
39
+ * - `"notFound"`: answer 404 without evaluating.
40
+ */
41
+ export type MissingResourceMode = "check" | "forbid" | "notFound";
42
+ /**
43
+ * Options for a guard with a resource loader.
44
+ *
45
+ * A 404 for a missing resource hides nothing on its own. An authenticated
46
+ * caller who lacks the permission still gets 404 for an id that does not
47
+ * exist and 403 for one that does, so the pair of statuses confirms which
48
+ * ids exist. It conceals existence only when it is used consistently: every
49
+ * route over the resource answers the same way, and a denial on an existing
50
+ * resource is also answered 404 (not something these guards do for you).
51
+ * Use it to take the not-found check out of the handler, not as concealment.
52
+ */
53
+ export interface MissingResourceOptions extends NotFoundResponseOptions {
54
+ /** What a missing resource answers. Default: `"check"`. */
55
+ readonly onMissingResource?: MissingResourceMode;
56
+ }
57
+ /** The decision recorded when `onMissingResource` refuses the request. */
58
+ export declare const RESOURCE_NOT_FOUND_DECISION: PermissionDecision;
59
+ /** The refusal for a missing resource, and the decision to record. */
60
+ export interface MissingResourceRefusal {
61
+ readonly decision: PermissionDecision;
62
+ readonly response: PermissionHttpResponse;
63
+ }
64
+ /**
65
+ * Decide whether a loaded resource counts as missing, and answer for it.
66
+ *
67
+ * Only a guard that has an `extractResource` can have a missing resource: a
68
+ * route without one never loads anything, so it is always checked.
69
+ *
70
+ * @returns The refusal to send, or `undefined` to evaluate as usual.
71
+ */
72
+ export declare function refuseMissingResource(extract: ResourceExtractor | undefined, resource: unknown, options: MissingResourceOptions & DeniedResponseOptions): MissingResourceRefusal | undefined;
30
73
  //# sourceMappingURL=httpResource.helper.d.ts.map
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @module http/httpResource.helper
5
5
  */
6
+ import { createForbiddenResponse, createNotFoundResponse, } from "./httpHelpers.js";
6
7
  /** The decision recorded when the resource loader fails. */
7
8
  export const RESOURCE_ERROR_DECISION = Object.freeze({
8
9
  allowed: false,
@@ -29,4 +30,30 @@ export async function loadResource(context, extract, onError) {
29
30
  return { ok: false, decision: RESOURCE_ERROR_DECISION };
30
31
  }
31
32
  }
33
+ /** The decision recorded when `onMissingResource` refuses the request. */
34
+ export const RESOURCE_NOT_FOUND_DECISION = Object.freeze({
35
+ allowed: false,
36
+ reason: "resource_not_found",
37
+ publicReason: "Access denied",
38
+ });
39
+ /**
40
+ * Decide whether a loaded resource counts as missing, and answer for it.
41
+ *
42
+ * Only a guard that has an `extractResource` can have a missing resource: a
43
+ * route without one never loads anything, so it is always checked.
44
+ *
45
+ * @returns The refusal to send, or `undefined` to evaluate as usual.
46
+ */
47
+ export function refuseMissingResource(extract, resource, options) {
48
+ const mode = options.onMissingResource ?? "check";
49
+ if (!extract || mode === "check")
50
+ return undefined;
51
+ if (resource !== undefined && resource !== null)
52
+ return undefined;
53
+ const decision = RESOURCE_NOT_FOUND_DECISION;
54
+ const response = mode === "notFound"
55
+ ? createNotFoundResponse(options)
56
+ : createForbiddenResponse(decision, options);
57
+ return { decision, response };
58
+ }
32
59
  //# sourceMappingURL=httpResource.helper.js.map
@@ -11,7 +11,7 @@
11
11
  * @module http
12
12
  */
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
- export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, type DeniedResponseOptions, type PermissionHttpResponse, } from "./httpHelpers.js";
15
- export { loadResource, RESOURCE_ERROR_DECISION, type ResourceExtractor, type ResourceOutcome, } from "./httpResource.helper.js";
14
+ export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, createNotFoundResponse, type DeniedResponseOptions, type NotFoundResponseOptions, type PermissionHttpResponse, } from "./httpHelpers.js";
15
+ export { loadResource, refuseMissingResource, RESOURCE_ERROR_DECISION, RESOURCE_NOT_FOUND_DECISION, type MissingResourceMode, type MissingResourceOptions, type MissingResourceRefusal, type ResourceExtractor, type ResourceOutcome, } from "./httpResource.helper.js";
16
16
  export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareOutcome, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.js";
17
17
  //# sourceMappingURL=index.d.ts.map
@@ -11,6 +11,6 @@
11
11
  * @module http
12
12
  */
13
13
  export { createActorMiddleware, createRequirePermissionMiddleware, authorize, createRequirePermissionsMiddleware, ACTOR_STATE_KEY, DECISION_STATE_KEY, DECISIONS_STATE_KEY, } from "./httpMiddleware.core.js";
14
- export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, } from "./httpHelpers.js";
15
- export { loadResource, RESOURCE_ERROR_DECISION, } from "./httpResource.helper.js";
14
+ export { createForbiddenResponse, createUnauthorizedResponse, createJsonResponse, createNotFoundResponse, } from "./httpHelpers.js";
15
+ export { loadResource, refuseMissingResource, RESOURCE_ERROR_DECISION, RESOURCE_NOT_FOUND_DECISION, } from "./httpResource.helper.js";
16
16
  //# sourceMappingURL=index.js.map
@@ -50,7 +50,13 @@ export interface PermissionCache {
50
50
  * permissions or rules must still grant the permission. Its deny denies.
51
51
  * - `"grant"`: the policy is an independent grant. Its allow grants the
52
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.
53
+ * Its deny (or a throw or timeout) *abstains*: the policy adds access but
54
+ * never takes away what roles, permissions or rules grant. Use an explicit
55
+ * deny rule or a constraining policy to deny.
56
+ *
57
+ * As an engine's `defaultPolicyEffect`, `"grant"` instead restores the
58
+ * pre-1.4 behaviour for policies that set no `effect`: an allow grants and a
59
+ * deny denies.
54
60
  */
55
61
  export type PolicyEffect = "constrain" | "grant";
56
62
  /** A named authorization policy. */
@@ -60,7 +66,7 @@ export interface PermissionPolicyDefinition {
60
66
  * Whether this policy's allow can grant a permission the actor's roles do
61
67
  * not include. Default: the engine's `defaultPolicyEffect`, which is
62
68
  * `"constrain"` — a policy only ever narrows access. Any value other than
63
- * `"grant"` constrains.
69
+ * `"grant"` constrains. With `"grant"`, a deny abstains rather than denies.
64
70
  */
65
71
  readonly effect?: PolicyEffect;
66
72
  /**
@@ -18,4 +18,25 @@ export declare const DEFAULT_POLICY_EFFECT: PolicyEffect;
18
18
  * @param defaultEffect - The engine's `defaultPolicyEffect`.
19
19
  */
20
20
  export declare function policyGrants(policy: PermissionPolicyDefinition, defaultEffect?: PolicyEffect): boolean;
21
+ /**
22
+ * How a policy's decision combines with RBAC/ABAC, once its own `effect`
23
+ * and the engine default are resolved.
24
+ *
25
+ * - `"constrain"`: an allow is "no objection", a deny denies.
26
+ * - `"grant"` (a policy's own `effect: "grant"`): an allow grants, a deny
27
+ * *abstains* — the policy can add access but never take away what roles,
28
+ * permissions or rules grant.
29
+ * - `"legacyGrant"` (a policy with no `effect` under an engine with
30
+ * `defaultPolicyEffect: "grant"`): the pre-1.4 behaviour, kept for
31
+ * backward compatibility — an allow grants and a deny denies.
32
+ */
33
+ export type ResolvedPolicyMode = "constrain" | "grant" | "legacyGrant";
34
+ /**
35
+ * Resolves a policy's {@link ResolvedPolicyMode}. Only the exact value
36
+ * `"grant"` grants; any other explicit value constrains.
37
+ *
38
+ * @param policy - The policy.
39
+ * @param defaultEffect - The engine's `defaultPolicyEffect`.
40
+ */
41
+ export declare function resolvePolicyMode(policy: PermissionPolicyDefinition, defaultEffect?: PolicyEffect): ResolvedPolicyMode;
21
42
  //# sourceMappingURL=policyEffect.core.d.ts.map
@@ -19,4 +19,18 @@ export const DEFAULT_POLICY_EFFECT = "constrain";
19
19
  export function policyGrants(policy, defaultEffect = DEFAULT_POLICY_EFFECT) {
20
20
  return (policy.effect ?? defaultEffect) === "grant";
21
21
  }
22
+ /**
23
+ * Resolves a policy's {@link ResolvedPolicyMode}. Only the exact value
24
+ * `"grant"` grants; any other explicit value constrains.
25
+ *
26
+ * @param policy - The policy.
27
+ * @param defaultEffect - The engine's `defaultPolicyEffect`.
28
+ */
29
+ export function resolvePolicyMode(policy, defaultEffect = DEFAULT_POLICY_EFFECT) {
30
+ if (policy.effect === "grant")
31
+ return "grant";
32
+ if (policy.effect !== undefined)
33
+ return "constrain";
34
+ return defaultEffect === "grant" ? "legacyGrant" : "constrain";
35
+ }
22
36
  //# sourceMappingURL=policyEffect.core.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/permissions",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.",
5
5
  "license": "MIT",
6
6
  "author": {