@nmnmcc/ability 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +143 -87
  2. package/dist/Authorizer.d.ts +43 -47
  3. package/dist/Authorizer.d.ts.map +1 -1
  4. package/dist/Authorizer.js +5 -290
  5. package/dist/Authorizer.js.map +1 -1
  6. package/dist/Condition.d.ts +164 -332
  7. package/dist/Condition.d.ts.map +1 -1
  8. package/dist/Condition.js +75 -890
  9. package/dist/Condition.js.map +1 -1
  10. package/dist/Decision.d.ts +13 -13
  11. package/dist/Decision.d.ts.map +1 -1
  12. package/dist/Decision.js.map +1 -1
  13. package/dist/Domain.d.ts +10 -21
  14. package/dist/Domain.d.ts.map +1 -1
  15. package/dist/Domain.js +4 -62
  16. package/dist/Domain.js.map +1 -1
  17. package/dist/HttpApi.d.ts +31 -7
  18. package/dist/HttpApi.d.ts.map +1 -1
  19. package/dist/HttpApi.js +28 -11
  20. package/dist/HttpApi.js.map +1 -1
  21. package/dist/Limits.d.ts +114 -0
  22. package/dist/Limits.d.ts.map +1 -0
  23. package/dist/Limits.js +132 -0
  24. package/dist/Limits.js.map +1 -0
  25. package/dist/Policy.d.ts +274 -272
  26. package/dist/Policy.d.ts.map +1 -1
  27. package/dist/Policy.js +71 -1180
  28. package/dist/Policy.js.map +1 -1
  29. package/dist/Resource.d.ts +8 -181
  30. package/dist/Resource.d.ts.map +1 -1
  31. package/dist/Resource.js +8 -807
  32. package/dist/Resource.js.map +1 -1
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +4 -5
  35. package/dist/internal/algebra.js +267 -0
  36. package/dist/internal/algebra.js.map +1 -0
  37. package/dist/internal/analysis.js +245 -0
  38. package/dist/internal/analysis.js.map +1 -0
  39. package/dist/internal/authorizer.d.ts +30 -0
  40. package/dist/internal/authorizer.d.ts.map +1 -0
  41. package/dist/internal/authorizer.js +221 -0
  42. package/dist/internal/authorizer.js.map +1 -0
  43. package/dist/internal/bind.js +286 -0
  44. package/dist/internal/bind.js.map +1 -0
  45. package/dist/internal/catalog.d.ts +56 -0
  46. package/dist/internal/catalog.d.ts.map +1 -0
  47. package/dist/internal/catalog.js +292 -0
  48. package/dist/internal/catalog.js.map +1 -0
  49. package/dist/internal/decide.js +166 -0
  50. package/dist/internal/decide.js.map +1 -0
  51. package/dist/internal/domain.js +32 -0
  52. package/dist/internal/domain.js.map +1 -0
  53. package/dist/internal/json.js +134 -0
  54. package/dist/internal/json.js.map +1 -0
  55. package/dist/internal/limits.d.ts +14 -0
  56. package/dist/internal/limits.d.ts.map +1 -0
  57. package/dist/internal/limits.js +43 -0
  58. package/dist/internal/limits.js.map +1 -0
  59. package/dist/internal/nodes.d.ts +121 -0
  60. package/dist/internal/nodes.d.ts.map +1 -0
  61. package/dist/internal/nodes.js +277 -0
  62. package/dist/internal/nodes.js.map +1 -0
  63. package/dist/internal/patterns.js +110 -0
  64. package/dist/internal/patterns.js.map +1 -0
  65. package/dist/internal/policy-json.js +255 -0
  66. package/dist/internal/policy-json.js.map +1 -0
  67. package/dist/internal/policy-nodes.d.ts +61 -0
  68. package/dist/internal/policy-nodes.d.ts.map +1 -0
  69. package/dist/internal/policy-nodes.js +146 -0
  70. package/dist/internal/policy-nodes.js.map +1 -0
  71. package/dist/internal/policy.d.ts +42 -0
  72. package/dist/internal/policy.d.ts.map +1 -0
  73. package/dist/internal/policy.js +124 -0
  74. package/dist/internal/policy.js.map +1 -0
  75. package/dist/internal/read.js +39 -0
  76. package/dist/internal/read.js.map +1 -0
  77. package/dist/internal/resource.js +115 -0
  78. package/dist/internal/resource.js.map +1 -0
  79. package/dist/internal/rule.d.ts +26 -0
  80. package/dist/internal/rule.d.ts.map +1 -0
  81. package/dist/internal/rule.js +93 -0
  82. package/dist/internal/rule.js.map +1 -0
  83. package/dist/internal/search.d.ts +22 -0
  84. package/dist/internal/search.d.ts.map +1 -0
  85. package/dist/internal/search.js +274 -0
  86. package/dist/internal/search.js.map +1 -0
  87. package/dist/internal/suggest.js +46 -0
  88. package/dist/internal/suggest.js.map +1 -0
  89. package/package.json +4 -5
  90. package/src/Authorizer.ts +69 -585
  91. package/src/Condition.ts +339 -2095
  92. package/src/Decision.ts +6 -5
  93. package/src/Domain.ts +13 -70
  94. package/src/HttpApi.ts +75 -13
  95. package/src/Limits.ts +132 -0
  96. package/src/Policy.ts +386 -2772
  97. package/src/Resource.ts +14 -1555
  98. package/src/index.ts +3 -9
  99. package/src/internal/algebra.ts +482 -0
  100. package/src/internal/analysis.ts +478 -0
  101. package/src/internal/authorizer.ts +418 -0
  102. package/src/internal/bind.ts +596 -0
  103. package/src/internal/catalog.ts +534 -0
  104. package/src/internal/decide.ts +402 -0
  105. package/src/internal/domain.ts +66 -0
  106. package/src/internal/json.ts +260 -0
  107. package/src/internal/limits.ts +78 -0
  108. package/src/internal/nodes.ts +404 -0
  109. package/src/internal/patterns.ts +211 -0
  110. package/src/internal/policy-json.ts +484 -0
  111. package/src/internal/policy-nodes.ts +207 -0
  112. package/src/internal/policy.ts +264 -0
  113. package/src/internal/read.ts +55 -0
  114. package/src/internal/resource.ts +180 -0
  115. package/src/internal/rule.ts +181 -0
  116. package/src/internal/search.ts +541 -0
  117. package/src/internal/suggest.ts +104 -0
  118. package/dist/Principal.d.ts +0 -35
  119. package/dist/Principal.d.ts.map +0 -1
  120. package/dist/Principal.js +0 -37
  121. package/dist/Principal.js.map +0 -1
  122. package/dist/Rule.d.ts +0 -275
  123. package/dist/Rule.d.ts.map +0 -1
  124. package/dist/Rule.js +0 -257
  125. package/dist/Rule.js.map +0 -1
  126. package/src/Principal.ts +0 -41
  127. package/src/Rule.ts +0 -683
@@ -0,0 +1,418 @@
1
+ /**
2
+ * The values of authorizers: the errors, the service class and the layer.
3
+ * The types are in `Authorizer.ts`.
4
+ */
5
+ // The service erases the error type and the service type of predicates,
6
+ // principals and deciders to `unknown`. The public types keep them, so the
7
+ // language service check for `unknown` in an Effect is off here.
8
+ // @effect-diagnostics anyUnknownInErrorContext:off
9
+ import * as Cache from "effect/Cache"
10
+ import * as Context from "effect/Context"
11
+ import type * as Duration from "effect/Duration"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Equal from "effect/Equal"
14
+ import * as Hash from "effect/Hash"
15
+ import * as Layer from "effect/Layer"
16
+ import * as Result from "effect/Result"
17
+ import * as Schema from "effect/Schema"
18
+ import type * as Authorizer from "../Authorizer.ts"
19
+ import type * as Condition from "../Condition.ts"
20
+ import * as Decision from "../Decision.ts"
21
+ import type * as Domain from "../Domain.ts"
22
+ import type * as Policy from "../Policy.ts"
23
+ import * as Analysis from "./analysis.ts"
24
+ import * as Decide from "./decide.ts"
25
+ import * as DomainData from "./domain.ts"
26
+ import * as Policies from "./policy.ts"
27
+ import * as ResourceData from "./resource.ts"
28
+
29
+ type AnyServiceClass = Authorizer.AnyServiceClass
30
+
31
+ /** The error when a policy says no. */
32
+ export class Forbidden extends Schema.TaggedError<Forbidden>(
33
+ "@nmnmcc/ability/Authorizer/Forbidden",
34
+ )("Forbidden", {
35
+ action: Schema.String,
36
+ kind: Schema.String,
37
+ field: Schema.optionalKey(Schema.String),
38
+ reason: Schema.optionalKey(Schema.String),
39
+ }) {}
40
+
41
+ /** The error when the policy of the principal can not be loaded. */
42
+ export class PolicyUnavailable extends Schema.TaggedError<PolicyUnavailable>(
43
+ "@nmnmcc/ability/Authorizer/PolicyUnavailable",
44
+ )("PolicyUnavailable", { cause: Schema.Defect() }) {}
45
+
46
+ /** The error when a decider must wait for an answer. */
47
+ export class Pending extends Schema.TaggedError<Pending>(
48
+ "@nmnmcc/ability/Authorizer/Pending",
49
+ )("Pending", {
50
+ ticket: Schema.String,
51
+ reason: Schema.optionalKey(Schema.String),
52
+ }) {}
53
+
54
+ /** Makes an authorizer service class for a domain. */
55
+ export const Service =
56
+ () =>
57
+ (
58
+ id: string,
59
+ options: {
60
+ readonly domain: Domain.Any
61
+ readonly principal: Effect.Effect<any, any, any>
62
+ },
63
+ // The class has no name, and its type uses types of Effect that a
64
+ // declaration file can not name. So this function gives a `Context.Key`.
65
+ // The public module gives the full type.
66
+ ): Context.Key<any, any> => {
67
+ const Base = Context.Service<any, any>()(id)
68
+ return class extends Base {
69
+ static readonly domain = options.domain
70
+ static readonly principal = options.principal
71
+ static readonly decide = (target: AnyTarget) =>
72
+ this.use((service: any) => service.decide(target))
73
+ static readonly check = (target: AnyTarget) =>
74
+ this.use((service: any) => service.check(target))
75
+ static readonly guard = (target: AnyTarget, options: unknown) =>
76
+ this.use((service: any) => service.guard(target, options))
77
+ static readonly filter = (target: AnyTarget) =>
78
+ this.use((service: any) => service.filter(target))
79
+ static readonly coverage = (target: AnyTarget) =>
80
+ this.use((service: any) => service.coverage(target))
81
+ static readonly permittedFields = (target: AnyTarget) =>
82
+ this.use((service: any) => service.permittedFields(target))
83
+ }
84
+ }
85
+
86
+ type AnyTarget = {
87
+ readonly action: string
88
+ readonly resource?: object
89
+ readonly kind?: string
90
+ readonly field?: string
91
+ readonly within?: unknown
92
+ }
93
+
94
+ /**
95
+ * A cache key for a principal. Two keys are equal when they have the same
96
+ * text, so two principals with the same key share one policy. The key also
97
+ * keeps the principal, so the cache can load its policy. A plain `Data` value
98
+ * can not do both, so the key gives its own equality with `Equal.Equal`.
99
+ */
100
+ class PrincipalKey implements Equal.Equal {
101
+ readonly principal: object
102
+ readonly text: string
103
+ constructor(principal: object, text: string) {
104
+ this.principal = principal
105
+ this.text = text
106
+ }
107
+ [Equal.symbol](that: unknown): boolean {
108
+ return that instanceof PrincipalKey && that.text === this.text
109
+ }
110
+ [Hash.symbol](): number {
111
+ return Hash.string(this.text)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Makes the cache key of a principal from its JSON form. The key keeps the
117
+ * principal that the Schema decodes from that form, so the loader sees only
118
+ * the data that the key shows. A principal gets no key when it does not
119
+ * encode, or when the decoded value is not `Equal` to it. Two classes, or a
120
+ * string and a number, may have one JSON form, as in a union, and `Equal`
121
+ * tells them apart. So two principals with one key are equal, and the cache
122
+ * can not give one the policy of the other.
123
+ */
124
+ /**
125
+ * Gives the JSON codec of a Schema, or `undefined` when the Schema has no JSON
126
+ * form, as a struct with a symbol key has none.
127
+ */
128
+ const jsonCodecOf = (
129
+ schema: Schema.Top,
130
+ ): Schema.Codec<unknown, unknown> | undefined => {
131
+ try {
132
+ return Schema.toCodecJson(schema) as unknown as Schema.Codec<
133
+ unknown,
134
+ unknown
135
+ >
136
+ } catch {
137
+ return undefined
138
+ }
139
+ }
140
+
141
+ const jsonKey = (
142
+ schema: Schema.Top,
143
+ ): ((principal: object) => PrincipalKey | undefined) => {
144
+ const codec = jsonCodecOf(schema)
145
+ // A principal with no JSON form is never kept.
146
+ if (codec === undefined) {
147
+ return () => undefined
148
+ }
149
+ const encode = Schema.encodeUnknownResult(codec)
150
+ const decode = Schema.decodeUnknownResult(codec)
151
+ const keyOf = (principal: object): PrincipalKey | undefined =>
152
+ Result.match(encode(principal), {
153
+ onFailure: () => undefined,
154
+ onSuccess: (json) => {
155
+ // The key is the text, so the check decodes the text. The encoded
156
+ // value may hold more than its text shows, as an object of a
157
+ // `Schema.Any` field does: `JSON.stringify` leaves out a map.
158
+ const text = JSON.stringify(json)
159
+ return Result.match(decode(JSON.parse(text)), {
160
+ onFailure: () => undefined,
161
+ onSuccess: (decoded) =>
162
+ Equal.equals(decoded, principal)
163
+ ? new PrincipalKey(decoded as object, text)
164
+ : undefined,
165
+ })
166
+ },
167
+ })
168
+ return (principal) => {
169
+ // A very deep principal may fill the stack of the encoder or of
170
+ // `Equal`. Such a principal is not kept.
171
+ try {
172
+ return keyOf(principal)
173
+ } catch {
174
+ return undefined
175
+ }
176
+ }
177
+ }
178
+
179
+ const forbidden = (
180
+ target: AnyTarget,
181
+ kind: string,
182
+ reason: string | undefined,
183
+ ): Forbidden =>
184
+ Forbidden.make({
185
+ action: target.action,
186
+ kind,
187
+ ...(target.field === undefined ? {} : { field: target.field }),
188
+ ...(reason === undefined ? {} : { reason }),
189
+ })
190
+
191
+ /** Makes the layer of an authorizer. */
192
+ export const makeLayer = (
193
+ service: AnyServiceClass,
194
+ options: {
195
+ readonly policy: (
196
+ principal: object,
197
+ ) => Effect.Effect<Policy.Any, unknown, unknown>
198
+ readonly decider?: (
199
+ request: object,
200
+ ) => Effect.Effect<Decision.Decision, unknown, unknown>
201
+ readonly cache?:
202
+ | {
203
+ readonly capacity?: number
204
+ readonly timeToLive?: Duration.Input
205
+ readonly key?: (principal: object) => string | undefined
206
+ }
207
+ | false
208
+ },
209
+ ) => {
210
+ const domain: Domain.Any = service.domain
211
+ // The JSON form of a principal, and the copy that the Schema decodes from
212
+ // it. The loader always gets the copy, with a cache or not, so a cache can
213
+ // not change an answer: a form may lose data that `Equal` does not see,
214
+ // as `-0` becomes `0`. A principal with no such form gives `undefined`.
215
+ const canonicalOf = jsonKey(domain.principal)
216
+ const custom = options.cache === false ? undefined : options.cache?.key
217
+ // It gives `undefined` when the principal has no key. Such a principal does
218
+ // not use the cache.
219
+ const keyOf = (
220
+ principal: object,
221
+ canonical: PrincipalKey | undefined,
222
+ ): PrincipalKey | undefined => {
223
+ if (custom === undefined) {
224
+ return canonical
225
+ }
226
+ const text = custom(principal)
227
+ return text === undefined
228
+ ? undefined
229
+ : new PrincipalKey(canonical?.principal ?? principal, text)
230
+ }
231
+ return Layer.effect(
232
+ service,
233
+ Effect.gen(function* () {
234
+ // The services of the policy loader and of the predicates come from the
235
+ // layer, so a check does not need them.
236
+ const services = yield* Effect.context<never>()
237
+
238
+ // The predicates of the policy get the services of the layer, so the
239
+ // policy and its residual conditions need no services.
240
+ // A policy of another domain may have the same types, but its resources
241
+ // may have other paths. So it fails closed, as a policy that can not be
242
+ // loaded.
243
+ const load = (principal: object) =>
244
+ options.policy(principal).pipe(
245
+ Effect.provideContext(services),
246
+ Effect.filterOrFail(
247
+ (value) => value.domain === domain,
248
+ () =>
249
+ new globalThis.Error(
250
+ "The loaded policy is not a policy of this domain",
251
+ ),
252
+ ),
253
+ Effect.mapError((cause) => PolicyUnavailable.make({ cause })),
254
+ Effect.map((value) =>
255
+ Policies.provideContext(value, services as never),
256
+ ),
257
+ )
258
+
259
+ const cache =
260
+ options.cache === false
261
+ ? undefined
262
+ : yield* Cache.make({
263
+ capacity: options.cache?.capacity ?? 1000,
264
+ timeToLive: options.cache?.timeToLive ?? "30 seconds",
265
+ lookup: (key: PrincipalKey) => load(key.principal),
266
+ })
267
+
268
+ const policyFor = (principal: object) => {
269
+ const canonical = canonicalOf(principal)
270
+ const key =
271
+ cache === undefined ? undefined : keyOf(principal, canonical)
272
+ if (cache === undefined || key === undefined) {
273
+ return load(canonical?.principal ?? principal)
274
+ }
275
+ // A failed lookup is kept in the cache. Remove it, so the next check
276
+ // tries again.
277
+ return Cache.get(cache, key).pipe(
278
+ Effect.tapError(() => Cache.invalidate(cache, key)),
279
+ )
280
+ }
281
+
282
+ const withServices = <A, E>(
283
+ effect: Effect.Effect<A, E, unknown>,
284
+ ): Effect.Effect<A, E, unknown> => Effect.provideContext(effect, services)
285
+
286
+ const current = Effect.gen(function* () {
287
+ const principal = yield* service.principal
288
+ const value = yield* policyFor(principal)
289
+ return { principal, policy: value }
290
+ })
291
+
292
+ // The principal and its policy, loaded one time. A check that asks
293
+ // the policy two times, such as `guard`, uses one snapshot for both,
294
+ // so the two answers come from one principal.
295
+ type Snapshot = Effect.Success<typeof current>
296
+
297
+ const decideWith = Effect.fnUntraced(function* (
298
+ { principal, policy: value }: Snapshot,
299
+ target: AnyTarget,
300
+ ) {
301
+ const decision: Decision.Decision = yield* withServices(
302
+ Decide.decideAny(value, { ...target, principal } as never),
303
+ )
304
+ return decision
305
+ })
306
+
307
+ const decide = (target: AnyTarget) =>
308
+ Effect.flatMap(current, (snapshot) => decideWith(snapshot, target))
309
+
310
+ const checkWith = Effect.fnUntraced(function* (
311
+ { principal, policy: value }: Snapshot,
312
+ target: AnyTarget,
313
+ ) {
314
+ const kind =
315
+ target.resource === undefined
316
+ ? (target.kind ?? "")
317
+ : (ResourceData.kindOf(target.resource) ?? "")
318
+ // A request that does not fit its kind gets a "no" at once. The
319
+ // decider must not say yes to an action that the kind does not have.
320
+ const descriptor = DomainData.descriptorOf(domain, kind)
321
+ if (descriptor === undefined || !Decide.fitsKind(descriptor, target)) {
322
+ return yield* forbidden(target, kind, undefined)
323
+ }
324
+ const fromPolicy: Decision.Decision =
325
+ target.resource === undefined
326
+ ? Analysis.decideKind(value, { ...target, principal } as never)
327
+ : yield* withServices(
328
+ Decide.decideAny(value, { ...target, principal } as never),
329
+ )
330
+ // When no rule gives an answer, the decider gives it.
331
+ const decision: Decision.Decision =
332
+ fromPolicy._tag === "NotApplicable" && options.decider !== undefined
333
+ ? yield* options.decider({ ...target, principal })
334
+ : fromPolicy
335
+ if (decision._tag !== "Allow") {
336
+ return yield* forbidden(target, kind, Decision.reason(decision))
337
+ }
338
+ })
339
+
340
+ const check = (target: AnyTarget) =>
341
+ Effect.flatMap(current, (snapshot) => checkWith(snapshot, target))
342
+
343
+ const guard = Effect.fnUntraced(function* (
344
+ target: AnyTarget,
345
+ guardOptions: {
346
+ readonly read?: string
347
+ readonly hide: () => unknown
348
+ },
349
+ ) {
350
+ const snapshot = yield* current
351
+ const read = guardOptions.read ?? "read"
352
+ const { field: _field, ...rest } = target
353
+ const visible: Decision.Decision = yield* decideWith(snapshot, {
354
+ ...rest,
355
+ action: read,
356
+ })
357
+ if (visible._tag !== "Allow") {
358
+ return yield* Effect.fail(guardOptions.hide())
359
+ }
360
+ if (target.action !== read || target.field !== undefined) {
361
+ yield* checkWith(snapshot, target)
362
+ }
363
+ })
364
+
365
+ // The filter is the condition of the coverage: the scope for `All`,
366
+ // `False` for `None`, and else the residual condition in the scope.
367
+ // The residual throws an error when it is too large, so the filter
368
+ // fails.
369
+ const filter = (
370
+ target: AnyTarget,
371
+ ): Effect.Effect<Condition.Ast, unknown, unknown> =>
372
+ Effect.map(
373
+ current,
374
+ ({ principal, policy: value }): Condition.Ast =>
375
+ Analysis.coverageAny(value, { ...target, principal } as never)
376
+ .condition as Condition.Ast,
377
+ )
378
+
379
+ const coverage = (
380
+ target: AnyTarget,
381
+ ): Effect.Effect<Analysis.AnyCoverage, unknown, unknown> =>
382
+ Effect.map(current, ({ principal, policy: value }) =>
383
+ Analysis.coverageAny(value, { ...target, principal } as never),
384
+ )
385
+
386
+ const permittedFields = Effect.fnUntraced(function* (target: AnyTarget) {
387
+ const { principal, policy: value } = yield* current
388
+ return yield* withServices(
389
+ Decide.permittedFields(value, { ...target, principal } as never),
390
+ )
391
+ })
392
+
393
+ const invalidate = (principal?: object) => {
394
+ if (cache === undefined) {
395
+ return Effect.void
396
+ }
397
+ if (principal === undefined) {
398
+ return Cache.invalidateAll(cache)
399
+ }
400
+ const key = keyOf(principal, canonicalOf(principal))
401
+ return key === undefined ? Effect.void : Cache.invalidate(cache, key)
402
+ }
403
+
404
+ return {
405
+ domain,
406
+ principal: service.principal,
407
+ policy: Effect.map(current, ({ policy: value }) => value),
408
+ decide,
409
+ check,
410
+ guard,
411
+ filter,
412
+ coverage,
413
+ permittedFields,
414
+ invalidate,
415
+ } as never
416
+ }),
417
+ )
418
+ }