@nmnmcc/ability 0.1.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.
Files changed (85) hide show
  1. package/README.md +353 -170
  2. package/dist/Authorizer.d.ts +216 -0
  3. package/dist/Authorizer.d.ts.map +1 -0
  4. package/dist/Authorizer.js +55 -0
  5. package/dist/Authorizer.js.map +1 -0
  6. package/dist/Condition.d.ts +536 -0
  7. package/dist/Condition.d.ts.map +1 -0
  8. package/dist/Condition.js +141 -0
  9. package/dist/Condition.js.map +1 -0
  10. package/dist/Decision.d.ts +131 -0
  11. package/dist/Decision.d.ts.map +1 -0
  12. package/dist/Decision.js +83 -0
  13. package/dist/Decision.js.map +1 -0
  14. package/dist/Domain.d.ts +148 -0
  15. package/dist/Domain.d.ts.map +1 -0
  16. package/dist/Domain.js +34 -0
  17. package/dist/Domain.js.map +1 -0
  18. package/dist/HttpApi.d.ts +129 -0
  19. package/dist/HttpApi.d.ts.map +1 -0
  20. package/dist/HttpApi.js +95 -0
  21. package/dist/HttpApi.js.map +1 -0
  22. package/dist/Policy.d.ts +429 -0
  23. package/dist/Policy.d.ts.map +1 -0
  24. package/dist/Policy.js +162 -0
  25. package/dist/Policy.js.map +1 -0
  26. package/dist/Principal.d.ts +35 -0
  27. package/dist/Principal.d.ts.map +1 -0
  28. package/dist/Principal.js +37 -0
  29. package/dist/Principal.js.map +1 -0
  30. package/dist/Resource.d.ts +213 -0
  31. package/dist/Resource.d.ts.map +1 -0
  32. package/dist/Resource.js +71 -0
  33. package/dist/Resource.js.map +1 -0
  34. package/dist/Rule.d.ts +189 -0
  35. package/dist/Rule.d.ts.map +1 -0
  36. package/dist/Rule.js +50 -0
  37. package/dist/Rule.js.map +1 -0
  38. package/dist/_virtual/_rolldown/runtime.js +1 -2
  39. package/dist/index.d.ts +9 -4
  40. package/dist/index.js +9 -4
  41. package/dist/internal/authorizer.d.ts +28 -0
  42. package/dist/internal/authorizer.d.ts.map +1 -0
  43. package/dist/internal/authorizer.js +153 -0
  44. package/dist/internal/authorizer.js.map +1 -0
  45. package/dist/internal/condition.d.ts +11 -0
  46. package/dist/internal/condition.d.ts.map +1 -0
  47. package/dist/internal/condition.js +393 -0
  48. package/dist/internal/condition.js.map +1 -0
  49. package/dist/internal/domain.js +34 -0
  50. package/dist/internal/domain.js.map +1 -0
  51. package/dist/internal/httpApi.js +41 -0
  52. package/dist/internal/httpApi.js.map +1 -0
  53. package/dist/internal/policy.js +524 -0
  54. package/dist/internal/policy.js.map +1 -0
  55. package/dist/internal/resource.js +66 -0
  56. package/dist/internal/resource.js.map +1 -0
  57. package/dist/internal/rule.js +131 -0
  58. package/dist/internal/rule.js.map +1 -0
  59. package/package.json +61 -74
  60. package/src/Authorizer.ts +330 -0
  61. package/src/Condition.ts +706 -0
  62. package/src/Decision.ts +117 -0
  63. package/src/Domain.ts +189 -0
  64. package/src/HttpApi.ts +148 -0
  65. package/src/Policy.ts +575 -0
  66. package/src/Principal.ts +41 -0
  67. package/src/Resource.ts +290 -0
  68. package/src/Rule.ts +265 -0
  69. package/src/index.ts +57 -0
  70. package/src/internal/authorizer.ts +271 -0
  71. package/src/internal/condition.ts +607 -0
  72. package/src/internal/domain.ts +56 -0
  73. package/src/internal/httpApi.ts +84 -0
  74. package/src/internal/policy.ts +815 -0
  75. package/src/internal/resource.ts +98 -0
  76. package/src/internal/rule.ts +203 -0
  77. package/dist/Ability.d.ts +0 -516
  78. package/dist/Ability.js +0 -211
  79. package/dist/AbilityExtra.d.ts +0 -79
  80. package/dist/AbilityExtra.js +0 -195
  81. package/dist/AbilityRef.d.ts +0 -110
  82. package/dist/AbilityRef.js +0 -160
  83. package/dist/internal/ability.d.ts +0 -114
  84. package/dist/internal/ability.js +0 -624
  85. package/dist/internal/serializable.js +0 -22
package/README.md CHANGED
@@ -1,251 +1,434 @@
1
1
  # @nmnmcc/ability
2
2
 
3
- An Effect-style authorization library inspired by CASL.
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
- ## Installation
7
+ The library is made with [Effect](https://effect.website) only:
6
8
 
7
- ```sh
8
- yarn add @nmnmcc/ability
9
- ```
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".
10
17
 
11
- The public API is a module of functions:
18
+ ## Requirements
12
19
 
13
- - `Ability.define` synchronously builds an immutable ability from a generator DSL.
14
- - `Ability.check` is the only authorization check API and returns `Effect<void, AuthorizationError | PredicateError | ConditionError>`.
15
- - There is no synchronous `can` / `cannot` / `allows` API. Boolean checks are derived with Effect combinators.
16
- - ORM adapters and `accessibleBy`-style helpers are intentionally out of scope.
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.
17
23
 
18
- ## Basic usage
24
+ ## Install
19
25
 
20
- ```ts
21
- import { Effect } from "effect"
22
- import { Ability } from "@nmnmcc/ability"
23
-
24
- interface Post {
25
- readonly id: string
26
- readonly authorId: string
27
- readonly published: boolean
28
- readonly title: string
29
- readonly body: string
30
- }
26
+ ```sh
27
+ pnpm add @nmnmcc/ability effect
28
+ ```
31
29
 
32
- type Subjects = {
33
- readonly Post: Post
34
- }
30
+ ## Overview
35
31
 
36
- const ability = Ability.define<Subjects>()(function* (ability) {
37
- yield* ability.allow("read", "Post")
32
+ ```ts
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"
44
+ ```
38
45
 
39
- yield* ability.allow("update", "Post", {
40
- fields: ["title", "body"],
41
- when: (post) => post.authorId === "u1"
42
- })
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. |
43
57
 
44
- yield* ability.deny("delete", "Post", {
45
- when: (post) => post.published,
46
- reason: "Published posts cannot be deleted"
47
- })
48
- })
58
+ ## First Steps
49
59
 
50
- const program = Effect.gen(function* () {
51
- const post = {} as Post
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:
52
63
 
53
- yield* Ability.check(ability, {
54
- action: "update",
55
- subject: "Post",
56
- value: post,
57
- field: "title"
58
- })
59
- })
64
+ ```ts
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] })
60
84
  ```
61
85
 
62
- Denied checks fail through the Effect error channel:
86
+ Make a policy. Each `yield*` gives one rule. The function runs at once and gives
87
+ back the policy:
63
88
 
64
89
  ```ts
65
- const guarded = Ability.check(ability, {
66
- action: "delete",
67
- subject: "Post",
68
- value: {
69
- id: "p1",
70
- authorId: "u1",
71
- published: true,
72
- title: "Hello",
73
- body: "World"
74
- }
75
- }).pipe(
76
- Effect.catchTag("AuthorizationError", (error) => Effect.succeed(error.reason))
77
- )
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
+ })
103
+ })
78
104
  ```
79
105
 
80
- ## CASL-style rules
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.
81
110
 
82
- Use `manage` for any action and `all` for any subject:
111
+ Get a decision:
83
112
 
84
113
  ```ts
85
- const ability = Ability.define<Subjects>()(function* (ability) {
86
- yield* ability.allow("manage", "all")
87
- yield* ability.deny("delete", "Post", {
88
- conditions: { published: true }
89
- })
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",
90
121
  })
122
+
123
+ const decision = Policy.decideSync(policy, {
124
+ principal: writer,
125
+ action: "delete",
126
+ resource: post,
127
+ })
128
+
129
+ decision._tag // "Deny"
130
+ Decision.reason(decision) // "No one may delete a published post"
91
131
  ```
92
132
 
93
- `allow` and `deny` accept arrays:
133
+ ## Resources
134
+
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.
138
+
139
+ There are two ways to make a resource:
94
140
 
95
141
  ```ts
96
- yield* ability.allow(["read", "update"], "Post", {
97
- fields: ["title", "body"],
98
- conditions: { authorId: "u1" }
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
+ }
150
+ }
151
+
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 },
99
156
  })
157
+ const trigger = Trigger.make({ id: "t1", enabled: true })
100
158
  ```
101
159
 
102
- `conditions` use Mongo-style object matching through `@ucast/mongo2js` for in-memory objects only:
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:
103
162
 
104
163
  ```ts
105
- yield* ability.allow("read", "Post", {
106
- conditions: {
107
- tags: { $in: ["effect"] },
108
- comments: { $elemMatch: { authorId: "u1" } }
109
- }
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")),
110
177
  })
111
178
  ```
112
179
 
113
- `conditions` and `when` are combined with AND. Predicate failures remain typed Effect failures.
114
-
115
- Action aliases can expand one rule into several checkable actions:
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. |
116
232
 
117
233
  ```ts
118
- const ability = Ability.define<Subjects>()(
119
- function* (ability) {
120
- yield* ability.allow("modify", "Post", {
121
- fields: ["title", "body"]
122
- })
123
- },
124
- {
125
- actionAliases: {
126
- modify: ["update", "delete"]
127
- } as const
128
- }
234
+ const policy = Policy.define(Blog, { algorithm: "lastApplicable" })(
235
+ function* (_) {
236
+ // ...
237
+ },
129
238
  )
130
-
131
- yield* Ability.check(ability, {
132
- action: "update",
133
- subject: "Post",
134
- value: post,
135
- field: "title"
136
- })
137
239
  ```
138
240
 
139
- Aliases are one-directional: a `modify` rule matches `update`, but separate `update` and `delete` rules do not imply `modify`.
241
+ Put policies together:
140
242
 
141
- ## Subjects and fields
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. |
142
249
 
143
- DTOs can be wrapped without mutation:
250
+ For example, a user token may never do more than its member, and the platform
251
+ has deny rules for everyone:
144
252
 
145
253
  ```ts
146
- yield* Ability.check(ability, {
147
- action: "read",
148
- value: Ability.subject("Post", post)
149
- })
254
+ const forToken = Policy.all([memberPolicy, tokenPolicy]).pipe(
255
+ Policy.ceiling(platformPolicy),
256
+ )
150
257
  ```
151
258
 
152
- You can also pass `detectSubjectType` to `define` or `make`.
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.
262
+
263
+ ## Questions to a Policy
264
+
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. |
272
+
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.
153
277
 
154
- Fields support dot paths and CASL-style patterns:
278
+ ## Queries
279
+
280
+ Change a residual condition into a query with `Condition.fold`. You give one
281
+ function for each kind of node:
155
282
 
156
283
  ```ts
157
- yield* ability.allow("update", "Post", {
158
- fields: ["title", "comments.**"]
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}`,
159
293
  })
294
+
295
+ const where =
296
+ yield *
297
+ toSql(Policy.residual(policy, { principal, action: "read", kind: "Post" }))
160
298
  ```
161
299
 
162
- To filter request payloads, compute permitted fields as an Effect:
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.
304
+
305
+ ## JSON
306
+
307
+ A policy with no predicates can become JSON:
163
308
 
164
309
  ```ts
165
- const fields = yield* Ability.permittedFields(ability, {
166
- action: "update",
167
- subject: "Post",
168
- value: post
169
- }, {
170
- fieldsFrom: (rule) => rule.fields ?? ["title", "body"]
171
- })
310
+ const PolicySchema = Policy.schema(Blog)
311
+
312
+ const json = Schema.encodeSync(PolicySchema)(policy)
313
+ const again = Schema.decodeUnknownSync(PolicySchema)(json)
172
314
  ```
173
315
 
174
- For debugging or extensions, inspect indexed rules as Effects:
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.
319
+
320
+ ## Authorizer
321
+
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.
175
324
 
176
325
  ```ts
177
- const rules = yield* Ability.rulesFor(ability, {
178
- action: "update",
179
- subject: "Post",
180
- field: "title"
181
- })
326
+ class Identity extends Context.Service<Identity, User>()("app/Identity") {}
182
327
 
183
- const rule = yield* Ability.relevantRuleFor(ability, {
184
- action: "delete",
185
- subject: "Post",
186
- value: post
187
- })
328
+ class Auth extends Authorizer.Service<Auth, { requires: Identity }>()(
329
+ "app/Auth",
330
+ Blog,
331
+ ) {}
188
332
 
189
- const actions = yield* Ability.actionsFor(ability, {
190
- subject: "Post"
333
+ const AuthLive = Authorizer.layer(Auth, {
334
+ principal: Identity,
335
+ policy: (user) => loadPolicyOf(user),
336
+ cache: { capacity: 1000, timeToLive: "30 seconds" },
191
337
  })
192
338
  ```
193
339
 
194
- ## Raw rules and dynamic updates
195
-
196
- Raw rules are JSON-safe and do not include `when` predicates:
340
+ Use it in a handler:
197
341
 
198
342
  ```ts
199
- const ability = yield* Ability.fromRawRules<Subjects>([
200
- { action: "read", subject: "Post", conditions: { authorId: "u1" } },
201
- { action: "delete", subject: "Post", inverted: true, reason: "No deletes" }
202
- ])
203
-
204
- const rules = yield* Ability.toRawRules(ability)
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" })
205
353
  ```
206
354
 
207
- Use `AbilityExtra` for compact raw-rule transport and ORM-independent rule transforms:
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. |
208
363
 
209
- ```ts
210
- import { AbilityExtra } from "@nmnmcc/ability"
364
+ A check on a kind (`{ action, kind }`) passes only when the policy allows the
365
+ act on every value of the kind.
211
366
 
212
- const packed = AbilityExtra.packRules(rules)
213
- const unpacked = AbilityExtra.unpackRules(packed)
367
+ The errors of a check:
214
368
 
215
- const defaults = yield* AbilityExtra.rulesToFields(ability, {
216
- action: "read",
217
- subject: "Post"
218
- })
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`.
219
373
 
220
- const condition = yield* AbilityExtra.rulesToCondition(
221
- ability,
222
- { action: "read", subject: "Post" },
223
- (rule) => rule.conditions ?? {},
224
- {
225
- and: (conditions) => ({ $and: conditions }),
226
- or: (conditions) => ({ $or: conditions }),
227
- not: (condition) => ({ $not: condition }),
228
- empty: () => ({})
229
- }
230
- )
374
+ ### Deciders
375
+
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:
379
+
380
+ ```ts
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 }))),
388
+ })
231
389
  ```
232
390
 
233
- Rule transforms fail with `QueryGenerationError` when a relevant rule contains a `when` predicate, because function predicates cannot be serialized into a query.
391
+ When the answer comes, keep it as a rule, for example in a grant policy that
392
+ comes first: `Policy.firstOf([grants, rules])`.
234
393
 
235
- `Ability` stays immutable. Use `AbilityRef` when an application needs to replace the current ability:
394
+ ## HttpApi
236
395
 
237
- ```ts
238
- import { AbilityRef } from "@nmnmcc/ability"
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:
239
399
 
240
- const ref = AbilityRef.make(ability)
400
+ ```ts
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
+ ])
241
415
 
242
- const unsubscribe = yield* AbilityRef.on(ref, "updated", () =>
243
- Effect.sync(() => {
244
- console.log("ability updated")
245
- }))
416
+ const health = HttpApiEndpoint.get("health", "/health").annotate(
417
+ AbilityHttpApi.Required,
418
+ [],
419
+ )
246
420
 
247
- yield* AbilityRef.set(ref, nextAbility)
248
- unsubscribe()
421
+ const AuthorizationLive = AbilityHttpApi.layer(Authorization, Auth)
249
422
  ```
250
423
 
251
- See [examples/basic.ts](./examples/basic.ts), [examples/advanced.ts](./examples/advanced.ts), and [tests/Ability.test.ts](./tests/Ability.test.ts).
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.
427
+
428
+ ## Examples
429
+
430
+ ```sh
431
+ node examples/basic.ts
432
+ node examples/authorizer.ts
433
+ node examples/openbot-rbac.ts
434
+ ```