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