@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,596 @@
1
+ /**
2
+ * The binding of rules to a domain. It is the one check of a rule, for a rule
3
+ * from the builder, from `Policy.rule`, and from JSON alike. It reads the
4
+ * catalogs of the resources and of the principal, and it gives one issue for
5
+ * each problem, with the path of the problem and, for a name with a mistake,
6
+ * the name that it may mean.
7
+ */
8
+ import * as Arr from "effect/Array"
9
+ import * as HashMap from "effect/HashMap"
10
+ import * as MutableHashMap from "effect/MutableHashMap"
11
+ import * as Option from "effect/Option"
12
+ import * as Result from "effect/Result"
13
+ import type * as Domain from "../Domain.ts"
14
+ import type * as Resource from "../Resource.ts"
15
+ import * as Catalog from "./catalog.ts"
16
+ import * as Limits from "./limits.ts"
17
+ import * as Nodes from "./nodes.ts"
18
+ import * as Patterns from "./patterns.ts"
19
+ import { withSuggestion } from "./suggest.ts"
20
+
21
+ /** One problem of a rule or a policy, with its path. */
22
+ export interface Issue {
23
+ readonly path: ReadonlyArray<PropertyKey>
24
+ readonly issue: string
25
+ }
26
+
27
+ /** The data of a rule that the binding reads. */
28
+ export interface RuleData {
29
+ readonly actions: ReadonlyArray<string>
30
+ readonly resources: ReadonlyArray<string>
31
+ readonly when?: unknown
32
+ readonly fields?: ReadonlyArray<string>
33
+ }
34
+
35
+ /**
36
+ * The memo of one binding, for all its rules. A policy may name one wrong
37
+ * field or one pattern in very many places, so the work on each is done one
38
+ * time. It goes away when the binding ends.
39
+ */
40
+ interface Memo {
41
+ /** The suggestion for each name, by the place where it was looked for. */
42
+ readonly suggestions: MutableHashMap.MutableHashMap<string, string>
43
+ /** If a pattern matches a field of a kind, by the kind and the pattern. */
44
+ readonly fits: MutableHashMap.MutableHashMap<string, boolean>
45
+ }
46
+
47
+ const makeMemo = (): Memo => ({
48
+ suggestions: MutableHashMap.empty(),
49
+ fits: MutableHashMap.empty(),
50
+ })
51
+
52
+ /**
53
+ * The most names that one binding makes a suggestion for. A suggestion
54
+ * compares the name with every name of a catalog, so a policy with very many
55
+ * wrong names would take a long time.
56
+ */
57
+ const maxSuggestions = 32
58
+
59
+ /** Adds a suggestion to a message, with the memo of the binding. */
60
+ const suggestIn = (
61
+ memo: Memo,
62
+ message: string,
63
+ name: string,
64
+ scope: string,
65
+ names: () => ReadonlyArray<string>,
66
+ ): string => {
67
+ const key = `${scope}\u0000${name}`
68
+ const known = MutableHashMap.get(memo.suggestions, key)
69
+ if (Option.isSome(known)) {
70
+ return known.value
71
+ }
72
+ if (MutableHashMap.size(memo.suggestions) >= maxSuggestions) {
73
+ return message
74
+ }
75
+ const made = withSuggestion(message, name, names())
76
+ MutableHashMap.set(memo.suggestions, key, made)
77
+ return made
78
+ }
79
+
80
+ /** The kinds that a rule may run on, from the view of its checks. */
81
+ interface Context {
82
+ readonly domain: Domain.Any
83
+ readonly memo: Memo
84
+ /** The rule is for every resource (`"*"`). */
85
+ readonly all: boolean
86
+ /** The resources that the rule names and that the domain has. */
87
+ readonly named: ReadonlyArray<Resource.Descriptor>
88
+ /** The named resources that have one of the actions of the rule. */
89
+ readonly applicable: ReadonlyArray<Resource.Descriptor>
90
+ /**
91
+ * The resources whose paths a path test may read: every resource with one
92
+ * of the actions, for a rule for every resource.
93
+ */
94
+ readonly paths: ReadonlyArray<Resource.Descriptor>
95
+ }
96
+
97
+ type Operand = Nodes.Operand
98
+
99
+ /**
100
+ * Tests if a field may hold a value of the type of a fixed value. A test with
101
+ * `null` is a test that the field is missing, so `null` always fits. A number
102
+ * fits any field of numbers: a test of an integer field with `1.5` or
103
+ * `Infinity` has a meaning, as `lt` shows.
104
+ */
105
+ const fitsLiteral = (kinds: Catalog.Kinds, value: unknown): boolean =>
106
+ value === null ||
107
+ (typeof value === "number" && kinds.number !== "none") ||
108
+ Catalog.accepts(kinds, value)
109
+
110
+ /**
111
+ * Tests if two fields may hold one scalar. A principal field that is `null`
112
+ * or that holds an object is a missing value, and a test with a missing value
113
+ * is false, so only scalars count.
114
+ */
115
+ const meet = (left: Catalog.Kinds, right: Catalog.Kinds): boolean =>
116
+ (left.string && right.string) ||
117
+ (left.boolean && right.boolean) ||
118
+ (left.number !== "none" && right.number !== "none") ||
119
+ Arr.some(left.literals, (value) => Catalog.accepts(right, value)) ||
120
+ Arr.some(right.literals, (value) => Catalog.accepts(left, value))
121
+
122
+ const hasStrings = (kinds: Catalog.Kinds): boolean =>
123
+ kinds.string || Arr.some(kinds.literals, (value) => typeof value === "string")
124
+
125
+ const hasNumbers = (kinds: Catalog.Kinds): boolean =>
126
+ kinds.number !== "none" ||
127
+ Arr.some(kinds.literals, (value) => typeof value === "number")
128
+
129
+ /** Tests if a field may hold a value with an order: a number or a string. */
130
+ const isOrdered = (kinds: Catalog.Kinds): boolean =>
131
+ hasStrings(kinds) || hasNumbers(kinds)
132
+
133
+ /** Tests if two fields may hold values of one ordered type. */
134
+ const orderedMeet = (left: Catalog.Kinds, right: Catalog.Kinds): boolean =>
135
+ (hasStrings(left) && hasStrings(right)) ||
136
+ (hasNumbers(left) && hasNumbers(right))
137
+
138
+ /** The texts of the fields of a catalog that have only names, for suggestions. */
139
+ const namesOf = (catalog: Catalog.Catalog): ReadonlyArray<string> =>
140
+ Arr.map(
141
+ Arr.filter(catalog.fields, (field) =>
142
+ Arr.every(field.path, (segment) => typeof segment === "string"),
143
+ ),
144
+ (field) => field.text,
145
+ )
146
+
147
+ /** The issue of a field that is not a field of every resource of the rule. */
148
+ const fieldIssue = (
149
+ context: Context,
150
+ field: string,
151
+ at: ReadonlyArray<PropertyKey>,
152
+ ): ReadonlyArray<Issue> => {
153
+ const message = `The field "${field}" is not a field of every resource of the rule`
154
+ if (context.all || context.named.length === 0) {
155
+ return [{ path: at, issue: message }]
156
+ }
157
+ const without = Arr.filter(
158
+ context.named,
159
+ (descriptor) => Catalog.lookup(descriptor.catalog, field) === undefined,
160
+ )
161
+ return without.length === 0
162
+ ? []
163
+ : [
164
+ {
165
+ path: at,
166
+ issue: suggestIn(context.memo, message, field, without[0]!.kind, () =>
167
+ namesOf(without[0]!.catalog),
168
+ ),
169
+ },
170
+ ]
171
+ }
172
+
173
+ /**
174
+ * The issues of a field pattern: a pattern that does not parse, or that
175
+ * matches no field of the resources of the rule, as a pattern with a mistake
176
+ * in a name does.
177
+ */
178
+ const patternIssues = (
179
+ memo: Memo,
180
+ known: ReadonlyArray<Resource.Descriptor>,
181
+ pattern: string,
182
+ at: ReadonlyArray<PropertyKey>,
183
+ ): ReadonlyArray<Issue> =>
184
+ Result.match(Patterns.parse(pattern), {
185
+ onFailure: (issue) => [{ path: at, issue }],
186
+ onSuccess: (parsed) =>
187
+ // A rule with no known resource has an issue for that already.
188
+ known.length === 0 ||
189
+ Arr.some(known, (descriptor) => {
190
+ const key = `${descriptor.kind}\u0000${pattern}`
191
+ return Option.getOrElse(MutableHashMap.get(memo.fits, key), () => {
192
+ const fits = Patterns.fitsCatalog(parsed, descriptor.catalog)
193
+ MutableHashMap.set(memo.fits, key, fits)
194
+ return fits
195
+ })
196
+ })
197
+ ? []
198
+ : [
199
+ {
200
+ path: at,
201
+ issue: `The field pattern "${pattern}" matches no field of the resources of the rule`,
202
+ },
203
+ ],
204
+ })
205
+
206
+ /** The fields of the resources that a test of the rule may read. */
207
+ const fieldsAt = (
208
+ context: Context,
209
+ field: string,
210
+ ): ReadonlyArray<Catalog.Field> =>
211
+ Arr.flatMap(context.applicable, (descriptor) => {
212
+ const found = Catalog.lookup(descriptor.catalog, field)
213
+ return found === undefined ? [] : [found]
214
+ })
215
+
216
+ /** The field of the principal that an operand names, or the issue when there is none. */
217
+ const principalField = (
218
+ context: Context,
219
+ ref: string,
220
+ at: ReadonlyArray<PropertyKey>,
221
+ ): Catalog.Field | Issue => {
222
+ const found = Catalog.lookup(context.domain.principalCatalog, ref)
223
+ return found !== undefined
224
+ ? found
225
+ : {
226
+ path: at,
227
+ issue: suggestIn(
228
+ context.memo,
229
+ `The principal has no field "${ref}"`,
230
+ ref,
231
+ "~principal",
232
+ () => namesOf(context.domain.principalCatalog),
233
+ ),
234
+ }
235
+ }
236
+
237
+ const isIssue = (value: Catalog.Field | Issue): value is Issue =>
238
+ "issue" in value
239
+
240
+ /** The issues of one operand of a test of a field. */
241
+ const operandIssues = (
242
+ context: Context,
243
+ field: string,
244
+ ordered: boolean,
245
+ operand: Operand,
246
+ at: ReadonlyArray<PropertyKey>,
247
+ ): ReadonlyArray<Issue> => {
248
+ const fields = fieldsAt(context, field)
249
+ if (operand._tag === "Literal") {
250
+ const value = operand.value
251
+ if (ordered && typeof value !== "string" && typeof value !== "number") {
252
+ return [
253
+ {
254
+ path: at,
255
+ issue: `The field "${field}" has an ordered test with a value that has no order`,
256
+ },
257
+ ]
258
+ }
259
+ return [
260
+ ...(Arr.some(fields, (found) => !fitsLiteral(found.kinds, value))
261
+ ? [
262
+ {
263
+ path: at,
264
+ issue: `The field "${field}" can not hold the value ${JSON.stringify(value)}`,
265
+ },
266
+ ]
267
+ : []),
268
+ ...(ordered && Arr.some(fields, (found) => !isOrdered(found.kinds))
269
+ ? [
270
+ {
271
+ path: at,
272
+ issue: `The field "${field}" has an ordered test with a value that has no order`,
273
+ },
274
+ ]
275
+ : []),
276
+ ]
277
+ }
278
+ const principal = principalField(context, operand.path, at)
279
+ if (isIssue(principal)) {
280
+ return [principal]
281
+ }
282
+ return [
283
+ ...(Arr.some(fields, (found) => !meet(found.kinds, principal.kinds))
284
+ ? [
285
+ {
286
+ path: at,
287
+ issue: `The field "${field}" can not hold a value of the principal field "${operand.path}"`,
288
+ },
289
+ ]
290
+ : []),
291
+ ...(ordered &&
292
+ Arr.some(fields, (found) => !orderedMeet(found.kinds, principal.kinds))
293
+ ? [
294
+ {
295
+ path: at,
296
+ issue: `The field "${field}" has an ordered test with a value that has no order`,
297
+ },
298
+ ]
299
+ : []),
300
+ ]
301
+ }
302
+
303
+ /**
304
+ * Tests if the path of a resource may start with a prefix: it is as long as
305
+ * the prefix, and each fixed string of the prefix is the string of the path
306
+ * at its place, when the path has a string there.
307
+ */
308
+ const mayStartWith = (
309
+ descriptor: Resource.Descriptor,
310
+ strings: ReadonlyArray<string | null>,
311
+ ): boolean =>
312
+ (descriptor.path?.length ?? 0) >= strings.length &&
313
+ Arr.every(strings, (string, index) => {
314
+ const segment = descriptor.path?.[index]
315
+ return string === null || typeof segment !== "string" || segment === string
316
+ })
317
+
318
+ /** The kinds of the part of the path of a resource at a place. */
319
+ const pathPartKinds = (
320
+ descriptor: Resource.Descriptor,
321
+ index: number,
322
+ ): Catalog.Kinds | string | undefined => {
323
+ const segment = descriptor.path?.[index]
324
+ return segment === undefined || typeof segment === "string"
325
+ ? segment
326
+ : (Catalog.lookup(descriptor.catalog, segment.field)?.kinds ??
327
+ Catalog.anyKinds)
328
+ }
329
+
330
+ /** The issues of the prefix of a path test. */
331
+ const underIssues = (
332
+ context: Context,
333
+ prefix: ReadonlyArray<Operand>,
334
+ at: ReadonlyArray<PropertyKey>,
335
+ ): ReadonlyArray<Issue> => {
336
+ const strings = Arr.map(prefix, (operand) =>
337
+ operand._tag === "Literal" && typeof operand.value === "string"
338
+ ? operand.value
339
+ : null,
340
+ )
341
+ // The test can match only a path that starts with the fixed strings of
342
+ // the prefix, so the other resources are left out.
343
+ const candidates = Arr.filter(context.paths, (descriptor) =>
344
+ mayStartWith(descriptor, strings),
345
+ )
346
+ return Arr.flatMap(prefix, (operand, index): ReadonlyArray<Issue> => {
347
+ const place = [...at, "prefix", index]
348
+ if (operand._tag === "Literal") {
349
+ return Arr.some(candidates, (descriptor) => {
350
+ const kinds = pathPartKinds(descriptor, index)
351
+ return typeof kinds === "object" && !fitsLiteral(kinds, operand.value)
352
+ })
353
+ ? [
354
+ {
355
+ path: place,
356
+ issue: `The path part ${String(index)} can not hold the value ${JSON.stringify(operand.value)}`,
357
+ },
358
+ ]
359
+ : []
360
+ }
361
+ const principal = principalField(context, operand.path, place)
362
+ if (isIssue(principal)) {
363
+ return [principal]
364
+ }
365
+ return Arr.some(candidates, (descriptor) => {
366
+ const kinds = pathPartKinds(descriptor, index)
367
+ return kinds === undefined
368
+ ? true
369
+ : typeof kinds === "string"
370
+ ? !Catalog.accepts(principal.kinds, kinds)
371
+ : !meet(kinds, principal.kinds)
372
+ })
373
+ ? [
374
+ {
375
+ path: place,
376
+ issue: `The principal field "${operand.path}" can not hold a part of the path of the resource`,
377
+ },
378
+ ]
379
+ : []
380
+ })
381
+ }
382
+
383
+ const notWhole = (at: ReadonlyArray<PropertyKey>): Issue => ({
384
+ path: at,
385
+ issue: "The condition is not a whole condition tree",
386
+ })
387
+
388
+ /**
389
+ * The issues of a condition. The constructors of the nodes checked the shape
390
+ * of each node, so this checks the names and the types against the domain. A
391
+ * node that the condition uses in many places is checked one time. The nodes
392
+ * it has seen are in a native `Set`, because it must find a node by the node
393
+ * itself: `Equal` compares nodes by structure.
394
+ */
395
+ const conditionIssues = (
396
+ context: Context,
397
+ node: Nodes.Node,
398
+ at: ReadonlyArray<PropertyKey>,
399
+ seen: Set<Nodes.Node>,
400
+ ): ReadonlyArray<Issue> => {
401
+ if (seen.has(node)) {
402
+ return []
403
+ }
404
+ seen.add(node)
405
+ switch (node._tag) {
406
+ case "Compare": {
407
+ const exists = fieldIssue(context, node.field, [...at, "field"])
408
+ return exists.length > 0
409
+ ? exists
410
+ : operandIssues(
411
+ context,
412
+ node.field,
413
+ node.op !== "eq" && node.op !== "ne",
414
+ node.value,
415
+ [...at, "value"],
416
+ )
417
+ }
418
+ case "In": {
419
+ const exists = fieldIssue(context, node.field, [...at, "field"])
420
+ return exists.length > 0
421
+ ? exists
422
+ : Arr.flatMap(node.values, (operand, index) =>
423
+ operandIssues(context, node.field, false, operand, [
424
+ ...at,
425
+ "values",
426
+ index,
427
+ ]),
428
+ )
429
+ }
430
+ case "Exists":
431
+ return fieldIssue(context, node.field, [...at, "field"])
432
+ case "Under":
433
+ return underIssues(context, node.prefix, at)
434
+ case "And":
435
+ case "Or":
436
+ return Arr.flatMap(node.conditions, (child, index) =>
437
+ conditionIssues(context, child, [...at, "conditions", index], seen),
438
+ )
439
+ case "Not":
440
+ return conditionIssues(
441
+ context,
442
+ node.condition,
443
+ [...at, "condition"],
444
+ seen,
445
+ )
446
+ default:
447
+ return []
448
+ }
449
+ }
450
+
451
+ const hasAction = (
452
+ descriptor: Resource.Descriptor,
453
+ actions: ReadonlyArray<string>,
454
+ ): boolean =>
455
+ actions.includes("*") ||
456
+ Arr.some(actions, (action) => descriptor.actions.includes(action))
457
+
458
+ /**
459
+ * Checks one rule against a domain. It gives one issue for each problem. `at`
460
+ * is the path of the rule, for the paths of the issues.
461
+ */
462
+ export const ruleIssues = (
463
+ domain: Domain.Any,
464
+ rule: RuleData,
465
+ at: ReadonlyArray<PropertyKey>,
466
+ memo: Memo = makeMemo(),
467
+ ): ReadonlyArray<Issue> => {
468
+ const all = rule.resources.includes("*")
469
+ // Each kind is checked one time, also when the list names it again.
470
+ const names = Arr.dedupe(Arr.filter(rule.resources, (kind) => kind !== "*"))
471
+ const kinds = Array.from(HashMap.keys(domain.resources))
472
+ const named = Arr.flatMap(names, (kind) =>
473
+ Option.toArray(HashMap.get(domain.resources, kind)),
474
+ )
475
+ const known = all ? Array.from(HashMap.values(domain.resources)) : named
476
+ // The rule never runs on a kind without one of its actions, so the types
477
+ // of its tests are checked only on the kinds with one.
478
+ const applicable = Arr.filter(named, (descriptor) =>
479
+ hasAction(descriptor, rule.actions),
480
+ )
481
+ const context: Context = {
482
+ domain,
483
+ memo,
484
+ all,
485
+ named,
486
+ applicable,
487
+ paths: all
488
+ ? Arr.filter(known, (descriptor) => hasAction(descriptor, rule.actions))
489
+ : applicable,
490
+ }
491
+ const actions = Arr.dedupe(
492
+ Arr.flatMap(known, (descriptor) => descriptor.actions),
493
+ )
494
+ return [
495
+ ...Arr.map(
496
+ Arr.filter(names, (kind) => !HashMap.has(domain.resources, kind)),
497
+ (kind): Issue => ({
498
+ path: [...at, "resources"],
499
+ issue: suggestIn(
500
+ memo,
501
+ `There is no resource with the kind "${kind}"`,
502
+ kind,
503
+ "~kinds",
504
+ () => kinds,
505
+ ),
506
+ }),
507
+ ),
508
+ ...Arr.map(
509
+ Arr.filter(
510
+ rule.actions,
511
+ (action) => action !== "*" && !actions.includes(action),
512
+ ),
513
+ (action): Issue => ({
514
+ path: [...at, "actions"],
515
+ issue: suggestIn(
516
+ memo,
517
+ `The action "${action}" is not an action of these resources`,
518
+ action,
519
+ `~actions\u0000${names.join(",")}`,
520
+ () => actions,
521
+ ),
522
+ }),
523
+ ),
524
+ ...Arr.flatMap(rule.fields ?? [], (field, index) =>
525
+ field.includes("*")
526
+ ? patternIssues(memo, known, field, [...at, "fields", index])
527
+ : fieldIssue(context, field, [...at, "fields", index]),
528
+ ),
529
+ ...(rule.when === undefined
530
+ ? []
531
+ : Nodes.isNode(rule.when)
532
+ ? conditionIssues(context, rule.when, [...at, "when"], new Set())
533
+ : [notWhole([...at, "when"])]),
534
+ ]
535
+ }
536
+
537
+ /** The JSON form of a policy tree, or a policy tree, as the binding reads it. */
538
+ type TreeNode =
539
+ | { readonly _tag: "Rules"; readonly rules: ReadonlyArray<RuleData> }
540
+ | {
541
+ readonly _tag: "All" | "Any" | "FirstOf"
542
+ readonly policies: ReadonlyArray<TreeNode>
543
+ }
544
+ | {
545
+ readonly _tag: "Ceiling"
546
+ readonly cap: TreeNode
547
+ readonly policy: TreeNode
548
+ }
549
+
550
+ /** Checks each rule of a policy tree against a domain. */
551
+ export const treeIssues = (
552
+ domain: Domain.Any,
553
+ root: TreeNode,
554
+ ): ReadonlyArray<Issue> => {
555
+ // The rules come out of the tree first, and each one is checked after.
556
+ // A check of a condition goes down its levels, so a check inside the walk
557
+ // of the tree would go down the levels of both.
558
+ const rulesOf = (
559
+ node: TreeNode,
560
+ path: ReadonlyArray<PropertyKey>,
561
+ level: number,
562
+ ): ReadonlyArray<Issue | readonly [RuleData, ReadonlyArray<PropertyKey>]> => {
563
+ if (level > Limits.policyDepth) {
564
+ return [
565
+ {
566
+ path,
567
+ issue: `A policy tree has more than ${String(Limits.policyDepth)} levels. The most is ${String(Limits.policyDepth)}.`,
568
+ },
569
+ ]
570
+ }
571
+ switch (node._tag) {
572
+ case "Rules":
573
+ return Arr.map(
574
+ node.rules,
575
+ (rule, index) => [rule, [...path, "rules", index]] as const,
576
+ )
577
+ case "All":
578
+ case "Any":
579
+ case "FirstOf":
580
+ return Arr.flatMap(node.policies, (child, index) =>
581
+ rulesOf(child, [...path, "policies", index], level + 1),
582
+ )
583
+ case "Ceiling":
584
+ return [
585
+ ...rulesOf(node.cap, [...path, "cap"], level + 1),
586
+ ...rulesOf(node.policy, [...path, "policy"], level + 1),
587
+ ]
588
+ }
589
+ }
590
+ const memo = makeMemo()
591
+ return Arr.flatMap(rulesOf(root, [], 1), (found) =>
592
+ Array.isArray(found)
593
+ ? ruleIssues(domain, found[0], found[1], memo)
594
+ : [found as Issue],
595
+ )
596
+ }