@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.
Files changed (83) hide show
  1. package/README.md +322 -469
  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 -3
  40. package/dist/index.js +9 -3
  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 -72
  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 -491
  78. package/dist/Ability.js +0 -207
  79. package/dist/AbilityExtra.d.ts +0 -79
  80. package/dist/AbilityExtra.js +0 -187
  81. package/dist/internal/ability.d.ts +0 -90
  82. package/dist/internal/ability.js +0 -550
  83. package/dist/internal/serializable.js +0 -22
package/README.md CHANGED
@@ -1,581 +1,434 @@
1
1
  # @nmnmcc/ability
2
2
 
3
- Authorization primitives for Effect programs, backed by CASL's rule engine.
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
- This package models authorization checks as `Effect` values and keeps failures
6
- in the typed error channel, while preserving the familiar CASL rule model:
7
- actions, subjects, fields, conditions, allow rules, and deny rules.
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
- - TypeScript projects should use strict type checking to get the full public API
12
- guarantees.
13
- - `effect` and `@casl/ability` are peer dependencies.
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
- ## Installation
24
+ ## Install
18
25
 
19
26
  ```sh
20
- yarn add @nmnmcc/ability effect @casl/ability
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 {Ability, AbilityExtra} from "@nmnmcc/ability"
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
- | Concept | Description |
32
- | ------- | ----------- |
33
- | `Ability` | An immutable, ordered set of authorization rules. |
34
- | `Rule` | A yieldable allow or deny rule recorded by `Ability.define`. |
35
- | `Ability.define` | Synchronously builds an `Ability` from a generator DSL. |
36
- | `Ability.check` | The authorization decision API. It succeeds with `void` or fails through the Effect error channel. |
37
- | `Ability.subject` | Wraps a plain object with an explicit subject name without mutating the object. |
38
- | `AbilityExtra` | Helpers for compact rule transport and rule-to-query transforms. |
39
-
40
- Important behavior:
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
- ## Getting Started
58
+ ## First Steps
49
59
 
50
- Define your domain subjects as a type-level map:
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 {Ability} from "@nmnmcc/ability"
55
-
56
- interface Post {
57
- readonly id: string
58
- readonly authorId: string
59
- readonly published: boolean
60
- readonly title: string
61
- readonly body: string
62
- }
63
-
64
- type Subjects = {
65
- readonly Post: Post
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
- Define an ability synchronously:
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 ability = Ability.define<Subjects>()(function* (ability) {
73
- yield* ability.allow("read", "Post")
74
-
75
- yield* ability.allow("update", "Post", {
76
- fields: ["title", "body"],
77
- conditions: {authorId: "u1"},
78
- reason: "Authors can edit their own content"
79
- })
80
-
81
- yield* ability.deny("delete", "Post", {
82
- conditions: {published: true},
83
- reason: "Published posts cannot be deleted"
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
- Check permissions inside an Effect program:
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
- ```ts
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
- Ability.define<Subjects>()(
129
- function* (ability) {
130
- yield* ability.allow(action, subject, options)
131
- yield* ability.deny(action, subject, options)
132
- },
133
- options
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
- ```ts
154
- const ability = Ability.define<Subjects>()(function* (ability) {
155
- yield* ability.allow("manage", "all")
156
- yield* ability.deny("delete", "Post", {
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
- **Syntax**
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
- `Ability.check` returns:
133
+ ## Resources
177
134
 
178
- ```ts
179
- Effect.Effect<void, Ability.AuthorizationError | Ability.ConditionError | Ability.SubjectDetectionError>
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
- interface PostWithComments extends Post {
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
- interface Address {
244
- readonly city: string
245
- readonly street: string
246
- }
247
-
248
- interface PostWithAddress extends Post {
249
- readonly address: Address
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
- type Subjects = {
253
- readonly Post: PostWithAddress
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
- When you check a request without `value`, conditional allow rules can still
274
- authorize the action for the subject type. Pass `value` when you need object
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
- Ability.permittedFields(ability, request, {
288
- fieldsFrom: (rule) => rule.fields ?? fallbackFields
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
- **Example**
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 fields = yield* Ability.permittedFields(
296
- ability,
297
- {
298
- action: "update",
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
- `fieldsFrom` is used when a matching rule does not declare explicit fields.
241
+ Put policies together:
309
242
 
310
- ## Subjects
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
- A check request can provide the subject name explicitly:
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
- yield* Ability.check(ability, {
316
- action: "read",
317
- subject: "Post",
318
- value: post
319
- })
254
+ const forToken = Policy.all([memberPolicy, tokenPolicy]).pipe(
255
+ Policy.ceiling(platformPolicy),
256
+ )
320
257
  ```
321
258
 
322
- You can also wrap a value with a subject name:
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
- ```ts
325
- const wrapped = Ability.subject("Post", post)
263
+ ## Questions to a Policy
326
264
 
327
- yield* Ability.check(ability, {
328
- action: "read",
329
- value: wrapped
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
- const original = Ability.unwrapSubject(wrapped)
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
- `Ability.subject` is pure and does not mutate the wrapped object.
278
+ ## Queries
336
279
 
337
- If neither `subject` nor a typed subject wrapper is provided, configure subject
338
- detection:
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 ability = Ability.define<Subjects>()(
342
- function* (ability) {
343
- yield* ability.allow("read", "Post")
344
- },
345
- {
346
- detectSubjectType: (value) => (value as {readonly __typename: "Post"}).__typename
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
- Without a custom detector, subject detection falls back to constructor metadata
360
- when available.
295
+ const where =
296
+ yield *
297
+ toSql(Policy.residual(policy, { principal, action: "read", kind: "Post" }))
298
+ ```
361
299
 
362
- ## Action Aliases
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
- Action aliases expand one rule action into additional checkable actions.
305
+ ## JSON
365
306
 
366
- **Example**
307
+ A policy with no predicates can become JSON:
367
308
 
368
309
  ```ts
369
- const ability = Ability.define<Subjects>()(
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
- yield* Ability.check(ability, {
384
- action: "update",
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
- Aliases are one-directional. A `modify` rule matches `update` and `delete`, but
392
- separate `update` and `delete` rules do not imply `modify`.
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
- Invalid aliases fail fast:
320
+ ## Authorizer
395
321
 
396
- - `manage` cannot be used as an alias name.
397
- - Aliases cannot target `manage`.
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
- const ability = yield* Ability.fromRawRules<Subjects>([
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
- const rules = yield* Ability.toRawRules(ability)
328
+ class Auth extends Authorizer.Service<Auth, { requires: Identity }>()(
329
+ "app/Auth",
330
+ Blog,
331
+ ) {}
432
332
 
433
- const nextAbility = yield* Ability.update(ability, [
434
- {
435
- action: "read",
436
- subject: "Post"
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
- `Ability.update` returns a new immutable ability and reuses the current ability
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
- const rules = yield* Ability.rulesFor(ability, {
465
- action: "update",
466
- subject: "Post",
467
- field: "title"
468
- })
469
-
470
- const rule = yield* Ability.relevantRuleFor(ability, {
471
- action: "delete",
472
- subject: "Post",
473
- value: post
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
- `possibleRulesFor`, `rulesFor`, and `actionsFor` can fail with
484
- `SubjectDetectionError`. `relevantRuleFor` can also fail with `ConditionError`.
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
- ## AbilityExtra
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
- `AbilityExtra` works with already-defined abilities and JSON-safe rules.
367
+ The errors of a check:
489
368
 
490
- ### Compact Rule Transport
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
- ```ts
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
- ### Default Fields From Conditions
503
-
504
- `rulesToFields` extracts scalar condition values from matching allow rules. This
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
- const defaults = yield* AbilityExtra.rulesToFields(ability, {
509
- action: "read",
510
- subject: "Post"
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
- For a rule with `{conditions: {authorId: "u1"}}`, the returned object includes
515
- `{authorId: "u1"}`.
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
- ### Rule-To-Query Conversion
394
+ ## HttpApi
518
395
 
519
- Use `rulesToCondition` when you own the logical query representation:
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
- const condition = yield* AbilityExtra.rulesToCondition(
523
- ability,
524
- {
525
- action: "read",
526
- subject: "Post"
527
- },
528
- (rule) => rule.conditions ?? {},
529
- {
530
- and: (conditions) => ({$and: conditions}),
531
- or: (conditions) => ({$or: conditions}),
532
- not: (condition) => ({$not: condition}),
533
- empty: () => ({})
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
- ```ts
541
- const query = yield* ability.pipe(
542
- AbilityExtra.rulesToQuery(
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
- Import CASL APIs from `@casl/ability` when you need them:
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
- ORM adapters and `accessibleBy`-style helpers are not implemented here. Use the
575
- official CASL ecosystem packages for those integrations.
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
- - [examples/basic.ts](./examples/basic.ts)
580
- - [examples/advanced.ts](./examples/advanced.ts)
581
- - [tests/Ability.test.ts](./tests/Ability.test.ts)
430
+ ```sh
431
+ node examples/basic.ts
432
+ node examples/authorizer.ts
433
+ node examples/openbot-rbac.ts
434
+ ```