@nmnmcc/ability 0.2.0 → 0.3.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/README.md +322 -469
- package/dist/Authorizer.d.ts +216 -0
- package/dist/Authorizer.d.ts.map +1 -0
- package/dist/Authorizer.js +55 -0
- package/dist/Authorizer.js.map +1 -0
- package/dist/Condition.d.ts +536 -0
- package/dist/Condition.d.ts.map +1 -0
- package/dist/Condition.js +141 -0
- package/dist/Condition.js.map +1 -0
- package/dist/Decision.d.ts +131 -0
- package/dist/Decision.d.ts.map +1 -0
- package/dist/Decision.js +83 -0
- package/dist/Decision.js.map +1 -0
- package/dist/Domain.d.ts +148 -0
- package/dist/Domain.d.ts.map +1 -0
- package/dist/Domain.js +34 -0
- package/dist/Domain.js.map +1 -0
- package/dist/HttpApi.d.ts +129 -0
- package/dist/HttpApi.d.ts.map +1 -0
- package/dist/HttpApi.js +95 -0
- package/dist/HttpApi.js.map +1 -0
- package/dist/Policy.d.ts +429 -0
- package/dist/Policy.d.ts.map +1 -0
- package/dist/Policy.js +162 -0
- package/dist/Policy.js.map +1 -0
- package/dist/Principal.d.ts +35 -0
- package/dist/Principal.d.ts.map +1 -0
- package/dist/Principal.js +37 -0
- package/dist/Principal.js.map +1 -0
- package/dist/Resource.d.ts +213 -0
- package/dist/Resource.d.ts.map +1 -0
- package/dist/Resource.js +71 -0
- package/dist/Resource.js.map +1 -0
- package/dist/Rule.d.ts +189 -0
- package/dist/Rule.d.ts.map +1 -0
- package/dist/Rule.js +50 -0
- package/dist/Rule.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +1 -2
- package/dist/index.d.ts +9 -3
- package/dist/index.js +9 -3
- package/dist/internal/authorizer.d.ts +28 -0
- package/dist/internal/authorizer.d.ts.map +1 -0
- package/dist/internal/authorizer.js +153 -0
- package/dist/internal/authorizer.js.map +1 -0
- package/dist/internal/condition.d.ts +11 -0
- package/dist/internal/condition.d.ts.map +1 -0
- package/dist/internal/condition.js +393 -0
- package/dist/internal/condition.js.map +1 -0
- package/dist/internal/domain.js +34 -0
- package/dist/internal/domain.js.map +1 -0
- package/dist/internal/httpApi.js +41 -0
- package/dist/internal/httpApi.js.map +1 -0
- package/dist/internal/policy.js +524 -0
- package/dist/internal/policy.js.map +1 -0
- package/dist/internal/resource.js +66 -0
- package/dist/internal/resource.js.map +1 -0
- package/dist/internal/rule.js +131 -0
- package/dist/internal/rule.js.map +1 -0
- package/package.json +61 -72
- package/src/Authorizer.ts +330 -0
- package/src/Condition.ts +706 -0
- package/src/Decision.ts +117 -0
- package/src/Domain.ts +189 -0
- package/src/HttpApi.ts +148 -0
- package/src/Policy.ts +575 -0
- package/src/Principal.ts +41 -0
- package/src/Resource.ts +290 -0
- package/src/Rule.ts +265 -0
- package/src/index.ts +57 -0
- package/src/internal/authorizer.ts +271 -0
- package/src/internal/condition.ts +607 -0
- package/src/internal/domain.ts +56 -0
- package/src/internal/httpApi.ts +84 -0
- package/src/internal/policy.ts +815 -0
- package/src/internal/resource.ts +98 -0
- package/src/internal/rule.ts +203 -0
- package/dist/Ability.d.ts +0 -491
- package/dist/Ability.js +0 -207
- package/dist/AbilityExtra.d.ts +0 -79
- package/dist/AbilityExtra.js +0 -187
- package/dist/internal/ability.d.ts +0 -90
- package/dist/internal/ability.js +0 -550
- package/dist/internal/serializable.js +0 -22
package/README.md
CHANGED
|
@@ -1,581 +1,434 @@
|
|
|
1
1
|
# @nmnmcc/ability
|
|
2
2
|
|
|
3
|
-
Authorization
|
|
3
|
+
Authorization tools for Effect programs. Authorization means the work of
|
|
4
|
+
deciding if a principal may do an act. A principal is the one who asks: a
|
|
5
|
+
person, a user token, or a Bot.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
The library is made with [Effect](https://effect.website) only:
|
|
8
|
+
|
|
9
|
+
- Resources and principals are Schema classes. The types of rules, conditions
|
|
10
|
+
and checks all come from them.
|
|
11
|
+
- A condition is plain data. You can test it on a value, make it smaller with
|
|
12
|
+
the principal, change it into a database query, and keep it as JSON.
|
|
13
|
+
- A policy is a tree. Lists of rules are its leaves, and you put policies
|
|
14
|
+
together with `all`, `any`, `firstOf` and `ceiling`.
|
|
15
|
+
- An `Authorizer` is an Effect service. A "no" goes into the error channel. When
|
|
16
|
+
a policy can not be loaded, the check fails, so an error is never a "yes".
|
|
8
17
|
|
|
9
18
|
## Requirements
|
|
10
19
|
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
- `effect`
|
|
14
|
-
- CASL APIs are not re-exported. Import official CASL helpers directly from
|
|
15
|
-
`@casl/ability`.
|
|
20
|
+
- Use strict type checking. Without it, the types do not give all of their
|
|
21
|
+
safety.
|
|
22
|
+
- `effect` is a peer dependency. Your project must install it.
|
|
16
23
|
|
|
17
|
-
##
|
|
24
|
+
## Install
|
|
18
25
|
|
|
19
26
|
```sh
|
|
20
|
-
|
|
27
|
+
pnpm add @nmnmcc/ability effect
|
|
21
28
|
```
|
|
22
29
|
|
|
23
30
|
## Overview
|
|
24
31
|
|
|
25
|
-
The main entry point is the following import:
|
|
26
|
-
|
|
27
32
|
```ts
|
|
28
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
Authorizer,
|
|
35
|
+
Condition,
|
|
36
|
+
Decision,
|
|
37
|
+
Domain,
|
|
38
|
+
Policy,
|
|
39
|
+
Principal,
|
|
40
|
+
Resource,
|
|
41
|
+
Rule,
|
|
42
|
+
} from "@nmnmcc/ability"
|
|
43
|
+
import * as AbilityHttpApi from "@nmnmcc/ability/HttpApi"
|
|
29
44
|
```
|
|
30
45
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- Last matching rule wins.
|
|
43
|
-
- `manage` means any action.
|
|
44
|
-
- `all` means any subject.
|
|
45
|
-
- Checks do not return booleans. Use Effect combinators to derive booleans when
|
|
46
|
-
needed.
|
|
46
|
+
| Module | What it is |
|
|
47
|
+
| ------------ | --------------------------------------------------------------------------- |
|
|
48
|
+
| `Resource` | A kind of thing to act on, with its actions, its Schema, and a path. |
|
|
49
|
+
| `Principal` | The Schema of the one who asks. |
|
|
50
|
+
| `Domain` | The principal and all the resources. Every other type comes from it. |
|
|
51
|
+
| `Condition` | A test on a resource value, as plain data. |
|
|
52
|
+
| `Rule` | An allow rule or a deny rule. |
|
|
53
|
+
| `Policy` | A tree of rules that gives a decision, a residual condition, and fields. |
|
|
54
|
+
| `Decision` | `Allow`, `Deny` or `NotApplicable`. |
|
|
55
|
+
| `Authorizer` | An Effect service that checks the principal of the current request. |
|
|
56
|
+
| `HttpApi` | Annotations and a middleware for `effect/http-api`. A separate entry point. |
|
|
47
57
|
|
|
48
|
-
##
|
|
58
|
+
## First Steps
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
Make the principal and the resources. `Resource.Class` works in the same way as
|
|
61
|
+
`Schema.TaggedClass`. The class is a Schema, a constructor, and a resource at
|
|
62
|
+
the same time:
|
|
51
63
|
|
|
52
64
|
```ts
|
|
53
|
-
import {Effect} from "effect"
|
|
54
|
-
import {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
import { Effect, Schema } from "effect"
|
|
66
|
+
import { Decision, Domain, Policy, Principal, Resource } from "@nmnmcc/ability"
|
|
67
|
+
|
|
68
|
+
class User extends Principal.Class<User>("User")({
|
|
69
|
+
id: Schema.String,
|
|
70
|
+
}) {}
|
|
71
|
+
|
|
72
|
+
class Post extends Resource.Class<Post>()("Post", {
|
|
73
|
+
actions: ["read", "update", "delete"],
|
|
74
|
+
fields: {
|
|
75
|
+
id: Schema.String,
|
|
76
|
+
authorId: Schema.String,
|
|
77
|
+
published: Schema.Boolean,
|
|
78
|
+
title: Schema.String,
|
|
79
|
+
body: Schema.String,
|
|
80
|
+
},
|
|
81
|
+
}) {}
|
|
82
|
+
|
|
83
|
+
const Blog = Domain.make({ principal: User, resources: [Post] })
|
|
67
84
|
```
|
|
68
85
|
|
|
69
|
-
|
|
86
|
+
Make a policy. Each `yield*` gives one rule. The function runs at once and gives
|
|
87
|
+
back the policy:
|
|
70
88
|
|
|
71
89
|
```ts
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
const policy = Policy.define(Blog)(function* (_) {
|
|
91
|
+
yield* _.allow("read", Post)
|
|
92
|
+
|
|
93
|
+
yield* _.allow("update", Post, {
|
|
94
|
+
fields: ["title", "body"],
|
|
95
|
+
when: ($) => $.eq("authorId", $.principal("id")),
|
|
96
|
+
reason: "Writers may change their own posts",
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
yield* _.deny("delete", Post, {
|
|
100
|
+
when: ($) => $.eq("published", true),
|
|
101
|
+
reason: "No one may delete a published post",
|
|
102
|
+
})
|
|
85
103
|
})
|
|
86
104
|
```
|
|
87
105
|
|
|
88
|
-
|
|
106
|
+
The types check every name. `_.allow("fly", Post)` is a type error, because
|
|
107
|
+
`fly` is not an action of `Post`. `$.eq("authorId", 5)` is a type error, because
|
|
108
|
+
`authorId` is a string. `$.principal("name")` is a type error, because the
|
|
109
|
+
principal has no `name` field.
|
|
89
110
|
|
|
90
|
-
|
|
91
|
-
const post: Post = {
|
|
92
|
-
id: "p1",
|
|
93
|
-
authorId: "u1",
|
|
94
|
-
published: true,
|
|
95
|
-
title: "Hello",
|
|
96
|
-
body: "World"
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const program = Effect.gen(function* () {
|
|
100
|
-
yield* Ability.check(ability, {
|
|
101
|
-
action: "update",
|
|
102
|
-
subject: "Post",
|
|
103
|
-
value: post,
|
|
104
|
-
field: "title"
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
const deleteResult = yield* Ability.check(ability, {
|
|
108
|
-
action: "delete",
|
|
109
|
-
subject: "Post",
|
|
110
|
-
value: post
|
|
111
|
-
}).pipe(
|
|
112
|
-
Effect.match({
|
|
113
|
-
onFailure: (error) =>
|
|
114
|
-
error._tag === "AuthorizationError" ? error.reason : "unexpected failure",
|
|
115
|
-
onSuccess: () => "deleted"
|
|
116
|
-
})
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
return deleteResult
|
|
120
|
-
})
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
## Defining Rules
|
|
124
|
-
|
|
125
|
-
**Syntax**
|
|
111
|
+
Get a decision:
|
|
126
112
|
|
|
127
113
|
```ts
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
`Ability.define` returns an `Ability` directly. It is not wrapped in `Effect`.
|
|
138
|
-
The yielded `Rule` values are collected in order and stored immutably.
|
|
139
|
-
|
|
140
|
-
**Example** (Multiple Actions)
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
const ability = Ability.define<Subjects>()(function* (ability) {
|
|
144
|
-
yield* ability.allow(["read", "update"], "Post", {
|
|
145
|
-
fields: ["title", "body"],
|
|
146
|
-
conditions: {authorId: "u1"}
|
|
147
|
-
})
|
|
114
|
+
const writer = User.make({ id: "u1" })
|
|
115
|
+
const post = Post.make({
|
|
116
|
+
id: "p1",
|
|
117
|
+
authorId: "u1",
|
|
118
|
+
published: true,
|
|
119
|
+
title: "Hello",
|
|
120
|
+
body: "World",
|
|
148
121
|
})
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
**Example** (`manage` and `all`)
|
|
152
122
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
conditions: {published: true},
|
|
158
|
-
reason: "Published posts cannot be deleted"
|
|
159
|
-
})
|
|
123
|
+
const decision = Policy.decideSync(policy, {
|
|
124
|
+
principal: writer,
|
|
125
|
+
action: "delete",
|
|
126
|
+
resource: post,
|
|
160
127
|
})
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Rules are resolved from the end of the rule list. In the example above, the
|
|
164
|
-
later `deny("delete", "Post")` can override the earlier `allow("manage", "all")`
|
|
165
|
-
when its condition matches.
|
|
166
|
-
|
|
167
|
-
## Checking Permissions
|
|
168
128
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
Ability.check(ability, request)
|
|
173
|
-
ability.pipe(Ability.check(request))
|
|
129
|
+
decision._tag // "Deny"
|
|
130
|
+
Decision.reason(decision) // "No one may delete a published post"
|
|
174
131
|
```
|
|
175
132
|
|
|
176
|
-
|
|
133
|
+
## Resources
|
|
177
134
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
- Success means the request is authorized.
|
|
183
|
-
- `AuthorizationError` means no allow rule matched, or the winning rule was a
|
|
184
|
-
deny rule.
|
|
185
|
-
- `ConditionError` means condition matching threw while evaluating a rule.
|
|
186
|
-
- `SubjectDetectionError` means the request did not provide a subject and the
|
|
187
|
-
subject could not be detected from the value.
|
|
188
|
-
|
|
189
|
-
**Example** (Boolean Derived From Effect)
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
const canUpdateTitle = yield* Ability.check(ability, {
|
|
193
|
-
action: "update",
|
|
194
|
-
subject: "Post",
|
|
195
|
-
value: post,
|
|
196
|
-
field: "title"
|
|
197
|
-
}).pipe(
|
|
198
|
-
Effect.match({
|
|
199
|
-
onFailure: () => false,
|
|
200
|
-
onSuccess: () => true
|
|
201
|
-
})
|
|
202
|
-
)
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
There is no synchronous `can`, `cannot`, or `allows` API in this package.
|
|
206
|
-
|
|
207
|
-
## Conditions And Fields
|
|
208
|
-
|
|
209
|
-
Conditions use CASL's Mongo-style matcher and `MongoQuery` type.
|
|
210
|
-
|
|
211
|
-
**Example** (Mongo-Style Conditions)
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
interface Comment {
|
|
215
|
-
readonly authorId: string
|
|
216
|
-
readonly body: string
|
|
217
|
-
}
|
|
135
|
+
A resource has a kind name, a list of actions, and Schema fields. Every value
|
|
136
|
+
has a `_tag` field with the kind name. The library uses `_tag` to find the
|
|
137
|
+
resource of a value.
|
|
218
138
|
|
|
219
|
-
|
|
220
|
-
readonly comments: ReadonlyArray<Comment>
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
type Subjects = {
|
|
224
|
-
readonly Post: PostWithComments
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const ability = Ability.define<Subjects>()(function* (ability) {
|
|
228
|
-
yield* ability.allow("read", "Post", {
|
|
229
|
-
conditions: {
|
|
230
|
-
published: {$eq: false},
|
|
231
|
-
comments: {$elemMatch: {authorId: "u1"}}
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
})
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Fields are typed as dot paths, with bounded recursion for predictable
|
|
238
|
-
type-checking. CASL-style field patterns are also accepted.
|
|
239
|
-
|
|
240
|
-
**Example** (Field Restrictions)
|
|
139
|
+
There are two ways to make a resource:
|
|
241
140
|
|
|
242
141
|
```ts
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
142
|
+
// A Schema class. Its body may have its own getters and methods.
|
|
143
|
+
class Bot extends Resource.Class<Bot>()("Bot", {
|
|
144
|
+
actions: ["read", "update"],
|
|
145
|
+
fields: { id: Schema.String, ownerUserId: Schema.NullOr(Schema.String) },
|
|
146
|
+
}) {
|
|
147
|
+
get shared() {
|
|
148
|
+
return this.ownerUserId === null
|
|
149
|
+
}
|
|
250
150
|
}
|
|
251
151
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const ability = Ability.define<Subjects>()(function* (ability) {
|
|
257
|
-
yield* ability.allow("update", "Post", {
|
|
258
|
-
fields: ["title", "address.**"],
|
|
259
|
-
conditions: {authorId: "u1"}
|
|
260
|
-
})
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
const post = {} as PostWithAddress
|
|
264
|
-
|
|
265
|
-
yield* Ability.check(ability, {
|
|
266
|
-
action: "update",
|
|
267
|
-
subject: "Post",
|
|
268
|
-
value: post,
|
|
269
|
-
field: "address.city"
|
|
152
|
+
// Plain objects with a `_tag` field.
|
|
153
|
+
const Trigger = Resource.make("Trigger", {
|
|
154
|
+
actions: ["read", "write"],
|
|
155
|
+
fields: { id: Schema.String, enabled: Schema.Boolean },
|
|
270
156
|
})
|
|
157
|
+
const trigger = Trigger.make({ id: "t1", enabled: true })
|
|
271
158
|
```
|
|
272
159
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
conditions to be evaluated for a specific resource. Conditional deny rules also
|
|
276
|
-
need a value before their conditions can match; unconditional deny rules still
|
|
277
|
-
apply without a value.
|
|
278
|
-
|
|
279
|
-
## Permitted Fields
|
|
280
|
-
|
|
281
|
-
Use `Ability.permittedFields` to derive the request fields allowed by matching
|
|
282
|
-
rules. Allow rules add fields; deny rules remove fields.
|
|
283
|
-
|
|
284
|
-
**Syntax**
|
|
160
|
+
A resource may have a path. The path puts the resource in a tree, so one rule
|
|
161
|
+
can cover a part of the tree:
|
|
285
162
|
|
|
286
163
|
```ts
|
|
287
|
-
|
|
288
|
-
|
|
164
|
+
class Bot extends Resource.Class<Bot>()("Bot", {
|
|
165
|
+
actions: ["read", "update"],
|
|
166
|
+
fields: {
|
|
167
|
+
id: Schema.String,
|
|
168
|
+
organizationId: Schema.String,
|
|
169
|
+
ownerUserId: Schema.NullOr(Schema.String),
|
|
170
|
+
},
|
|
171
|
+
path: (f) => ["org", f.organizationId, "user", f.ownerUserId, "bot", f.id],
|
|
172
|
+
}) {}
|
|
173
|
+
|
|
174
|
+
// Every resource of the organization of the principal.
|
|
175
|
+
_.allow("read", "*", {
|
|
176
|
+
when: ($) => $.under("org", $.principal("organizationId")),
|
|
289
177
|
})
|
|
290
178
|
```
|
|
291
179
|
|
|
292
|
-
|
|
180
|
+
## Rules
|
|
181
|
+
|
|
182
|
+
- `_.allow(action, resource, options)` and `_.deny(...)` make rules. The action
|
|
183
|
+
and the resource may be lists.
|
|
184
|
+
- The action `"*"` means every action. The resource `"*"` means every resource.
|
|
185
|
+
- `when` is the condition. It gets a builder with the types of the resource.
|
|
186
|
+
- `fields` makes the rule about some fields only. `*` stands for any part of one
|
|
187
|
+
field name, and `**` for any number of parts: `settings.*` covers
|
|
188
|
+
`settings.model`.
|
|
189
|
+
- `reason`, `id` and `meta` are kept with the rule. `meta` is extra data, such
|
|
190
|
+
as the scope of a grant. Give its Schema to `Domain.make({ meta })`.
|
|
191
|
+
|
|
192
|
+
An allow rule with fields covers a request for the whole value: "may update some
|
|
193
|
+
fields" is a yes. A deny rule with fields does not stop a request for the whole
|
|
194
|
+
value.
|
|
195
|
+
|
|
196
|
+
## Conditions
|
|
197
|
+
|
|
198
|
+
The builder in `when` has these functions:
|
|
199
|
+
|
|
200
|
+
| Function | True when |
|
|
201
|
+
| ------------------------ | ------------------------------------------------- |
|
|
202
|
+
| `eq`, `ne` | The field is (not) equal to the value. |
|
|
203
|
+
| `lt`, `lte`, `gt`, `gte` | The field is less or greater. Numbers or strings. |
|
|
204
|
+
| `in` | The field is equal to one of the values. |
|
|
205
|
+
| `exists` | The field is not `null` and not missing. |
|
|
206
|
+
| `under(...prefix)` | The path of the resource starts with the prefix. |
|
|
207
|
+
| `and`, `or`, `not` | Logic. |
|
|
208
|
+
| `principal(field)` | Not a test. It gives a field of the principal. |
|
|
209
|
+
| `predicate(name, test)` | The test function says yes. It may be an Effect. |
|
|
210
|
+
|
|
211
|
+
A missing field of a resource has the value `null`. A field of the principal
|
|
212
|
+
that is `null` or missing is different: a test with it is always false, also
|
|
213
|
+
with `ne`. So a guest with no `userId` never matches a Bot with no owner. To
|
|
214
|
+
test for `null`, use a fixed value, as in `$.eq("ownerUserId", null)`.
|
|
215
|
+
|
|
216
|
+
`predicate` is the one way out of plain data. Its test may give a boolean or an
|
|
217
|
+
`Effect`. The error type and the service type of the `Effect` go into the types
|
|
218
|
+
of the policy and of every check. A policy with a predicate can not become JSON
|
|
219
|
+
or a query, and the type system stops you when you try.
|
|
220
|
+
|
|
221
|
+
## Policies
|
|
222
|
+
|
|
223
|
+
A list of rules has a combining algorithm. It decides which rule gives the
|
|
224
|
+
answer when more than one rule applies:
|
|
225
|
+
|
|
226
|
+
| Algorithm | Winner |
|
|
227
|
+
| ------------------------- | ---------------------------------------- |
|
|
228
|
+
| `denyOverrides` (default) | A deny rule wins over every allow rule. |
|
|
229
|
+
| `permitOverrides` | An allow rule wins over every deny rule. |
|
|
230
|
+
| `firstApplicable` | The first rule that applies. |
|
|
231
|
+
| `lastApplicable` | The last rule that applies. |
|
|
293
232
|
|
|
294
233
|
```ts
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
subject: "Post",
|
|
300
|
-
value: post
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
fieldsFrom: (rule) => rule.fields ?? ["id", "authorId", "published", "title", "body"]
|
|
304
|
-
}
|
|
234
|
+
const policy = Policy.define(Blog, { algorithm: "lastApplicable" })(
|
|
235
|
+
function* (_) {
|
|
236
|
+
// ...
|
|
237
|
+
},
|
|
305
238
|
)
|
|
306
239
|
```
|
|
307
240
|
|
|
308
|
-
|
|
241
|
+
Put policies together:
|
|
309
242
|
|
|
310
|
-
|
|
243
|
+
| Combinator | Decision |
|
|
244
|
+
| ----------------------------- | ---------------------------------------------------------------- |
|
|
245
|
+
| `Policy.all([a, b])` | Every policy must allow. One deny is enough to deny. |
|
|
246
|
+
| `Policy.any([a, b])` | One allow is enough. |
|
|
247
|
+
| `Policy.firstOf([a, b])` | The first policy that allows or denies. |
|
|
248
|
+
| `Policy.ceiling(policy, cap)` | A deny from `cap` always wins. An allow from `cap` does nothing. |
|
|
311
249
|
|
|
312
|
-
|
|
250
|
+
For example, a user token may never do more than its member, and the platform
|
|
251
|
+
has deny rules for everyone:
|
|
313
252
|
|
|
314
253
|
```ts
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
value: post
|
|
319
|
-
})
|
|
254
|
+
const forToken = Policy.all([memberPolicy, tokenPolicy]).pipe(
|
|
255
|
+
Policy.ceiling(platformPolicy),
|
|
256
|
+
)
|
|
320
257
|
```
|
|
321
258
|
|
|
322
|
-
|
|
259
|
+
A decision is one of three answers: `Allow`, `Deny`, or `NotApplicable`, when no
|
|
260
|
+
rule gives an answer. A check treats `NotApplicable` as a "no", but an
|
|
261
|
+
authorizer with a decider asks the decider first.
|
|
323
262
|
|
|
324
|
-
|
|
325
|
-
const wrapped = Ability.subject("Post", post)
|
|
263
|
+
## Questions to a Policy
|
|
326
264
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
265
|
+
| Function | Answer |
|
|
266
|
+
| ------------------------ | -------------------------------------------------------------- |
|
|
267
|
+
| `Policy.decide` | The decision for one resource value, as an `Effect`. |
|
|
268
|
+
| `Policy.decideSync` | The same, at once. Only for policies with no predicates. |
|
|
269
|
+
| `Policy.explain` | The decision and the rules that were tested, for an audit log. |
|
|
270
|
+
| `Policy.residual` | The condition on resource values that the policy allows. |
|
|
271
|
+
| `Policy.permittedFields` | The fields of a value that the policy lets a request use. |
|
|
331
272
|
|
|
332
|
-
|
|
333
|
-
|
|
273
|
+
`Policy.residual` puts the principal into every rule and gives a condition on
|
|
274
|
+
the resource fields only. It is `True` when the policy allows every value of the
|
|
275
|
+
kind, and `False` when it allows none. Use it to ask "may this principal do this
|
|
276
|
+
act on some value of this kind?", or to make a database query.
|
|
334
277
|
|
|
335
|
-
|
|
278
|
+
## Queries
|
|
336
279
|
|
|
337
|
-
|
|
338
|
-
|
|
280
|
+
Change a residual condition into a query with `Condition.fold`. You give one
|
|
281
|
+
function for each kind of node:
|
|
339
282
|
|
|
340
283
|
```ts
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
)
|
|
349
|
-
|
|
350
|
-
yield* Ability.check(ability, {
|
|
351
|
-
action: "read",
|
|
352
|
-
value: {
|
|
353
|
-
...post,
|
|
354
|
-
__typename: "Post"
|
|
355
|
-
}
|
|
284
|
+
const toSql = Condition.fold<string>({
|
|
285
|
+
true: () => "TRUE",
|
|
286
|
+
false: () => "FALSE",
|
|
287
|
+
compare: (op, field, value) => `${field} ${sqlOperator(op)} ${quote(value)}`,
|
|
288
|
+
in: (field, values) => `${field} IN (${values.map(quote).join(", ")})`,
|
|
289
|
+
exists: (field) => `${field} IS NOT NULL`,
|
|
290
|
+
and: (parts) => `(${parts.join(" AND ")})`,
|
|
291
|
+
or: (parts) => `(${parts.join(" OR ")})`,
|
|
292
|
+
not: (part) => `NOT ${part}`,
|
|
356
293
|
})
|
|
357
|
-
```
|
|
358
294
|
|
|
359
|
-
|
|
360
|
-
|
|
295
|
+
const where =
|
|
296
|
+
yield *
|
|
297
|
+
toSql(Policy.residual(policy, { principal, action: "read", kind: "Post" }))
|
|
298
|
+
```
|
|
361
299
|
|
|
362
|
-
|
|
300
|
+
`fold` fails with `Untranslatable` on a predicate, unless you give a `predicate`
|
|
301
|
+
function too. Take care with `null`: in this library `ne` is true when the field
|
|
302
|
+
is `null`, but in SQL `x <> 'a'` is not true when `x` is `NULL`. Use
|
|
303
|
+
`IS DISTINCT FROM` or a similar form.
|
|
363
304
|
|
|
364
|
-
|
|
305
|
+
## JSON
|
|
365
306
|
|
|
366
|
-
|
|
307
|
+
A policy with no predicates can become JSON:
|
|
367
308
|
|
|
368
309
|
```ts
|
|
369
|
-
const
|
|
370
|
-
function* (ability) {
|
|
371
|
-
yield* ability.allow("modify", "Post", {
|
|
372
|
-
fields: ["title", "body"],
|
|
373
|
-
conditions: {authorId: "u1"}
|
|
374
|
-
})
|
|
375
|
-
},
|
|
376
|
-
{
|
|
377
|
-
actionAliases: {
|
|
378
|
-
modify: ["update", "delete"]
|
|
379
|
-
} as const
|
|
380
|
-
}
|
|
381
|
-
)
|
|
310
|
+
const PolicySchema = Policy.schema(Blog)
|
|
382
311
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
subject: "Post",
|
|
386
|
-
value: post,
|
|
387
|
-
field: "title"
|
|
388
|
-
})
|
|
312
|
+
const json = Schema.encodeSync(PolicySchema)(policy)
|
|
313
|
+
const again = Schema.decodeUnknownSync(PolicySchema)(json)
|
|
389
314
|
```
|
|
390
315
|
|
|
391
|
-
|
|
392
|
-
|
|
316
|
+
Decoding checks every kind, action, field and principal field against the
|
|
317
|
+
domain, and `meta` against the `meta` Schema of the domain. So a policy from a
|
|
318
|
+
database can not name a field that does not exist.
|
|
393
319
|
|
|
394
|
-
|
|
320
|
+
## Authorizer
|
|
395
321
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
- Aliases cannot target an empty list.
|
|
399
|
-
- Cyclic aliases are rejected.
|
|
400
|
-
|
|
401
|
-
## Raw Rules And Updates
|
|
402
|
-
|
|
403
|
-
Raw rules are JSON-safe data structures.
|
|
404
|
-
|
|
405
|
-
**Syntax**
|
|
406
|
-
|
|
407
|
-
```ts
|
|
408
|
-
Ability.fromRawRules<Subjects>(rules, options)
|
|
409
|
-
Ability.toRawRules(ability)
|
|
410
|
-
Ability.update(ability, rules)
|
|
411
|
-
ability.pipe(Ability.update(rules))
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
**Example**
|
|
322
|
+
An authorizer is an Effect service. It gets the principal of the current
|
|
323
|
+
request, loads its policy, and keeps the policy in a cache.
|
|
415
324
|
|
|
416
325
|
```ts
|
|
417
|
-
|
|
418
|
-
{
|
|
419
|
-
action: "read",
|
|
420
|
-
subject: "Post",
|
|
421
|
-
conditions: {authorId: "u1"}
|
|
422
|
-
},
|
|
423
|
-
{
|
|
424
|
-
action: "delete",
|
|
425
|
-
subject: "Post",
|
|
426
|
-
inverted: true,
|
|
427
|
-
reason: "No deletes"
|
|
428
|
-
}
|
|
429
|
-
])
|
|
326
|
+
class Identity extends Context.Service<Identity, User>()("app/Identity") {}
|
|
430
327
|
|
|
431
|
-
|
|
328
|
+
class Auth extends Authorizer.Service<Auth, { requires: Identity }>()(
|
|
329
|
+
"app/Auth",
|
|
330
|
+
Blog,
|
|
331
|
+
) {}
|
|
432
332
|
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
])
|
|
333
|
+
const AuthLive = Authorizer.layer(Auth, {
|
|
334
|
+
principal: Identity,
|
|
335
|
+
policy: (user) => loadPolicyOf(user),
|
|
336
|
+
cache: { capacity: 1000, timeToLive: "30 seconds" },
|
|
337
|
+
})
|
|
439
338
|
```
|
|
440
339
|
|
|
441
|
-
|
|
442
|
-
options, including action aliases and subject detection.
|
|
443
|
-
|
|
444
|
-
`Ability.fromRawRules` and `Ability.update` can fail with:
|
|
445
|
-
|
|
446
|
-
- `RawRuleError` for invalid raw rules.
|
|
447
|
-
- `AliasError` for invalid action alias configuration.
|
|
448
|
-
|
|
449
|
-
## Introspection
|
|
450
|
-
|
|
451
|
-
The introspection helpers are also Effect values and support data-first and
|
|
452
|
-
data-last usage.
|
|
453
|
-
|
|
454
|
-
| Function | Behavior |
|
|
455
|
-
| -------- | -------- |
|
|
456
|
-
| `Ability.possibleRulesFor` | Returns rules that may apply before field and condition checks. |
|
|
457
|
-
| `Ability.rulesFor` | Returns rules that may apply after field checks, but before condition checks. |
|
|
458
|
-
| `Ability.relevantRuleFor` | Returns the winning rule after field and condition checks. |
|
|
459
|
-
| `Ability.actionsFor` | Returns actions that have rules for a subject. |
|
|
460
|
-
|
|
461
|
-
**Example**
|
|
340
|
+
Use it in a handler:
|
|
462
341
|
|
|
463
342
|
```ts
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
})
|
|
475
|
-
|
|
476
|
-
const actions = yield* ability.pipe(
|
|
477
|
-
Ability.actionsFor({
|
|
478
|
-
subject: "Post"
|
|
479
|
-
})
|
|
480
|
-
)
|
|
343
|
+
yield * Auth.check({ action: "update", resource: post })
|
|
344
|
+
yield * Auth.check({ action: "read", kind: "Post" })
|
|
345
|
+
yield *
|
|
346
|
+
Auth.guard(
|
|
347
|
+
{ action: "update", resource: post },
|
|
348
|
+
{
|
|
349
|
+
hide: () => PostNotFound.make({ id: post.id }),
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
const where = yield * Auth.filter({ action: "read", kind: "Post" })
|
|
481
353
|
```
|
|
482
354
|
|
|
483
|
-
|
|
484
|
-
|
|
355
|
+
| Function | What it does |
|
|
356
|
+
| ----------------- | ----------------------------------------------------------------------------- |
|
|
357
|
+
| `check` | Succeeds with `void`, or fails with `Forbidden`. |
|
|
358
|
+
| `guard` | Fails with your error when the principal may not read the value, then checks. |
|
|
359
|
+
| `filter` | Gives the residual condition, for a list query. |
|
|
360
|
+
| `decide` | Gives the decision. It does not fail on a "no". |
|
|
361
|
+
| `permittedFields` | Gives the fields that the principal may use. |
|
|
362
|
+
| `invalidate` | Removes a cached policy, after a change to the rules of a principal. |
|
|
485
363
|
|
|
486
|
-
|
|
364
|
+
A check on a kind (`{ action, kind }`) passes only when the policy allows the
|
|
365
|
+
act on every value of the kind.
|
|
487
366
|
|
|
488
|
-
|
|
367
|
+
The errors of a check:
|
|
489
368
|
|
|
490
|
-
|
|
369
|
+
- `Forbidden`: the policy says no. It has the action, the kind, the field, and
|
|
370
|
+
the reason of the rule.
|
|
371
|
+
- `PolicyUnavailable`: the policy could not be loaded. It keeps the cause.
|
|
372
|
+
- `Pending`: the decider must wait for an answer. It has a `ticket`.
|
|
491
373
|
|
|
492
|
-
|
|
493
|
-
import {AbilityExtra} from "@nmnmcc/ability"
|
|
494
|
-
|
|
495
|
-
const packed = AbilityExtra.packRules(rules)
|
|
496
|
-
const unpacked = AbilityExtra.unpackRules(packed)
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
`packRules` converts raw rules into compact tuples. `unpackRules` restores the
|
|
500
|
-
raw rule objects.
|
|
374
|
+
### Deciders
|
|
501
375
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
is useful when creating initial values from authorization rules.
|
|
376
|
+
When no rule gives an answer, the authorizer asks the decider, if there is one.
|
|
377
|
+
A decider may answer at once, or fail with `Pending` when a person or a Bot must
|
|
378
|
+
answer later:
|
|
506
379
|
|
|
507
380
|
```ts
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
381
|
+
Authorizer.layer(Auth, {
|
|
382
|
+
principal: Identity,
|
|
383
|
+
policy: loadPolicyOf,
|
|
384
|
+
decider: (request) =>
|
|
385
|
+
requests
|
|
386
|
+
.create(request)
|
|
387
|
+
.pipe(Effect.flatMap((ticket) => Authorizer.Pending.make({ ticket }))),
|
|
511
388
|
})
|
|
512
389
|
```
|
|
513
390
|
|
|
514
|
-
|
|
515
|
-
|
|
391
|
+
When the answer comes, keep it as a rule, for example in a grant policy that
|
|
392
|
+
comes first: `Policy.firstOf([grants, rules])`.
|
|
516
393
|
|
|
517
|
-
|
|
394
|
+
## HttpApi
|
|
518
395
|
|
|
519
|
-
|
|
396
|
+
`@nmnmcc/ability/HttpApi` checks requests in an `effect/http-api` server. Each
|
|
397
|
+
endpoint says what it needs. An endpoint with no annotation is closed, so a new
|
|
398
|
+
endpoint does nothing until you say what it needs:
|
|
520
399
|
|
|
521
400
|
```ts
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
)
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
Use `rulesToQuery` for the built-in generic logical shape:
|
|
401
|
+
import * as AbilityHttpApi from "@nmnmcc/ability/HttpApi"
|
|
402
|
+
|
|
403
|
+
class Authorization extends HttpApiMiddleware.Service<
|
|
404
|
+
Authorization,
|
|
405
|
+
{ requires: Identity }
|
|
406
|
+
>()("app/Authorization", { error: AbilityHttpApi.errors }) {}
|
|
407
|
+
|
|
408
|
+
const update = HttpApiEndpoint.patch("update", "/posts/:id", {
|
|
409
|
+
params: { id: Schema.String },
|
|
410
|
+
}).annotate(AbilityHttpApi.Required, [
|
|
411
|
+
AbilityHttpApi.requires(Auth, "update", "Post", (params) =>
|
|
412
|
+
Posts.find(params["id"]),
|
|
413
|
+
),
|
|
414
|
+
])
|
|
539
415
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
{
|
|
544
|
-
action: "read",
|
|
545
|
-
subject: "Post"
|
|
546
|
-
},
|
|
547
|
-
(rule) => rule.conditions ?? {}
|
|
548
|
-
)
|
|
416
|
+
const health = HttpApiEndpoint.get("health", "/health").annotate(
|
|
417
|
+
AbilityHttpApi.Required,
|
|
418
|
+
[],
|
|
549
419
|
)
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
Rule conversion callbacks may return plain values or Effects. If a conversion
|
|
553
|
-
callback throws, the helper fails with `QueryGenerationError`.
|
|
554
|
-
|
|
555
|
-
## CASL Peer Dependency
|
|
556
|
-
|
|
557
|
-
This package uses the official CASL rule engine for indexing and Mongo-style
|
|
558
|
-
condition matching, but it does not re-export CASL APIs.
|
|
559
420
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
import {AbilityBuilder, createMongoAbility, subject} from "@casl/ability"
|
|
564
|
-
|
|
565
|
-
const {can, cannot, build} = new AbilityBuilder(createMongoAbility)
|
|
566
|
-
|
|
567
|
-
can("read", "Post")
|
|
568
|
-
cannot("delete", "Post", {published: true})
|
|
569
|
-
|
|
570
|
-
const caslAbility = build()
|
|
571
|
-
const canRead = caslAbility.can("read", subject("Post", post))
|
|
421
|
+
const AuthorizationLive = AbilityHttpApi.layer(Authorization, Auth)
|
|
572
422
|
```
|
|
573
423
|
|
|
574
|
-
|
|
575
|
-
|
|
424
|
+
`GroupRequired` gives a default for a whole group: one list for GET requests and
|
|
425
|
+
one for every other method. `AbilityHttpApi.errors` gives the errors with HTTP
|
|
426
|
+
status codes: 403, 503, and 409.
|
|
576
427
|
|
|
577
428
|
## Examples
|
|
578
429
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
430
|
+
```sh
|
|
431
|
+
node examples/basic.ts
|
|
432
|
+
node examples/authorizer.ts
|
|
433
|
+
node examples/openbot-rbac.ts
|
|
434
|
+
```
|