@zudojs/permissions 0.1.0 → 1.1.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +350 -14
  3. package/dist/ability/ability.core.d.ts +2 -1
  4. package/dist/ability/ability.core.js +34 -9
  5. package/dist/cache/cache.core.d.ts +25 -5
  6. package/dist/cache/cache.core.js +77 -20
  7. package/dist/cache/index.d.ts +1 -1
  8. package/dist/conditions/conditions.core.d.ts +18 -2
  9. package/dist/conditions/conditions.core.js +46 -6
  10. package/dist/conditions/index.d.ts +1 -1
  11. package/dist/conditions/index.js +1 -1
  12. package/dist/evaluator/authorizationEngine.d.ts +48 -6
  13. package/dist/evaluator/authorizationEngine.js +156 -16
  14. package/dist/evaluator/evaluator.core.d.ts +28 -4
  15. package/dist/evaluator/evaluator.core.js +259 -39
  16. package/dist/evaluator/evaluator.explain.d.ts +6 -3
  17. package/dist/evaluator/evaluator.explain.js +8 -71
  18. package/dist/evaluator/evaluator.pipeline.d.ts +70 -9
  19. package/dist/evaluator/evaluator.pipeline.js +194 -49
  20. package/dist/evaluator/index.d.ts +4 -2
  21. package/dist/evaluator/index.js +4 -1
  22. package/dist/http/httpHelpers.d.ts +28 -12
  23. package/dist/http/httpHelpers.js +33 -6
  24. package/dist/http/httpMiddleware.core.d.ts +54 -13
  25. package/dist/http/httpMiddleware.core.js +72 -28
  26. package/dist/http/index.d.ts +7 -3
  27. package/dist/http/index.js +7 -3
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -2
  30. package/dist/observability/index.d.ts +1 -1
  31. package/dist/observability/observability.core.d.ts +32 -12
  32. package/dist/observability/observability.core.js +41 -20
  33. package/dist/permission/index.d.ts +2 -2
  34. package/dist/permission/index.js +1 -1
  35. package/dist/permission/permission.core.d.ts +13 -2
  36. package/dist/permission/permission.core.js +29 -21
  37. package/dist/permission/permissionRegistry.d.ts +27 -33
  38. package/dist/permission/permissionRegistry.js +59 -41
  39. package/dist/permissionErrors/index.d.ts +1 -1
  40. package/dist/permissionErrors/index.js +1 -1
  41. package/dist/permissionErrors/permissionError.access.d.ts +24 -0
  42. package/dist/permissionErrors/permissionError.access.js +25 -5
  43. package/dist/permissionErrors/permissionError.types.d.ts +1 -1
  44. package/dist/permissionErrors/permissionError.types.js +1 -1
  45. package/dist/permissionTypes/index.d.ts +2 -2
  46. package/dist/permissionTypes/index.js +1 -1
  47. package/dist/permissionTypes/policyTypes.d.ts +35 -2
  48. package/dist/permissionTypes/ruleTypes.d.ts +38 -5
  49. package/dist/policy/index.d.ts +1 -1
  50. package/dist/policy/index.js +1 -1
  51. package/dist/policy/policyRegistry.d.ts +16 -23
  52. package/dist/policy/policyRegistry.js +32 -24
  53. package/dist/role/index.d.ts +2 -2
  54. package/dist/role/index.js +1 -1
  55. package/dist/role/roleHierarchy.d.ts +34 -4
  56. package/dist/role/roleHierarchy.js +48 -23
  57. package/dist/role/roleRegistry.d.ts +25 -25
  58. package/dist/role/roleRegistry.js +46 -24
  59. package/dist/rule/index.d.ts +2 -2
  60. package/dist/rule/index.js +2 -2
  61. package/dist/rule/rule.core.d.ts +39 -10
  62. package/dist/rule/rule.core.js +95 -38
  63. package/dist/rule/ruleCompiler.d.ts +14 -5
  64. package/dist/rule/ruleCompiler.js +46 -30
  65. package/dist/utils/utils.helper.d.ts +8 -0
  66. package/dist/utils/utils.helper.js +16 -14
  67. package/package.json +27 -16
  68. package/dist/.tsbuildinfo +0 -1
  69. package/dist/ability/ability.core.d.ts.map +0 -1
  70. package/dist/ability/ability.core.js.map +0 -1
  71. package/dist/ability/index.d.ts.map +0 -1
  72. package/dist/ability/index.js.map +0 -1
  73. package/dist/actor/actor.core.d.ts.map +0 -1
  74. package/dist/actor/actor.core.js.map +0 -1
  75. package/dist/actor/index.d.ts.map +0 -1
  76. package/dist/actor/index.js.map +0 -1
  77. package/dist/cache/cache.core.d.ts.map +0 -1
  78. package/dist/cache/cache.core.js.map +0 -1
  79. package/dist/cache/index.d.ts.map +0 -1
  80. package/dist/cache/index.js.map +0 -1
  81. package/dist/conditions/conditions.core.d.ts.map +0 -1
  82. package/dist/conditions/conditions.core.js.map +0 -1
  83. package/dist/conditions/index.d.ts.map +0 -1
  84. package/dist/conditions/index.js.map +0 -1
  85. package/dist/evaluator/authorizationEngine.d.ts.map +0 -1
  86. package/dist/evaluator/authorizationEngine.js.map +0 -1
  87. package/dist/evaluator/evaluator.core.d.ts.map +0 -1
  88. package/dist/evaluator/evaluator.core.js.map +0 -1
  89. package/dist/evaluator/evaluator.explain.d.ts.map +0 -1
  90. package/dist/evaluator/evaluator.explain.js.map +0 -1
  91. package/dist/evaluator/evaluator.pipeline.d.ts.map +0 -1
  92. package/dist/evaluator/evaluator.pipeline.js.map +0 -1
  93. package/dist/evaluator/index.d.ts.map +0 -1
  94. package/dist/evaluator/index.js.map +0 -1
  95. package/dist/http/httpHelpers.d.ts.map +0 -1
  96. package/dist/http/httpHelpers.js.map +0 -1
  97. package/dist/http/httpMiddleware.core.d.ts.map +0 -1
  98. package/dist/http/httpMiddleware.core.js.map +0 -1
  99. package/dist/http/httpTypes.d.ts.map +0 -1
  100. package/dist/http/httpTypes.js.map +0 -1
  101. package/dist/http/index.d.ts.map +0 -1
  102. package/dist/http/index.js.map +0 -1
  103. package/dist/index.d.ts.map +0 -1
  104. package/dist/index.js.map +0 -1
  105. package/dist/observability/index.d.ts.map +0 -1
  106. package/dist/observability/index.js.map +0 -1
  107. package/dist/observability/observability.core.d.ts.map +0 -1
  108. package/dist/observability/observability.core.js.map +0 -1
  109. package/dist/permission/index.d.ts.map +0 -1
  110. package/dist/permission/index.js.map +0 -1
  111. package/dist/permission/permission.core.d.ts.map +0 -1
  112. package/dist/permission/permission.core.js.map +0 -1
  113. package/dist/permission/permissionRegistry.d.ts.map +0 -1
  114. package/dist/permission/permissionRegistry.js.map +0 -1
  115. package/dist/permissionErrors/index.d.ts.map +0 -1
  116. package/dist/permissionErrors/index.js.map +0 -1
  117. package/dist/permissionErrors/permissionError.access.d.ts.map +0 -1
  118. package/dist/permissionErrors/permissionError.access.js.map +0 -1
  119. package/dist/permissionErrors/permissionError.base.d.ts.map +0 -1
  120. package/dist/permissionErrors/permissionError.base.js.map +0 -1
  121. package/dist/permissionErrors/permissionError.types.d.ts.map +0 -1
  122. package/dist/permissionErrors/permissionError.types.js.map +0 -1
  123. package/dist/permissionErrors/permissionError.validation.d.ts.map +0 -1
  124. package/dist/permissionErrors/permissionError.validation.js.map +0 -1
  125. package/dist/permissionTypes/index.d.ts.map +0 -1
  126. package/dist/permissionTypes/index.js.map +0 -1
  127. package/dist/permissionTypes/permissionActor.d.ts.map +0 -1
  128. package/dist/permissionTypes/permissionActor.js.map +0 -1
  129. package/dist/permissionTypes/policyTypes.d.ts.map +0 -1
  130. package/dist/permissionTypes/policyTypes.js.map +0 -1
  131. package/dist/permissionTypes/ruleTypes.d.ts.map +0 -1
  132. package/dist/permissionTypes/ruleTypes.js.map +0 -1
  133. package/dist/policy/index.d.ts.map +0 -1
  134. package/dist/policy/index.js.map +0 -1
  135. package/dist/policy/policyRegistry.d.ts.map +0 -1
  136. package/dist/policy/policyRegistry.js.map +0 -1
  137. package/dist/resolvers/index.d.ts.map +0 -1
  138. package/dist/resolvers/index.js.map +0 -1
  139. package/dist/resolvers/resolvers.core.d.ts.map +0 -1
  140. package/dist/resolvers/resolvers.core.js.map +0 -1
  141. package/dist/role/index.d.ts.map +0 -1
  142. package/dist/role/index.js.map +0 -1
  143. package/dist/role/roleHierarchy.d.ts.map +0 -1
  144. package/dist/role/roleHierarchy.js.map +0 -1
  145. package/dist/role/roleRegistry.d.ts.map +0 -1
  146. package/dist/role/roleRegistry.js.map +0 -1
  147. package/dist/rule/index.d.ts.map +0 -1
  148. package/dist/rule/index.js.map +0 -1
  149. package/dist/rule/rule.core.d.ts.map +0 -1
  150. package/dist/rule/rule.core.js.map +0 -1
  151. package/dist/rule/ruleCompiler.d.ts.map +0 -1
  152. package/dist/rule/ruleCompiler.js.map +0 -1
  153. package/dist/utils/index.d.ts.map +0 -1
  154. package/dist/utils/index.js.map +0 -1
  155. package/dist/utils/utils.helper.d.ts.map +0 -1
  156. package/dist/utils/utils.helper.js.map +0 -1
@@ -4,80 +4,225 @@
4
4
  * @module evaluator/evaluator.pipeline
5
5
  */
6
6
  import { resolveRolePermissions } from "../role/roleHierarchy.js";
7
- import { AuthorizationAbortedError } from "../permissionErrors/index.js";
7
+ import { matches } from "../permission/permission.core.js";
8
+ import { AuthorizationAbortedError, PermissionResolverError, PolicyError, PolicyTimeoutError, } from "../permissionErrors/index.js";
8
9
  /**
9
- * Resolve all permissions for an actor (direct + role-based).
10
+ * Normalize a thrown value into an `Error` before it reaches `onError`.
11
+ *
12
+ * A resolver or a policy is caller code and may throw anything at all — a
13
+ * string, `undefined`, a plain object. An `onError` handler that reads
14
+ * `error.message` then fails inside the failure path, so anything that is not
15
+ * already an `Error` is wrapped, keeping the original as `cause`.
10
16
  */
11
- export function resolveActorPermissions(actor, options) {
17
+ function reportable(value, wrap) {
18
+ return value instanceof Error ? value : wrap(value);
19
+ }
20
+ /** Throws if the caller has cancelled the check. */
21
+ export function assertNotAborted(signal) {
22
+ if (signal?.aborted)
23
+ throw new AuthorizationAbortedError();
24
+ }
25
+ /** Normalizes caller-supplied metadata into the map conditions read. */
26
+ export function toMetadataMap(metadata) {
27
+ if (!metadata)
28
+ return undefined;
29
+ if (metadata instanceof Map)
30
+ return metadata;
31
+ return new Map(Object.entries(metadata));
32
+ }
33
+ /**
34
+ * Resolve everything an actor holds: direct permissions, role permissions
35
+ * (following inheritance), resolver-supplied roles and rules.
36
+ *
37
+ * A role the lookup cannot find is reported rather than thrown. An unknown
38
+ * role in a token is a data problem, and turning every request from that
39
+ * actor into an exception hands the decision to whichever error handler
40
+ * happens to be installed — which may well be more permissive than a denial.
41
+ */
42
+ export async function resolveActorGrants(actor, options, signal) {
12
43
  const permissions = new Set();
13
- // Direct permissions
14
- if (actor.permissions) {
15
- for (const p of actor.permissions)
16
- permissions.add(p);
44
+ const rules = [...(options.rules ?? [])];
45
+ const unknownRoles = [];
46
+ for (const permission of actor.permissions ?? []) {
47
+ permissions.add(permission);
48
+ }
49
+ const roleNames = new Set(actor.roles ?? []);
50
+ if (options.roleResolver) {
51
+ assertNotAborted(signal);
52
+ try {
53
+ for (const role of await options.roleResolver.resolveRoles(actor)) {
54
+ roleNames.add(role);
55
+ }
56
+ }
57
+ catch (error) {
58
+ options.onError?.(reportable(error, (cause) => new PermissionResolverError("RoleResolver.resolveRoles failed", cause)), "RoleResolver.resolveRoles");
59
+ }
60
+ }
61
+ if (roleNames.size > 0 && options.getRole) {
62
+ try {
63
+ const resolution = resolveRolePermissions([...roleNames], options.getRole, { onUnknownRole: (name) => unknownRoles.push(name) });
64
+ for (const permission of resolution.permissions)
65
+ permissions.add(permission);
66
+ rules.push(...resolution.rules);
67
+ }
68
+ catch (error) {
69
+ // A cycle in the role graph, or a role source that throws, is a
70
+ // configuration failure. Throwing here would hand the outcome to
71
+ // whichever error handler the caller installed, which may well be more
72
+ // permissive than a denial — so every role is treated as unresolved and
73
+ // the check falls through to deny.
74
+ options.onError?.(error, "RoleHierarchy");
75
+ for (const name of roleNames) {
76
+ if (!unknownRoles.includes(name))
77
+ unknownRoles.push(name);
78
+ }
79
+ }
17
80
  }
18
- // Role-based permissions
81
+ if (options.permissionResolver) {
82
+ assertNotAborted(signal);
83
+ try {
84
+ rules.push(...(await options.permissionResolver.resolvePermissions(actor)));
85
+ }
86
+ catch (error) {
87
+ options.onError?.(reportable(error, (cause) => new PermissionResolverError("PermissionResolver.resolvePermissions failed", cause)), "PermissionResolver.resolvePermissions");
88
+ }
89
+ }
90
+ if (options.expandImplied) {
91
+ for (const permission of [...permissions]) {
92
+ for (const implied of options.expandImplied(permission)) {
93
+ permissions.add(implied);
94
+ }
95
+ }
96
+ }
97
+ return {
98
+ permissions: [...permissions],
99
+ rules,
100
+ unknownRoles,
101
+ };
102
+ }
103
+ /**
104
+ * Resolve all permission strings for an actor (direct + role-based).
105
+ *
106
+ * Kept for callers that only need the strings; the engine uses
107
+ * {@link resolveActorGrants}, which also carries rules and unknown roles.
108
+ */
109
+ export function resolveActorPermissions(actor, options) {
110
+ const permissions = new Set(actor.permissions ?? []);
19
111
  if (actor.roles && options.getRole) {
20
- const rolePerms = resolveRolePermissions(actor.roles, options.getRole);
21
- for (const p of rolePerms)
22
- permissions.add(p);
112
+ const resolution = resolveRolePermissions(actor.roles, options.getRole, {
113
+ onUnknownRole: () => { },
114
+ });
115
+ for (const permission of resolution.permissions)
116
+ permissions.add(permission);
23
117
  }
24
- return Array.from(permissions);
118
+ return [...permissions];
119
+ }
120
+ /** Policies that apply to a permission, most important first. */
121
+ export function selectPolicies(policies, permissionStr) {
122
+ return policies
123
+ .filter((policy) =>
124
+ // Wildcards work here for the same reason they work for grants: a
125
+ // policy registered for "post:*" is meant to cover "post:update".
126
+ policy.permissions.some((pattern) => matches(pattern, permissionStr)))
127
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
25
128
  }
26
129
  /**
27
- * Evaluate policies for a context. Returns null if no policy matched.
130
+ * Evaluate policies for a context.
131
+ *
132
+ * Policies are evaluated highest priority first and short-circuit on the
133
+ * first denial. A policy that throws or times out denies — an authorization
134
+ * check that cannot complete must not fall through to "allowed".
28
135
  */
29
- export async function evaluatePolicies(context, policies, authOptions) {
30
- if (policies.length === 0)
31
- return null;
136
+ export async function evaluatePolicies(context, policies, options, authOptions) {
137
+ if (policies.length === 0) {
138
+ return { decision: null, cacheable: true, evaluated: [] };
139
+ }
32
140
  const permissionStr = `${context.permission.resource}:${context.permission.action}`;
33
- const applicable = policies.filter((p) => p.permissions.includes(permissionStr));
34
- if (applicable.length === 0)
35
- return null;
141
+ const applicable = selectPolicies(policies, permissionStr);
142
+ if (applicable.length === 0) {
143
+ return { decision: null, cacheable: true, evaluated: [] };
144
+ }
145
+ // The per-call timeout wins, but the engine-level default is what makes a
146
+ // configured timeout mean anything at all.
147
+ const timeoutMs = authOptions?.policyTimeout ?? options.policyTimeout;
148
+ const evaluated = [];
149
+ let cacheable = true;
36
150
  for (const policy of applicable) {
151
+ assertNotAborted(context.signal ?? authOptions?.signal);
152
+ evaluated.push(policy.name);
153
+ if (policy.cacheable === false)
154
+ cacheable = false;
37
155
  try {
38
- const result = await withTimeout(Promise.resolve(policy.evaluate(context)), authOptions?.policyTimeout);
156
+ const result = await withTimeout(Promise.resolve(policy.evaluate(context)), timeoutMs, policy.name);
39
157
  if (!result.allowed) {
40
- return Object.freeze({
41
- allowed: false,
42
- reason: result.reason ?? `policy:${policy.name}`,
43
- policy: policy.name,
44
- });
158
+ return {
159
+ decision: Object.freeze({
160
+ allowed: false,
161
+ reason: result.reason ?? `policy:${policy.name}`,
162
+ policy: policy.name,
163
+ publicReason: result.publicReason ?? "Access denied",
164
+ }),
165
+ cacheable,
166
+ evaluated,
167
+ };
45
168
  }
46
169
  }
47
170
  catch (error) {
48
171
  if (error instanceof AuthorizationAbortedError)
49
172
  throw error;
50
- // Policy error — fail closed
51
- return Object.freeze({
52
- allowed: false,
53
- reason: `policy_error:${policy.name}`,
54
- policy: policy.name,
55
- });
173
+ options.onError?.(reportable(error, (cause) => new PolicyError(policy.name, cause)), `Policy.${policy.name}`);
174
+ // Policy error — fail closed.
175
+ return {
176
+ decision: Object.freeze({
177
+ allowed: false,
178
+ reason: `policy_error:${policy.name}`,
179
+ policy: policy.name,
180
+ publicReason: "Access denied",
181
+ }),
182
+ cacheable: false,
183
+ evaluated,
184
+ };
56
185
  }
57
186
  }
58
- return Object.freeze({
59
- allowed: true,
60
- reason: "policy_allow",
61
- policy: applicable.map((p) => p.name).join(","),
62
- });
187
+ return {
188
+ decision: Object.freeze({
189
+ allowed: true,
190
+ reason: "policy_allow",
191
+ policy: applicable.map((policy) => policy.name).join(","),
192
+ }),
193
+ cacheable,
194
+ evaluated,
195
+ };
63
196
  }
64
197
  /**
65
198
  * Run a promise with an optional timeout.
199
+ *
200
+ * `0` means "expire immediately", not "no timeout" — treating a falsy value
201
+ * as "disabled" silently turned `policyTimeout: 0` into no timeout at all.
202
+ * Pass `undefined` to disable.
66
203
  */
67
- async function withTimeout(promise, timeoutMs) {
68
- if (!timeoutMs)
204
+ export async function withTimeout(promise, timeoutMs, policyName = "policy") {
205
+ if (timeoutMs === undefined)
69
206
  return promise;
70
- return new Promise((resolve, reject) => {
71
- const timer = setTimeout(() => {
72
- reject(new Error("Policy timeout"));
73
- }, timeoutMs);
74
- promise.then((value) => {
75
- clearTimeout(timer);
76
- resolve(value);
77
- }, (error) => {
207
+ let timer;
208
+ // Keep the loser handled so a late rejection cannot escape as an unhandled
209
+ // rejection once the race is over.
210
+ void promise.catch(() => { });
211
+ try {
212
+ return await Promise.race([
213
+ promise,
214
+ new Promise((_resolve, reject) => {
215
+ timer = setTimeout(() => {
216
+ reject(new PolicyTimeoutError(policyName, timeoutMs));
217
+ }, Math.max(0, timeoutMs));
218
+ if (typeof timer === "object" && "unref" in timer)
219
+ timer.unref();
220
+ }),
221
+ ]);
222
+ }
223
+ finally {
224
+ if (timer !== undefined)
78
225
  clearTimeout(timer);
79
- reject(error);
80
- });
81
- });
226
+ }
82
227
  }
83
228
  //# sourceMappingURL=evaluator.pipeline.js.map
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * @module evaluator
5
5
  */
6
- export { evaluate, evaluateWithExplain } from "./evaluator.core.js";
7
- export type { EvaluatorOptions } from "./evaluator.pipeline.js";
6
+ export { evaluate, evaluateWithTrace } from "./evaluator.core.js";
7
+ export { evaluateWithExplain } from "./evaluator.explain.js";
8
+ export { resolveActorGrants, resolveActorPermissions, evaluatePolicies, selectPolicies, withTimeout, toMetadataMap, assertNotAborted, type EvaluatorOptions, type ResolvedGrants, type PolicyOutcome, } from "./evaluator.pipeline.js";
9
+ export { createPermissionEngine, type PermissionEngine, type PermissionEngineOptions, } from "./authorizationEngine.js";
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,8 @@
3
3
  *
4
4
  * @module evaluator
5
5
  */
6
- export { evaluate, evaluateWithExplain } from "./evaluator.core.js";
6
+ export { evaluate, evaluateWithTrace } from "./evaluator.core.js";
7
+ export { evaluateWithExplain } from "./evaluator.explain.js";
8
+ export { resolveActorGrants, resolveActorPermissions, evaluatePolicies, selectPolicies, withTimeout, toMetadataMap, assertNotAborted, } from "./evaluator.pipeline.js";
9
+ export { createPermissionEngine, } from "./authorizationEngine.js";
7
10
  //# sourceMappingURL=index.js.map
@@ -6,23 +6,39 @@
6
6
  import type { PermissionDecision } from "../permissionTypes/index.js";
7
7
  /** Options for creating denied responses. */
8
8
  export interface DeniedResponseOptions {
9
- /** Custom 403 response body function. */
9
+ /**
10
+ * Builds the 403 body. Receives the real decision, not a reconstruction of
11
+ * it, so `policy`, `matchedPermission` and `metadata` are all available.
12
+ */
10
13
  readonly deniedResponse?: (decision: PermissionDecision) => unknown;
14
+ /** Builds the 401 body for an unauthenticated request. */
15
+ readonly unauthenticatedResponse?: () => unknown;
16
+ /** Value for the `WWW-Authenticate` header on a 401. */
17
+ readonly authenticateChallenge?: string;
18
+ }
19
+ /** A framework-agnostic response. */
20
+ export interface PermissionHttpResponse {
21
+ readonly status: number;
22
+ readonly body: unknown;
23
+ readonly headers: Readonly<Record<string, string>>;
11
24
  }
12
25
  /**
13
- * Create a 403 Forbidden JSON response.
26
+ * Create a 403 Forbidden JSON response for a decision.
27
+ *
28
+ * The default body carries `decision.publicReason`, never `decision.reason`:
29
+ * the internal reason names policies and rules (`policy_error:billingOwner`),
30
+ * which tells a prober about the authorization model.
14
31
  */
15
- export declare function createForbiddenResponse(reason: string, options?: DeniedResponseOptions): {
16
- readonly status: 403;
17
- readonly body: unknown;
18
- readonly headers: Record<string, string>;
19
- };
32
+ export declare function createForbiddenResponse(decision: PermissionDecision, options?: DeniedResponseOptions): PermissionHttpResponse;
33
+ /**
34
+ * Create a 401 Unauthorized JSON response.
35
+ *
36
+ * A missing actor is not the same as a denied one: answering both with 403
37
+ * leaves a client unable to tell "log in" from "you may not do this".
38
+ */
39
+ export declare function createUnauthorizedResponse(options?: DeniedResponseOptions): PermissionHttpResponse;
20
40
  /**
21
41
  * Create a JSON response.
22
42
  */
23
- export declare function createJsonResponse(status: number, body: unknown): {
24
- readonly status: number;
25
- readonly body: unknown;
26
- readonly headers: Record<string, string>;
27
- };
43
+ export declare function createJsonResponse(status: number, body: unknown): PermissionHttpResponse;
28
44
  //# sourceMappingURL=httpHelpers.d.ts.map
@@ -3,17 +3,44 @@
3
3
  *
4
4
  * @module http/httpHelpers
5
5
  */
6
+ const JSON_HEADERS = { "content-type": "application/json" };
6
7
  /**
7
- * Create a 403 Forbidden JSON response.
8
+ * Create a 403 Forbidden JSON response for a decision.
9
+ *
10
+ * The default body carries `decision.publicReason`, never `decision.reason`:
11
+ * the internal reason names policies and rules (`policy_error:billingOwner`),
12
+ * which tells a prober about the authorization model.
8
13
  */
9
- export function createForbiddenResponse(reason, options) {
14
+ export function createForbiddenResponse(decision, options) {
10
15
  const body = options?.deniedResponse
11
- ? options.deniedResponse({ allowed: false, reason })
12
- : { error: "Forbidden", message: reason };
16
+ ? options.deniedResponse(decision)
17
+ : {
18
+ error: "Forbidden",
19
+ message: decision.publicReason ?? "Access denied",
20
+ };
13
21
  return Object.freeze({
14
22
  status: 403,
15
23
  body,
16
- headers: { "content-type": "application/json" },
24
+ headers: JSON_HEADERS,
25
+ });
26
+ }
27
+ /**
28
+ * Create a 401 Unauthorized JSON response.
29
+ *
30
+ * A missing actor is not the same as a denied one: answering both with 403
31
+ * leaves a client unable to tell "log in" from "you may not do this".
32
+ */
33
+ export function createUnauthorizedResponse(options) {
34
+ const body = options?.unauthenticatedResponse
35
+ ? options.unauthenticatedResponse()
36
+ : { error: "Unauthorized", message: "Authentication required" };
37
+ return Object.freeze({
38
+ status: 401,
39
+ body,
40
+ headers: Object.freeze({
41
+ ...JSON_HEADERS,
42
+ "www-authenticate": options?.authenticateChallenge ?? "Bearer",
43
+ }),
17
44
  });
18
45
  }
19
46
  /**
@@ -23,7 +50,7 @@ export function createJsonResponse(status, body) {
23
50
  return Object.freeze({
24
51
  status,
25
52
  body,
26
- headers: { "content-type": "application/json" },
53
+ headers: JSON_HEADERS,
27
54
  });
28
55
  }
29
56
  //# sourceMappingURL=httpHelpers.js.map
@@ -6,43 +6,84 @@
6
6
  *
7
7
  * @module http/httpMiddleware
8
8
  */
9
- import type { PermissionActor, PermissionDecision, AuthorizationOptions } from "../permissionTypes/index.js";
9
+ import type { PermissionActor, AuthorizationOptions } from "../permissionTypes/index.js";
10
10
  import type { PermissionEngine } from "../evaluator/authorizationEngine.js";
11
11
  import type { HttpMiddleware, HttpMiddlewareContext } from "./httpTypes.js";
12
- /** Options for the authorize middleware. */
13
- export interface AuthorizeMiddlewareOptions {
14
- /** Function to extract an actor from the request context. */
15
- readonly extractActor: (context: HttpMiddlewareContext) => PermissionActor | Promise<PermissionActor> | undefined;
16
- /** Authorization options (signal, policyTimeout). */
12
+ import { type DeniedResponseOptions } from "./httpHelpers.js";
13
+ /** Options shared by the permission middleware. */
14
+ export interface AuthorizeMiddlewareOptions extends DeniedResponseOptions {
15
+ /**
16
+ * Extracts an actor from the request context.
17
+ *
18
+ * The guard middleware calls this itself when no actor is already in
19
+ * state, so `authorize(engine, permission, { extractActor })` works on its
20
+ * own — installing `createActorMiddleware` first is an optimisation, not a
21
+ * requirement.
22
+ */
23
+ readonly extractActor?: (context: HttpMiddlewareContext) => PermissionActor | Promise<PermissionActor> | undefined;
24
+ /** Authorization options (policyTimeout, metadata). */
17
25
  readonly authorization?: AuthorizationOptions;
18
- /** Custom 403 response body. */
19
- readonly deniedResponse?: (decision: PermissionDecision) => unknown;
26
+ /**
27
+ * Forwards `context.signal` to the engine so a client disconnect stops
28
+ * policy evaluation. Default: `true`.
29
+ */
30
+ readonly forwardSignal?: boolean;
31
+ /** Builds per-request metadata for conditions and policies. */
32
+ readonly extractMetadata?: (context: HttpMiddlewareContext) => Record<string, unknown> | undefined;
20
33
  }
21
34
  /** Options for the requirePermission middleware. */
22
35
  export interface RequirePermissionMiddlewareOptions extends AuthorizeMiddlewareOptions {
23
36
  /** The permission to check (e.g. "post:update"). */
24
37
  readonly permission: string;
25
- /** Function to extract the resource from the request (optional). */
38
+ /** Extracts the resource from the request (optional). */
26
39
  readonly extractResource?: (context: HttpMiddlewareContext) => unknown;
27
40
  }
41
+ /** Options for {@link createActorMiddleware}. */
42
+ export interface ActorMiddlewareOptions extends AuthorizeMiddlewareOptions {
43
+ /** Required: this middleware exists to run it. */
44
+ readonly extractActor: (context: HttpMiddlewareContext) => PermissionActor | Promise<PermissionActor> | undefined;
45
+ /**
46
+ * Answer 401 immediately when no actor could be extracted, instead of
47
+ * letting the request continue to whatever comes next. Default: `false`,
48
+ * preserving the "authenticate here, authorize later" split.
49
+ */
50
+ readonly requireActor?: boolean;
51
+ }
28
52
  /** State key for the current actor. */
29
53
  export declare const ACTOR_STATE_KEY = "permissions:actor";
30
54
  /** State key for the authorization decision. */
31
55
  export declare const DECISION_STATE_KEY = "permissions:decision";
56
+ /** State key for the batch decision map. */
57
+ export declare const DECISIONS_STATE_KEY = "permissions:decisions";
32
58
  /**
33
59
  * Create middleware that extracts the actor from the request and stores it in state.
34
60
  */
35
- export declare function createActorMiddleware(options: AuthorizeMiddlewareOptions): HttpMiddleware;
61
+ export declare function createActorMiddleware(options: ActorMiddlewareOptions): HttpMiddleware;
36
62
  /**
37
63
  * Create middleware that checks a permission and returns 403 if denied.
64
+ *
65
+ * An unauthenticated request gets 401; an authenticated one that is not
66
+ * permitted gets 403.
38
67
  */
39
68
  export declare function createRequirePermissionMiddleware(engine: PermissionEngine, options: RequirePermissionMiddlewareOptions): HttpMiddleware;
40
69
  /**
41
70
  * Create middleware that checks a permission and short-circuits on denial.
42
71
  */
43
- export declare function authorize(engine: PermissionEngine, permission: string, options: Omit<RequirePermissionMiddlewareOptions, "permission">): HttpMiddleware;
72
+ export declare function authorize(engine: PermissionEngine, permission: string, options?: Omit<RequirePermissionMiddlewareOptions, "permission">): HttpMiddleware;
73
+ /** Options for {@link createRequirePermissionsMiddleware}. */
74
+ export interface RequirePermissionsMiddlewareOptions extends AuthorizeMiddlewareOptions {
75
+ /** Extracts the resource checked for every permission (optional). */
76
+ readonly extractResource?: (context: HttpMiddlewareContext) => unknown;
77
+ /**
78
+ * `"all"` (default) requires every permission; `"any"` requires one.
79
+ */
80
+ readonly mode?: "all" | "any";
81
+ }
44
82
  /**
45
- * Create middleware that checks multiple permissions in batch.
83
+ * Create middleware that checks multiple permissions.
84
+ *
85
+ * Under `"all"` the first denial short-circuits: evaluating the rest costs
86
+ * policy calls and timeouts for an answer that is already decided.
46
87
  */
47
- export declare function createRequirePermissionsMiddleware(engine: PermissionEngine, permissions: readonly string[], options: AuthorizeMiddlewareOptions): HttpMiddleware;
88
+ export declare function createRequirePermissionsMiddleware(engine: PermissionEngine, permissions: readonly string[], options?: RequirePermissionsMiddlewareOptions): HttpMiddleware;
48
89
  //# sourceMappingURL=httpMiddleware.core.d.ts.map
@@ -6,12 +6,37 @@
6
6
  *
7
7
  * @module http/httpMiddleware
8
8
  */
9
- import { createForbiddenResponse, createJsonResponse } from "./httpHelpers.js";
9
+ import { createForbiddenResponse, createUnauthorizedResponse, } from "./httpHelpers.js";
10
10
  // ─── State Keys ───────────────────────────────────────────────────────────
11
11
  /** State key for the current actor. */
12
12
  export const ACTOR_STATE_KEY = "permissions:actor";
13
13
  /** State key for the authorization decision. */
14
14
  export const DECISION_STATE_KEY = "permissions:decision";
15
+ /** State key for the batch decision map. */
16
+ export const DECISIONS_STATE_KEY = "permissions:decisions";
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────
18
+ /**
19
+ * Finds the actor for a request: from state first, then by extracting it.
20
+ */
21
+ async function resolveActor(context, options) {
22
+ const existing = context.state.get(ACTOR_STATE_KEY);
23
+ if (existing)
24
+ return existing;
25
+ if (!options.extractActor)
26
+ return undefined;
27
+ const actor = await options.extractActor(context);
28
+ if (actor)
29
+ context.state.set(ACTOR_STATE_KEY, actor);
30
+ return actor;
31
+ }
32
+ function buildAuthorization(context, options) {
33
+ const metadata = options.extractMetadata?.(context);
34
+ return {
35
+ ...options.authorization,
36
+ ...(options.forwardSignal !== false ? { signal: context.signal } : {}),
37
+ ...(metadata ? { metadata } : {}),
38
+ };
39
+ }
15
40
  // ─── Middleware Factories ──────────────────────────────────────────────────
16
41
  /**
17
42
  * Create middleware that extracts the actor from the request and stores it in state.
@@ -22,59 +47,78 @@ export function createActorMiddleware(options) {
22
47
  if (actor) {
23
48
  context.state.set(ACTOR_STATE_KEY, actor);
24
49
  }
50
+ else if (options.requireActor === true) {
51
+ return createUnauthorizedResponse(options);
52
+ }
25
53
  return next();
26
54
  };
27
55
  }
28
56
  /**
29
57
  * Create middleware that checks a permission and returns 403 if denied.
58
+ *
59
+ * An unauthenticated request gets 401; an authenticated one that is not
60
+ * permitted gets 403.
30
61
  */
31
62
  export function createRequirePermissionMiddleware(engine, options) {
32
63
  return async (context, next) => {
33
- const actor = context.state.get(ACTOR_STATE_KEY);
34
- if (!actor) {
35
- return createForbiddenResponse("No actor found in request context", options);
36
- }
64
+ const actor = await resolveActor(context, options);
65
+ if (!actor)
66
+ return createUnauthorizedResponse(options);
37
67
  const resource = options.extractResource?.(context);
38
- const decision = await engine.check(actor, options.permission, resource, options.authorization);
68
+ const decision = await engine.check(actor, options.permission, resource, buildAuthorization(context, options));
39
69
  context.state.set(DECISION_STATE_KEY, decision);
40
- if (!decision.allowed) {
41
- return createForbiddenResponse(decision.reason ?? "Access denied", options);
42
- }
70
+ if (!decision.allowed)
71
+ return createForbiddenResponse(decision, options);
43
72
  return next();
44
73
  };
45
74
  }
46
75
  /**
47
76
  * Create middleware that checks a permission and short-circuits on denial.
48
77
  */
49
- export function authorize(engine, permission, options) {
78
+ export function authorize(engine, permission, options = {}) {
50
79
  return createRequirePermissionMiddleware(engine, {
80
+ ...options,
51
81
  permission,
52
- extractActor: options.extractActor,
53
- authorization: options.authorization,
54
- deniedResponse: options.deniedResponse,
55
- extractResource: options.extractResource,
56
82
  });
57
83
  }
58
84
  /**
59
- * Create middleware that checks multiple permissions in batch.
85
+ * Create middleware that checks multiple permissions.
86
+ *
87
+ * Under `"all"` the first denial short-circuits: evaluating the rest costs
88
+ * policy calls and timeouts for an answer that is already decided.
60
89
  */
61
- export function createRequirePermissionsMiddleware(engine, permissions, options) {
90
+ export function createRequirePermissionsMiddleware(engine, permissions, options = {}) {
91
+ const mode = options.mode ?? "all";
62
92
  return async (context, next) => {
63
- const actor = context.state.get(ACTOR_STATE_KEY);
64
- if (!actor) {
65
- return createForbiddenResponse("No actor found in request context", options);
66
- }
93
+ const actor = await resolveActor(context, options);
94
+ if (!actor)
95
+ return createUnauthorizedResponse(options);
96
+ const resource = options.extractResource?.(context);
97
+ const authorization = buildAuthorization(context, options);
67
98
  const results = new Map();
68
- for (const perm of permissions) {
69
- const decision = await engine.check(actor, perm, undefined, options.authorization);
70
- results.set(perm, decision);
71
- }
72
- context.state.set("permissions:decisions", results);
73
- for (const [perm, decision] of results) {
74
- if (!decision.allowed) {
75
- return createForbiddenResponse(decision.reason ?? `Missing permission: ${perm}`, options);
99
+ let lastDenial;
100
+ for (const permission of permissions) {
101
+ const decision = await engine.check(actor, permission, resource, authorization);
102
+ results.set(permission, decision);
103
+ if (decision.allowed) {
104
+ if (mode === "any") {
105
+ context.state.set(DECISIONS_STATE_KEY, results);
106
+ return next();
107
+ }
108
+ }
109
+ else {
110
+ lastDenial = decision;
111
+ if (mode === "all")
112
+ break;
76
113
  }
77
114
  }
115
+ context.state.set(DECISIONS_STATE_KEY, results);
116
+ if (mode === "all" && lastDenial) {
117
+ return createForbiddenResponse(lastDenial, options);
118
+ }
119
+ if (mode === "any") {
120
+ return createForbiddenResponse(lastDenial ?? { allowed: false, publicReason: "Access denied" }, options);
121
+ }
78
122
  return next();
79
123
  };
80
124
  }