@zudojs/permissions 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +70 -20
  2. package/dist/ability/ability.core.js +4 -24
  3. package/dist/cache/cache.actorDigest.d.ts +26 -0
  4. package/dist/cache/cache.actorDigest.js +90 -0
  5. package/dist/cache/cache.core.d.ts +7 -1
  6. package/dist/cache/cache.core.js +9 -2
  7. package/dist/cache/index.d.ts +1 -0
  8. package/dist/cache/index.js +1 -0
  9. package/dist/evaluator/authorizationEngine.d.ts +20 -3
  10. package/dist/evaluator/authorizationEngine.js +43 -68
  11. package/dist/evaluator/engineSupport/authorizationEngine.validation.d.ts +37 -0
  12. package/dist/evaluator/engineSupport/authorizationEngine.validation.js +90 -0
  13. package/dist/evaluator/engineSupport/evaluator.cacheKey.d.ts +24 -0
  14. package/dist/evaluator/engineSupport/evaluator.cacheKey.js +56 -0
  15. package/dist/evaluator/engineSupport/evaluator.observed.d.ts +18 -0
  16. package/dist/evaluator/engineSupport/evaluator.observed.js +49 -0
  17. package/dist/evaluator/engineSupport/index.d.ts +10 -0
  18. package/dist/evaluator/engineSupport/index.js +10 -0
  19. package/dist/evaluator/evaluator.core.js +19 -37
  20. package/dist/evaluator/evaluator.pipeline.d.ts +6 -0
  21. package/dist/evaluator/evaluator.pipeline.js +25 -3
  22. package/dist/http/httpMiddleware.core.d.ts +15 -5
  23. package/dist/http/httpMiddleware.core.js +18 -4
  24. package/dist/http/httpResource.helper.d.ts +30 -0
  25. package/dist/http/httpResource.helper.js +32 -0
  26. package/dist/http/httpTypes.d.ts +17 -4
  27. package/dist/http/index.d.ts +7 -5
  28. package/dist/http/index.js +6 -4
  29. package/dist/permission/index.d.ts +1 -1
  30. package/dist/permission/index.js +1 -1
  31. package/dist/permission/permission.core.d.ts +12 -0
  32. package/dist/permission/permission.core.js +20 -1
  33. package/dist/policy/policyRegistry.d.ts +12 -0
  34. package/dist/policy/policyRegistry.js +17 -1
  35. package/dist/role/roleRegistry.d.ts +9 -0
  36. package/dist/role/roleRegistry.js +19 -1
  37. package/dist/rule/rule.core.d.ts +14 -22
  38. package/dist/rule/rule.core.js +49 -86
  39. package/dist/rule/rule.pattern.d.ts +44 -0
  40. package/dist/rule/rule.pattern.js +72 -0
  41. package/dist/utils/utils.notifier.d.ts +19 -0
  42. package/dist/utils/utils.notifier.js +31 -0
  43. package/package.json +2 -10
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Configuration validation for the authorization engine.
3
+ *
4
+ * A malformed pattern can never match. In a grant that is a silent no-op; in
5
+ * a denying policy or a deny rule it is a restriction that fails open. The
6
+ * same pattern therefore has to be rejected wherever it can be written, not
7
+ * only in a role's grant list.
8
+ *
9
+ * @module evaluator/authorizationEngine.validation
10
+ */
11
+ import { InvalidPermissionError, InvalidRoleError, } from "../../permissionErrors/index.js";
12
+ import { isValidPermission } from "../../permission/permission.core.js";
13
+ function patterns(value) {
14
+ return Array.isArray(value) ? value : [value];
15
+ }
16
+ /**
17
+ * The first `resource:action` pair a rule names that is not a valid
18
+ * permission pattern, or `undefined` when every pair is valid.
19
+ */
20
+ export function invalidRulePattern(rule) {
21
+ const resources = patterns(rule.resource);
22
+ const actions = patterns(rule.action);
23
+ if (resources.length === 0 || actions.length === 0) {
24
+ return `${resources.join(",")}:${actions.join(",")}`;
25
+ }
26
+ for (const resource of resources) {
27
+ for (const action of actions) {
28
+ const pair = `${String(resource)}:${String(action)}`;
29
+ if (!isValidPermission(pair))
30
+ return pair;
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * Reject a rule whose resource or action could never match.
37
+ *
38
+ * @throws {InvalidPermissionError} naming the offending pair.
39
+ */
40
+ export function validateRule(rule) {
41
+ const invalid = invalidRulePattern(rule);
42
+ if (invalid !== undefined)
43
+ throw new InvalidPermissionError(invalid);
44
+ }
45
+ /**
46
+ * Reject a policy scoped to a pattern that could never match.
47
+ *
48
+ * @throws {InvalidPermissionError} naming the offending pattern.
49
+ */
50
+ export function validatePolicy(policy) {
51
+ for (const pattern of policy.permissions) {
52
+ if (!isValidPermission(pattern))
53
+ throw new InvalidPermissionError(pattern);
54
+ }
55
+ }
56
+ /**
57
+ * Reject a role whose grants or rules could never match.
58
+ *
59
+ * @throws {InvalidRoleError} naming the role and the offending pattern.
60
+ */
61
+ export function validateRole(role) {
62
+ if (!role.name || role.name.trim() === "") {
63
+ throw new InvalidRoleError("Role name cannot be empty");
64
+ }
65
+ for (const permission of role.permissions) {
66
+ if (!isValidPermission(permission)) {
67
+ throw new InvalidRoleError(`Role "${role.name}" grants "${permission}", which is not a valid ` +
68
+ `"resource:action" permission`);
69
+ }
70
+ }
71
+ for (const rule of role.rules ?? []) {
72
+ const invalid = invalidRulePattern(rule);
73
+ if (invalid !== undefined) {
74
+ throw new InvalidRoleError(`Role "${role.name}" has a rule on "${invalid}", which is not a valid ` +
75
+ `"resource:action" pattern`);
76
+ }
77
+ }
78
+ }
79
+ /** Validate a role list, rejecting duplicates as well. */
80
+ export function validateRoles(roles) {
81
+ const seen = new Set();
82
+ for (const role of roles) {
83
+ validateRole(role);
84
+ if (seen.has(role.name)) {
85
+ throw new InvalidRoleError(`Role "${role.name}" is defined more than once`);
86
+ }
87
+ seen.add(role.name);
88
+ }
89
+ }
90
+ //# sourceMappingURL=authorizationEngine.validation.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Decision cache key for one evaluation.
3
+ *
4
+ * @module evaluator/evaluator.cacheKey
5
+ */
6
+ import type { AuthorizationOptions, PermissionActor } from "../../permissionTypes/index.js";
7
+ import type { EvaluatorOptions } from "../evaluator.pipeline.js";
8
+ /**
9
+ * The cache key for a decision, or `undefined` when it must not be cached.
10
+ *
11
+ * Only a decision the key can describe completely may be cached:
12
+ *
13
+ * - A resource with no derivable id would collapse the key to
14
+ * `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer for
15
+ * `{ ownerId: "bob" }`.
16
+ * - Request metadata is not in the key, and `tenantIsolation()` reads the
17
+ * tenant from it.
18
+ * - The actor's roles, permissions, type and any other field it carries are
19
+ * in the key as a digest; an actor the digest cannot describe is not cached.
20
+ * - The engine's configuration generation is in the key, so a role removed
21
+ * from a live registry cannot be served from an entry written before.
22
+ */
23
+ export declare function decisionCacheKey(actor: PermissionActor, permissionStr: string, resource: unknown, options: EvaluatorOptions, authOptions?: AuthorizationOptions): string | undefined;
24
+ //# sourceMappingURL=evaluator.cacheKey.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Decision cache key for one evaluation.
3
+ *
4
+ * @module evaluator/evaluator.cacheKey
5
+ */
6
+ import { permissionCacheKey } from "../../cache/cache.core.js";
7
+ import { actorCacheDigest } from "../../cache/cache.actorDigest.js";
8
+ /** True when the caller supplied request metadata the cache key cannot carry. */
9
+ function hasMetadata(metadata) {
10
+ if (!metadata)
11
+ return false;
12
+ if (metadata instanceof Map)
13
+ return metadata.size > 0;
14
+ return Object.keys(metadata).length > 0;
15
+ }
16
+ /** Best-effort resource identity for the cache key. */
17
+ function resourceIdOf(resource) {
18
+ if (typeof resource !== "object" || resource === null)
19
+ return undefined;
20
+ const id = resource.id;
21
+ if (typeof id === "string")
22
+ return id;
23
+ if (typeof id === "number")
24
+ return String(id);
25
+ return undefined;
26
+ }
27
+ /**
28
+ * The cache key for a decision, or `undefined` when it must not be cached.
29
+ *
30
+ * Only a decision the key can describe completely may be cached:
31
+ *
32
+ * - A resource with no derivable id would collapse the key to
33
+ * `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer for
34
+ * `{ ownerId: "bob" }`.
35
+ * - Request metadata is not in the key, and `tenantIsolation()` reads the
36
+ * tenant from it.
37
+ * - The actor's roles, permissions, type and any other field it carries are
38
+ * in the key as a digest; an actor the digest cannot describe is not cached.
39
+ * - The engine's configuration generation is in the key, so a role removed
40
+ * from a live registry cannot be served from an entry written before.
41
+ */
42
+ export function decisionCacheKey(actor, permissionStr, resource, options, authOptions) {
43
+ if (!options.cache || authOptions?.skipCache === true)
44
+ return undefined;
45
+ const resourceId = authOptions?.resourceId ?? resourceIdOf(resource);
46
+ if (resource !== undefined && resourceId === undefined)
47
+ return undefined;
48
+ if (hasMetadata(authOptions?.metadata))
49
+ return undefined;
50
+ const digest = actorCacheDigest(actor);
51
+ if (digest === undefined)
52
+ return undefined;
53
+ const generation = options.cacheScope?.() ?? "";
54
+ return permissionCacheKey(actor.id, permissionStr, resourceId, `${generation}${digest}`);
55
+ }
56
+ //# sourceMappingURL=evaluator.cacheKey.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Audit-event wrapper shared by every path that makes a decision.
3
+ *
4
+ * @module evaluator/evaluator.observed
5
+ */
6
+ import type { PermissionActor, PermissionDecision } from "../../permissionTypes/index.js";
7
+ import type { PermissionEventEmitter } from "../../observability/observability.core.js";
8
+ /** Best-effort resource type for an audit event. */
9
+ export declare function resourceTypeOf(resource: unknown): string | undefined;
10
+ /**
11
+ * Run one authorization and emit an audit event whichever way it ends —
12
+ * including when it throws. An authorization trail that records only the
13
+ * successful paths is not a trail, and one that skips `explain()` misses
14
+ * decisions that are real: they run the same evaluation and write the same
15
+ * cache entries that later checks are served from.
16
+ */
17
+ export declare function observed<T>(emitter: PermissionEventEmitter | undefined, actor: PermissionActor, permission: string, resource: unknown, run: () => Promise<T>, decisionOf: (result: T) => PermissionDecision): Promise<T>;
18
+ //# sourceMappingURL=evaluator.observed.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Audit-event wrapper shared by every path that makes a decision.
3
+ *
4
+ * @module evaluator/evaluator.observed
5
+ */
6
+ /** Best-effort resource type for an audit event. */
7
+ export function resourceTypeOf(resource) {
8
+ if (typeof resource !== "object" || resource === null)
9
+ return undefined;
10
+ const record = resource;
11
+ if (typeof record.type === "string")
12
+ return record.type;
13
+ const name = record.constructor?.name;
14
+ return name && name !== "Object" ? name : undefined;
15
+ }
16
+ /**
17
+ * Run one authorization and emit an audit event whichever way it ends —
18
+ * including when it throws. An authorization trail that records only the
19
+ * successful paths is not a trail, and one that skips `explain()` misses
20
+ * decisions that are real: they run the same evaluation and write the same
21
+ * cache entries that later checks are served from.
22
+ */
23
+ export async function observed(emitter, actor, permission, resource, run, decisionOf) {
24
+ const start = performance.now();
25
+ let decision;
26
+ let failure;
27
+ try {
28
+ const result = await run();
29
+ decision = decisionOf(result);
30
+ return result;
31
+ }
32
+ catch (error) {
33
+ failure = error;
34
+ throw error;
35
+ }
36
+ finally {
37
+ emitter?.emit({
38
+ actorId: actor.id,
39
+ permission,
40
+ resourceType: resourceTypeOf(resource),
41
+ allowed: decision?.allowed ?? false,
42
+ reason: decision?.reason ??
43
+ (failure instanceof Error ? `error:${failure.name}` : undefined),
44
+ durationMs: performance.now() - start,
45
+ errored: failure !== undefined,
46
+ });
47
+ }
48
+ }
49
+ //# sourceMappingURL=evaluator.observed.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Support for the authorization engine: configuration validation, the
3
+ * decision-cache key, and the audit-event wrapper every decision path uses.
4
+ *
5
+ * @module evaluator/engineSupport
6
+ */
7
+ export { invalidRulePattern, validatePolicy, validateRole, validateRoles, validateRule, } from "./authorizationEngine.validation.js";
8
+ export { decisionCacheKey } from "./evaluator.cacheKey.js";
9
+ export { observed, resourceTypeOf } from "./evaluator.observed.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Support for the authorization engine: configuration validation, the
3
+ * decision-cache key, and the audit-event wrapper every decision path uses.
4
+ *
5
+ * @module evaluator/engineSupport
6
+ */
7
+ export { invalidRulePattern, validatePolicy, validateRole, validateRoles, validateRule, } from "./authorizationEngine.validation.js";
8
+ export { decisionCacheKey } from "./evaluator.cacheKey.js";
9
+ export { observed, resourceTypeOf } from "./evaluator.observed.js";
10
+ //# sourceMappingURL=index.js.map
@@ -8,12 +8,13 @@
8
8
  *
9
9
  * @module evaluator/evaluator
10
10
  */
11
- import { parsePermissionSafe, matches } from "../permission/permission.core.js";
11
+ import { parsePermissionSafe, matches, permissionsOverlap, } from "../permission/permission.core.js";
12
12
  import { InvalidPermissionError } from "../permissionErrors/index.js";
13
13
  import { compileRules, findMatchingRules } from "../rule/ruleCompiler.js";
14
14
  import { evaluateRules } from "../rule/rule.core.js";
15
+ import { isWildcardTarget } from "../rule/rule.pattern.js";
15
16
  import { assertNotAborted, evaluatePolicies, resolveActorGrants, toMetadataMap, } from "./evaluator.pipeline.js";
16
- import { permissionCacheKey } from "../cache/cache.core.js";
17
+ import { decisionCacheKey } from "./engineSupport/index.js";
17
18
  export { evaluateWithExplain } from "./evaluator.explain.js";
18
19
  /** A public message that names nothing internal. */
19
20
  const PUBLIC_DENIED = "Access denied";
@@ -66,7 +67,10 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
66
67
  });
67
68
  continue;
68
69
  }
69
- if (matches(deny, permissionStr)) {
70
+ // Overlap, not match: for a concrete target the two are the same, but a
71
+ // wildcard target (`post:*`) asks about every action under it, and a
72
+ // deny on one of them has to refuse it.
73
+ if (permissionsOverlap(deny, permissionStr)) {
70
74
  trace?.push({
71
75
  type: "deny",
72
76
  detail: `Explicit deny: ${deny}`,
@@ -76,19 +80,7 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
76
80
  }
77
81
  }
78
82
  /* ── Cache ───────────────────────────────────────────────────────────── */
79
- const resourceId = authOptions?.resourceId ?? resourceIdOf(resource);
80
- // Only a decision the key can fully describe may be cached.
81
- //
82
- // A resource with no derivable id is the sharp case: the key would collapse
83
- // to `actor|permission`, so an allow for `{ ownerId: "ada" }` would answer
84
- // for `{ ownerId: "bob" }` on the next call. Request metadata is the other:
85
- // `tenantIsolation()` reads the tenant from it, and it is not part of the
86
- // key, so a decision made for one tenant must not answer for another.
87
- const keyable = (resource === undefined || resourceId !== undefined) &&
88
- !hasMetadata(authOptions?.metadata);
89
- const cacheKey = options.cache && authOptions?.skipCache !== true && keyable
90
- ? permissionCacheKey(actor.id, permissionStr, resourceId)
91
- : undefined;
83
+ const cacheKey = decisionCacheKey(actor, permissionStr, resource, options, authOptions);
92
84
  if (options.cache && cacheKey) {
93
85
  try {
94
86
  const cached = await options.cache.get(cacheKey);
@@ -160,9 +152,16 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
160
152
  })),
161
153
  ...grants.rules,
162
154
  ];
163
- const ruleResult = await evaluateRules(findMatchingRules(compileRules(rules), permission), permission, context, {
155
+ // A wildcard target is not a key the index can look up, and the deny
156
+ // rules that merely overlap it are exactly the ones an index lookup misses,
157
+ // so it is checked against every rule.
158
+ let conditionFailed = false;
159
+ const ruleResult = await evaluateRules(isWildcardTarget(permission)
160
+ ? rules
161
+ : findMatchingRules(compileRules(rules), permission), permission, context, {
164
162
  algorithm: options.algorithm,
165
163
  onConditionError: (rule, error) => {
164
+ conditionFailed = true;
166
165
  options.onError?.(error, `RuleCondition.${rule.name ?? "unnamed"}`);
167
166
  },
168
167
  });
@@ -188,7 +187,9 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
188
187
  }
189
188
  const decision = combine(ruleResult, outcome.decision, permissionStr);
190
189
  /* ── Cache write ─────────────────────────────────────────────────────── */
191
- if (options.cache && cacheKey && outcome.cacheable) {
190
+ // A decision forced by a condition that threw describes the failure, not
191
+ // the actor, and must not outlive it.
192
+ if (options.cache && cacheKey && outcome.cacheable && !conditionFailed) {
192
193
  try {
193
194
  await options.cache.set(cacheKey, decision, {
194
195
  ttl: options.cacheTtlMs,
@@ -235,25 +236,6 @@ function combine(ruleResult, policyDecision, permissionStr) {
235
236
  return policyDecision;
236
237
  return denied("no_matching_rule");
237
238
  }
238
- /** True when the caller supplied request metadata the cache key cannot carry. */
239
- function hasMetadata(metadata) {
240
- if (!metadata)
241
- return false;
242
- if (metadata instanceof Map)
243
- return metadata.size > 0;
244
- return Object.keys(metadata).length > 0;
245
- }
246
- /** Best-effort resource identity for the cache key. */
247
- function resourceIdOf(resource) {
248
- if (typeof resource !== "object" || resource === null)
249
- return undefined;
250
- const id = resource.id;
251
- if (typeof id === "string")
252
- return id;
253
- if (typeof id === "number")
254
- return String(id);
255
- return undefined;
256
- }
257
239
  /**
258
240
  * Evaluate and collect the trace, in one pass.
259
241
  *
@@ -31,6 +31,12 @@ export interface EvaluatorOptions {
31
31
  readonly expandImplied?: (permission: string) => readonly string[];
32
32
  /** Reports a failure that authorization swallowed to stay fail-closed. */
33
33
  readonly onError?: (error: unknown, source: string) => void;
34
+ /**
35
+ * Extra decision-cache key scope, read on every evaluation. The engine
36
+ * passes its configuration generation, so a role change invalidates every
37
+ * entry written before it.
38
+ */
39
+ readonly cacheScope?: () => string;
34
40
  }
35
41
  /** The permissions and rules an actor holds, once everything is resolved. */
36
42
  export interface ResolvedGrants {
@@ -4,7 +4,8 @@
4
4
  * @module evaluator/evaluator.pipeline
5
5
  */
6
6
  import { resolveRolePermissions } from "../role/roleHierarchy.js";
7
- import { matches } from "../permission/permission.core.js";
7
+ import { matches, permissionsOverlap, parsePermissionSafe, } from "../permission/permission.core.js";
8
+ import { isWildcardTarget } from "../rule/rule.pattern.js";
8
9
  import { AuthorizationAbortedError, PermissionResolverError, PolicyError, PolicyTimeoutError, } from "../permissionErrors/index.js";
9
10
  /**
10
11
  * Normalize a thrown value into an `Error` before it reaches `onError`.
@@ -126,6 +127,23 @@ export function selectPolicies(policies, permissionStr) {
126
127
  policy.permissions.some((pattern) => matches(pattern, permissionStr)))
127
128
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
128
129
  }
130
+ /**
131
+ * Policies narrower than a wildcard target, which may deny it but not grant it.
132
+ *
133
+ * `can(actor, "post:*")` asks about every action under `post`. A policy
134
+ * registered for `post:delete` does not cover that question, so its allow
135
+ * must not grant it — but its denial is a denial of part of it, and a
136
+ * wildcard check must not side-step it.
137
+ */
138
+ function narrowerPolicies(policies, applicable, permissionStr) {
139
+ const target = parsePermissionSafe(permissionStr);
140
+ if (!target || !isWildcardTarget(target))
141
+ return [];
142
+ return policies
143
+ .filter((policy) => !applicable.includes(policy) &&
144
+ policy.permissions.some((pattern) => permissionsOverlap(pattern, permissionStr)))
145
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
146
+ }
129
147
  /**
130
148
  * Evaluate policies for a context.
131
149
  *
@@ -139,7 +157,8 @@ export async function evaluatePolicies(context, policies, options, authOptions)
139
157
  }
140
158
  const permissionStr = `${context.permission.resource}:${context.permission.action}`;
141
159
  const applicable = selectPolicies(policies, permissionStr);
142
- if (applicable.length === 0) {
160
+ const denyOnly = narrowerPolicies(policies, applicable, permissionStr);
161
+ if (applicable.length === 0 && denyOnly.length === 0) {
143
162
  return { decision: null, cacheable: true, evaluated: [] };
144
163
  }
145
164
  // The per-call timeout wins, but the engine-level default is what makes a
@@ -147,7 +166,7 @@ export async function evaluatePolicies(context, policies, options, authOptions)
147
166
  const timeoutMs = authOptions?.policyTimeout ?? options.policyTimeout;
148
167
  const evaluated = [];
149
168
  let cacheable = true;
150
- for (const policy of applicable) {
169
+ for (const policy of [...applicable, ...denyOnly]) {
151
170
  assertNotAborted(context.signal ?? authOptions?.signal);
152
171
  evaluated.push(policy.name);
153
172
  if (policy.cacheable === false)
@@ -184,6 +203,9 @@ export async function evaluatePolicies(context, policies, options, authOptions)
184
203
  };
185
204
  }
186
205
  }
206
+ if (applicable.length === 0) {
207
+ return { decision: null, cacheable, evaluated };
208
+ }
187
209
  return {
188
210
  decision: Object.freeze({
189
211
  allowed: true,
@@ -10,6 +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
14
  /** Options shared by the permission middleware. */
14
15
  export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
15
16
  /**
@@ -30,13 +31,18 @@ export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
30
31
  readonly forwardSignal?: boolean;
31
32
  /** Builds per-request metadata for conditions and policies. */
32
33
  readonly extractMetadata?: (context: HttpMiddlewareContext) => Record<string, unknown> | undefined;
34
+ /** Reports a failure the guard turned into a denial (a failed resource load). */
35
+ readonly onError?: (error: unknown, source: string) => void;
33
36
  }
34
37
  /** Options for the requirePermission middleware. */
35
38
  export interface RequirePermissionMiddlewareOptions extends AuthorizeMiddlewareOptions {
36
39
  /** The permission to check (e.g. "post:update"). */
37
40
  readonly permission: string;
38
- /** Extracts the resource from the request (optional). */
39
- readonly extractResource?: (context: HttpMiddlewareContext) => unknown;
41
+ /**
42
+ * Loads the resource the permission is checked against (optional). May be
43
+ * async; it is awaited, and a loader that throws or rejects denies (403).
44
+ */
45
+ readonly extractResource?: ResourceExtractor;
40
46
  }
41
47
  /** Options for {@link createActorMiddleware}. */
42
48
  export interface ActorMiddlewareOptions extends AuthorizeMiddlewareOptions {
@@ -72,8 +78,11 @@ export declare function createRequirePermissionMiddleware(engine: PermissionEngi
72
78
  export declare function authorize(engine: PermissionEngine, permission: string, options?: Omit<RequirePermissionMiddlewareOptions, "permission">): HttpMiddleware;
73
79
  /** Options for {@link createRequirePermissionsMiddleware}. */
74
80
  export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddlewareOptions {
75
- /** Extracts the resource checked for every permission (optional). */
76
- readonly extractResource?: (context: HttpMiddlewareContext) => unknown;
81
+ /**
82
+ * Loads the resource checked for every permission (optional). May be
83
+ * async; it is awaited, and a loader that throws or rejects denies (403).
84
+ */
85
+ readonly extractResource?: ResourceExtractor;
77
86
  /**
78
87
  * `"all"` (default) requires every permission; `"any"` requires one.
79
88
  */
@@ -83,7 +92,8 @@ export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddleware
83
92
  * Create middleware that checks multiple permissions.
84
93
  *
85
94
  * Under `"all"` the first denial short-circuits: evaluating the rest costs
86
- * policy calls and timeouts for an answer that is already decided.
95
+ * policy calls and timeouts for an answer that is already decided. An empty
96
+ * permission list denies in either mode.
87
97
  */
88
98
  export declare function createRequirePermissionsMiddleware(engine: PermissionEngine, permissions: readonly string[], options?: RequirePermissionsMiddlewareOptions): HttpMiddleware;
89
99
  //# sourceMappingURL=httpMiddleware.core.d.ts.map
@@ -7,6 +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
11
  // ─── State Keys ───────────────────────────────────────────────────────────
11
12
  /** State key for the current actor. */
12
13
  export const ACTOR_STATE_KEY = "permissions:actor";
@@ -64,8 +65,12 @@ export function createRequirePermissionMiddleware(engine, options) {
64
65
  const actor = await resolveActor(context, options);
65
66
  if (!actor)
66
67
  return createUnauthorizedResponse(options);
67
- const resource = options.extractResource?.(context);
68
- const decision = await engine.check(actor, options.permission, resource, buildAuthorization(context, options));
68
+ const loaded = await loadResource(context, options.extractResource, options.onError);
69
+ if (!loaded.ok) {
70
+ context.state.set(DECISION_STATE_KEY, loaded.decision);
71
+ return createForbiddenResponse(loaded.decision, options);
72
+ }
73
+ const decision = await engine.check(actor, options.permission, loaded.resource, buildAuthorization(context, options));
69
74
  context.state.set(DECISION_STATE_KEY, decision);
70
75
  if (!decision.allowed)
71
76
  return createForbiddenResponse(decision, options);
@@ -85,7 +90,8 @@ export function authorize(engine, permission, options = {}) {
85
90
  * Create middleware that checks multiple permissions.
86
91
  *
87
92
  * Under `"all"` the first denial short-circuits: evaluating the rest costs
88
- * policy calls and timeouts for an answer that is already decided.
93
+ * policy calls and timeouts for an answer that is already decided. An empty
94
+ * permission list denies in either mode.
89
95
  */
90
96
  export function createRequirePermissionsMiddleware(engine, permissions, options = {}) {
91
97
  const mode = options.mode ?? "all";
@@ -93,7 +99,15 @@ export function createRequirePermissionsMiddleware(engine, permissions, options
93
99
  const actor = await resolveActor(context, options);
94
100
  if (!actor)
95
101
  return createUnauthorizedResponse(options);
96
- const resource = options.extractResource?.(context);
102
+ // An empty list is a configuration error, not a grant: under "all" it
103
+ // used to fall through to next() for any authenticated actor.
104
+ if (permissions.length === 0) {
105
+ return createForbiddenResponse({ allowed: false, reason: "no_permissions", publicReason: "Access denied" }, options);
106
+ }
107
+ const loaded = await loadResource(context, options.extractResource, options.onError);
108
+ if (!loaded.ok)
109
+ return createForbiddenResponse(loaded.decision, options);
110
+ const resource = loaded.resource;
97
111
  const authorization = buildAuthorization(context, options);
98
112
  const results = new Map();
99
113
  let lastDenial;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resource loading for the permission guards.
3
+ *
4
+ * @module http/httpResource.helper
5
+ */
6
+ import type { PermissionDecision } from "../permissionTypes/index.js";
7
+ import type { HttpMiddlewareContext } from "./httpTypes.js";
8
+ /** Loads the resource a permission is checked against. May be async. */
9
+ export type ResourceExtractor = (context: HttpMiddlewareContext) => unknown | Promise<unknown>;
10
+ /** The resource, or the denial to answer with when it could not be loaded. */
11
+ export type ResourceOutcome = {
12
+ readonly ok: true;
13
+ readonly resource: unknown;
14
+ } | {
15
+ readonly ok: false;
16
+ readonly decision: PermissionDecision;
17
+ };
18
+ /** The decision recorded when the resource loader fails. */
19
+ export declare const RESOURCE_ERROR_DECISION: PermissionDecision;
20
+ /**
21
+ * Run the extractor and await it.
22
+ *
23
+ * The guard used to hand the engine whatever the extractor returned — for an
24
+ * async loader, a Promise — so every resource condition saw `undefined`:
25
+ * deny rules on a locked or foreign resource never fired, and a loader that
26
+ * rejected let the request through while its rejection went unhandled. A
27
+ * loader that throws or rejects now denies; the error goes to `onError`.
28
+ */
29
+ export declare function loadResource(context: HttpMiddlewareContext, extract: ResourceExtractor | undefined, onError?: (error: unknown, source: string) => void): Promise<ResourceOutcome>;
30
+ //# sourceMappingURL=httpResource.helper.d.ts.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Resource loading for the permission guards.
3
+ *
4
+ * @module http/httpResource.helper
5
+ */
6
+ /** The decision recorded when the resource loader fails. */
7
+ export const RESOURCE_ERROR_DECISION = Object.freeze({
8
+ allowed: false,
9
+ reason: "resource_error",
10
+ publicReason: "Access denied",
11
+ });
12
+ /**
13
+ * Run the extractor and await it.
14
+ *
15
+ * The guard used to hand the engine whatever the extractor returned — for an
16
+ * async loader, a Promise — so every resource condition saw `undefined`:
17
+ * deny rules on a locked or foreign resource never fired, and a loader that
18
+ * rejected let the request through while its rejection went unhandled. A
19
+ * loader that throws or rejects now denies; the error goes to `onError`.
20
+ */
21
+ export async function loadResource(context, extract, onError) {
22
+ if (!extract)
23
+ return { ok: true, resource: undefined };
24
+ try {
25
+ return { ok: true, resource: await extract(context) };
26
+ }
27
+ catch (error) {
28
+ onError?.(error, "extractResource");
29
+ return { ok: false, decision: RESOURCE_ERROR_DECISION };
30
+ }
31
+ }
32
+ //# sourceMappingURL=httpResource.helper.js.map
@@ -16,20 +16,33 @@ export interface HttpMiddlewareContext {
16
16
  readonly signal: AbortSignal;
17
17
  readonly metadata: Readonly<Record<string, unknown>>;
18
18
  }
19
+ /**
20
+ * A request's headers, params or query as either shape a caller may hold.
21
+ *
22
+ * The real `@zudojs/http` request exposes plain frozen objects
23
+ * (`Readonly<Record<…>>`); this mirror used to say `ReadonlyMap`, so code
24
+ * written against it called `.get()` on an object that has none. Both shapes
25
+ * are accepted, and the accessor methods below are the portable way to read.
26
+ */
27
+ export type HttpRequestBag<V> = ReadonlyMap<string, V> | Readonly<Record<string, V | undefined>>;
19
28
  /** HTTP request context from @zudojs/http. */
20
29
  export interface HttpRequestContext {
21
30
  readonly id: string;
22
31
  readonly method: string;
23
32
  readonly url: string;
24
33
  readonly path: string;
25
- readonly headers: ReadonlyMap<string, string>;
26
- readonly params: ReadonlyMap<string, string>;
27
- readonly query: ReadonlyMap<string, string | readonly string[] | undefined>;
34
+ readonly headers: HttpRequestBag<string>;
35
+ readonly params: HttpRequestBag<string>;
36
+ readonly query: HttpRequestBag<string | readonly string[]>;
37
+ /** Case-insensitive header lookup, as `@zudojs/http` provides it. */
38
+ getHeader?(name: string): string | undefined;
39
+ /** Route parameter lookup, as `@zudojs/http` provides it. */
40
+ getParam?(name: string): string | undefined;
28
41
  }
29
42
  /** HTTP response context from @zudojs/http. */
30
43
  export interface HttpResponseContext {
31
44
  readonly status: number;
32
- readonly headers: Headers | Record<string, string>;
45
+ readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
33
46
  readonly body?: unknown;
34
47
  }
35
48
  /** HTTP middleware state from @zudojs/http. */