@zudojs/permissions 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +345 -14
- package/dist/ability/ability.core.d.ts +2 -1
- package/dist/ability/ability.core.js +34 -9
- package/dist/cache/cache.core.d.ts +24 -5
- package/dist/cache/cache.core.js +62 -20
- package/dist/cache/index.d.ts +1 -1
- package/dist/conditions/conditions.core.d.ts +18 -2
- package/dist/conditions/conditions.core.js +46 -6
- package/dist/conditions/index.d.ts +1 -1
- package/dist/conditions/index.js +1 -1
- package/dist/evaluator/authorizationEngine.d.ts +48 -6
- package/dist/evaluator/authorizationEngine.js +146 -17
- package/dist/evaluator/evaluator.core.d.ts +28 -4
- package/dist/evaluator/evaluator.core.js +246 -39
- package/dist/evaluator/evaluator.explain.d.ts +6 -3
- package/dist/evaluator/evaluator.explain.js +8 -71
- package/dist/evaluator/evaluator.pipeline.d.ts +70 -9
- package/dist/evaluator/evaluator.pipeline.js +194 -49
- package/dist/evaluator/index.d.ts +4 -2
- package/dist/evaluator/index.js +4 -1
- package/dist/http/httpHelpers.d.ts +28 -12
- package/dist/http/httpHelpers.js +33 -6
- package/dist/http/httpMiddleware.core.d.ts +54 -13
- package/dist/http/httpMiddleware.core.js +72 -28
- package/dist/http/index.d.ts +7 -3
- package/dist/http/index.js +7 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/observability.core.d.ts +32 -12
- package/dist/observability/observability.core.js +41 -20
- package/dist/permission/index.d.ts +2 -2
- package/dist/permission/index.js +1 -1
- package/dist/permission/permission.core.d.ts +13 -2
- package/dist/permission/permission.core.js +29 -21
- package/dist/permission/permissionRegistry.d.ts +27 -33
- package/dist/permission/permissionRegistry.js +59 -41
- package/dist/permissionErrors/index.d.ts +1 -1
- package/dist/permissionErrors/index.js +1 -1
- package/dist/permissionErrors/permissionError.access.d.ts +24 -0
- package/dist/permissionErrors/permissionError.access.js +25 -5
- package/dist/permissionErrors/permissionError.types.d.ts +1 -1
- package/dist/permissionErrors/permissionError.types.js +1 -1
- package/dist/permissionTypes/index.d.ts +2 -2
- package/dist/permissionTypes/index.js +1 -1
- package/dist/permissionTypes/policyTypes.d.ts +35 -2
- package/dist/permissionTypes/ruleTypes.d.ts +38 -5
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/policyRegistry.d.ts +16 -23
- package/dist/policy/policyRegistry.js +26 -23
- package/dist/role/index.d.ts +2 -2
- package/dist/role/index.js +1 -1
- package/dist/role/roleHierarchy.d.ts +34 -4
- package/dist/role/roleHierarchy.js +48 -23
- package/dist/role/roleRegistry.d.ts +16 -25
- package/dist/role/roleRegistry.js +27 -23
- package/dist/rule/index.d.ts +2 -2
- package/dist/rule/index.js +2 -2
- package/dist/rule/rule.core.d.ts +39 -10
- package/dist/rule/rule.core.js +95 -38
- package/dist/rule/ruleCompiler.d.ts +14 -5
- package/dist/rule/ruleCompiler.js +46 -30
- package/dist/utils/utils.helper.d.ts +8 -0
- package/dist/utils/utils.helper.js +16 -14
- package/package.json +23 -16
- package/dist/.tsbuildinfo +0 -1
- package/dist/ability/ability.core.d.ts.map +0 -1
- package/dist/ability/ability.core.js.map +0 -1
- package/dist/ability/index.d.ts.map +0 -1
- package/dist/ability/index.js.map +0 -1
- package/dist/actor/actor.core.d.ts.map +0 -1
- package/dist/actor/actor.core.js.map +0 -1
- package/dist/actor/index.d.ts.map +0 -1
- package/dist/actor/index.js.map +0 -1
- package/dist/cache/cache.core.d.ts.map +0 -1
- package/dist/cache/cache.core.js.map +0 -1
- package/dist/cache/index.d.ts.map +0 -1
- package/dist/cache/index.js.map +0 -1
- package/dist/conditions/conditions.core.d.ts.map +0 -1
- package/dist/conditions/conditions.core.js.map +0 -1
- package/dist/conditions/index.d.ts.map +0 -1
- package/dist/conditions/index.js.map +0 -1
- package/dist/evaluator/authorizationEngine.d.ts.map +0 -1
- package/dist/evaluator/authorizationEngine.js.map +0 -1
- package/dist/evaluator/evaluator.core.d.ts.map +0 -1
- package/dist/evaluator/evaluator.core.js.map +0 -1
- package/dist/evaluator/evaluator.explain.d.ts.map +0 -1
- package/dist/evaluator/evaluator.explain.js.map +0 -1
- package/dist/evaluator/evaluator.pipeline.d.ts.map +0 -1
- package/dist/evaluator/evaluator.pipeline.js.map +0 -1
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/evaluator/index.js.map +0 -1
- package/dist/http/httpHelpers.d.ts.map +0 -1
- package/dist/http/httpHelpers.js.map +0 -1
- package/dist/http/httpMiddleware.core.d.ts.map +0 -1
- package/dist/http/httpMiddleware.core.js.map +0 -1
- package/dist/http/httpTypes.d.ts.map +0 -1
- package/dist/http/httpTypes.js.map +0 -1
- package/dist/http/index.d.ts.map +0 -1
- package/dist/http/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/observability/index.d.ts.map +0 -1
- package/dist/observability/index.js.map +0 -1
- package/dist/observability/observability.core.d.ts.map +0 -1
- package/dist/observability/observability.core.js.map +0 -1
- package/dist/permission/index.d.ts.map +0 -1
- package/dist/permission/index.js.map +0 -1
- package/dist/permission/permission.core.d.ts.map +0 -1
- package/dist/permission/permission.core.js.map +0 -1
- package/dist/permission/permissionRegistry.d.ts.map +0 -1
- package/dist/permission/permissionRegistry.js.map +0 -1
- package/dist/permissionErrors/index.d.ts.map +0 -1
- package/dist/permissionErrors/index.js.map +0 -1
- package/dist/permissionErrors/permissionError.access.d.ts.map +0 -1
- package/dist/permissionErrors/permissionError.access.js.map +0 -1
- package/dist/permissionErrors/permissionError.base.d.ts.map +0 -1
- package/dist/permissionErrors/permissionError.base.js.map +0 -1
- package/dist/permissionErrors/permissionError.types.d.ts.map +0 -1
- package/dist/permissionErrors/permissionError.types.js.map +0 -1
- package/dist/permissionErrors/permissionError.validation.d.ts.map +0 -1
- package/dist/permissionErrors/permissionError.validation.js.map +0 -1
- package/dist/permissionTypes/index.d.ts.map +0 -1
- package/dist/permissionTypes/index.js.map +0 -1
- package/dist/permissionTypes/permissionActor.d.ts.map +0 -1
- package/dist/permissionTypes/permissionActor.js.map +0 -1
- package/dist/permissionTypes/policyTypes.d.ts.map +0 -1
- package/dist/permissionTypes/policyTypes.js.map +0 -1
- package/dist/permissionTypes/ruleTypes.d.ts.map +0 -1
- package/dist/permissionTypes/ruleTypes.js.map +0 -1
- package/dist/policy/index.d.ts.map +0 -1
- package/dist/policy/index.js.map +0 -1
- package/dist/policy/policyRegistry.d.ts.map +0 -1
- package/dist/policy/policyRegistry.js.map +0 -1
- package/dist/resolvers/index.d.ts.map +0 -1
- package/dist/resolvers/index.js.map +0 -1
- package/dist/resolvers/resolvers.core.d.ts.map +0 -1
- package/dist/resolvers/resolvers.core.js.map +0 -1
- package/dist/role/index.d.ts.map +0 -1
- package/dist/role/index.js.map +0 -1
- package/dist/role/roleHierarchy.d.ts.map +0 -1
- package/dist/role/roleHierarchy.js.map +0 -1
- package/dist/role/roleRegistry.d.ts.map +0 -1
- package/dist/role/roleRegistry.js.map +0 -1
- package/dist/rule/index.d.ts.map +0 -1
- package/dist/rule/index.js.map +0 -1
- package/dist/rule/rule.core.d.ts.map +0 -1
- package/dist/rule/rule.core.js.map +0 -1
- package/dist/rule/ruleCompiler.d.ts.map +0 -1
- package/dist/rule/ruleCompiler.js.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/utils.helper.d.ts.map +0 -1
- package/dist/utils/utils.helper.js.map +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zudojs Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -11,27 +11,358 @@ npm install @zudojs/permissions
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createPermissionEngine,
|
|
16
|
+
createPermissionActor,
|
|
17
|
+
} from "@zudojs/permissions";
|
|
15
18
|
|
|
16
19
|
const engine = createPermissionEngine({
|
|
17
|
-
roles:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
roles: [
|
|
21
|
+
{ name: "reader", permissions: ["post:read"] },
|
|
22
|
+
{ name: "editor", permissions: ["post:update"], inherits: ["reader"] },
|
|
23
|
+
{ name: "admin", permissions: ["*:*"] },
|
|
24
|
+
],
|
|
21
25
|
});
|
|
22
26
|
|
|
23
|
-
const
|
|
27
|
+
const actor = createPermissionActor("user_1", { roles: ["editor"] });
|
|
28
|
+
|
|
29
|
+
await engine.can(actor, "post:update"); // true
|
|
30
|
+
await engine.can(actor, "post:read"); // true — inherited from "reader"
|
|
31
|
+
await engine.can(actor, "user:delete"); // false
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`roles` is an array of `RoleDefinition`, and every check takes the **actor**,
|
|
35
|
+
not a role name. Malformed configuration is rejected at construction: a grant
|
|
36
|
+
like `"post"` (no action) could never match, so it is an error rather than a
|
|
37
|
+
silent no-op.
|
|
38
|
+
|
|
39
|
+
## Permissions
|
|
40
|
+
|
|
41
|
+
Permissions are `resource:action`. Two wildcard forms are supported, and they
|
|
42
|
+
mean the same thing everywhere — in a grant, a deny, a rule, or a policy:
|
|
43
|
+
|
|
44
|
+
| Pattern | Matches |
|
|
45
|
+
| ---------------- | ----------------------------------------- |
|
|
46
|
+
| `post:read` | exactly that |
|
|
47
|
+
| `post:*` | every action on `post` |
|
|
48
|
+
| `*:read` | `read` on every resource |
|
|
49
|
+
| `*:*` | everything |
|
|
50
|
+
| `billing.*:read` | `billing:read`, `billing.invoice:read`, … |
|
|
51
|
+
|
|
52
|
+
A partial wildcard such as `post*:read` is rejected: the matcher has no
|
|
53
|
+
meaning for it, so accepting one would register a grant that never matches.
|
|
54
|
+
|
|
55
|
+
## Denies
|
|
56
|
+
|
|
57
|
+
`deniedPermissions` is matched with the same rules as grants, so a wildcard
|
|
58
|
+
deny works:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const restricted = createPermissionActor("user_2", {
|
|
62
|
+
roles: ["admin"],
|
|
63
|
+
deniedPermissions: ["*:delete", "billing.*:read"],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await engine.can(restricted, "post:delete"); // false
|
|
67
|
+
await engine.can(restricted, "post:read"); // true
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
An explicit deny short-circuits before any rule or policy runs.
|
|
71
|
+
|
|
72
|
+
## Rules and ABAC
|
|
73
|
+
|
|
74
|
+
A `PermissionRule` carries an optional **condition**, and the rule applies
|
|
75
|
+
only when that condition holds. This is what makes "owners may edit their own
|
|
76
|
+
posts" expressible:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { isOwner, tenantIsolation, allOf } from "@zudojs/permissions";
|
|
80
|
+
|
|
81
|
+
const engine = createPermissionEngine({
|
|
82
|
+
roles,
|
|
83
|
+
rules: [
|
|
84
|
+
{
|
|
85
|
+
name: "own-posts",
|
|
86
|
+
effect: "allow",
|
|
87
|
+
resource: "post",
|
|
88
|
+
action: "update",
|
|
89
|
+
condition: isOwner(), // compares resource.ownerId to actor.id
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "locked-posts",
|
|
93
|
+
effect: "deny",
|
|
94
|
+
resource: "post",
|
|
95
|
+
action: "update",
|
|
96
|
+
condition: (context) => Boolean((context.resource as Post).locked),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await engine.can(actor, "post:update", { ownerId: "user_1" }); // true
|
|
102
|
+
await engine.can(actor, "post:update", { ownerId: "user_9" }); // false
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Conditions may be async. A condition that throws is treated as unmet — an
|
|
106
|
+
authorization check fails closed.
|
|
107
|
+
|
|
108
|
+
**Combining.** The default is `deny-overrides`: any applicable deny wins,
|
|
109
|
+
whatever its priority. Pass `algorithm: "priority"` for highest-priority-wins,
|
|
110
|
+
where a tie still goes to deny.
|
|
111
|
+
|
|
112
|
+
Combinators: `allOf`, `anyOf`, `not`, `always`, `never`, `isOwner`,
|
|
113
|
+
`tenantIsolation`, `metadataEquals`, `resourceEquals`.
|
|
114
|
+
|
|
115
|
+
### Request metadata
|
|
116
|
+
|
|
117
|
+
Conditions read request-scoped facts from `context.metadata`, supplied per
|
|
118
|
+
check:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
await engine.can(actor, "invoice:read", invoice, {
|
|
122
|
+
metadata: { tenantId: request.tenantId },
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`tenantIsolation()` compares that value against the resource's tenant, and
|
|
127
|
+
denies when either is missing.
|
|
128
|
+
|
|
129
|
+
## Policies
|
|
130
|
+
|
|
131
|
+
A policy is a named, prioritised hook that runs alongside the rules.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const engine = createPermissionEngine({
|
|
135
|
+
roles,
|
|
136
|
+
policyTimeout: 250,
|
|
137
|
+
policies: [
|
|
138
|
+
{
|
|
139
|
+
name: "business-hours",
|
|
140
|
+
permissions: ["post:*"], // wildcards work here too
|
|
141
|
+
priority: 10, // higher runs first
|
|
142
|
+
cacheable: false, // depends on the clock, so never cache it
|
|
143
|
+
evaluate: (context) =>
|
|
144
|
+
isBusinessHours()
|
|
145
|
+
? { allowed: true }
|
|
146
|
+
: { allowed: false, reason: "outside_business_hours" },
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Policies run highest priority first and stop at the first denial. A policy
|
|
153
|
+
that throws, or exceeds `policyTimeout`, denies. A denying policy always wins;
|
|
154
|
+
an allowing one can grant access the rules did not, but never overrides a
|
|
155
|
+
denial.
|
|
156
|
+
|
|
157
|
+
`policyTimeout: 0` means "expire immediately", not "no timeout" — omit it to
|
|
158
|
+
disable.
|
|
159
|
+
|
|
160
|
+
## Registries
|
|
161
|
+
|
|
162
|
+
Registries can be handed straight to the engine, and stay live:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import {
|
|
166
|
+
createRoleRegistry,
|
|
167
|
+
createPolicyRegistry,
|
|
168
|
+
createPermissionRegistry,
|
|
169
|
+
} from "@zudojs/permissions";
|
|
170
|
+
|
|
171
|
+
const roles = createRoleRegistry();
|
|
172
|
+
roles.define({ name: "reader", permissions: ["post:read"] });
|
|
173
|
+
roles.define({ name: "staff", permissions: [], inherits: ["reader"] });
|
|
174
|
+
|
|
175
|
+
const policies = createPolicyRegistry();
|
|
176
|
+
const engine = createPermissionEngine({ roles, policies });
|
|
177
|
+
|
|
178
|
+
roles.define({ name: "auditor", permissions: ["audit:read"] });
|
|
179
|
+
engine.invalidateRoles(); // pick up the change
|
|
180
|
+
|
|
181
|
+
roles.require("auditor"); // throws RoleNotFoundError when unregistered
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
All three registries reject a duplicate name — re-registering a role or a
|
|
185
|
+
policy is an authorization rule disappearing without a trace. Pass
|
|
186
|
+
`{ allowOverride: true }` when replacement is what you mean.
|
|
187
|
+
|
|
188
|
+
`validateConfiguration` (default `true`) applies to a registry as well as to
|
|
189
|
+
an inline array: a role whose grant could never match is rejected when the
|
|
190
|
+
engine looks it up, and the check denies.
|
|
191
|
+
|
|
192
|
+
A permission registry records descriptions and implications:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const permissions = createPermissionRegistry();
|
|
196
|
+
permissions.define("post:admin", { implies: ["post:write"] });
|
|
197
|
+
permissions.define("post:write", { implies: ["post:read"] });
|
|
198
|
+
|
|
199
|
+
const engine = createPermissionEngine({
|
|
200
|
+
roles,
|
|
201
|
+
expandImplied: (permission) => permissions.expandImplied(permission),
|
|
202
|
+
});
|
|
203
|
+
// An actor granted post:admin now passes post:read.
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Caching
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { createMemoryPermissionCache } from "@zudojs/permissions";
|
|
210
|
+
|
|
211
|
+
const engine = createPermissionEngine({
|
|
212
|
+
roles,
|
|
213
|
+
cache: createMemoryPermissionCache({
|
|
214
|
+
defaultTtlMs: 30_000,
|
|
215
|
+
maxEntries: 5_000,
|
|
216
|
+
}),
|
|
217
|
+
cacheTtlMs: 30_000,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await engine.can(actor, "post:read", post); // evaluated
|
|
221
|
+
await engine.can(actor, "post:read", post); // cached
|
|
222
|
+
await engine.can(actor, "post:read", post, { skipCache: true });
|
|
223
|
+
await engine.invalidateActor("user_1");
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Keys include the actor, the permission and the resource id (from
|
|
227
|
+
`resource.id`, or `options.resourceId`), so two resources never share one
|
|
228
|
+
decision.
|
|
229
|
+
|
|
230
|
+
A check is cached only when the key can describe it completely:
|
|
231
|
+
|
|
232
|
+
- a resource with no `id` and no `options.resourceId` is **not cached**, since
|
|
233
|
+
the key would collapse to actor + permission and an allow for one object
|
|
234
|
+
would answer for the next;
|
|
235
|
+
- a check carrying `metadata` is **not cached**, because conditions such as
|
|
236
|
+
`tenantIsolation()` read the tenant from there and it is not part of the
|
|
237
|
+
key;
|
|
238
|
+
- a decision produced by a policy marked `cacheable: false` is not stored;
|
|
239
|
+
- a TTL of `0` or less means "do not cache".
|
|
240
|
+
|
|
241
|
+
`deniedPermissions` is evaluated before the cache is consulted, so a deny
|
|
242
|
+
added to the actor takes effect immediately rather than waiting for a cached
|
|
243
|
+
allow to expire. A malformed entry there can never match, so it is reported
|
|
244
|
+
through `onError` instead of being dropped in silence.
|
|
245
|
+
|
|
246
|
+
## External sources
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
const engine = createPermissionEngine({
|
|
250
|
+
roles,
|
|
251
|
+
roleResolver: { resolveRoles: (actor) => db.rolesFor(actor.id) },
|
|
252
|
+
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
A resolver that fails is reported through `onError` and the check continues
|
|
257
|
+
fail-closed, rather than throwing out of the authorization path.
|
|
258
|
+
|
|
259
|
+
## Failure behaviour
|
|
260
|
+
|
|
261
|
+
Every failure denies:
|
|
262
|
+
|
|
263
|
+
| Situation | Result |
|
|
264
|
+
| --------------------------- | ------------------------------------------------------ |
|
|
265
|
+
| Unknown role on the actor | denied; reported to `onError`; other roles still apply |
|
|
266
|
+
| Malformed permission string | denied, `reason: "invalid_permission"` |
|
|
267
|
+
| Condition throws | rule does not apply |
|
|
268
|
+
| Policy throws or times out | denied, `reason: "policy_error:<name>"` |
|
|
269
|
+
| Role inheritance cycle | denied; reported to `onError` |
|
|
270
|
+
| Role source throws | denied; reported to `onError` |
|
|
271
|
+
| `signal` aborted | throws `AuthorizationAbortedError` |
|
|
272
|
+
|
|
273
|
+
A value that is not an `Error` — a policy or resolver that throws a string —
|
|
274
|
+
reaches `onError` wrapped in `PolicyError` or `PermissionResolverError`, with
|
|
275
|
+
the original as `cause`.
|
|
276
|
+
|
|
277
|
+
Cancellation is the one case that throws, because the caller asked for the
|
|
278
|
+
work to stop.
|
|
279
|
+
|
|
280
|
+
## Abilities
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
const ability = engine.createAbility(actor);
|
|
284
|
+
|
|
285
|
+
await ability.can("post:update", post);
|
|
286
|
+
await ability.cannot("post:delete");
|
|
287
|
+
await ability.check("post:update", post); // full decision
|
|
288
|
+
await ability.authorize("post:update", post); // throws PermissionDeniedError
|
|
289
|
+
await ability.explain("post:update", post);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Explain
|
|
293
|
+
|
|
294
|
+
`explain` runs the same evaluation as `check` and collects the steps, so the
|
|
295
|
+
trace always describes the decision that was made:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
const { allowed, steps, decision } = await engine.explain(
|
|
299
|
+
actor,
|
|
300
|
+
"post:update",
|
|
301
|
+
post,
|
|
302
|
+
);
|
|
303
|
+
steps.forEach((step) => console.log(step.type, step.detail, step.matched));
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The trace lists the grants that bear on this decision, not every permission
|
|
307
|
+
the actor holds.
|
|
308
|
+
|
|
309
|
+
## Audit events
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { createPermissionEventEmitter } from "@zudojs/permissions";
|
|
313
|
+
|
|
314
|
+
const emitter = createPermissionEventEmitter({
|
|
315
|
+
onHandlerError: (error) => logger.error({ error }, "audit sink failed"),
|
|
316
|
+
});
|
|
317
|
+
emitter.on((event) => auditLog.write(event));
|
|
318
|
+
|
|
319
|
+
const engine = createPermissionEngine({ roles, emitter });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Every check emits — allowed, denied, and the ones that throw. A handler that
|
|
323
|
+
throws cannot break authorization, but it is reported rather than swallowed.
|
|
324
|
+
|
|
325
|
+
## HTTP middleware
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { authorize, createActorMiddleware } from "@zudojs/permissions";
|
|
329
|
+
|
|
330
|
+
const guard = authorize(engine, "post:update", {
|
|
331
|
+
extractActor: (context) => context.state.get("auth:user"),
|
|
332
|
+
extractResource: (context) => loadPost(context.request.params.get("id")),
|
|
333
|
+
extractMetadata: (context) => ({
|
|
334
|
+
tenantId: context.request.headers.get("x-tenant"),
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
24
337
|
```
|
|
25
338
|
|
|
26
|
-
|
|
339
|
+
- The guard extracts the actor itself when one is not already in state, so it
|
|
340
|
+
works without a separate actor middleware.
|
|
341
|
+
- No actor → **401** with `WWW-Authenticate`. Actor but not permitted →
|
|
342
|
+
**403**.
|
|
343
|
+
- The 403 body carries `decision.publicReason`, never the internal reason:
|
|
344
|
+
`policy_error:<name>` names your policies and does not belong in a
|
|
345
|
+
response. Pass `deniedResponse` to shape the body — it receives the real
|
|
346
|
+
decision.
|
|
347
|
+
- `context.signal` is forwarded, so a client disconnect stops policy
|
|
348
|
+
evaluation.
|
|
349
|
+
- `createRequirePermissionsMiddleware(engine, permissions, { mode })` checks
|
|
350
|
+
several permissions, short-circuiting on the first that decides the outcome.
|
|
351
|
+
|
|
352
|
+
`@zudojs/http` is an optional peer dependency; the middleware types are
|
|
353
|
+
mirrored locally so this package works without it.
|
|
354
|
+
|
|
355
|
+
## Errors
|
|
356
|
+
|
|
357
|
+
`PermissionError` is the base. `PermissionDeniedError` carries a caller-safe
|
|
358
|
+
message; the actor id, reason and policy live on `error.details` for the log,
|
|
359
|
+
not in the exposed metadata.
|
|
27
360
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- Ability-based checks
|
|
34
|
-
- Explain mode for debugging
|
|
361
|
+
`PermissionNotFoundError` · `DuplicatePermissionError` · `RoleNotFoundError` ·
|
|
362
|
+
`DuplicateRoleError` · `DuplicatePolicyError` · `InvalidPermissionError` ·
|
|
363
|
+
`InvalidRoleError` ·
|
|
364
|
+
`CircularRoleInheritanceError` · `PolicyError` · `PolicyTimeoutError` ·
|
|
365
|
+
`PermissionResolverError` · `AuthorizationAbortedError`
|
|
35
366
|
|
|
36
367
|
## Use Cases
|
|
37
368
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { PermissionActor, PermissionDecision, ExplainResult, AuthorizationOptions } from "../permissionTypes/index.js";
|
|
7
7
|
import type { EvaluatorOptions } from "../evaluator/evaluator.pipeline.js";
|
|
8
|
+
import type { PermissionEventEmitter } from "../observability/observability.core.js";
|
|
8
9
|
/**
|
|
9
10
|
* An Ability provides fast permission checks for a pre-resolved actor.
|
|
10
11
|
*/
|
|
@@ -25,5 +26,5 @@ export interface Ability {
|
|
|
25
26
|
/**
|
|
26
27
|
* Create an Ability for an actor.
|
|
27
28
|
*/
|
|
28
|
-
export declare function createAbility(actor: PermissionActor, evaluatorOptions: EvaluatorOptions): Ability;
|
|
29
|
+
export declare function createAbility(actor: PermissionActor, evaluatorOptions: EvaluatorOptions, emitter?: PermissionEventEmitter): Ability;
|
|
29
30
|
//# sourceMappingURL=ability.core.d.ts.map
|
|
@@ -3,33 +3,58 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module ability/ability
|
|
5
5
|
*/
|
|
6
|
-
import { evaluate,
|
|
6
|
+
import { evaluate, evaluateWithTrace } from "../evaluator/evaluator.core.js";
|
|
7
7
|
import { PermissionDeniedError } from "../permissionErrors/index.js";
|
|
8
8
|
/**
|
|
9
9
|
* Create an Ability for an actor.
|
|
10
10
|
*/
|
|
11
|
-
export function createAbility(actor, evaluatorOptions) {
|
|
11
|
+
export function createAbility(actor, evaluatorOptions, emitter) {
|
|
12
|
+
async function run(permission, resource, options) {
|
|
13
|
+
const start = performance.now();
|
|
14
|
+
let decision;
|
|
15
|
+
let failure;
|
|
16
|
+
try {
|
|
17
|
+
decision = await evaluate(actor, permission, resource, evaluatorOptions, options);
|
|
18
|
+
return decision;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
failure = error;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
emitter?.emit({
|
|
26
|
+
actorId: actor.id,
|
|
27
|
+
permission,
|
|
28
|
+
allowed: decision?.allowed ?? false,
|
|
29
|
+
reason: decision?.reason ??
|
|
30
|
+
(failure instanceof Error ? `error:${failure.name}` : undefined),
|
|
31
|
+
durationMs: performance.now() - start,
|
|
32
|
+
errored: failure !== undefined,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
12
36
|
return {
|
|
13
37
|
actor,
|
|
14
38
|
async can(permission, resource, options) {
|
|
15
|
-
|
|
16
|
-
return decision.allowed;
|
|
39
|
+
return (await run(permission, resource, options)).allowed;
|
|
17
40
|
},
|
|
18
41
|
async cannot(permission, resource, options) {
|
|
19
|
-
return !(await
|
|
42
|
+
return !(await run(permission, resource, options)).allowed;
|
|
20
43
|
},
|
|
21
44
|
async check(permission, resource, options) {
|
|
22
|
-
return
|
|
45
|
+
return run(permission, resource, options);
|
|
23
46
|
},
|
|
24
47
|
async explain(permission, resource, options) {
|
|
25
|
-
return
|
|
48
|
+
return evaluateWithTrace(actor, permission, resource, evaluatorOptions, options);
|
|
26
49
|
},
|
|
27
50
|
async authorize(permission, resource, options) {
|
|
28
|
-
const decision = await
|
|
51
|
+
const decision = await run(permission, resource, options);
|
|
29
52
|
if (!decision.allowed) {
|
|
30
|
-
throw new PermissionDeniedError(decision.
|
|
53
|
+
throw new PermissionDeniedError(decision.publicReason ?? "Access denied", {
|
|
31
54
|
actorId: actor.id,
|
|
32
55
|
permission,
|
|
56
|
+
reason: decision.reason,
|
|
57
|
+
policy: decision.policy,
|
|
33
58
|
});
|
|
34
59
|
}
|
|
35
60
|
},
|
|
@@ -5,13 +5,32 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { PermissionCache } from "../permissionTypes/index.js";
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Generate a cache key for a permission check.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
11
|
+
* actors `10` and `123`.
|
|
11
12
|
*/
|
|
12
|
-
export declare function
|
|
13
|
+
export declare function permissionCacheKey(actorId: string, permission: string, resourceId?: string): string;
|
|
14
|
+
/** Options for {@link createMemoryPermissionCache}. */
|
|
15
|
+
export interface MemoryPermissionCacheOptions {
|
|
16
|
+
/** Default TTL in milliseconds. Defaults to 60,000 (1 minute). */
|
|
17
|
+
readonly defaultTtlMs?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Maximum entries held. The oldest are evicted past it. Default: 10,000.
|
|
20
|
+
*
|
|
21
|
+
* Keys carry actor and resource ids, so an unbounded cache grows with
|
|
22
|
+
* traffic — and this one holds authorization decisions, which is the last
|
|
23
|
+
* place to want unbounded retention.
|
|
24
|
+
*/
|
|
25
|
+
readonly maxEntries?: number;
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
|
-
*
|
|
28
|
+
* Create an in-memory permission cache.
|
|
29
|
+
*
|
|
30
|
+
* A TTL of `0` or less means "do not cache", not "cache forever" — the
|
|
31
|
+
* inverse is a decision that outlives the grant that produced it.
|
|
15
32
|
*/
|
|
16
|
-
export declare function
|
|
33
|
+
export declare function createMemoryPermissionCache(optionsOrTtl?: MemoryPermissionCacheOptions | number): PermissionCache & {
|
|
34
|
+
size(): number;
|
|
35
|
+
};
|
|
17
36
|
//# sourceMappingURL=cache.core.d.ts.map
|
package/dist/cache/cache.core.js
CHANGED
|
@@ -3,49 +3,91 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module cache/cache
|
|
5
5
|
*/
|
|
6
|
+
/** Separates the actor id from the rest of the key. */
|
|
7
|
+
const KEY_DELIMITER = "|";
|
|
8
|
+
/** Prefix identifying an actor's cache entries. */
|
|
9
|
+
function actorPrefix(actorId) {
|
|
10
|
+
return `actor:${actorId}${KEY_DELIMITER}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generate a cache key for a permission check.
|
|
14
|
+
*
|
|
15
|
+
* The actor id is delimited, so invalidating actor `1` cannot also clear
|
|
16
|
+
* actors `10` and `123`.
|
|
17
|
+
*/
|
|
18
|
+
export function permissionCacheKey(actorId, permission, resourceId) {
|
|
19
|
+
const base = `${actorPrefix(actorId)}${permission}`;
|
|
20
|
+
return resourceId ? `${base}${KEY_DELIMITER}${resourceId}` : base;
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
23
|
+
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
6
24
|
/**
|
|
7
25
|
* Create an in-memory permission cache.
|
|
8
26
|
*
|
|
9
|
-
*
|
|
27
|
+
* A TTL of `0` or less means "do not cache", not "cache forever" — the
|
|
28
|
+
* inverse is a decision that outlives the grant that produced it.
|
|
10
29
|
*/
|
|
11
|
-
export function createMemoryPermissionCache(
|
|
30
|
+
export function createMemoryPermissionCache(optionsOrTtl = {}) {
|
|
31
|
+
const options = typeof optionsOrTtl === "number"
|
|
32
|
+
? { defaultTtlMs: optionsOrTtl }
|
|
33
|
+
: optionsOrTtl;
|
|
34
|
+
const defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
|
|
35
|
+
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
12
36
|
const store = new Map();
|
|
37
|
+
function evictIfNeeded() {
|
|
38
|
+
if (store.size <= maxEntries)
|
|
39
|
+
return;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [key, entry] of store) {
|
|
42
|
+
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
|
|
43
|
+
store.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Map iterates in insertion order, so the head is the oldest entry.
|
|
47
|
+
while (store.size > maxEntries) {
|
|
48
|
+
const oldest = store.keys().next();
|
|
49
|
+
if (oldest.done === true)
|
|
50
|
+
break;
|
|
51
|
+
store.delete(oldest.value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
13
54
|
return {
|
|
14
55
|
async get(key) {
|
|
15
56
|
const entry = store.get(key);
|
|
16
57
|
if (!entry)
|
|
17
58
|
return undefined;
|
|
18
|
-
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
59
|
+
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
|
|
19
60
|
store.delete(key);
|
|
20
61
|
return undefined;
|
|
21
62
|
}
|
|
22
63
|
return entry.value;
|
|
23
64
|
},
|
|
24
|
-
async set(key, value,
|
|
25
|
-
const ttl =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
65
|
+
async set(key, value, setOptions) {
|
|
66
|
+
const ttl = setOptions?.ttl ?? defaultTtlMs;
|
|
67
|
+
if (ttl <= 0) {
|
|
68
|
+
store.delete(key);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
store.delete(key);
|
|
72
|
+
store.set(key, { value, expiresAt: Date.now() + ttl });
|
|
73
|
+
evictIfNeeded();
|
|
30
74
|
},
|
|
31
75
|
async delete(key) {
|
|
32
76
|
store.delete(key);
|
|
33
77
|
},
|
|
34
78
|
async invalidateActor(actorId) {
|
|
35
|
-
const prefix =
|
|
36
|
-
for (const key of store.keys()) {
|
|
37
|
-
if (key.startsWith(prefix))
|
|
79
|
+
const prefix = actorPrefix(actorId);
|
|
80
|
+
for (const key of [...store.keys()]) {
|
|
81
|
+
if (key.startsWith(prefix))
|
|
38
82
|
store.delete(key);
|
|
39
|
-
}
|
|
40
83
|
}
|
|
41
84
|
},
|
|
85
|
+
async clear() {
|
|
86
|
+
store.clear();
|
|
87
|
+
},
|
|
88
|
+
size() {
|
|
89
|
+
return store.size;
|
|
90
|
+
},
|
|
42
91
|
};
|
|
43
92
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Generate a cache key for a permission check.
|
|
46
|
-
*/
|
|
47
|
-
export function permissionCacheKey(actorId, permission, resourceId) {
|
|
48
|
-
const base = `actor:${actorId}:${permission}`;
|
|
49
|
-
return resourceId ? `${base}:${resourceId}` : base;
|
|
50
|
-
}
|
|
51
93
|
//# sourceMappingURL=cache.core.js.map
|
package/dist/cache/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Condition combinators for composing authorization policies.
|
|
3
3
|
*
|
|
4
|
+
* Conditions are evaluated by the rule engine: attach one to a
|
|
5
|
+
* `PermissionRule` and the rule applies only when it returns `true`.
|
|
6
|
+
*
|
|
4
7
|
* @module conditions/conditions
|
|
5
8
|
*/
|
|
6
9
|
import type { PermissionConditionFn } from "../permissionTypes/index.js";
|
|
@@ -33,8 +36,21 @@ export declare function isOwner(ownerField?: string): PermissionConditionFn;
|
|
|
33
36
|
/**
|
|
34
37
|
* Enforce tenant isolation — actor and resource must share the same tenant.
|
|
35
38
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
39
|
+
* The actor's tenant is read from `context.metadata`, which is supplied per
|
|
40
|
+
* check through `AuthorizationOptions.metadata`:
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* engine.check(actor, "invoice:read", invoice, {
|
|
44
|
+
* metadata: { tenantId: request.tenantId },
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @param actorTenantField - Key in the context metadata holding the actor's tenant. Defaults to "tenantId".
|
|
49
|
+
* @param resourceTenantField - Field on the resource holding the tenant ID. Defaults to "tenantId".
|
|
38
50
|
*/
|
|
39
51
|
export declare function tenantIsolation(actorTenantField?: string, resourceTenantField?: string): PermissionConditionFn;
|
|
52
|
+
/** Check that a value in the context metadata equals an expected value. */
|
|
53
|
+
export declare function metadataEquals(key: string, expected: unknown): PermissionConditionFn;
|
|
54
|
+
/** Check that the resource field matches the expected value. */
|
|
55
|
+
export declare function resourceEquals(field: string, expected: unknown): PermissionConditionFn;
|
|
40
56
|
//# sourceMappingURL=conditions.core.d.ts.map
|