@nmnmcc/ability 1.0.1 → 2.0.1

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 +112 -69
  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 +25 -8
  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 +276 -269
  26. package/dist/Policy.d.ts.map +1 -1
  27. package/dist/Policy.js +73 -1177
  28. package/dist/Policy.js.map +1 -1
  29. package/dist/Resource.d.ts +4 -177
  30. package/dist/Resource.d.ts.map +1 -1
  31. package/dist/Resource.js +4 -803
  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 +311 -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 +265 -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 +103 -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 +3 -4
  90. package/src/Authorizer.ts +67 -583
  91. package/src/Condition.ts +339 -2095
  92. package/src/Decision.ts +6 -5
  93. package/src/Domain.ts +12 -69
  94. package/src/HttpApi.ts +72 -10
  95. package/src/Limits.ts +132 -0
  96. package/src/Policy.ts +391 -2772
  97. package/src/Resource.ts +7 -1548
  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 +648 -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 +487 -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 +196 -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,487 @@
1
+ /**
2
+ * The JSON form of a policy tree. The decoder is a plain walk, as the decoder
3
+ * of a condition is, and the two share one budget of `jsonNodes` objects and
4
+ * items. It makes each node with its constructor, so the limits of the nodes
5
+ * hold for a decoded tree too. The binding of the rules to the domain comes
6
+ * after, in the Schema of the policy.
7
+ */
8
+ import * as Arr from "effect/Array"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Schema from "effect/Schema"
11
+ import type * as Domain from "../Domain.ts"
12
+ import * as Rule from "./rule.ts"
13
+ import * as Json from "./json.ts"
14
+ import * as Limits from "./limits.ts"
15
+ import * as PolicyNodes from "./policy-nodes.ts"
16
+
17
+ type Node = PolicyNodes.Node
18
+
19
+ /**
20
+ * The codec of the `meta` Schema of a domain to and from JSON. The JSON form
21
+ * of a policy must stay the same through `JSON.stringify` and `JSON.parse`, so
22
+ * a `Date` in `meta`, for one, becomes a string in it, and a string again
23
+ * when it is decoded.
24
+ */
25
+ const metaCodec = (meta: Schema.Top): Schema.Codec<unknown, unknown> =>
26
+ Schema.toCodecJson(meta) as unknown as Schema.Codec<unknown, unknown>
27
+
28
+ /** Gives the JSON form of the `meta` of a rule, or `undefined` for none. */
29
+ export type MetaJson = (rule: Rule.Rule) => unknown
30
+
31
+ /**
32
+ * Gives the JSON form of the `meta` of each rule at once, with the `meta`
33
+ * Schema of a domain. `JSON.stringify` needs it at once, so the Schema must
34
+ * not need an async step there.
35
+ */
36
+ export const syncMeta =
37
+ (meta: Schema.Top | undefined): MetaJson =>
38
+ (rule) =>
39
+ rule.meta === undefined || meta === undefined
40
+ ? undefined
41
+ : Schema.encodeUnknownSync(metaCodec(meta))(rule.meta)
42
+
43
+ /** The JSON form of the condition of each rule, by the rule. */
44
+ type WhenJson = (rule: Rule.Rule) => unknown
45
+
46
+ /** Gives the JSON form of a rule. The domain of a rule is not in it. */
47
+ const ruleJson = (
48
+ rule: Rule.Rule,
49
+ metaJson: MetaJson,
50
+ whenJson: WhenJson,
51
+ ): unknown => {
52
+ const meta = metaJson(rule)
53
+ return {
54
+ _tag: rule._tag,
55
+ actions: rule.actions,
56
+ resources: rule.resources,
57
+ ...(rule.when === undefined ? {} : { when: whenJson(rule) }),
58
+ ...(rule.fields === undefined ? {} : { fields: rule.fields }),
59
+ ...(rule.reason === undefined ? {} : { reason: rule.reason }),
60
+ ...(rule.id === undefined ? {} : { id: rule.id }),
61
+ // The `meta` of a rule is a decoded value. Its JSON form comes from the
62
+ // `meta` Schema of the domain.
63
+ ...(meta === undefined ? {} : { meta }),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Gives the JSON form of a policy tree with no predicate. A condition with a
69
+ * predicate has no JSON form, so it throws an error. The conditions are
70
+ * written first, from a flat list of the rules, so the walk does not go down
71
+ * the levels of the tree and of a condition at once.
72
+ */
73
+ export const toJson = (node: Node, meta: MetaJson): unknown => {
74
+ // A native map, because it must find a rule by the rule itself: two rules
75
+ // with the same data are `Equal`.
76
+ const whens = new Map<Rule.Rule, unknown>(
77
+ Arr.map(PolicyNodes.rulesOf(node), (rule) => [
78
+ rule,
79
+ rule.when === undefined ? undefined : Json.toJson(rule.when as never),
80
+ ]),
81
+ )
82
+ const tree = (current: Node): unknown => {
83
+ switch (current._tag) {
84
+ case "Rules":
85
+ return {
86
+ _tag: "Rules",
87
+ algorithm: current.algorithm,
88
+ rules: Arr.map(current.rules, (rule) =>
89
+ ruleJson(rule, meta, (found) => whens.get(found)),
90
+ ),
91
+ }
92
+ case "All":
93
+ case "Any":
94
+ case "FirstOf":
95
+ return {
96
+ _tag: current._tag,
97
+ policies: Arr.map(current.policies, tree),
98
+ }
99
+ case "Ceiling":
100
+ return {
101
+ _tag: "Ceiling",
102
+ cap: tree(current.cap),
103
+ policy: tree(current.policy),
104
+ }
105
+ }
106
+ }
107
+ return tree(node)
108
+ }
109
+
110
+ /**
111
+ * Gives the JSON form of a policy tree as an Effect. The `meta` Schema of the
112
+ * domain may need an async step, so each `meta` is encoded as an Effect.
113
+ */
114
+ export const encode = (
115
+ node: Node,
116
+ meta: Schema.Top | undefined,
117
+ ): Effect.Effect<unknown, Schema.SchemaError> =>
118
+ Effect.gen(function* () {
119
+ const rules = Arr.filter(
120
+ PolicyNodes.rulesOf(node),
121
+ (rule) => rule.meta !== undefined,
122
+ )
123
+ const encoded =
124
+ meta === undefined
125
+ ? []
126
+ : yield* Effect.forEach(rules, (rule) =>
127
+ Effect.map(
128
+ Schema.encodeUnknownEffect(metaCodec(meta))(rule.meta),
129
+ (json) => [rule, json] as const,
130
+ ),
131
+ )
132
+ // A native map, because it must find a rule by the rule itself: two
133
+ // rules with the same data are `Equal`, and each keeps its own form.
134
+ const forms = new Map<Rule.Rule, unknown>(encoded)
135
+ return toJson(node, (rule) => forms.get(rule))
136
+ })
137
+
138
+ const algorithms: ReadonlyArray<string> = [
139
+ "denyOverrides",
140
+ "permitOverrides",
141
+ "firstApplicable",
142
+ "lastApplicable",
143
+ ]
144
+
145
+ /** Reads a list of strings. */
146
+ const readStrings = (
147
+ input: object,
148
+ name: string,
149
+ path: ReadonlyArray<PropertyKey>,
150
+ budget: Json.Budget,
151
+ ): ReadonlyArray<string> =>
152
+ Arr.map(Json.readList(input, name, path, budget), (value, index) => {
153
+ if (typeof value !== "string") {
154
+ throw Json.problem([...path, name, index], "The item must be a string")
155
+ }
156
+ return value
157
+ })
158
+
159
+ /**
160
+ * Reads a field that a rule may leave out. A field that the input has only
161
+ * from the prototype of a class is not plain data, so it is a problem: a rule
162
+ * that loses its condition would allow more.
163
+ */
164
+ const readOptional = <A>(
165
+ input: object,
166
+ name: string,
167
+ path: ReadonlyArray<PropertyKey>,
168
+ read: () => A,
169
+ ): A | undefined => {
170
+ if (Object.hasOwn(input, name)) {
171
+ return read()
172
+ }
173
+ // A read of the field throws a problem when the field is not data.
174
+ Json.read(input, name, path)
175
+ return undefined
176
+ }
177
+
178
+ /** Counts the objects and items of a value, as the other parts count. */
179
+ const spendValue = (
180
+ value: unknown,
181
+ path: ReadonlyArray<PropertyKey>,
182
+ depth: number,
183
+ budget: Json.Budget,
184
+ ): void => {
185
+ if (typeof value !== "object" || value === null) {
186
+ return
187
+ }
188
+ if (depth > Limits.conditionDepth) {
189
+ throw Json.problem(
190
+ path,
191
+ `The value has more than ${String(Limits.conditionDepth)} levels. The most is ${String(Limits.conditionDepth)}.`,
192
+ )
193
+ }
194
+ // The length of an array counts before its items are read, so a sparse
195
+ // array with a very large length stops at once.
196
+ const list = Array.isArray(value)
197
+ if (list) {
198
+ Json.spend(budget, 1 + value.length, path)
199
+ }
200
+ const names = list
201
+ ? Array.from({ length: value.length }, (_, index) => String(index))
202
+ : Object.keys(value)
203
+ if (!list) {
204
+ Json.spend(budget, 1 + names.length, path)
205
+ }
206
+ Arr.forEach(names, (name) =>
207
+ spendValue(
208
+ Json.read(value, name, path),
209
+ [...path, name],
210
+ depth + 1,
211
+ budget,
212
+ ),
213
+ )
214
+ }
215
+
216
+ /** A rule in the input, with its place, to decode after the tree. */
217
+ interface RuleInput {
218
+ readonly input: unknown
219
+ readonly path: ReadonlyArray<PropertyKey>
220
+ }
221
+
222
+ /**
223
+ * The shape of a tree, with the place of each rule in a flat list. `size`
224
+ * counts the nodes and the rules, as the nodes do, so a shared input stops
225
+ * at `policyNodes` before the decoder reads all of it.
226
+ */
227
+ type Plan =
228
+ | {
229
+ readonly _tag: "Rules"
230
+ readonly path: ReadonlyArray<PropertyKey>
231
+ readonly size: number
232
+ readonly algorithm: PolicyNodes.Algorithm
233
+ readonly rules: ReadonlyArray<number>
234
+ }
235
+ | {
236
+ readonly _tag: "All" | "Any" | "FirstOf"
237
+ readonly path: ReadonlyArray<PropertyKey>
238
+ readonly size: number
239
+ readonly policies: ReadonlyArray<Plan>
240
+ }
241
+ | {
242
+ readonly _tag: "Ceiling"
243
+ readonly path: ReadonlyArray<PropertyKey>
244
+ readonly size: number
245
+ readonly cap: Plan
246
+ readonly policy: Plan
247
+ }
248
+
249
+ /**
250
+ * Decodes the `meta` of a rule with the `meta` Schema of the domain, as an
251
+ * Effect, since the Schema may need an async step. The Schema reads all of
252
+ * the value, so its size counts in the budget.
253
+ */
254
+ const decodeMeta = (
255
+ { input, path }: RuleInput,
256
+ domain: Domain.Any,
257
+ budget: Json.Budget,
258
+ ): Effect.Effect<unknown, Json.Problem> =>
259
+ Effect.flatMap(
260
+ attempt(() =>
261
+ typeof input !== "object" || input === null
262
+ ? undefined
263
+ : readOptional(input, "meta", path, () => {
264
+ const value = Json.read(input, "meta", path)
265
+ if (domain.meta === undefined) {
266
+ throw Json.problem(
267
+ [...path, "meta"],
268
+ "The domain has no Schema for meta",
269
+ )
270
+ }
271
+ spendValue(value, [...path, "meta"], 1, budget)
272
+ return { value, schema: domain.meta }
273
+ }),
274
+ ),
275
+ (found) =>
276
+ found === undefined
277
+ ? Effect.void
278
+ : Effect.mapError(
279
+ Schema.decodeUnknownEffect(metaCodec(found.schema))(found.value),
280
+ (error) =>
281
+ new Json.Problem({
282
+ path: [...path, "meta"],
283
+ message: error.message,
284
+ issue: error.issue,
285
+ }),
286
+ ),
287
+ )
288
+
289
+ const decodeRule = (
290
+ { input, path }: RuleInput,
291
+ meta: unknown,
292
+ budget: Json.Budget,
293
+ domain: Domain.Any,
294
+ ): Rule.Rule => {
295
+ if (typeof input !== "object" || input === null) {
296
+ throw Json.problem(path, "A rule must be an object")
297
+ }
298
+ Json.spend(budget, 1, path)
299
+ const tag = Json.read(input, "_tag", path)
300
+ if (tag !== "Allow" && tag !== "Deny") {
301
+ throw Json.problem([...path, "_tag"], 'A rule must be "Allow" or "Deny"')
302
+ }
303
+ const when = readOptional(input, "when", path, () =>
304
+ Json.decodeAt(Json.read(input, "when", path), [...path, "when"], budget),
305
+ )
306
+ const fields = readOptional(input, "fields", path, () =>
307
+ readStrings(input, "fields", path, budget),
308
+ )
309
+ const reason = readOptional(input, "reason", path, () =>
310
+ Json.readString(input, "reason", path),
311
+ )
312
+ const id = readOptional(input, "id", path, () =>
313
+ Json.readString(input, "id", path),
314
+ )
315
+ // Each rule gets the domain of the Schema, as a rule from the builder of
316
+ // the domain has it.
317
+ return Rule.make({
318
+ _tag: tag,
319
+ actions: readStrings(input, "actions", path, budget),
320
+ resources: readStrings(input, "resources", path, budget),
321
+ ...(when === undefined ? {} : { when: when as never }),
322
+ ...(fields === undefined ? {} : { fields }),
323
+ ...(reason === undefined ? {} : { reason }),
324
+ ...(id === undefined ? {} : { id }),
325
+ ...(meta === undefined ? {} : { meta }),
326
+ domain,
327
+ })
328
+ }
329
+
330
+ /**
331
+ * Reads the shape of a tree, and puts each rule in `rules`. The rules are
332
+ * decoded after the walk: a condition has levels of its own, and a decode
333
+ * inside the walk would go down the levels of both.
334
+ */
335
+ const readTree = (
336
+ input: unknown,
337
+ path: ReadonlyArray<PropertyKey>,
338
+ depth: number,
339
+ budget: Json.Budget,
340
+ // The rules of one decode. The list belongs to that decode.
341
+ rules: Array<RuleInput>,
342
+ ): Plan => {
343
+ if (depth > Limits.policyDepth) {
344
+ throw Json.problem(
345
+ path,
346
+ `A policy tree has more than ${String(Limits.policyDepth)} levels. The most is ${String(Limits.policyDepth)}.`,
347
+ )
348
+ }
349
+ if (typeof input !== "object" || input === null) {
350
+ throw Json.problem(path, "A node of a policy must be an object")
351
+ }
352
+ Json.spend(budget, 1, path)
353
+ const child = (value: unknown, at: ReadonlyArray<PropertyKey>) =>
354
+ readTree(value, at, depth + 1, budget, rules)
355
+ const tag = Json.read(input, "_tag", path)
356
+ switch (tag) {
357
+ case "Rules": {
358
+ const algorithm = Json.readString(input, "algorithm", path)
359
+ if (!algorithms.includes(algorithm)) {
360
+ throw Json.problem(
361
+ [...path, "algorithm"],
362
+ `"${algorithm}" is not a combining algorithm`,
363
+ )
364
+ }
365
+ const found = Arr.map(
366
+ Json.readList(input, "rules", path, budget),
367
+ (rule, index) =>
368
+ rules.push({ input: rule, path: [...path, "rules", index] }) - 1,
369
+ )
370
+ return sized({
371
+ _tag: "Rules",
372
+ path,
373
+ size: 1 + found.length,
374
+ algorithm: algorithm as PolicyNodes.Algorithm,
375
+ rules: found,
376
+ })
377
+ }
378
+ case "All":
379
+ case "Any":
380
+ case "FirstOf": {
381
+ const policies = Arr.map(
382
+ Json.readList(input, "policies", path, budget),
383
+ (value, index) => child(value, [...path, "policies", index]),
384
+ )
385
+ return sized({
386
+ _tag: tag,
387
+ path,
388
+ size: Arr.reduce(policies, 1, (total, plan) => total + plan.size),
389
+ policies,
390
+ })
391
+ }
392
+ case "Ceiling": {
393
+ const cap = child(Json.read(input, "cap", path), [...path, "cap"])
394
+ const policy = child(Json.read(input, "policy", path), [
395
+ ...path,
396
+ "policy",
397
+ ])
398
+ return sized({
399
+ _tag: "Ceiling",
400
+ path,
401
+ size: 1 + cap.size + policy.size,
402
+ cap,
403
+ policy,
404
+ })
405
+ }
406
+ default:
407
+ throw Json.problem([...path, "_tag"], "The node has no known tag")
408
+ }
409
+ }
410
+
411
+ /** Stops a plan past `policyNodes`, with the message of the nodes. */
412
+ const sized = (plan: Plan): Plan => {
413
+ if (plan.size > Limits.policyNodes) {
414
+ throw Json.problem(
415
+ plan.path,
416
+ `A policy tree has ${String(plan.size)} nodes. The most is ${String(Limits.policyNodes)}.`,
417
+ )
418
+ }
419
+ return plan
420
+ }
421
+
422
+ /** Makes the nodes of a plan, with the decoded rules. */
423
+ const build = (plan: Plan, rules: ReadonlyArray<Rule.Rule>): Node => {
424
+ try {
425
+ switch (plan._tag) {
426
+ case "Rules":
427
+ return new PolicyNodes.Rules({
428
+ algorithm: plan.algorithm,
429
+ rules: Arr.map(plan.rules, (index) => rules[index]!),
430
+ })
431
+ case "All":
432
+ case "Any":
433
+ case "FirstOf":
434
+ return new PolicyNodes[plan._tag](
435
+ Arr.map(plan.policies, (child) => build(child, rules)),
436
+ )
437
+ case "Ceiling":
438
+ return new PolicyNodes.Ceiling({
439
+ cap: build(plan.cap, rules),
440
+ policy: build(plan.policy, rules),
441
+ })
442
+ }
443
+ } catch (error) {
444
+ // A node past a limit is a problem at its place.
445
+ if (error instanceof Limits.LimitExceeded) {
446
+ throw Json.problem(plan.path, error.message)
447
+ }
448
+ throw error
449
+ }
450
+ }
451
+
452
+ /** Runs a step that may throw a problem, and puts the problem in the error. */
453
+ const attempt = <A>(step: () => A): Effect.Effect<A, Json.Problem> =>
454
+ Effect.try({
455
+ try: step,
456
+ catch: (error) => {
457
+ if (error instanceof Json.Problem) {
458
+ return error
459
+ }
460
+ throw error
461
+ },
462
+ })
463
+
464
+ /**
465
+ * Decodes the JSON form of a policy tree for a domain. It reads the tree
466
+ * first, then the `meta` of each rule as an Effect, then the rules, and it
467
+ * makes the nodes last.
468
+ */
469
+ export const decode = (
470
+ input: unknown,
471
+ domain: Domain.Any,
472
+ ): Effect.Effect<Node, Json.Problem> =>
473
+ Effect.gen(function* () {
474
+ const budget: Json.Budget = { used: 0 }
475
+ // The rules of this decode. The list belongs to this decode.
476
+ const inputs: Array<RuleInput> = []
477
+ const plan = yield* attempt(() => readTree(input, [], 1, budget, inputs))
478
+ const metas = yield* Effect.forEach(inputs, (rule) =>
479
+ decodeMeta(rule, domain, budget),
480
+ )
481
+ const rules = yield* attempt(() =>
482
+ Arr.map(inputs, (rule, index) =>
483
+ decodeRule(rule, metas[index], budget, domain),
484
+ ),
485
+ )
486
+ return yield* attempt(() => build(plan, rules))
487
+ })
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The nodes of a policy tree. As with the nodes of a condition, each node is
3
+ * a class, only its constructor makes one, and it works out three facts once:
4
+ * - `size`: the number of nodes, with each rule and each node of its condition,
5
+ * and a shared child counted for each place;
6
+ * - `depth`: the number of levels of the tree;
7
+ * - `opaque`: a rule of the tree has a predicate.
8
+ *
9
+ * The constructor stops a tree past `policyNodes` or `policyDepth`. So a walk
10
+ * over any tree, such as a decision, needs no memo.
11
+ */
12
+ import * as Arr from "effect/Array"
13
+ import * as Data from "effect/Data"
14
+ import * as Rule from "./rule.ts"
15
+ import * as Limits from "./limits.ts"
16
+ import * as Nodes from "./nodes.ts"
17
+
18
+ export type Algorithm =
19
+ "denyOverrides" | "permitOverrides" | "firstApplicable" | "lastApplicable"
20
+
21
+ const algorithms: ReadonlyArray<string> = [
22
+ "denyOverrides",
23
+ "permitOverrides",
24
+ "firstApplicable",
25
+ "lastApplicable",
26
+ ]
27
+
28
+ interface Facts {
29
+ readonly size: number
30
+ readonly depth: number
31
+ readonly opaque: boolean
32
+ }
33
+
34
+ /** Checks the facts of a node against the limits. */
35
+ const checked = (facts: Facts): Facts => {
36
+ if (facts.size > Limits.policyNodes) {
37
+ throw Limits.exceeded(
38
+ "A policy tree",
39
+ "policyNodes",
40
+ "nodes",
41
+ facts.size,
42
+ Limits.policyNodes,
43
+ )
44
+ }
45
+ if (facts.depth > Limits.policyDepth) {
46
+ throw Limits.exceeded(
47
+ "A policy tree",
48
+ "policyDepth",
49
+ "levels",
50
+ facts.depth,
51
+ Limits.policyDepth,
52
+ )
53
+ }
54
+ return facts
55
+ }
56
+
57
+ const factsOf = (children: ReadonlyArray<Node>): Facts =>
58
+ checked({
59
+ size: Arr.reduce(children, 1, (total, child) => total + child.size),
60
+ depth:
61
+ 1 + Arr.reduce(children, 0, (top, child) => Math.max(top, child.depth)),
62
+ opaque: Arr.some(children, (child) => child.opaque),
63
+ })
64
+
65
+ /** The base of every node. It keeps the facts. */
66
+ abstract class Base extends Data.Class {
67
+ readonly #facts: Facts
68
+
69
+ constructor(props: object, facts: Facts) {
70
+ super(props as never)
71
+ this.#facts = facts
72
+ }
73
+
74
+ /** The number of nodes, with the rules and their conditions. */
75
+ get size(): number {
76
+ return this.#facts.size
77
+ }
78
+
79
+ /** The number of levels of the tree. */
80
+ get depth(): number {
81
+ return this.#facts.depth
82
+ }
83
+
84
+ /** A rule of the tree has a predicate. */
85
+ get opaque(): boolean {
86
+ return this.#facts.opaque
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The condition of a rule. A value that is not a node counts as one node
92
+ * here: the binding of the rule gives the issue for it, before any walk.
93
+ */
94
+ const whenOf = (rule: Rule.Rule): Nodes.Node | undefined =>
95
+ Nodes.isNode(rule.when) ? rule.when : undefined
96
+
97
+ /** A list of rules with a combining algorithm. */
98
+ export class Rules extends Base {
99
+ readonly _tag = "Rules"
100
+ declare readonly algorithm: Algorithm
101
+ declare readonly rules: ReadonlyArray<Rule.Rule>
102
+ constructor(props: {
103
+ readonly algorithm: Algorithm
104
+ readonly rules: ReadonlyArray<Rule.Rule>
105
+ }) {
106
+ if (!algorithms.includes(props.algorithm)) {
107
+ throw new Error(
108
+ `"${String(props.algorithm)}" is not a combining algorithm`,
109
+ )
110
+ }
111
+ const rules = Object.freeze(Array.from(props.rules))
112
+ // A value that is not a rule, such as a copy of a rule made with a
113
+ // spread, may keep the type fields of another rule, and it does not
114
+ // work in a generator. It is a mistake in the code, so it is a defect.
115
+ if (!Arr.every(rules, Rule.isRule)) {
116
+ throw new Error("A value in the list is not a rule")
117
+ }
118
+ const conditions = Arr.map(rules, whenOf)
119
+ super(
120
+ { algorithm: props.algorithm, rules },
121
+ checked({
122
+ size: Arr.reduce(
123
+ conditions,
124
+ 1,
125
+ (total, when) => total + 1 + (when?.size ?? 0),
126
+ ),
127
+ depth: 1,
128
+ opaque: Arr.some(conditions, (when) => when?.opaque ?? false),
129
+ }),
130
+ )
131
+ }
132
+ }
133
+
134
+ const checkChildren = (
135
+ policies: ReadonlyArray<unknown>,
136
+ ): ReadonlyArray<Node> => {
137
+ const children = Array.from(policies)
138
+ if (!Arr.every(children, isNode)) {
139
+ throw new Error("A child of a policy tree is not a node of a policy")
140
+ }
141
+ return Object.freeze(children)
142
+ }
143
+
144
+ /** Every policy must allow, and one deny is enough to deny. */
145
+ export class All extends Base {
146
+ readonly _tag = "All"
147
+ declare readonly policies: ReadonlyArray<Node>
148
+ constructor(policies: ReadonlyArray<Node>) {
149
+ const children = checkChildren(policies)
150
+ super({ policies: children }, factsOf(children))
151
+ }
152
+ }
153
+
154
+ /** One allow is enough. Without an allow, one deny gives a deny. */
155
+ export class Any extends Base {
156
+ readonly _tag = "Any"
157
+ declare readonly policies: ReadonlyArray<Node>
158
+ constructor(policies: ReadonlyArray<Node>) {
159
+ const children = checkChildren(policies)
160
+ super({ policies: children }, factsOf(children))
161
+ }
162
+ }
163
+
164
+ /** The first policy that allows or denies gives the answer. */
165
+ export class FirstOf extends Base {
166
+ readonly _tag = "FirstOf"
167
+ declare readonly policies: ReadonlyArray<Node>
168
+ constructor(policies: ReadonlyArray<Node>) {
169
+ const children = checkChildren(policies)
170
+ super({ policies: children }, factsOf(children))
171
+ }
172
+ }
173
+
174
+ /** A deny from `cap` always wins. An allow from `cap` does nothing. */
175
+ export class Ceiling extends Base {
176
+ readonly _tag = "Ceiling"
177
+ declare readonly cap: Node
178
+ declare readonly policy: Node
179
+ constructor(props: { readonly cap: Node; readonly policy: Node }) {
180
+ const [cap, policy] = checkChildren([props.cap, props.policy])
181
+ super({ cap, policy }, factsOf([cap!, policy!]))
182
+ }
183
+ }
184
+
185
+ /** A node of a policy tree. */
186
+ export type Node = Rules | All | Any | FirstOf | Ceiling
187
+
188
+ /** Tests if a value is a node of a policy tree. */
189
+ export const isNode = (u: unknown): u is Node => u instanceof Base
190
+
191
+ /**
192
+ * The rules of a tree, in order, once for each place. A walk that also goes
193
+ * into the condition of each rule takes the rules from this list first, so it
194
+ * does not go down the levels of the tree and of a condition at once.
195
+ */
196
+ export const rulesOf = (node: Node): ReadonlyArray<Rule.Rule> => {
197
+ switch (node._tag) {
198
+ case "Rules":
199
+ return node.rules
200
+ case "All":
201
+ case "Any":
202
+ case "FirstOf":
203
+ return Arr.flatMap(node.policies, rulesOf)
204
+ case "Ceiling":
205
+ return [...rulesOf(node.cap), ...rulesOf(node.policy)]
206
+ }
207
+ }