@zudojs/permissions 1.3.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 +143 -10
- package/dist/evaluator/authorizationEngine.d.ts +13 -1
- package/dist/evaluator/authorizationEngine.js +1 -0
- package/dist/evaluator/evaluator.core.js +15 -10
- package/dist/evaluator/evaluator.pipeline.d.ts +16 -1
- package/dist/evaluator/evaluator.pipeline.js +40 -18
- package/dist/http/httpHelpers.d.ts +17 -6
- package/dist/http/httpHelpers.js +16 -13
- package/dist/http/httpMiddleware.core.d.ts +5 -3
- package/dist/http/httpMiddleware.core.js +9 -1
- package/dist/http/httpResource.helper.d.ts +43 -0
- package/dist/http/httpResource.helper.js +27 -0
- package/dist/http/httpTypes.d.ts +17 -2
- package/dist/http/index.d.ts +3 -3
- package/dist/http/index.js +2 -2
- package/dist/permissionTypes/index.d.ts +1 -1
- package/dist/permissionTypes/policyTypes.d.ts +24 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/policyEffect.core.d.ts +42 -0
- package/dist/policy/policyEffect.core.js +36 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -153,33 +153,111 @@ denies when either is missing. It is only as good as the value you pass: fill
|
|
|
153
153
|
the tenant `@zudojs/tenancy` resolved), **never from a request header** — a
|
|
154
154
|
caller would set the header to the resource's tenant and pass.
|
|
155
155
|
|
|
156
|
+
Enforce it as a **deny** rule, `not(tenantIsolation())`:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { createPermissionEngine, not, tenantIsolation } from "@zudojs/permissions";
|
|
160
|
+
|
|
161
|
+
const engine = createPermissionEngine({
|
|
162
|
+
roles: [{ name: "accountant", permissions: ["invoice:read"] }],
|
|
163
|
+
rules: [
|
|
164
|
+
{
|
|
165
|
+
name: "tenant-isolation",
|
|
166
|
+
effect: "deny",
|
|
167
|
+
resource: "invoice",
|
|
168
|
+
action: "*",
|
|
169
|
+
condition: not(tenantIsolation()), // other tenant, or no tenant → deny
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A conditional **allow** rule with `tenantIsolation()` only adds a way in: a
|
|
176
|
+
role that grants `invoice:read` allows the check whatever the condition says,
|
|
177
|
+
so that role reads every tenant's invoices. The deny form refuses cross-tenant
|
|
178
|
+
access even for roles that hold the permission, and no policy can override it.
|
|
179
|
+
|
|
156
180
|
## Policies
|
|
157
181
|
|
|
158
|
-
A policy is a named, prioritised hook that runs alongside the rules.
|
|
182
|
+
A policy is a named, prioritised hook that runs alongside the rules. By
|
|
183
|
+
default it is an **extra condition on top of RBAC/ABAC**: it can take access
|
|
184
|
+
away, never hand it out.
|
|
159
185
|
|
|
160
186
|
```typescript
|
|
161
187
|
const engine = createPermissionEngine({
|
|
162
|
-
roles,
|
|
188
|
+
roles: [{ name: "staff", permissions: ["task:*"] }],
|
|
163
189
|
policyTimeout: 250,
|
|
164
190
|
policies: [
|
|
165
191
|
{
|
|
166
192
|
name: "business-hours",
|
|
167
|
-
permissions: ["
|
|
193
|
+
permissions: ["task:*"], // wildcards work here too
|
|
168
194
|
priority: 10, // higher runs first
|
|
169
195
|
cacheable: false, // depends on the clock, so never cache it
|
|
170
|
-
evaluate: (
|
|
196
|
+
evaluate: () =>
|
|
171
197
|
isBusinessHours()
|
|
172
|
-
? { allowed: true }
|
|
198
|
+
? { allowed: true } // "no objection" — the role still has to grant
|
|
173
199
|
: { allowed: false, reason: "outside_business_hours" },
|
|
174
200
|
},
|
|
175
201
|
],
|
|
176
202
|
});
|
|
203
|
+
|
|
204
|
+
await engine.can(staff, "task:delete"); // true in hours, false after
|
|
205
|
+
await engine.can(guest, "task:delete"); // false: no role grants it
|
|
177
206
|
```
|
|
178
207
|
|
|
208
|
+
> **Warning — a policy's `allowed: true` is not a grant.** Before 1.4 it was:
|
|
209
|
+
> an allowing policy granted the permission even to an actor with no roles, so
|
|
210
|
+
> the "business-hours" policy above handed `task:delete` to everyone during
|
|
211
|
+
> office hours. A policy now only constrains, unless it opts in with
|
|
212
|
+
> `effect: "grant"`.
|
|
213
|
+
|
|
179
214
|
Policies run highest priority first and stop at the first denial. A policy
|
|
180
|
-
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins
|
|
181
|
-
|
|
182
|
-
|
|
215
|
+
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins.
|
|
216
|
+
An allowing policy grants nothing by itself: the actor's roles, direct
|
|
217
|
+
permissions or rules must still grant the permission.
|
|
218
|
+
|
|
219
|
+
### Policies that grant
|
|
220
|
+
|
|
221
|
+
A policy that establishes the right on its own — ownership is the usual one —
|
|
222
|
+
says so with `effect: "grant"`:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
const engine = createPermissionEngine({
|
|
226
|
+
roles,
|
|
227
|
+
policies: [
|
|
228
|
+
{
|
|
229
|
+
name: "author-can-edit",
|
|
230
|
+
permissions: ["post:update"],
|
|
231
|
+
effect: "grant", // an allow here grants, even with no role
|
|
232
|
+
evaluate: ({ actor, resource }) => ({
|
|
233
|
+
allowed: (resource as { authorId?: string })?.authorId === actor.id,
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
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
|
|
242
|
+
```
|
|
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
|
+
|
|
252
|
+
A granting policy still never overrides a denial — from another policy, an
|
|
253
|
+
explicit deny, or a deny rule that applied. Only the exact value `"grant"`
|
|
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.
|
|
183
261
|
|
|
184
262
|
`policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
|
|
185
263
|
disable.
|
|
@@ -428,7 +506,11 @@ const guard = authorize(engine, "post:update", {
|
|
|
428
506
|
- The guard extracts the actor itself when one is not already in state, so it
|
|
429
507
|
works without a separate actor middleware.
|
|
430
508
|
- No actor → **401** with `WWW-Authenticate`. Actor but not permitted →
|
|
431
|
-
**403**.
|
|
509
|
+
**403**. The refusal is a `GuardResponse` (`createGuardResponse` from
|
|
510
|
+
`@zudojs/middleware`), which `@zudojs/http` sends with that status, body and
|
|
511
|
+
headers. It used to be a plain `{ status, body, headers }` object, which a
|
|
512
|
+
route middleware's return ignored: the handler did not run, but the client
|
|
513
|
+
got `200`.
|
|
432
514
|
- The 403 body carries `decision.publicReason`, never the internal reason:
|
|
433
515
|
`policy_error:<name>` names your policies and does not belong in a
|
|
434
516
|
response. Pass `deniedResponse` to shape the body — it receives the real
|
|
@@ -442,9 +524,60 @@ const guard = authorize(engine, "post:update", {
|
|
|
442
524
|
several permissions, short-circuiting on the first that decides the outcome.
|
|
443
525
|
An empty list denies in either mode.
|
|
444
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
|
+
|
|
445
567
|
The middleware composes with the real `@zudojs/http` pipeline without
|
|
446
568
|
depending on it: the HTTP types are mirrored structurally (headers, params and
|
|
447
|
-
query may be plain objects, as `@zudojs/http` provides them, or maps)
|
|
569
|
+
query may be plain objects, as `@zudojs/http` provides them, or maps), and
|
|
570
|
+
`HttpMiddleware` is assignable to `@zudojs/http`'s own `HttpMiddleware` — pass
|
|
571
|
+
a guard straight to a route, no `as never`:
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
router.put("/posts/:id", updatePost, { middleware: [guard] });
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
A middleware of your own typed as this package's `HttpMiddleware` answers a
|
|
578
|
+
refusal with `createGuardResponse({ status, body })`; returning a plain
|
|
579
|
+
`{ status, body, headers }` object is no longer typed, because
|
|
580
|
+
`@zudojs/http` never sent it as a response.
|
|
448
581
|
|
|
449
582
|
## Errors
|
|
450
583
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/authorizationEngine
|
|
5
5
|
*/
|
|
6
|
-
import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PermissionCache, PermissionResolver, RoleResolver, RoleDefinition, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
6
|
+
import type { PermissionActor, PermissionDecision, ExplainResult, PermissionRule, PermissionPolicyDefinition, PolicyEffect, PermissionCache, PermissionResolver, RoleResolver, RoleDefinition, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
7
7
|
import { type Ability } from "../ability/ability.core.js";
|
|
8
8
|
import type { PermissionEventEmitter } from "../observability/observability.core.js";
|
|
9
9
|
/**
|
|
@@ -57,6 +57,18 @@ export interface PermissionEngineOptions {
|
|
|
57
57
|
readonly rules?: readonly PermissionRule[];
|
|
58
58
|
/** Default timeout for async policy evaluation (ms). */
|
|
59
59
|
readonly policyTimeout?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Effect of a policy that sets no `effect` of its own. Default:
|
|
62
|
+
* `"constrain"` — an allowing policy is an extra condition and cannot
|
|
63
|
+
* grant a permission the actor's roles, permissions or rules do not.
|
|
64
|
+
*
|
|
65
|
+
* `"grant"` restores the behaviour before 1.4 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.
|
|
70
|
+
*/
|
|
71
|
+
readonly defaultPolicyEffect?: PolicyEffect;
|
|
60
72
|
/** How competing rules combine. Default: `"deny-overrides"`. */
|
|
61
73
|
readonly algorithm?: RuleCombiningAlgorithm;
|
|
62
74
|
/** Caches decisions. Create one with `createMemoryPermissionCache()`. */
|
|
@@ -144,6 +144,7 @@ export function createPermissionEngine(options) {
|
|
|
144
144
|
},
|
|
145
145
|
rules: options?.rules,
|
|
146
146
|
policyTimeout: options?.policyTimeout,
|
|
147
|
+
defaultPolicyEffect: options?.defaultPolicyEffect,
|
|
147
148
|
algorithm: options?.algorithm,
|
|
148
149
|
cache: options?.cache,
|
|
149
150
|
cacheTtlMs: options?.cacheTtlMs,
|
|
@@ -185,7 +185,7 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
const decision = combine(ruleResult, outcome.decision, permissionStr);
|
|
188
|
+
const decision = combine(ruleResult, outcome.decision, outcome.grants, permissionStr);
|
|
189
189
|
/* ── Cache write ─────────────────────────────────────────────────────── */
|
|
190
190
|
// A decision forced by a condition that threw describes the failure, not
|
|
191
191
|
// the actor, and must not outlive it.
|
|
@@ -204,15 +204,20 @@ export async function evaluate(actor, permissionStr, resource, options, authOpti
|
|
|
204
204
|
/**
|
|
205
205
|
* Combine the rule outcome with the policy outcome.
|
|
206
206
|
*
|
|
207
|
-
* A denying policy always wins. An allowing policy
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
207
|
+
* A denying policy always wins. An allowing policy is, by default, only an
|
|
208
|
+
* extra condition: the actor's roles, permissions or rules must still grant
|
|
209
|
+
* the permission. It used to grant on its own, so a "business-hours" policy
|
|
210
|
+
* handed `task:delete` to an actor with no roles at all. Only a policy with
|
|
211
|
+
* `effect: "grant"` (or an engine with `defaultPolicyEffect: "grant"`) can
|
|
212
|
+
* grant access the rules did not. 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
|
|
215
|
+
* denial, including one the *rules* produced. "The rules did not allow"
|
|
216
|
+
* covers two cases: no rule matched, and a deny rule matched. Treating them
|
|
217
|
+
* alike let an allowing policy for `post:*` cancel a `deny post:update` rule
|
|
218
|
+
* — the exact inversion of `deny-overrides`.
|
|
214
219
|
*/
|
|
215
|
-
function combine(ruleResult, policyDecision, permissionStr) {
|
|
220
|
+
function combine(ruleResult, policyDecision, policyGrants, permissionStr) {
|
|
216
221
|
if (policyDecision && !policyDecision.allowed)
|
|
217
222
|
return policyDecision;
|
|
218
223
|
const denyRule = !ruleResult.allowed && ruleResult.matchedRule?.effect === "deny"
|
|
@@ -232,7 +237,7 @@ function combine(ruleResult, policyDecision, permissionStr) {
|
|
|
232
237
|
...(policyDecision?.policy ? { policy: policyDecision.policy } : {}),
|
|
233
238
|
});
|
|
234
239
|
}
|
|
235
|
-
if (policyDecision?.allowed)
|
|
240
|
+
if (policyDecision?.allowed && policyGrants)
|
|
236
241
|
return policyDecision;
|
|
237
242
|
return denied("no_matching_rule");
|
|
238
243
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/evaluator.pipeline
|
|
5
5
|
*/
|
|
6
|
-
import type { PermissionActor, PermissionContext, PermissionDecision, PermissionPolicyDefinition, PermissionRule, PermissionCache, PermissionResolver, RoleDefinition, RoleResolver, RuleCombiningAlgorithm, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
6
|
+
import type { PermissionActor, PermissionContext, PermissionDecision, PermissionPolicyDefinition, PermissionRule, PermissionCache, PermissionResolver, RoleDefinition, RoleResolver, RuleCombiningAlgorithm, AuthorizationOptions, PolicyEffect } from "../permissionTypes/index.js";
|
|
7
7
|
/** Configuration for the evaluator. */
|
|
8
8
|
export interface EvaluatorOptions {
|
|
9
9
|
/** Function to look up a role definition by name. */
|
|
@@ -14,6 +14,8 @@ export interface EvaluatorOptions {
|
|
|
14
14
|
readonly rules?: readonly PermissionRule[];
|
|
15
15
|
/** Default timeout for async policy evaluation (ms). */
|
|
16
16
|
readonly policyTimeout?: number;
|
|
17
|
+
/** Effect of a policy that sets none. Default: `"constrain"`. */
|
|
18
|
+
readonly defaultPolicyEffect?: PolicyEffect;
|
|
17
19
|
/** How competing rules combine. Default: `"deny-overrides"`. */
|
|
18
20
|
readonly algorithm?: RuleCombiningAlgorithm;
|
|
19
21
|
/** Decision cache. */
|
|
@@ -80,6 +82,13 @@ export interface PolicyOutcome {
|
|
|
80
82
|
/** Whether every policy that ran allows the result to be cached. */
|
|
81
83
|
readonly cacheable: boolean;
|
|
82
84
|
readonly evaluated: readonly string[];
|
|
85
|
+
/**
|
|
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
|
+
*/
|
|
91
|
+
readonly grants: boolean;
|
|
83
92
|
}
|
|
84
93
|
/**
|
|
85
94
|
* Evaluate policies for a context.
|
|
@@ -87,6 +96,12 @@ export interface PolicyOutcome {
|
|
|
87
96
|
* Policies are evaluated highest priority first and short-circuit on the
|
|
88
97
|
* first denial. A policy that throws or times out denies — an authorization
|
|
89
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.
|
|
90
105
|
*/
|
|
91
106
|
export declare function evaluatePolicies(context: PermissionContext, policies: readonly PermissionPolicyDefinition[], options: EvaluatorOptions, authOptions?: AuthorizationOptions): Promise<PolicyOutcome>;
|
|
92
107
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module evaluator/evaluator.pipeline
|
|
5
5
|
*/
|
|
6
|
+
import { resolvePolicyMode } from "../policy/policyEffect.core.js";
|
|
6
7
|
import { resolveRolePermissions } from "../role/roleHierarchy.js";
|
|
7
8
|
import { matches, permissionsOverlap, parsePermissionSafe, } from "../permission/permission.core.js";
|
|
8
9
|
import { isWildcardTarget } from "../rule/rule.pattern.js";
|
|
@@ -144,76 +145,97 @@ function narrowerPolicies(policies, applicable, permissionStr) {
|
|
|
144
145
|
policy.permissions.some((pattern) => permissionsOverlap(pattern, permissionStr)))
|
|
145
146
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
146
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
|
+
}
|
|
147
157
|
/**
|
|
148
158
|
* Evaluate policies for a context.
|
|
149
159
|
*
|
|
150
160
|
* Policies are evaluated highest priority first and short-circuit on the
|
|
151
161
|
* first denial. A policy that throws or times out denies — an authorization
|
|
152
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.
|
|
153
169
|
*/
|
|
154
170
|
export async function evaluatePolicies(context, policies, options, authOptions) {
|
|
155
171
|
if (policies.length === 0) {
|
|
156
|
-
return { decision: null, cacheable: true, evaluated: [] };
|
|
172
|
+
return { decision: null, cacheable: true, evaluated: [], grants: false };
|
|
157
173
|
}
|
|
158
174
|
const permissionStr = `${context.permission.resource}:${context.permission.action}`;
|
|
159
175
|
const applicable = selectPolicies(policies, permissionStr);
|
|
160
176
|
const denyOnly = narrowerPolicies(policies, applicable, permissionStr);
|
|
161
177
|
if (applicable.length === 0 && denyOnly.length === 0) {
|
|
162
|
-
return { decision: null, cacheable: true, evaluated: [] };
|
|
178
|
+
return { decision: null, cacheable: true, evaluated: [], grants: false };
|
|
163
179
|
}
|
|
164
180
|
// The per-call timeout wins, but the engine-level default is what makes a
|
|
165
181
|
// configured timeout mean anything at all.
|
|
166
182
|
const timeoutMs = authOptions?.policyTimeout ?? options.policyTimeout;
|
|
167
183
|
const evaluated = [];
|
|
184
|
+
const allowedBy = [];
|
|
168
185
|
let cacheable = true;
|
|
186
|
+
let grants = false;
|
|
169
187
|
for (const policy of [...applicable, ...denyOnly]) {
|
|
170
188
|
assertNotAborted(context.signal ?? authOptions?.signal);
|
|
171
189
|
evaluated.push(policy.name);
|
|
172
190
|
if (policy.cacheable === false)
|
|
173
191
|
cacheable = false;
|
|
192
|
+
const mode = resolvePolicyMode(policy, options.defaultPolicyEffect);
|
|
174
193
|
try {
|
|
175
194
|
const result = await withTimeout(Promise.resolve(policy.evaluate(context)), timeoutMs, policy.name);
|
|
176
195
|
if (!result.allowed) {
|
|
196
|
+
if (mode === "grant")
|
|
197
|
+
continue;
|
|
177
198
|
return {
|
|
178
|
-
decision:
|
|
179
|
-
allowed: false,
|
|
180
|
-
reason: result.reason ?? `policy:${policy.name}`,
|
|
181
|
-
policy: policy.name,
|
|
182
|
-
publicReason: result.publicReason ?? "Access denied",
|
|
183
|
-
}),
|
|
199
|
+
decision: policyDenial(policy, result.reason ?? `policy:${policy.name}`, result.publicReason ?? "Access denied"),
|
|
184
200
|
cacheable,
|
|
185
201
|
evaluated,
|
|
202
|
+
grants: false,
|
|
186
203
|
};
|
|
187
204
|
}
|
|
205
|
+
if (applicable.includes(policy)) {
|
|
206
|
+
allowedBy.push(policy);
|
|
207
|
+
if (mode !== "constrain")
|
|
208
|
+
grants = true;
|
|
209
|
+
}
|
|
188
210
|
}
|
|
189
211
|
catch (error) {
|
|
190
212
|
if (error instanceof AuthorizationAbortedError)
|
|
191
213
|
throw error;
|
|
192
214
|
options.onError?.(reportable(error, (cause) => new PolicyError(policy.name, cause)), `Policy.${policy.name}`);
|
|
215
|
+
cacheable = false;
|
|
216
|
+
if (mode === "grant")
|
|
217
|
+
continue;
|
|
193
218
|
// Policy error — fail closed.
|
|
194
219
|
return {
|
|
195
|
-
decision:
|
|
196
|
-
allowed: false,
|
|
197
|
-
reason: `policy_error:${policy.name}`,
|
|
198
|
-
policy: policy.name,
|
|
199
|
-
publicReason: "Access denied",
|
|
200
|
-
}),
|
|
220
|
+
decision: policyDenial(policy, `policy_error:${policy.name}`, "Access denied"),
|
|
201
221
|
cacheable: false,
|
|
202
222
|
evaluated,
|
|
223
|
+
grants: false,
|
|
203
224
|
};
|
|
204
225
|
}
|
|
205
226
|
}
|
|
206
|
-
if (
|
|
207
|
-
return { decision: null, cacheable, evaluated };
|
|
227
|
+
if (allowedBy.length === 0) {
|
|
228
|
+
return { decision: null, cacheable, evaluated, grants: false };
|
|
208
229
|
}
|
|
209
230
|
return {
|
|
210
231
|
decision: Object.freeze({
|
|
211
232
|
allowed: true,
|
|
212
|
-
reason: "policy_allow",
|
|
213
|
-
policy:
|
|
233
|
+
reason: grants ? "policy_allow" : "policy_pass",
|
|
234
|
+
policy: allowedBy.map((policy) => policy.name).join(","),
|
|
214
235
|
}),
|
|
215
236
|
cacheable,
|
|
216
237
|
evaluated,
|
|
238
|
+
grants,
|
|
217
239
|
};
|
|
218
240
|
}
|
|
219
241
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module http/httpHelpers
|
|
5
5
|
*/
|
|
6
|
+
import { type GuardResponse } from "@zudojs/middleware";
|
|
6
7
|
import type { PermissionDecision } from "../permissionTypes/index.js";
|
|
7
8
|
/** Options for creating denied responses. */
|
|
8
9
|
export interface DeniedResponseOptions {
|
|
@@ -16,12 +17,12 @@ export interface DeniedResponseOptions {
|
|
|
16
17
|
/** Value for the `WWW-Authenticate` header on a 401. */
|
|
17
18
|
readonly authenticateChallenge?: string;
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A framework-agnostic response: a `GuardResponse` from `@zudojs/middleware`,
|
|
22
|
+
* which `@zudojs/http` sends with its own status. It still has the
|
|
23
|
+
* `status`, `body` and `headers` fields this type always had.
|
|
24
|
+
*/
|
|
25
|
+
export type PermissionHttpResponse = GuardResponse;
|
|
25
26
|
/**
|
|
26
27
|
* Create a 403 Forbidden JSON response for a decision.
|
|
27
28
|
*
|
|
@@ -37,6 +38,16 @@ export declare function createForbiddenResponse(decision: PermissionDecision, op
|
|
|
37
38
|
* leaves a client unable to tell "log in" from "you may not do this".
|
|
38
39
|
*/
|
|
39
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;
|
|
40
51
|
/**
|
|
41
52
|
* Create a JSON response.
|
|
42
53
|
*/
|
package/dist/http/httpHelpers.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module http/httpHelpers
|
|
5
5
|
*/
|
|
6
|
+
import { createGuardResponse } from "@zudojs/middleware";
|
|
6
7
|
const JSON_HEADERS = { "content-type": "application/json" };
|
|
7
8
|
/**
|
|
8
9
|
* Create a 403 Forbidden JSON response for a decision.
|
|
@@ -18,11 +19,7 @@ export function createForbiddenResponse(decision, options) {
|
|
|
18
19
|
error: "Forbidden",
|
|
19
20
|
message: decision.publicReason ?? "Access denied",
|
|
20
21
|
};
|
|
21
|
-
return
|
|
22
|
-
status: 403,
|
|
23
|
-
body,
|
|
24
|
-
headers: JSON_HEADERS,
|
|
25
|
-
});
|
|
22
|
+
return createGuardResponse({ status: 403, body, headers: JSON_HEADERS });
|
|
26
23
|
}
|
|
27
24
|
/**
|
|
28
25
|
* Create a 401 Unauthorized JSON response.
|
|
@@ -34,23 +31,29 @@ export function createUnauthorizedResponse(options) {
|
|
|
34
31
|
const body = options?.unauthenticatedResponse
|
|
35
32
|
? options.unauthenticatedResponse()
|
|
36
33
|
: { error: "Unauthorized", message: "Authentication required" };
|
|
37
|
-
return
|
|
34
|
+
return createGuardResponse({
|
|
38
35
|
status: 401,
|
|
39
36
|
body,
|
|
40
|
-
headers:
|
|
37
|
+
headers: {
|
|
41
38
|
...JSON_HEADERS,
|
|
42
39
|
"www-authenticate": options?.authenticateChallenge ?? "Bearer",
|
|
43
|
-
}
|
|
40
|
+
},
|
|
44
41
|
});
|
|
45
42
|
}
|
|
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
|
+
}
|
|
46
53
|
/**
|
|
47
54
|
* Create a JSON response.
|
|
48
55
|
*/
|
|
49
56
|
export function createJsonResponse(status, body) {
|
|
50
|
-
return
|
|
51
|
-
status,
|
|
52
|
-
body,
|
|
53
|
-
headers: JSON_HEADERS,
|
|
54
|
-
});
|
|
57
|
+
return createGuardResponse({ status, body, headers: JSON_HEADERS });
|
|
55
58
|
}
|
|
56
59
|
//# sourceMappingURL=httpHelpers.js.map
|
|
@@ -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
|
package/dist/http/httpTypes.d.ts
CHANGED
|
@@ -6,8 +6,23 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module http/httpTypes
|
|
8
8
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import type { GuardResponse } from "@zudojs/middleware";
|
|
10
|
+
/**
|
|
11
|
+
* What a permission middleware returns: nothing, a web `Response`, a
|
|
12
|
+
* `GuardResponse` refusing the request, or whatever `next()` produced.
|
|
13
|
+
*/
|
|
14
|
+
export type HttpMiddlewareOutcome<Downstream> = void | Response | GuardResponse | Downstream;
|
|
15
|
+
/**
|
|
16
|
+
* HTTP middleware signature, structurally assignable to `@zudojs/http`'s
|
|
17
|
+
* `HttpMiddleware` without a cast.
|
|
18
|
+
*
|
|
19
|
+
* Generic over what `next()` resolves to, so a middleware hands back the
|
|
20
|
+
* real pipeline's response unchanged. A refusal is a `GuardResponse`
|
|
21
|
+
* (`createGuardResponse` from `@zudojs/middleware`), which `@zudojs/http`
|
|
22
|
+
* sends with its own status; a plain `{ status, body, headers }` object is
|
|
23
|
+
* not a response.
|
|
24
|
+
*/
|
|
25
|
+
export type HttpMiddleware = <Downstream extends HttpResponseContext>(context: HttpMiddlewareContext, next: () => Promise<Downstream>) => HttpMiddlewareOutcome<Downstream> | Promise<HttpMiddlewareOutcome<Downstream>>;
|
|
11
26
|
/** HTTP middleware context from @zudojs/http. */
|
|
12
27
|
export interface HttpMiddlewareContext {
|
|
13
28
|
readonly request: HttpRequestContext;
|
package/dist/http/index.d.ts
CHANGED
|
@@ -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";
|
|
16
|
-
export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.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
|
+
export type { HttpRequestBag, HttpMiddleware, HttpMiddlewareOutcome, HttpMiddlewareContext, HttpRequestContext, HttpResponseContext, HttpMiddlewareState, } from "./httpTypes.js";
|
|
17
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/http/index.js
CHANGED
|
@@ -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
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { type PermissionActor, type Permission, type PermissionString, } from "./permissionActor.js";
|
|
7
7
|
export { type RuleEffect, type RuleCombiningAlgorithm, type RuleEvaluation, type PermissionRule, type PermissionConditionFn, type PermissionContext, type PermissionDecision, } from "./ruleTypes.js";
|
|
8
|
-
export { type RoleDefinition, type PermissionResolver, type RoleResolver, type PermissionCache, type PermissionPolicyDefinition, type ExplainStep, type ExplainResult, type AuthorizationOptions, } from "./policyTypes.js";
|
|
8
|
+
export { type RoleDefinition, type PermissionResolver, type RoleResolver, type PermissionCache, type PermissionPolicyDefinition, type PolicyEffect, type ExplainStep, type ExplainResult, type AuthorizationOptions, } from "./policyTypes.js";
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -42,9 +42,33 @@ export interface PermissionCache {
|
|
|
42
42
|
/** Drops every entry. */
|
|
43
43
|
clear?(): Promise<void>;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What an allowing policy means.
|
|
47
|
+
*
|
|
48
|
+
* - `"constrain"` (the default): the policy is an extra condition on top of
|
|
49
|
+
* RBAC/ABAC. Its allow means "no objection"; the actor's roles, direct
|
|
50
|
+
* permissions or rules must still grant the permission. Its deny denies.
|
|
51
|
+
* - `"grant"`: the policy is an independent grant. Its allow grants the
|
|
52
|
+
* permission even when no role or rule does — an ownership check, say.
|
|
53
|
+
* 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.
|
|
60
|
+
*/
|
|
61
|
+
export type PolicyEffect = "constrain" | "grant";
|
|
45
62
|
/** A named authorization policy. */
|
|
46
63
|
export interface PermissionPolicyDefinition {
|
|
47
64
|
readonly name: string;
|
|
65
|
+
/**
|
|
66
|
+
* Whether this policy's allow can grant a permission the actor's roles do
|
|
67
|
+
* not include. Default: the engine's `defaultPolicyEffect`, which is
|
|
68
|
+
* `"constrain"` — a policy only ever narrows access. Any value other than
|
|
69
|
+
* `"grant"` constrains. With `"grant"`, a deny abstains rather than denies.
|
|
70
|
+
*/
|
|
71
|
+
readonly effect?: PolicyEffect;
|
|
48
72
|
/**
|
|
49
73
|
* Permissions this policy applies to. Wildcards are honoured, so
|
|
50
74
|
* `["post:*"]` covers `post:update` — the same matching grants use.
|
package/dist/policy/index.d.ts
CHANGED
package/dist/policy/index.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy effect resolution: whether an allowing policy may grant access on
|
|
3
|
+
* its own or only constrains what RBAC/ABAC already granted.
|
|
4
|
+
*
|
|
5
|
+
* @module policy/policyEffect.core
|
|
6
|
+
*/
|
|
7
|
+
import type { PermissionPolicyDefinition, PolicyEffect } from "../permissionTypes/index.js";
|
|
8
|
+
/** The effect a policy without its own `effect` has. */
|
|
9
|
+
export declare const DEFAULT_POLICY_EFFECT: PolicyEffect;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a policy's allow is an independent grant.
|
|
12
|
+
*
|
|
13
|
+
* Only the exact value `"grant"` grants. A typo, a missing value or anything
|
|
14
|
+
* else falls back to constraining, so a mistake narrows access instead of
|
|
15
|
+
* widening it.
|
|
16
|
+
*
|
|
17
|
+
* @param policy - The policy that allowed.
|
|
18
|
+
* @param defaultEffect - The engine's `defaultPolicyEffect`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function policyGrants(policy: PermissionPolicyDefinition, defaultEffect?: PolicyEffect): boolean;
|
|
21
|
+
/**
|
|
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;
|
|
42
|
+
//# sourceMappingURL=policyEffect.core.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy effect resolution: whether an allowing policy may grant access on
|
|
3
|
+
* its own or only constrains what RBAC/ABAC already granted.
|
|
4
|
+
*
|
|
5
|
+
* @module policy/policyEffect.core
|
|
6
|
+
*/
|
|
7
|
+
/** The effect a policy without its own `effect` has. */
|
|
8
|
+
export const DEFAULT_POLICY_EFFECT = "constrain";
|
|
9
|
+
/**
|
|
10
|
+
* Whether a policy's allow is an independent grant.
|
|
11
|
+
*
|
|
12
|
+
* Only the exact value `"grant"` grants. A typo, a missing value or anything
|
|
13
|
+
* else falls back to constraining, so a mistake narrows access instead of
|
|
14
|
+
* widening it.
|
|
15
|
+
*
|
|
16
|
+
* @param policy - The policy that allowed.
|
|
17
|
+
* @param defaultEffect - The engine's `defaultPolicyEffect`.
|
|
18
|
+
*/
|
|
19
|
+
export function policyGrants(policy, defaultEffect = DEFAULT_POLICY_EFFECT) {
|
|
20
|
+
return (policy.effect ?? defaultEffect) === "grant";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
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
|
+
}
|
|
36
|
+
//# sourceMappingURL=policyEffect.core.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/permissions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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": {
|
|
@@ -26,14 +26,15 @@
|
|
|
26
26
|
"!dist/.tsbuildinfo"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@zudojs/errors": "1.
|
|
29
|
+
"@zudojs/errors": "1.3.0",
|
|
30
|
+
"@zudojs/middleware": "1.1.0"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=24.0.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"typescript": "7.0.2",
|
|
36
|
-
"vitest": "^
|
|
37
|
+
"vitest": "^5.0.1"
|
|
37
38
|
},
|
|
38
39
|
"publishConfig": {
|
|
39
40
|
"access": "public"
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"authorization",
|
|
46
47
|
"permissions"
|
|
47
48
|
],
|
|
48
|
-
"homepage": "https://
|
|
49
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-permissions",
|
|
49
50
|
"bugs": {
|
|
50
51
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
51
52
|
},
|