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