@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,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
|
+
}
|