@nmnmcc/ability 0.1.0 → 0.2.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 CHANGED
@@ -1,25 +1,57 @@
1
1
  # @nmnmcc/ability
2
2
 
3
- An Effect-style authorization library inspired by CASL.
3
+ Authorization primitives for Effect programs, backed by CASL's rule engine.
4
+
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.
8
+
9
+ ## Requirements
10
+
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`.
4
16
 
5
17
  ## Installation
6
18
 
7
19
  ```sh
8
- yarn add @nmnmcc/ability
20
+ yarn add @nmnmcc/ability effect @casl/ability
21
+ ```
22
+
23
+ ## Overview
24
+
25
+ The main entry point is the following import:
26
+
27
+ ```ts
28
+ import {Ability, AbilityExtra} from "@nmnmcc/ability"
9
29
  ```
10
30
 
11
- The public API is a module of functions:
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:
12
41
 
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.
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.
17
47
 
18
- ## Basic usage
48
+ ## Getting Started
49
+
50
+ Define your domain subjects as a type-level map:
19
51
 
20
52
  ```ts
21
- import { Effect } from "effect"
22
- import { Ability } from "@nmnmcc/ability"
53
+ import {Effect} from "effect"
54
+ import {Ability} from "@nmnmcc/ability"
23
55
 
24
56
  interface Post {
25
57
  readonly id: string
@@ -32,93 +64,313 @@ interface Post {
32
64
  type Subjects = {
33
65
  readonly Post: Post
34
66
  }
67
+ ```
35
68
 
69
+ Define an ability synchronously:
70
+
71
+ ```ts
36
72
  const ability = Ability.define<Subjects>()(function* (ability) {
37
73
  yield* ability.allow("read", "Post")
38
74
 
39
75
  yield* ability.allow("update", "Post", {
40
76
  fields: ["title", "body"],
41
- when: (post) => post.authorId === "u1"
77
+ conditions: {authorId: "u1"},
78
+ reason: "Authors can edit their own content"
42
79
  })
43
80
 
44
81
  yield* ability.deny("delete", "Post", {
45
- when: (post) => post.published,
82
+ conditions: {published: true},
46
83
  reason: "Published posts cannot be deleted"
47
84
  })
48
85
  })
86
+ ```
49
87
 
50
- const program = Effect.gen(function* () {
51
- const post = {} as Post
88
+ Check permissions inside an Effect program:
89
+
90
+ ```ts
91
+ const post: Post = {
92
+ id: "p1",
93
+ authorId: "u1",
94
+ published: true,
95
+ title: "Hello",
96
+ body: "World"
97
+ }
52
98
 
99
+ const program = Effect.gen(function* () {
53
100
  yield* Ability.check(ability, {
54
101
  action: "update",
55
102
  subject: "Post",
56
103
  value: post,
57
104
  field: "title"
58
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
59
120
  })
60
121
  ```
61
122
 
62
- Denied checks fail through the Effect error channel:
123
+ ## Defining Rules
124
+
125
+ **Syntax**
63
126
 
64
127
  ```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))
128
+ Ability.define<Subjects>()(
129
+ function* (ability) {
130
+ yield* ability.allow(action, subject, options)
131
+ yield* ability.deny(action, subject, options)
132
+ },
133
+ options
77
134
  )
78
135
  ```
79
136
 
80
- ## CASL-style rules
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
+ })
148
+ })
149
+ ```
81
150
 
82
- Use `manage` for any action and `all` for any subject:
151
+ **Example** (`manage` and `all`)
83
152
 
84
153
  ```ts
85
154
  const ability = Ability.define<Subjects>()(function* (ability) {
86
155
  yield* ability.allow("manage", "all")
87
156
  yield* ability.deny("delete", "Post", {
88
- conditions: { published: true }
157
+ conditions: {published: true},
158
+ reason: "Published posts cannot be deleted"
89
159
  })
90
160
  })
91
161
  ```
92
162
 
93
- `allow` and `deny` accept arrays:
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
+
169
+ **Syntax**
170
+
171
+ ```ts
172
+ Ability.check(ability, request)
173
+ ability.pipe(Ability.check(request))
174
+ ```
175
+
176
+ `Ability.check` returns:
177
+
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
+ }
218
+
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)
241
+
242
+ ```ts
243
+ interface Address {
244
+ readonly city: string
245
+ readonly street: string
246
+ }
247
+
248
+ interface PostWithAddress extends Post {
249
+ readonly address: Address
250
+ }
251
+
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"
270
+ })
271
+ ```
272
+
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**
94
285
 
95
286
  ```ts
96
- yield* ability.allow(["read", "update"], "Post", {
97
- fields: ["title", "body"],
98
- conditions: { authorId: "u1" }
287
+ Ability.permittedFields(ability, request, {
288
+ fieldsFrom: (rule) => rule.fields ?? fallbackFields
99
289
  })
100
290
  ```
101
291
 
102
- `conditions` use Mongo-style object matching through `@ucast/mongo2js` for in-memory objects only:
292
+ **Example**
103
293
 
104
294
  ```ts
105
- yield* ability.allow("read", "Post", {
106
- conditions: {
107
- tags: { $in: ["effect"] },
108
- comments: { $elemMatch: { authorId: "u1" } }
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"]
109
304
  }
305
+ )
306
+ ```
307
+
308
+ `fieldsFrom` is used when a matching rule does not declare explicit fields.
309
+
310
+ ## Subjects
311
+
312
+ A check request can provide the subject name explicitly:
313
+
314
+ ```ts
315
+ yield* Ability.check(ability, {
316
+ action: "read",
317
+ subject: "Post",
318
+ value: post
110
319
  })
111
320
  ```
112
321
 
113
- `conditions` and `when` are combined with AND. Predicate failures remain typed Effect failures.
322
+ You can also wrap a value with a subject name:
114
323
 
115
- Action aliases can expand one rule into several checkable actions:
324
+ ```ts
325
+ const wrapped = Ability.subject("Post", post)
326
+
327
+ yield* Ability.check(ability, {
328
+ action: "read",
329
+ value: wrapped
330
+ })
331
+
332
+ const original = Ability.unwrapSubject(wrapped)
333
+ ```
334
+
335
+ `Ability.subject` is pure and does not mutate the wrapped object.
336
+
337
+ If neither `subject` nor a typed subject wrapper is provided, configure subject
338
+ detection:
339
+
340
+ ```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
+ }
356
+ })
357
+ ```
358
+
359
+ Without a custom detector, subject detection falls back to constructor metadata
360
+ when available.
361
+
362
+ ## Action Aliases
363
+
364
+ Action aliases expand one rule action into additional checkable actions.
365
+
366
+ **Example**
116
367
 
117
368
  ```ts
118
369
  const ability = Ability.define<Subjects>()(
119
370
  function* (ability) {
120
371
  yield* ability.allow("modify", "Post", {
121
- fields: ["title", "body"]
372
+ fields: ["title", "body"],
373
+ conditions: {authorId: "u1"}
122
374
  })
123
375
  },
124
376
  {
@@ -136,42 +388,77 @@ yield* Ability.check(ability, {
136
388
  })
137
389
  ```
138
390
 
139
- Aliases are one-directional: a `modify` rule matches `update`, but separate `update` and `delete` rules do not imply `modify`.
391
+ Aliases are one-directional. A `modify` rule matches `update` and `delete`, but
392
+ separate `update` and `delete` rules do not imply `modify`.
140
393
 
141
- ## Subjects and fields
394
+ Invalid aliases fail fast:
142
395
 
143
- DTOs can be wrapped without mutation:
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.
144
400
 
145
- ```ts
146
- yield* Ability.check(ability, {
147
- action: "read",
148
- value: Ability.subject("Post", post)
149
- })
150
- ```
401
+ ## Raw Rules And Updates
151
402
 
152
- You can also pass `detectSubjectType` to `define` or `make`.
403
+ Raw rules are JSON-safe data structures.
153
404
 
154
- Fields support dot paths and CASL-style patterns:
405
+ **Syntax**
155
406
 
156
407
  ```ts
157
- yield* ability.allow("update", "Post", {
158
- fields: ["title", "comments.**"]
159
- })
408
+ Ability.fromRawRules<Subjects>(rules, options)
409
+ Ability.toRawRules(ability)
410
+ Ability.update(ability, rules)
411
+ ability.pipe(Ability.update(rules))
160
412
  ```
161
413
 
162
- To filter request payloads, compute permitted fields as an Effect:
414
+ **Example**
163
415
 
164
416
  ```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
- })
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
+ ])
430
+
431
+ const rules = yield* Ability.toRawRules(ability)
432
+
433
+ const nextAbility = yield* Ability.update(ability, [
434
+ {
435
+ action: "read",
436
+ subject: "Post"
437
+ }
438
+ ])
172
439
  ```
173
440
 
174
- For debugging or extensions, inspect indexed rules as Effects:
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**
175
462
 
176
463
  ```ts
177
464
  const rules = yield* Ability.rulesFor(ability, {
@@ -186,66 +473,109 @@ const rule = yield* Ability.relevantRuleFor(ability, {
186
473
  value: post
187
474
  })
188
475
 
189
- const actions = yield* Ability.actionsFor(ability, {
190
- subject: "Post"
191
- })
476
+ const actions = yield* ability.pipe(
477
+ Ability.actionsFor({
478
+ subject: "Post"
479
+ })
480
+ )
192
481
  ```
193
482
 
194
- ## Raw rules and dynamic updates
483
+ `possibleRulesFor`, `rulesFor`, and `actionsFor` can fail with
484
+ `SubjectDetectionError`. `relevantRuleFor` can also fail with `ConditionError`.
195
485
 
196
- Raw rules are JSON-safe and do not include `when` predicates:
486
+ ## AbilityExtra
197
487
 
198
- ```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
- ])
488
+ `AbilityExtra` works with already-defined abilities and JSON-safe rules.
203
489
 
204
- const rules = yield* Ability.toRawRules(ability)
205
- ```
206
-
207
- Use `AbilityExtra` for compact raw-rule transport and ORM-independent rule transforms:
490
+ ### Compact Rule Transport
208
491
 
209
492
  ```ts
210
- import { AbilityExtra } from "@nmnmcc/ability"
493
+ import {AbilityExtra} from "@nmnmcc/ability"
211
494
 
212
495
  const packed = AbilityExtra.packRules(rules)
213
496
  const unpacked = AbilityExtra.unpackRules(packed)
497
+ ```
498
+
499
+ `packRules` converts raw rules into compact tuples. `unpackRules` restores the
500
+ raw rule objects.
214
501
 
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.
506
+
507
+ ```ts
215
508
  const defaults = yield* AbilityExtra.rulesToFields(ability, {
216
509
  action: "read",
217
510
  subject: "Post"
218
511
  })
512
+ ```
513
+
514
+ For a rule with `{conditions: {authorId: "u1"}}`, the returned object includes
515
+ `{authorId: "u1"}`.
516
+
517
+ ### Rule-To-Query Conversion
219
518
 
519
+ Use `rulesToCondition` when you own the logical query representation:
520
+
521
+ ```ts
220
522
  const condition = yield* AbilityExtra.rulesToCondition(
221
523
  ability,
222
- { action: "read", subject: "Post" },
524
+ {
525
+ action: "read",
526
+ subject: "Post"
527
+ },
223
528
  (rule) => rule.conditions ?? {},
224
529
  {
225
- and: (conditions) => ({ $and: conditions }),
226
- or: (conditions) => ({ $or: conditions }),
227
- not: (condition) => ({ $not: condition }),
530
+ and: (conditions) => ({$and: conditions}),
531
+ or: (conditions) => ({$or: conditions}),
532
+ not: (condition) => ({$not: condition}),
228
533
  empty: () => ({})
229
534
  }
230
535
  )
231
536
  ```
232
537
 
233
- Rule transforms fail with `QueryGenerationError` when a relevant rule contains a `when` predicate, because function predicates cannot be serialized into a query.
538
+ Use `rulesToQuery` for the built-in generic logical shape:
234
539
 
235
- `Ability` stays immutable. Use `AbilityRef` when an application needs to replace the current ability:
540
+ ```ts
541
+ const query = yield* ability.pipe(
542
+ AbilityExtra.rulesToQuery(
543
+ {
544
+ action: "read",
545
+ subject: "Post"
546
+ },
547
+ (rule) => rule.conditions ?? {}
548
+ )
549
+ )
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
+
560
+ Import CASL APIs from `@casl/ability` when you need them:
236
561
 
237
562
  ```ts
238
- import { AbilityRef } from "@nmnmcc/ability"
563
+ import {AbilityBuilder, createMongoAbility, subject} from "@casl/ability"
239
564
 
240
- const ref = AbilityRef.make(ability)
565
+ const {can, cannot, build} = new AbilityBuilder(createMongoAbility)
241
566
 
242
- const unsubscribe = yield* AbilityRef.on(ref, "updated", () =>
243
- Effect.sync(() => {
244
- console.log("ability updated")
245
- }))
567
+ can("read", "Post")
568
+ cannot("delete", "Post", {published: true})
246
569
 
247
- yield* AbilityRef.set(ref, nextAbility)
248
- unsubscribe()
570
+ const caslAbility = build()
571
+ const canRead = caslAbility.can("read", subject("Post", post))
249
572
  ```
250
573
 
251
- See [examples/basic.ts](./examples/basic.ts), [examples/advanced.ts](./examples/advanced.ts), and [tests/Ability.test.ts](./tests/Ability.test.ts).
574
+ ORM adapters and `accessibleBy`-style helpers are not implemented here. Use the
575
+ official CASL ecosystem packages for those integrations.
576
+
577
+ ## Examples
578
+
579
+ - [examples/basic.ts](./examples/basic.ts)
580
+ - [examples/advanced.ts](./examples/advanced.ts)
581
+ - [tests/Ability.test.ts](./tests/Ability.test.ts)