@nmnmcc/ability 0.3.0 → 1.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.
- package/README.md +169 -14
- package/dist/Authorizer.d.ts +64 -39
- package/dist/Authorizer.d.ts.map +1 -1
- package/dist/Authorizer.js +291 -6
- package/dist/Authorizer.js.map +1 -1
- package/dist/Condition.d.ts +444 -65
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +1007 -25
- package/dist/Condition.js.map +1 -1
- package/dist/Domain.d.ts +27 -17
- package/dist/Domain.d.ts.map +1 -1
- package/dist/Domain.js +52 -10
- package/dist/Domain.js.map +1 -1
- package/dist/HttpApi.d.ts +84 -40
- package/dist/HttpApi.d.ts.map +1 -1
- package/dist/HttpApi.js +101 -11
- package/dist/HttpApi.js.map +1 -1
- package/dist/Policy.d.ts +311 -53
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +1212 -35
- package/dist/Policy.js.map +1 -1
- package/dist/Resource.d.ts +167 -16
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +784 -11
- package/dist/Resource.js.map +1 -1
- package/dist/Rule.d.ts +113 -27
- package/dist/Rule.d.ts.map +1 -1
- package/dist/Rule.js +217 -10
- package/dist/Rule.js.map +1 -1
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/src/Authorizer.ts +623 -59
- package/src/Condition.ts +2403 -112
- package/src/Domain.ts +79 -22
- package/src/HttpApi.ts +223 -23
- package/src/Policy.ts +2833 -93
- package/src/Resource.ts +1537 -22
- package/src/Rule.ts +461 -43
- package/dist/internal/authorizer.d.ts +0 -28
- package/dist/internal/authorizer.d.ts.map +0 -1
- package/dist/internal/authorizer.js +0 -153
- package/dist/internal/authorizer.js.map +0 -1
- package/dist/internal/condition.d.ts +0 -11
- package/dist/internal/condition.d.ts.map +0 -1
- package/dist/internal/condition.js +0 -393
- package/dist/internal/condition.js.map +0 -1
- package/dist/internal/domain.js +0 -34
- package/dist/internal/domain.js.map +0 -1
- package/dist/internal/httpApi.js +0 -41
- package/dist/internal/httpApi.js.map +0 -1
- package/dist/internal/policy.js +0 -524
- package/dist/internal/policy.js.map +0 -1
- package/dist/internal/resource.js +0 -66
- package/dist/internal/resource.js.map +0 -1
- package/dist/internal/rule.js +0 -131
- package/dist/internal/rule.js.map +0 -1
- package/src/internal/authorizer.ts +0 -271
- package/src/internal/condition.ts +0 -607
- package/src/internal/domain.ts +0 -56
- package/src/internal/httpApi.ts +0 -84
- package/src/internal/policy.ts +0 -815
- package/src/internal/resource.ts +0 -98
- package/src/internal/rule.ts +0 -203
package/src/Policy.ts
CHANGED
|
@@ -25,35 +25,41 @@
|
|
|
25
25
|
* this kind?"
|
|
26
26
|
* - {@link permittedFields}: the fields that the policy lets a request use.
|
|
27
27
|
*
|
|
28
|
+
* Each of them also takes the request first and the policy later. In a `pipe`,
|
|
29
|
+
* the policy gives the domain. Outside a `pipe`, give the domain by hand, as
|
|
30
|
+
* in `Policy.decide<typeof App>(request)`. Without it the request has the type
|
|
31
|
+
* `never`, so TypeScript can still check it.
|
|
32
|
+
*
|
|
28
33
|
* A policy without predicates can become JSON with {@link schema}.
|
|
29
34
|
*
|
|
30
35
|
* @stability stable
|
|
31
36
|
* @since 0.3.0
|
|
32
37
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
// A policy keeps the error type and the service type of its predicates in the
|
|
39
|
+
// public types. The code below works on the nodes with no types, where they
|
|
40
|
+
// are `unknown`, so the language service check for `unknown` in an Effect is
|
|
41
|
+
// off here.
|
|
42
|
+
// @effect-diagnostics anyUnknownInErrorContext:off
|
|
43
|
+
import type * as Context from "effect/Context"
|
|
44
|
+
import * as Data from "effect/Data"
|
|
45
|
+
import * as Arr from "effect/Array"
|
|
46
|
+
import * as Chunk from "effect/Chunk"
|
|
47
|
+
import * as Effect from "effect/Effect"
|
|
48
|
+
import { dual } from "effect/Function"
|
|
49
|
+
import * as HashMap from "effect/HashMap"
|
|
50
|
+
import * as HashSet from "effect/HashSet"
|
|
51
|
+
import * as Option from "effect/Option"
|
|
52
|
+
import { hasProperty } from "effect/Predicate"
|
|
36
53
|
import type * as Pipeable from "effect/Pipeable"
|
|
37
|
-
import
|
|
38
|
-
import
|
|
39
|
-
import
|
|
40
|
-
import
|
|
41
|
-
import * as
|
|
42
|
-
import
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
* @category type IDs
|
|
47
|
-
* @since 0.3.0
|
|
48
|
-
*/
|
|
49
|
-
export const TypeId: "~@nmnmcc/ability/Policy" = internal.TypeId
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @stability stable
|
|
53
|
-
* @category type IDs
|
|
54
|
-
* @since 0.3.0
|
|
55
|
-
*/
|
|
56
|
-
export type TypeId = typeof TypeId
|
|
54
|
+
import * as Ref from "effect/Ref"
|
|
55
|
+
import * as Schema from "effect/Schema"
|
|
56
|
+
import * as SchemaIssue from "effect/SchemaIssue"
|
|
57
|
+
import * as SchemaTransformation from "effect/SchemaTransformation"
|
|
58
|
+
import * as Condition from "./Condition.ts"
|
|
59
|
+
import * as Decision from "./Decision.ts"
|
|
60
|
+
import * as Domain from "./Domain.ts"
|
|
61
|
+
import * as Resource from "./Resource.ts"
|
|
62
|
+
import * as Rule from "./Rule.ts"
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* @stability stable
|
|
@@ -124,6 +130,38 @@ export interface CeilingNode {
|
|
|
124
130
|
*/
|
|
125
131
|
export type Node = RulesNode | AllNode | AnyNode | FirstOfNode | CeilingNode
|
|
126
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The tree of a policy with the types of its predicates and its domain `D`.
|
|
135
|
+
* Each rule and each child has the types too, and `"~Node"` is only in the types. A node made by
|
|
136
|
+
* hand does not have it. So the node of one policy, or a part of it, can not
|
|
137
|
+
* go into a policy with other types.
|
|
138
|
+
*
|
|
139
|
+
* @stability stable
|
|
140
|
+
* @category models
|
|
141
|
+
* @since 1.0.0
|
|
142
|
+
*/
|
|
143
|
+
export type TypedNode<E = never, R = never, O = never, D = any> = (
|
|
144
|
+
| {
|
|
145
|
+
readonly _tag: "Rules"
|
|
146
|
+
readonly algorithm: Algorithm
|
|
147
|
+
// The rules may read different values, so a node keeps no one type of
|
|
148
|
+
// what they read. With `never`, a condition taken from a node fits no
|
|
149
|
+
// rule that reads a value or a principal.
|
|
150
|
+
readonly rules: ReadonlyArray<Rule.Rule<E, R, O, D, never>>
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
readonly _tag: "All" | "Any" | "FirstOf"
|
|
154
|
+
readonly policies: ReadonlyArray<TypedNode<E, R, O, D>>
|
|
155
|
+
}
|
|
156
|
+
| {
|
|
157
|
+
readonly _tag: "Ceiling"
|
|
158
|
+
readonly cap: TypedNode<E, R, O, D>
|
|
159
|
+
readonly policy: TypedNode<E, R, O, D>
|
|
160
|
+
}
|
|
161
|
+
) & {
|
|
162
|
+
readonly "~Node": Condition.Variance<E, R, O>
|
|
163
|
+
}
|
|
164
|
+
|
|
127
165
|
/**
|
|
128
166
|
* A policy for the domain `D`. `E`, `R` and `O` come from the conditions of
|
|
129
167
|
* its rules. See `Condition.Condition`.
|
|
@@ -133,11 +171,15 @@ export type Node = RulesNode | AllNode | AnyNode | FirstOfNode | CeilingNode
|
|
|
133
171
|
* @since 0.3.0
|
|
134
172
|
*/
|
|
135
173
|
export interface Policy<D extends Domain.Any, E = never, R = never, O = never>
|
|
136
|
-
extends
|
|
137
|
-
readonly [TypeId]: TypeId
|
|
174
|
+
extends Pipeable.Pipeable {
|
|
138
175
|
readonly domain: D
|
|
139
|
-
readonly node:
|
|
140
|
-
readonly "~Policy"
|
|
176
|
+
readonly node: TypedNode<E, R, O, D>
|
|
177
|
+
readonly "~Policy": Condition.Variance<E, R, O>
|
|
178
|
+
/**
|
|
179
|
+
* Only in the types. A policy reads the values and the principal of its
|
|
180
|
+
* domain, so a policy of one domain is not a policy of a wider domain.
|
|
181
|
+
*/
|
|
182
|
+
readonly "~Domain": (domain: D) => D
|
|
141
183
|
}
|
|
142
184
|
|
|
143
185
|
/**
|
|
@@ -209,12 +251,53 @@ export type Target<D> = {
|
|
|
209
251
|
[K in Domain.Kind<D>]: {
|
|
210
252
|
readonly action: Domain.ActionOf<D, K>
|
|
211
253
|
readonly resource: Domain.ValueOf<D, K>
|
|
212
|
-
readonly field?: Condition.
|
|
254
|
+
readonly field?: Condition.FieldSelector<Domain.DataOf<D, K>>
|
|
255
|
+
/** A target has a resource value or a kind, never both. */
|
|
256
|
+
readonly kind?: never
|
|
257
|
+
}
|
|
258
|
+
}[Domain.Kind<D>]
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A {@link Target} with no field. Each kind keeps its own actions.
|
|
262
|
+
*
|
|
263
|
+
* @stability stable
|
|
264
|
+
* @category models
|
|
265
|
+
* @since 0.3.0
|
|
266
|
+
*/
|
|
267
|
+
export type FieldlessTarget<D> = {
|
|
268
|
+
[K in Domain.Kind<D>]: {
|
|
269
|
+
readonly action: Domain.ActionOf<D, K>
|
|
270
|
+
readonly resource: Domain.ValueOf<D, K>
|
|
271
|
+
readonly kind?: never
|
|
213
272
|
}
|
|
214
273
|
}[Domain.Kind<D>]
|
|
215
274
|
|
|
216
275
|
/**
|
|
217
|
-
*
|
|
276
|
+
* A scope: a part of a kind, as a condition on its values. It gets a builder
|
|
277
|
+
* with the types of the kind and of the principal, like the condition of a
|
|
278
|
+
* rule. A scope is plain data, so it can not have a predicate.
|
|
279
|
+
*
|
|
280
|
+
* @stability stable
|
|
281
|
+
* @category models
|
|
282
|
+
* @since 1.0.0
|
|
283
|
+
*/
|
|
284
|
+
export type Scope<D, K extends string> = (
|
|
285
|
+
builder: Condition.Builder<
|
|
286
|
+
Domain.DataOf<D, K>,
|
|
287
|
+
Domain.ValueOf<D, K>,
|
|
288
|
+
Domain.Principal<D>
|
|
289
|
+
>,
|
|
290
|
+
) => Condition.Condition<
|
|
291
|
+
never,
|
|
292
|
+
never,
|
|
293
|
+
never,
|
|
294
|
+
Condition.Reads<Domain.ValueOf<D, K>, Domain.Principal<D>>
|
|
295
|
+
>
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One act on one kind of resource, with no value. With `within`, the target is
|
|
299
|
+
* only the values of the kind in the scope. Without it, the target is every
|
|
300
|
+
* value of the kind.
|
|
218
301
|
*
|
|
219
302
|
* @stability stable
|
|
220
303
|
* @category models
|
|
@@ -224,7 +307,11 @@ export type KindTarget<D> = {
|
|
|
224
307
|
[K in Domain.Kind<D>]: {
|
|
225
308
|
readonly action: Domain.ActionOf<D, K>
|
|
226
309
|
readonly kind: K
|
|
227
|
-
readonly field?: Condition.
|
|
310
|
+
readonly field?: Condition.FieldSelector<Domain.DataOf<D, K>>
|
|
311
|
+
/** The part of the kind. See {@link Scope}. */
|
|
312
|
+
readonly within?: Scope<D, K>
|
|
313
|
+
/** A target has a resource value or a kind, never both. */
|
|
314
|
+
readonly resource?: never
|
|
228
315
|
}
|
|
229
316
|
}[Domain.Kind<D>]
|
|
230
317
|
|
|
@@ -237,6 +324,17 @@ export type KindTarget<D> = {
|
|
|
237
324
|
*/
|
|
238
325
|
export type Request<D> = Target<D> & { readonly principal: Domain.Principal<D> }
|
|
239
326
|
|
|
327
|
+
/**
|
|
328
|
+
* A {@link Request} with no field.
|
|
329
|
+
*
|
|
330
|
+
* @stability stable
|
|
331
|
+
* @category models
|
|
332
|
+
* @since 0.3.0
|
|
333
|
+
*/
|
|
334
|
+
export type FieldlessRequest<D> = FieldlessTarget<D> & {
|
|
335
|
+
readonly principal: Domain.Principal<D>
|
|
336
|
+
}
|
|
337
|
+
|
|
240
338
|
/**
|
|
241
339
|
* A request about a kind of resource, with no value.
|
|
242
340
|
*
|
|
@@ -301,6 +399,216 @@ export interface Options {
|
|
|
301
399
|
readonly algorithm?: Algorithm
|
|
302
400
|
}
|
|
303
401
|
|
|
402
|
+
const nodes = Data.taggedEnum<Node>()
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The most nodes that the JSON form of a policy may have. A tree may use one
|
|
406
|
+
* child in many places, and the JSON form writes it out each time, so a small
|
|
407
|
+
* tree may have a very large JSON form.
|
|
408
|
+
*/
|
|
409
|
+
const maxEncodedNodes = 100_000
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The number of nodes of the JSON form of a condition. A shared child counts
|
|
413
|
+
* one time for each place, as the JSON form writes it out each time.
|
|
414
|
+
*/
|
|
415
|
+
const conditionSize = (ast: Ast, memo: Map<Ast, number>): number =>
|
|
416
|
+
remember(memo, ast, () => {
|
|
417
|
+
switch (ast._tag) {
|
|
418
|
+
case "And":
|
|
419
|
+
case "Or":
|
|
420
|
+
return Arr.reduce(
|
|
421
|
+
ast.conditions,
|
|
422
|
+
1,
|
|
423
|
+
(size, child) => size + conditionSize(child, memo),
|
|
424
|
+
)
|
|
425
|
+
case "Not":
|
|
426
|
+
return 1 + conditionSize(ast.condition, memo)
|
|
427
|
+
default:
|
|
428
|
+
return 1
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The number of nodes of the JSON form of a tree, with the conditions of its
|
|
434
|
+
* rules.
|
|
435
|
+
*/
|
|
436
|
+
const encodedSize = (
|
|
437
|
+
node: Node,
|
|
438
|
+
memo: Map<Node, number> = new Map(),
|
|
439
|
+
conditions: Map<Ast, number> = new Map(),
|
|
440
|
+
): number =>
|
|
441
|
+
remember(memo, node, () => {
|
|
442
|
+
switch (node._tag) {
|
|
443
|
+
case "Rules":
|
|
444
|
+
return Arr.reduce(
|
|
445
|
+
node.rules,
|
|
446
|
+
1,
|
|
447
|
+
(size, value) =>
|
|
448
|
+
size +
|
|
449
|
+
1 +
|
|
450
|
+
(value.when === undefined
|
|
451
|
+
? 0
|
|
452
|
+
: conditionSize(value.when as Ast, conditions)),
|
|
453
|
+
)
|
|
454
|
+
case "All":
|
|
455
|
+
case "Any":
|
|
456
|
+
case "FirstOf":
|
|
457
|
+
return Arr.reduce(
|
|
458
|
+
node.policies,
|
|
459
|
+
1,
|
|
460
|
+
(size, child) => size + encodedSize(child, memo, conditions),
|
|
461
|
+
)
|
|
462
|
+
case "Ceiling":
|
|
463
|
+
return (
|
|
464
|
+
1 +
|
|
465
|
+
encodedSize(node.cap, memo, conditions) +
|
|
466
|
+
encodedSize(node.policy, memo, conditions)
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
/** Gives the JSON form of a tree, when it is not too large. */
|
|
472
|
+
const encodeTree = (node: Node): EncodedNode => {
|
|
473
|
+
if (encodedSize(node) > maxEncodedNodes) {
|
|
474
|
+
throw new globalThis.Error(tooLargeMessage)
|
|
475
|
+
}
|
|
476
|
+
return encodeNode(node)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const tooLargeMessage = `The JSON form of this policy would have more than ${String(maxEncodedNodes)} nodes`
|
|
480
|
+
|
|
481
|
+
const encodeNode = (node: Node): EncodedNode => {
|
|
482
|
+
switch (node._tag) {
|
|
483
|
+
case "Rules":
|
|
484
|
+
return {
|
|
485
|
+
_tag: "Rules",
|
|
486
|
+
algorithm: node.algorithm,
|
|
487
|
+
// The domain of a rule is not in its JSON form.
|
|
488
|
+
rules: node.rules.map(
|
|
489
|
+
({ domain: _domain, ...value }) => ({ ...value }) as EncodedRule,
|
|
490
|
+
),
|
|
491
|
+
}
|
|
492
|
+
case "All":
|
|
493
|
+
case "Any":
|
|
494
|
+
case "FirstOf":
|
|
495
|
+
return { _tag: node._tag, policies: node.policies.map(encodeNode) }
|
|
496
|
+
case "Ceiling":
|
|
497
|
+
return {
|
|
498
|
+
_tag: "Ceiling",
|
|
499
|
+
cap: encodeNode(node.cap),
|
|
500
|
+
policy: encodeNode(node.policy),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The tree of a policy as a value that `JSON.stringify` keeps with no loss.
|
|
507
|
+
* See `Condition.toJsonValue`.
|
|
508
|
+
*/
|
|
509
|
+
const jsonNode = (node: EncodedNode, meta: Schema.Top | undefined): unknown => {
|
|
510
|
+
const json = (child: EncodedNode) => jsonNode(child, meta)
|
|
511
|
+
switch (node._tag) {
|
|
512
|
+
case "Rules":
|
|
513
|
+
return {
|
|
514
|
+
...node,
|
|
515
|
+
rules: Arr.map(node.rules, (value) => ({
|
|
516
|
+
...value,
|
|
517
|
+
...(value.when === undefined
|
|
518
|
+
? {}
|
|
519
|
+
: { when: Condition.toJsonValue(value.when as Ast) }),
|
|
520
|
+
// The `meta` of a rule is a decoded value. Its JSON form comes
|
|
521
|
+
// from the `meta` Schema of the domain, as in `schema`.
|
|
522
|
+
...(value.meta === undefined || meta === undefined
|
|
523
|
+
? {}
|
|
524
|
+
: {
|
|
525
|
+
meta: Schema.encodeUnknownSync(
|
|
526
|
+
meta as Schema.Codec<unknown, unknown>,
|
|
527
|
+
)(value.meta),
|
|
528
|
+
}),
|
|
529
|
+
})),
|
|
530
|
+
}
|
|
531
|
+
case "All":
|
|
532
|
+
case "Any":
|
|
533
|
+
case "FirstOf":
|
|
534
|
+
return { ...node, policies: Arr.map(node.policies, json) }
|
|
535
|
+
case "Ceiling":
|
|
536
|
+
return { ...node, cap: json(node.cap), policy: json(node.policy) }
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The class of every policy value. Two policies of one domain with the same
|
|
542
|
+
* tree are equal. As JSON, a policy is its tree.
|
|
543
|
+
*/
|
|
544
|
+
class PolicyData extends Data.Class<{
|
|
545
|
+
readonly domain: Domain.Any
|
|
546
|
+
readonly node: Node
|
|
547
|
+
}> {
|
|
548
|
+
toJSON(): unknown {
|
|
549
|
+
return jsonNode(encodeTree(this.node), this.domain.meta)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The node of a policy, with no types, for the code below. */
|
|
554
|
+
const nodeOf = (policy: Any): Node => policy.node as Node
|
|
555
|
+
|
|
556
|
+
const fromNode = (domain: Domain.Any, node: Node): any =>
|
|
557
|
+
new PolicyData({ domain, node })
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Makes a policy from a list of rules.
|
|
561
|
+
*
|
|
562
|
+
* @stability stable
|
|
563
|
+
* @category constructors
|
|
564
|
+
* @since 0.3.0
|
|
565
|
+
*/
|
|
566
|
+
export const rules: <
|
|
567
|
+
D extends Domain.Any,
|
|
568
|
+
Rules extends Rule.Rule<any, any, any, D, never>,
|
|
569
|
+
>(
|
|
570
|
+
domain: D,
|
|
571
|
+
rules: Iterable<Rules>,
|
|
572
|
+
options?: Options,
|
|
573
|
+
) => Policy<
|
|
574
|
+
D,
|
|
575
|
+
Rule.Error<Rules>,
|
|
576
|
+
Rule.Services<Rules>,
|
|
577
|
+
Rule.OpaqueOf<Rules>
|
|
578
|
+
> = (domain, values, options) => {
|
|
579
|
+
const node = nodes.Rules({
|
|
580
|
+
algorithm: options?.algorithm ?? "denyOverrides",
|
|
581
|
+
rules: Array.from(values),
|
|
582
|
+
})
|
|
583
|
+
// A value that is not a rule, such as a copy of a rule made with a spread,
|
|
584
|
+
// may keep the type fields of another rule, and it does not work in a
|
|
585
|
+
// generator. It is a mistake in the code, so it is a defect.
|
|
586
|
+
if (!Arr.every(node.rules, Rule.isRule)) {
|
|
587
|
+
throw new globalThis.Error("A value in the list is not a rule")
|
|
588
|
+
}
|
|
589
|
+
// A rule from a builder of another domain is a mistake in the code, so it
|
|
590
|
+
// is a defect. Two domains may have the same types, so the types do not
|
|
591
|
+
// find it.
|
|
592
|
+
if (
|
|
593
|
+
Arr.some(node.rules, (value) => {
|
|
594
|
+
const origin = Rule.domainOf(value)
|
|
595
|
+
return origin !== undefined && origin !== domain
|
|
596
|
+
})
|
|
597
|
+
) {
|
|
598
|
+
throw new globalThis.Error(
|
|
599
|
+
"A rule comes from the builder of another domain",
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
// A rule from `Rule.make` keeps no domain, so its names are checked here.
|
|
603
|
+
const issues = validate(domain, encodeNode(node))
|
|
604
|
+
if (issues.length > 0) {
|
|
605
|
+
throw new globalThis.Error(
|
|
606
|
+
`A rule does not fit the domain: ${issues.map((issue) => issue.issue).join("; ")}`,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
return fromNode(domain, node)
|
|
610
|
+
}
|
|
611
|
+
|
|
304
612
|
/**
|
|
305
613
|
* Makes a policy from a generator function that gives rules. It is pure and it
|
|
306
614
|
* gives its result at once.
|
|
@@ -319,27 +627,25 @@ export interface Options {
|
|
|
319
627
|
* @category constructors
|
|
320
628
|
* @since 0.3.0
|
|
321
629
|
*/
|
|
322
|
-
export const define
|
|
323
|
-
domain: D,
|
|
324
|
-
|
|
325
|
-
) => <Rules
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
) => Policy<D, Rule.Error<Rules>, Rule.Services<Rules>, Rule.OpaqueOf<Rules>> =
|
|
342
|
-
internal.rules
|
|
630
|
+
export const define =
|
|
631
|
+
<D extends Domain.Any>(domain: D, options?: Options) =>
|
|
632
|
+
<Rules extends Rule.Rule<any, any, any, D, never>, A>(
|
|
633
|
+
body: (builder: Rule.Builder<D>) => Generator<Rules, A, unknown>,
|
|
634
|
+
): Policy<
|
|
635
|
+
D,
|
|
636
|
+
Rule.Error<Rules>,
|
|
637
|
+
Rule.Services<Rules>,
|
|
638
|
+
Rule.OpaqueOf<Rules>
|
|
639
|
+
> => {
|
|
640
|
+
const iterator = body(Rule.builder(domain))
|
|
641
|
+
// Each rule goes back into the generator, as `yield*` gives it.
|
|
642
|
+
const collected = Arr.unfold(iterator.next(), (state) =>
|
|
643
|
+
state.done === true
|
|
644
|
+
? Option.none()
|
|
645
|
+
: Option.some([state.value, iterator.next(state.value)] as const),
|
|
646
|
+
)
|
|
647
|
+
return rules(domain, collected, options)
|
|
648
|
+
}
|
|
343
649
|
|
|
344
650
|
/**
|
|
345
651
|
* A policy with no rules. It is not applicable to any request.
|
|
@@ -348,8 +654,8 @@ export const rules: <D extends Domain.Any, Rules extends Rule.Any>(
|
|
|
348
654
|
* @category constructors
|
|
349
655
|
* @since 0.3.0
|
|
350
656
|
*/
|
|
351
|
-
export const empty
|
|
352
|
-
|
|
657
|
+
export const empty = <D extends Domain.Any>(domain: D): Policy<D> =>
|
|
658
|
+
rules(domain, [])
|
|
353
659
|
|
|
354
660
|
type Combined<D extends Domain.Any, Ps extends ReadonlyArray<Any>> = Policy<
|
|
355
661
|
D,
|
|
@@ -358,6 +664,69 @@ type Combined<D extends Domain.Any, Ps extends ReadonlyArray<Any>> = Policy<
|
|
|
358
664
|
OpaqueOf<Ps[number]>
|
|
359
665
|
>
|
|
360
666
|
|
|
667
|
+
const sameDomain = (policies: ReadonlyArray<Any>): Domain.Any => {
|
|
668
|
+
// A copy of a policy made with a spread may keep the type fields of another
|
|
669
|
+
// policy. Only a policy value goes into a tree.
|
|
670
|
+
if (!Arr.every(policies, isPolicy)) {
|
|
671
|
+
throw new globalThis.Error("A value in the list is not a policy")
|
|
672
|
+
}
|
|
673
|
+
const domain = policies[0]!.domain
|
|
674
|
+
if (!Arr.every(policies, (policy) => policy.domain === domain)) {
|
|
675
|
+
throw new globalThis.Error(
|
|
676
|
+
"Policies from two domains can not be put together",
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
return domain
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The most levels in a policy tree. A walk over a tree calls itself for each
|
|
684
|
+
* level, so a much deeper tree could fill the stack and stop a check.
|
|
685
|
+
*/
|
|
686
|
+
const maxDepth = 256
|
|
687
|
+
|
|
688
|
+
/** The number of levels of a policy tree. */
|
|
689
|
+
const heightOf = (node: Node, memo: Map<Node, number> = new Map()): number =>
|
|
690
|
+
remember(memo, node, () => {
|
|
691
|
+
const height = (child: Node) => heightOf(child, memo)
|
|
692
|
+
switch (node._tag) {
|
|
693
|
+
case "Rules":
|
|
694
|
+
return 1
|
|
695
|
+
case "All":
|
|
696
|
+
case "Any":
|
|
697
|
+
case "FirstOf":
|
|
698
|
+
return (
|
|
699
|
+
1 +
|
|
700
|
+
Arr.reduce(node.policies, 0, (top, child) =>
|
|
701
|
+
Math.max(top, height(child)),
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
case "Ceiling":
|
|
705
|
+
return 1 + Math.max(height(node.cap), height(node.policy))
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Makes a policy from a new node. Its children come from policies, so each
|
|
711
|
+
* has at most `maxDepth` levels, and the walk of `heightOf` is safe.
|
|
712
|
+
*/
|
|
713
|
+
const fromCombined = (domain: Domain.Any, node: Node): any => {
|
|
714
|
+
if (heightOf(node) > maxDepth) {
|
|
715
|
+
throw new globalThis.Error(
|
|
716
|
+
`A policy tree may have at most ${maxDepth} levels`,
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
return fromNode(domain, node)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const combine =
|
|
723
|
+
(tag: "All" | "Any" | "FirstOf") =>
|
|
724
|
+
(policies: ReadonlyArray<Any>): any =>
|
|
725
|
+
fromCombined(
|
|
726
|
+
sameDomain(policies),
|
|
727
|
+
nodes[tag]({ policies: policies.map(nodeOf) }),
|
|
728
|
+
)
|
|
729
|
+
|
|
361
730
|
/**
|
|
362
731
|
* Every policy must allow, and one deny is enough to deny. With some policies
|
|
363
732
|
* not applicable and none that deny, the result is not applicable.
|
|
@@ -370,7 +739,7 @@ export const all: <
|
|
|
370
739
|
const Ps extends readonly [Policy<any, any, any, any>, ...Array<Any>],
|
|
371
740
|
>(
|
|
372
741
|
policies: Ps,
|
|
373
|
-
) => Combined<DomainOf<Ps[0]>, Ps> =
|
|
742
|
+
) => Combined<DomainOf<Ps[0]>, Ps> = combine("All")
|
|
374
743
|
|
|
375
744
|
/**
|
|
376
745
|
* One allow is enough. Without an allow, one deny gives a deny.
|
|
@@ -383,7 +752,7 @@ export const any: <
|
|
|
383
752
|
const Ps extends readonly [Policy<any, any, any, any>, ...Array<Any>],
|
|
384
753
|
>(
|
|
385
754
|
policies: Ps,
|
|
386
|
-
) => Combined<DomainOf<Ps[0]>, Ps> =
|
|
755
|
+
) => Combined<DomainOf<Ps[0]>, Ps> = combine("Any")
|
|
387
756
|
|
|
388
757
|
/**
|
|
389
758
|
* The first policy that allows or denies gives the answer.
|
|
@@ -396,7 +765,7 @@ export const firstOf: <
|
|
|
396
765
|
const Ps extends readonly [Policy<any, any, any, any>, ...Array<Any>],
|
|
397
766
|
>(
|
|
398
767
|
policies: Ps,
|
|
399
|
-
) => Combined<DomainOf<Ps[0]>, Ps> =
|
|
768
|
+
) => Combined<DomainOf<Ps[0]>, Ps> = combine("FirstOf")
|
|
400
769
|
|
|
401
770
|
/**
|
|
402
771
|
* Puts a limit on a policy. A deny from `cap` always wins. An allow from `cap`
|
|
@@ -414,7 +783,408 @@ export const ceiling: {
|
|
|
414
783
|
policy: Policy<D, E, R, O>,
|
|
415
784
|
cap: Policy<D, E2, R2, O2>,
|
|
416
785
|
): Policy<D, E | E2, R | R2, O | O2>
|
|
417
|
-
} =
|
|
786
|
+
} = dual(2, (policy: Any, cap: Any): any =>
|
|
787
|
+
fromCombined(
|
|
788
|
+
sameDomain([policy, cap]),
|
|
789
|
+
nodes.Ceiling({ cap: nodeOf(cap), policy: nodeOf(policy) }),
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Gives the answer for a key from a memo, or works it out and keeps it. One
|
|
795
|
+
* node may be a child of many nodes, so each walk over a tree keeps its work
|
|
796
|
+
* on a node in a memo that the walk makes for itself. No memo lives longer
|
|
797
|
+
* than one call.
|
|
798
|
+
*
|
|
799
|
+
* The memo is a native `Map`, because it must find a node by the node itself.
|
|
800
|
+
* An Effect `HashMap` finds a key by `Equal`, and the nodes are Effect data,
|
|
801
|
+
* so it would hash the whole structure of a node, and a shared child again for
|
|
802
|
+
* each path to it.
|
|
803
|
+
*/
|
|
804
|
+
const remember = <K, A>(memo: Map<K, A>, key: K, work: () => A): A => {
|
|
805
|
+
// A result may be `undefined`, as the result of a fold may be, so the test
|
|
806
|
+
// asks for the key.
|
|
807
|
+
if (memo.has(key)) {
|
|
808
|
+
return memo.get(key) as A
|
|
809
|
+
}
|
|
810
|
+
const result = work()
|
|
811
|
+
memo.set(key, result)
|
|
812
|
+
return result
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The memos of one walk over a policy tree. */
|
|
816
|
+
interface TreeMemo {
|
|
817
|
+
readonly done: Map<Node, Node>
|
|
818
|
+
readonly predicates: Map<Node, boolean>
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const provideNode = (
|
|
822
|
+
node: Node,
|
|
823
|
+
context: Context.Context<never>,
|
|
824
|
+
memo: TreeMemo = { done: new Map(), predicates: new Map() },
|
|
825
|
+
): Node =>
|
|
826
|
+
// A node with no predicate needs no services, and it stays the same node.
|
|
827
|
+
// One node may be a child of many nodes, and it gives one new node.
|
|
828
|
+
!nodeHasPredicate(node, memo.predicates)
|
|
829
|
+
? node
|
|
830
|
+
: remember(memo.done, node, () => provideOnce(node, context, memo))
|
|
831
|
+
|
|
832
|
+
const provideOnce = (
|
|
833
|
+
node: Node,
|
|
834
|
+
context: Context.Context<never>,
|
|
835
|
+
memo: TreeMemo,
|
|
836
|
+
): Node => {
|
|
837
|
+
switch (node._tag) {
|
|
838
|
+
case "Rules":
|
|
839
|
+
return nodes.Rules({
|
|
840
|
+
algorithm: node.algorithm,
|
|
841
|
+
rules: node.rules.map((value) =>
|
|
842
|
+
value.when === undefined || !Condition.hasPredicate(value.when)
|
|
843
|
+
? value
|
|
844
|
+
: Rule.make({
|
|
845
|
+
...value,
|
|
846
|
+
when: Condition.provideContext(value.when, context as never),
|
|
847
|
+
}),
|
|
848
|
+
),
|
|
849
|
+
})
|
|
850
|
+
case "All":
|
|
851
|
+
case "Any":
|
|
852
|
+
case "FirstOf":
|
|
853
|
+
return nodes[node._tag]({
|
|
854
|
+
policies: Arr.map(node.policies, (child) =>
|
|
855
|
+
provideNode(child, context, memo),
|
|
856
|
+
),
|
|
857
|
+
})
|
|
858
|
+
case "Ceiling":
|
|
859
|
+
return nodes.Ceiling({
|
|
860
|
+
cap: provideNode(node.cap, context, memo),
|
|
861
|
+
policy: provideNode(node.policy, context, memo),
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Gives the services in `context` to every predicate of a policy, so the
|
|
868
|
+
* policy needs no services when it runs.
|
|
869
|
+
*
|
|
870
|
+
* @stability stable
|
|
871
|
+
* @category services
|
|
872
|
+
* @since 1.0.0
|
|
873
|
+
*/
|
|
874
|
+
export const provideContext: {
|
|
875
|
+
<R>(
|
|
876
|
+
context: Context.Context<R>,
|
|
877
|
+
): <D extends Domain.Any, E, O>(
|
|
878
|
+
policy: Policy<D, E, R, O>,
|
|
879
|
+
) => Policy<D, E, never, O>
|
|
880
|
+
<D extends Domain.Any, E, R, O>(
|
|
881
|
+
policy: Policy<D, E, R, O>,
|
|
882
|
+
context: Context.Context<R>,
|
|
883
|
+
): Policy<D, E, never, O>
|
|
884
|
+
} = dual(2, (policy: Any, context: Context.Context<never>): any =>
|
|
885
|
+
fromNode(policy.domain, provideNode(nodeOf(policy), context)),
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
// Decisions
|
|
889
|
+
|
|
890
|
+
interface DecideContext {
|
|
891
|
+
readonly action: string
|
|
892
|
+
readonly kind: string
|
|
893
|
+
readonly field: string | undefined
|
|
894
|
+
readonly input: Condition.Input
|
|
895
|
+
/** The resource of the kind, for the fields that a rule may grant. */
|
|
896
|
+
readonly descriptor: Resource.Descriptor
|
|
897
|
+
/** The rules that were tested, for {@link explain}. */
|
|
898
|
+
readonly trace: Ref.Ref<Chunk.Chunk<Step>> | undefined
|
|
899
|
+
readonly predicates: Map<Node, boolean>
|
|
900
|
+
/**
|
|
901
|
+
* The decisions of nodes with no predicates. One node may be a child of
|
|
902
|
+
* many nodes, and its decision is the same each time, so it is made only
|
|
903
|
+
* one time. A tree with shared nodes may have very many paths.
|
|
904
|
+
*/
|
|
905
|
+
readonly decisions: Map<Node, Decision.Decision>
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const ruleApplies = (
|
|
909
|
+
value: Rule.Any,
|
|
910
|
+
context: DecideContext,
|
|
911
|
+
path: ReadonlyArray<number>,
|
|
912
|
+
): Effect.Effect<boolean, unknown, unknown> => {
|
|
913
|
+
if (!Rule.matchesTarget(value, context)) {
|
|
914
|
+
return Effect.succeed(false)
|
|
915
|
+
}
|
|
916
|
+
const test =
|
|
917
|
+
value.when === undefined
|
|
918
|
+
? Effect.succeed(true)
|
|
919
|
+
: // A rule of a node keeps no type of what it reads. The decision gives
|
|
920
|
+
// it the value and the principal of the request.
|
|
921
|
+
Condition.evaluate(value.when as Condition.Any, context.input)
|
|
922
|
+
const trace = context.trace
|
|
923
|
+
return trace === undefined
|
|
924
|
+
? test
|
|
925
|
+
: Effect.tap(test, (applies) =>
|
|
926
|
+
// A chunk adds a step with no copy of the steps before.
|
|
927
|
+
Ref.update(trace, Chunk.append({ path, rule: value, applies })),
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const fromRule = (value: Rule.Any): Decision.Decision =>
|
|
932
|
+
value._tag === "Allow"
|
|
933
|
+
? Decision.Allow({ rule: value })
|
|
934
|
+
: value.reason === undefined
|
|
935
|
+
? Decision.Deny({ rule: value })
|
|
936
|
+
: Decision.Deny({ rule: value, reason: value.reason })
|
|
937
|
+
|
|
938
|
+
/** Gives the first rule that applies, in order. */
|
|
939
|
+
const firstApplying = (
|
|
940
|
+
candidates: ReadonlyArray<readonly [Rule.Any, number]>,
|
|
941
|
+
context: DecideContext,
|
|
942
|
+
path: ReadonlyArray<number>,
|
|
943
|
+
): Effect.Effect<Option.Option<Rule.Any>, unknown, unknown> =>
|
|
944
|
+
Effect.map(
|
|
945
|
+
Effect.findFirst(candidates, ([value, index]) =>
|
|
946
|
+
ruleApplies(value, context, [...path, index]),
|
|
947
|
+
),
|
|
948
|
+
Option.map(([value]) => value),
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
/** Gives the first result that is found, and tests `next` only when none is. */
|
|
952
|
+
const orElse = <A, E, R>(
|
|
953
|
+
first: Effect.Effect<Option.Option<A>, E, R>,
|
|
954
|
+
next: () => Effect.Effect<Option.Option<A>, E, R>,
|
|
955
|
+
): Effect.Effect<Option.Option<A>, E, R> =>
|
|
956
|
+
Effect.filterOrElse(first, Option.isSome, next)
|
|
957
|
+
|
|
958
|
+
const decideRules = (
|
|
959
|
+
node: RulesNode,
|
|
960
|
+
context: DecideContext,
|
|
961
|
+
path: ReadonlyArray<number>,
|
|
962
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
963
|
+
const indexed = Arr.map(node.rules, (value, index) => [value, index] as const)
|
|
964
|
+
const denies = Arr.filter(indexed, ([value]) => value._tag === "Deny")
|
|
965
|
+
const allows = Arr.filter(indexed, ([value]) => value._tag === "Allow")
|
|
966
|
+
const first = (candidates: ReadonlyArray<readonly [Rule.Any, number]>) =>
|
|
967
|
+
firstApplying(candidates, context, path)
|
|
968
|
+
const found = (() => {
|
|
969
|
+
switch (node.algorithm) {
|
|
970
|
+
case "denyOverrides":
|
|
971
|
+
return orElse(first(denies), () => first(allows))
|
|
972
|
+
case "permitOverrides":
|
|
973
|
+
return orElse(first(allows), () => first(denies))
|
|
974
|
+
case "firstApplicable":
|
|
975
|
+
return first(indexed)
|
|
976
|
+
case "lastApplicable":
|
|
977
|
+
return first(Arr.reverse(indexed))
|
|
978
|
+
}
|
|
979
|
+
})()
|
|
980
|
+
return Effect.map(
|
|
981
|
+
found,
|
|
982
|
+
Option.match({ onNone: () => Decision.NotApplicable, onSome: fromRule }),
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const decideNode = (
|
|
987
|
+
node: Node,
|
|
988
|
+
context: DecideContext,
|
|
989
|
+
path: ReadonlyArray<number>,
|
|
990
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
991
|
+
// A predicate that the request may use must run each time. A predicate of
|
|
992
|
+
// a rule for another action, kind or field never runs, so the decision may
|
|
993
|
+
// be kept. A trace keeps the steps of a node only the first time, so a
|
|
994
|
+
// shared tree does not give a step for each path to a node.
|
|
995
|
+
if (usesPredicate(node, context)) {
|
|
996
|
+
return decideOnce(node, context, path)
|
|
997
|
+
}
|
|
998
|
+
const cached = context.decisions.get(node)
|
|
999
|
+
return cached !== undefined
|
|
1000
|
+
? Effect.succeed(cached)
|
|
1001
|
+
: Effect.tap(decideOnce(node, context, path), (decision) =>
|
|
1002
|
+
Effect.sync(() => context.decisions.set(node, decision)),
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** The next step of a walk over children: stop with a decision, or go on. */
|
|
1007
|
+
type WalkStep<S> =
|
|
1008
|
+
| { readonly _tag: "Stop"; readonly decision: Decision.Decision }
|
|
1009
|
+
| { readonly _tag: "Next"; readonly state: S }
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Decides the children in order. After each child, `step` stops with a
|
|
1013
|
+
* decision, or gives the state for the next child. After the last child,
|
|
1014
|
+
* `end` gives the decision. A child after a stop is not decided, so its
|
|
1015
|
+
* predicates do not run.
|
|
1016
|
+
*/
|
|
1017
|
+
const walkChildren = <S>(
|
|
1018
|
+
children: ReadonlyArray<Node>,
|
|
1019
|
+
context: DecideContext,
|
|
1020
|
+
path: ReadonlyArray<number>,
|
|
1021
|
+
start: S,
|
|
1022
|
+
step: (state: S, decision: Decision.Decision) => WalkStep<S>,
|
|
1023
|
+
end: (state: S) => Decision.Decision,
|
|
1024
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
1025
|
+
const go = (
|
|
1026
|
+
index: number,
|
|
1027
|
+
state: S,
|
|
1028
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> =>
|
|
1029
|
+
index === children.length
|
|
1030
|
+
? Effect.succeed(end(state))
|
|
1031
|
+
: Effect.flatMap(
|
|
1032
|
+
decideNode(children[index]!, context, [...path, index]),
|
|
1033
|
+
(decision) => {
|
|
1034
|
+
const next = step(state, decision)
|
|
1035
|
+
return next._tag === "Stop"
|
|
1036
|
+
? Effect.succeed(next.decision)
|
|
1037
|
+
: go(index + 1, next.state)
|
|
1038
|
+
},
|
|
1039
|
+
)
|
|
1040
|
+
return go(0, start)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const stop = (decision: Decision.Decision): WalkStep<never> => ({
|
|
1044
|
+
_tag: "Stop",
|
|
1045
|
+
decision,
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
const next = <S>(state: S): WalkStep<S> => ({ _tag: "Next", state })
|
|
1049
|
+
|
|
1050
|
+
const decideOnce = (
|
|
1051
|
+
node: Node,
|
|
1052
|
+
context: DecideContext,
|
|
1053
|
+
path: ReadonlyArray<number>,
|
|
1054
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
1055
|
+
switch (node._tag) {
|
|
1056
|
+
case "Rules":
|
|
1057
|
+
return decideRules(node, context, path)
|
|
1058
|
+
case "All":
|
|
1059
|
+
// One deny is enough to deny. Every child must allow.
|
|
1060
|
+
return walkChildren(
|
|
1061
|
+
node.policies,
|
|
1062
|
+
context,
|
|
1063
|
+
path,
|
|
1064
|
+
{
|
|
1065
|
+
firstAllow: Option.none<Decision.Decision>(),
|
|
1066
|
+
allAllow: node.policies.length > 0,
|
|
1067
|
+
},
|
|
1068
|
+
(state, decision) =>
|
|
1069
|
+
decision._tag === "Deny"
|
|
1070
|
+
? stop(decision)
|
|
1071
|
+
: next({
|
|
1072
|
+
firstAllow: Option.orElse(state.firstAllow, () =>
|
|
1073
|
+
decision._tag === "Allow"
|
|
1074
|
+
? Option.some(decision)
|
|
1075
|
+
: Option.none(),
|
|
1076
|
+
),
|
|
1077
|
+
allAllow: state.allAllow && decision._tag === "Allow",
|
|
1078
|
+
}),
|
|
1079
|
+
(state) =>
|
|
1080
|
+
state.allAllow
|
|
1081
|
+
? Option.getOrElse(state.firstAllow, () => Decision.NotApplicable)
|
|
1082
|
+
: Decision.NotApplicable,
|
|
1083
|
+
)
|
|
1084
|
+
case "Any":
|
|
1085
|
+
// One allow is enough. Without one, the first deny denies.
|
|
1086
|
+
return walkChildren(
|
|
1087
|
+
node.policies,
|
|
1088
|
+
context,
|
|
1089
|
+
path,
|
|
1090
|
+
Option.none<Decision.Decision>(),
|
|
1091
|
+
(firstDeny, decision) =>
|
|
1092
|
+
decision._tag === "Allow"
|
|
1093
|
+
? stop(decision)
|
|
1094
|
+
: next(
|
|
1095
|
+
Option.orElse(firstDeny, () =>
|
|
1096
|
+
decision._tag === "Deny"
|
|
1097
|
+
? Option.some(decision)
|
|
1098
|
+
: Option.none(),
|
|
1099
|
+
),
|
|
1100
|
+
),
|
|
1101
|
+
(firstDeny) =>
|
|
1102
|
+
Option.getOrElse(firstDeny, () => Decision.NotApplicable),
|
|
1103
|
+
)
|
|
1104
|
+
case "FirstOf":
|
|
1105
|
+
return walkChildren(
|
|
1106
|
+
node.policies,
|
|
1107
|
+
context,
|
|
1108
|
+
path,
|
|
1109
|
+
undefined,
|
|
1110
|
+
(_, decision) =>
|
|
1111
|
+
decision._tag === "NotApplicable" ? next(undefined) : stop(decision),
|
|
1112
|
+
() => Decision.NotApplicable,
|
|
1113
|
+
)
|
|
1114
|
+
case "Ceiling":
|
|
1115
|
+
// A deny of the cap wins. Else the policy decides.
|
|
1116
|
+
return Effect.filterOrElse(
|
|
1117
|
+
decideNode(node.cap, context, [...path, 0]),
|
|
1118
|
+
(capped) => capped._tag === "Deny",
|
|
1119
|
+
() => decideNode(node.policy, context, [...path, 1]),
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Tests if a request fits its kind: the action is an action of the kind, and
|
|
1126
|
+
* the field, if there is one, is a field of the kind. The types check this,
|
|
1127
|
+
* but a value with a union type, or a value from outside, may get past them.
|
|
1128
|
+
* A rule for every action (`"*"`) must not cover an action that the kind does
|
|
1129
|
+
* not have, so such a request is not applicable.
|
|
1130
|
+
*
|
|
1131
|
+
* @stability stable
|
|
1132
|
+
* @category predicates
|
|
1133
|
+
* @since 1.0.0
|
|
1134
|
+
*/
|
|
1135
|
+
export const fitsKind = (
|
|
1136
|
+
descriptor: Resource.Descriptor,
|
|
1137
|
+
request: { readonly action: string; readonly field?: string | undefined },
|
|
1138
|
+
): boolean =>
|
|
1139
|
+
descriptor.actions.includes(request.action) &&
|
|
1140
|
+
(request.field === undefined ||
|
|
1141
|
+
descriptor.fieldPaths.includes(request.field) ||
|
|
1142
|
+
Resource.hasPath(descriptor.schema, request.field) ||
|
|
1143
|
+
Resource.hasGetter(descriptor.schema, request.field))
|
|
1144
|
+
|
|
1145
|
+
const makeContext = (
|
|
1146
|
+
domain: Domain.Any,
|
|
1147
|
+
request: AnyRequest,
|
|
1148
|
+
trace: Ref.Ref<Chunk.Chunk<Step>> | undefined,
|
|
1149
|
+
): DecideContext | undefined => {
|
|
1150
|
+
const kind = Resource.kindOf(request.resource)
|
|
1151
|
+
const descriptor =
|
|
1152
|
+
kind === undefined ? undefined : Domain.descriptorOf(domain, kind)
|
|
1153
|
+
if (
|
|
1154
|
+
kind === undefined ||
|
|
1155
|
+
descriptor === undefined ||
|
|
1156
|
+
!fitsKind(descriptor, request)
|
|
1157
|
+
) {
|
|
1158
|
+
return undefined
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
action: request.action,
|
|
1162
|
+
kind,
|
|
1163
|
+
field: request.field,
|
|
1164
|
+
input: {
|
|
1165
|
+
value: request.resource,
|
|
1166
|
+
principal: request.principal,
|
|
1167
|
+
path: descriptor.path,
|
|
1168
|
+
},
|
|
1169
|
+
descriptor,
|
|
1170
|
+
trace,
|
|
1171
|
+
predicates: new Map(),
|
|
1172
|
+
decisions: new Map(),
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const decideAny = (
|
|
1177
|
+
policy: Any,
|
|
1178
|
+
request: AnyRequest,
|
|
1179
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> =>
|
|
1180
|
+
// Each run of the Effect needs its own store of decisions, because the
|
|
1181
|
+
// resource value may change between two runs.
|
|
1182
|
+
Effect.suspend(() => {
|
|
1183
|
+
const context = makeContext(policy.domain, request, undefined)
|
|
1184
|
+
return context === undefined
|
|
1185
|
+
? Effect.succeed(Decision.NotApplicable)
|
|
1186
|
+
: decideNode(nodeOf(policy), context, [])
|
|
1187
|
+
})
|
|
418
1188
|
|
|
419
1189
|
/**
|
|
420
1190
|
* Gives the decision of a policy for a request.
|
|
@@ -424,7 +1194,7 @@ export const ceiling: {
|
|
|
424
1194
|
* @since 0.3.0
|
|
425
1195
|
*/
|
|
426
1196
|
export const decide: {
|
|
427
|
-
<D extends Domain.Any>(
|
|
1197
|
+
<D extends Domain.Any = never>(
|
|
428
1198
|
request: Request<D>,
|
|
429
1199
|
): <E, R, O>(
|
|
430
1200
|
policy: Policy<D, E, R, O>,
|
|
@@ -433,7 +1203,7 @@ export const decide: {
|
|
|
433
1203
|
policy: Policy<D, E, R, O>,
|
|
434
1204
|
request: Request<NoInfer<D>>,
|
|
435
1205
|
): Effect.Effect<Decision.Decision, E, R>
|
|
436
|
-
} =
|
|
1206
|
+
} = dual(2, decideAny) as never
|
|
437
1207
|
|
|
438
1208
|
/**
|
|
439
1209
|
* Gives the decision of a policy with no predicates, at once.
|
|
@@ -443,31 +1213,715 @@ export const decide: {
|
|
|
443
1213
|
* @since 0.3.0
|
|
444
1214
|
*/
|
|
445
1215
|
export const decideSync: {
|
|
446
|
-
<D extends Domain.Any>(
|
|
1216
|
+
<D extends Domain.Any = never>(
|
|
447
1217
|
request: Request<D>,
|
|
448
1218
|
): (policy: Policy<D>) => Decision.Decision
|
|
449
1219
|
<D extends Domain.Any>(
|
|
450
1220
|
policy: Policy<D>,
|
|
451
1221
|
request: Request<NoInfer<D>>,
|
|
452
1222
|
): Decision.Decision
|
|
453
|
-
} =
|
|
1223
|
+
} = dual(2, (policy: Any, request: AnyRequest): Decision.Decision =>
|
|
1224
|
+
Effect.runSync(
|
|
1225
|
+
// The public type of `decideSync` takes only policies with no
|
|
1226
|
+
// predicates, so this Effect has no error and needs no services.
|
|
1227
|
+
// @effect-diagnostics-next-line unsafeEffectTypeAssertion:off
|
|
1228
|
+
decideAny(policy, request) as Effect.Effect<Decision.Decision>,
|
|
1229
|
+
),
|
|
1230
|
+
)
|
|
454
1231
|
|
|
455
1232
|
/**
|
|
456
1233
|
* Gives the decision of a policy and the rules that were tested to make it.
|
|
457
1234
|
*
|
|
1235
|
+
* One node may be a child of many nodes. A node with no predicate that the
|
|
1236
|
+
* request may use gives the same decision each time, so its rules are tested,
|
|
1237
|
+
* and are in the steps, only the first time. So a small tree with many shared
|
|
1238
|
+
* nodes gives a short list of steps.
|
|
1239
|
+
*
|
|
458
1240
|
* @stability stable
|
|
459
1241
|
* @category evaluation
|
|
460
1242
|
* @since 0.3.0
|
|
461
1243
|
*/
|
|
462
1244
|
export const explain: {
|
|
463
|
-
<D extends Domain.Any>(
|
|
1245
|
+
<D extends Domain.Any = never>(
|
|
464
1246
|
request: Request<D>,
|
|
465
1247
|
): <E, R, O>(policy: Policy<D, E, R, O>) => Effect.Effect<Explanation, E, R>
|
|
466
1248
|
<D extends Domain.Any, E, R, O>(
|
|
467
1249
|
policy: Policy<D, E, R, O>,
|
|
468
1250
|
request: Request<NoInfer<D>>,
|
|
469
1251
|
): Effect.Effect<Explanation, E, R>
|
|
470
|
-
} =
|
|
1252
|
+
} = dual(
|
|
1253
|
+
2,
|
|
1254
|
+
(
|
|
1255
|
+
policy: Any,
|
|
1256
|
+
request: AnyRequest,
|
|
1257
|
+
): Effect.Effect<Explanation, unknown, unknown> =>
|
|
1258
|
+
// Each run of the Effect needs its own trace, so the trace is made inside
|
|
1259
|
+
// the Effect.
|
|
1260
|
+
Effect.gen(function* () {
|
|
1261
|
+
const trace = yield* Ref.make(Chunk.empty<Step>())
|
|
1262
|
+
const context = makeContext(policy.domain, request, trace)
|
|
1263
|
+
if (context === undefined) {
|
|
1264
|
+
return { decision: Decision.NotApplicable, steps: [] }
|
|
1265
|
+
}
|
|
1266
|
+
const decision = yield* decideNode(nodeOf(policy), context, [])
|
|
1267
|
+
return { decision, steps: Chunk.toReadonlyArray(yield* Ref.get(trace)) }
|
|
1268
|
+
}),
|
|
1269
|
+
) as never
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Tests if a deny rule of the request names a part inside `field`.
|
|
1273
|
+
*/
|
|
1274
|
+
const deniesInside = (
|
|
1275
|
+
node: Node,
|
|
1276
|
+
target: { readonly action: string; readonly kind: string },
|
|
1277
|
+
field: string,
|
|
1278
|
+
// A native set: it must find a node by the node itself. See `remember`.
|
|
1279
|
+
seen: Set<Node> = new Set(),
|
|
1280
|
+
): boolean => {
|
|
1281
|
+
// One node may be a child of many nodes. It is tested one time.
|
|
1282
|
+
if (seen.has(node)) {
|
|
1283
|
+
return false
|
|
1284
|
+
}
|
|
1285
|
+
seen.add(node)
|
|
1286
|
+
switch (node._tag) {
|
|
1287
|
+
case "Rules":
|
|
1288
|
+
return node.rules.some(
|
|
1289
|
+
(value) =>
|
|
1290
|
+
value._tag === "Deny" &&
|
|
1291
|
+
value.fields !== undefined &&
|
|
1292
|
+
value.fields.some((pattern) => Rule.matchesInside(pattern, field)) &&
|
|
1293
|
+
(value.actions.includes("*") ||
|
|
1294
|
+
value.actions.includes(target.action)) &&
|
|
1295
|
+
(value.resources.includes("*") ||
|
|
1296
|
+
value.resources.includes(target.kind)),
|
|
1297
|
+
)
|
|
1298
|
+
case "All":
|
|
1299
|
+
case "Any":
|
|
1300
|
+
case "FirstOf":
|
|
1301
|
+
return node.policies.some((child) =>
|
|
1302
|
+
deniesInside(child, target, field, seen),
|
|
1303
|
+
)
|
|
1304
|
+
case "Ceiling":
|
|
1305
|
+
return (
|
|
1306
|
+
deniesInside(node.cap, target, field, seen) ||
|
|
1307
|
+
deniesInside(node.policy, target, field, seen)
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Gives the fields of a resource value that the policy lets a request use. It
|
|
1314
|
+
* tests each field of the resource Schema, with dots for nested fields.
|
|
1315
|
+
*
|
|
1316
|
+
* A field that holds more than one value, such as a record, is left out when a
|
|
1317
|
+
* deny rule names a part of it. The list can not name that part, and the whole
|
|
1318
|
+
* field would show it.
|
|
1319
|
+
*
|
|
1320
|
+
* @stability stable
|
|
1321
|
+
* @category evaluation
|
|
1322
|
+
* @since 0.3.0
|
|
1323
|
+
*/
|
|
1324
|
+
export const permittedFields: {
|
|
1325
|
+
<D extends Domain.Any = never>(
|
|
1326
|
+
request: FieldlessRequest<D>,
|
|
1327
|
+
): <E, R, O>(
|
|
1328
|
+
policy: Policy<D, E, R, O>,
|
|
1329
|
+
) => Effect.Effect<ReadonlyArray<string>, E, R>
|
|
1330
|
+
<D extends Domain.Any, E, R, O>(
|
|
1331
|
+
policy: Policy<D, E, R, O>,
|
|
1332
|
+
request: FieldlessRequest<NoInfer<D>>,
|
|
1333
|
+
): Effect.Effect<ReadonlyArray<string>, E, R>
|
|
1334
|
+
} = dual(
|
|
1335
|
+
2,
|
|
1336
|
+
(
|
|
1337
|
+
policy: Any,
|
|
1338
|
+
request: Omit<AnyRequest, "field">,
|
|
1339
|
+
): Effect.Effect<ReadonlyArray<string>, unknown, unknown> => {
|
|
1340
|
+
const kind = Resource.kindOf(request.resource)
|
|
1341
|
+
const descriptor: Resource.Descriptor | undefined =
|
|
1342
|
+
kind === undefined ? undefined : Domain.descriptorOf(policy.domain, kind)
|
|
1343
|
+
if (descriptor === undefined) {
|
|
1344
|
+
return Effect.succeed([])
|
|
1345
|
+
}
|
|
1346
|
+
return Effect.filter(descriptor.fieldPaths, (field) =>
|
|
1347
|
+
Effect.map(
|
|
1348
|
+
decideAny(policy, { ...request, field }),
|
|
1349
|
+
(result) =>
|
|
1350
|
+
result._tag === "Allow" &&
|
|
1351
|
+
!(
|
|
1352
|
+
descriptor.openPaths.includes(field) &&
|
|
1353
|
+
deniesInside(
|
|
1354
|
+
nodeOf(policy),
|
|
1355
|
+
{ ...request, kind: descriptor.kind },
|
|
1356
|
+
field,
|
|
1357
|
+
)
|
|
1358
|
+
),
|
|
1359
|
+
),
|
|
1360
|
+
)
|
|
1361
|
+
},
|
|
1362
|
+
) as never
|
|
1363
|
+
|
|
1364
|
+
// Residual conditions
|
|
1365
|
+
|
|
1366
|
+
type Ast = Condition.Ast
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* The two residual conditions of a request. See {@link residuals}.
|
|
1370
|
+
*
|
|
1371
|
+
* @stability stable
|
|
1372
|
+
* @category models
|
|
1373
|
+
* @since 1.0.0
|
|
1374
|
+
*/
|
|
1375
|
+
export interface Residuals<E = never, R = never, O = never, I = unknown> {
|
|
1376
|
+
/** The condition on resource values for which the policy allows. */
|
|
1377
|
+
readonly allow: Condition.Condition<E, R, O, I>
|
|
1378
|
+
/** The condition on resource values for which the policy denies. */
|
|
1379
|
+
readonly deny: Condition.Condition<E, R, O, I>
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* A request about a kind of resource, with no types.
|
|
1384
|
+
*
|
|
1385
|
+
* @stability stable
|
|
1386
|
+
* @category models
|
|
1387
|
+
* @since 1.0.0
|
|
1388
|
+
*/
|
|
1389
|
+
export interface AnyKindRequest {
|
|
1390
|
+
readonly principal: object
|
|
1391
|
+
readonly action: string
|
|
1392
|
+
readonly kind: string
|
|
1393
|
+
readonly field?: string | undefined
|
|
1394
|
+
/**
|
|
1395
|
+
* A scope needs the types of the domain for its builder. With the request
|
|
1396
|
+
* first, the domain is not known yet, so give the policy first.
|
|
1397
|
+
*/
|
|
1398
|
+
readonly within?: never
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* `unknown` when the request `Req` is a request of the type `T`, and `never`
|
|
1403
|
+
* when it is not. With the request first and the policy later, the request is
|
|
1404
|
+
* checked when the policy, and so the domain, is known. For a union, each
|
|
1405
|
+
* member must be such a request.
|
|
1406
|
+
*/
|
|
1407
|
+
type Fits<Req, T> = [Req] extends [T] ? unknown : never
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* What a residual condition reads: a value of the kind `K`. The principal is
|
|
1411
|
+
* already in it. A residual is for one kind, so when `K` is a union of kinds,
|
|
1412
|
+
* the value must be a value of each of them. No value is, so such a residual
|
|
1413
|
+
* can not be evaluated on a value of the wrong kind.
|
|
1414
|
+
*
|
|
1415
|
+
* @stability stable
|
|
1416
|
+
* @category utility types
|
|
1417
|
+
* @since 1.0.0
|
|
1418
|
+
*/
|
|
1419
|
+
export type ResidualReads<D, K> = Condition.Reads<
|
|
1420
|
+
(
|
|
1421
|
+
K extends unknown ? (value: Domain.ValueOf<D, K & string>) => void : never
|
|
1422
|
+
) extends (value: infer V) => void
|
|
1423
|
+
? V
|
|
1424
|
+
: never,
|
|
1425
|
+
unknown
|
|
1426
|
+
>
|
|
1427
|
+
|
|
1428
|
+
const always: Ast = Condition.always
|
|
1429
|
+
const never: Ast = Condition.never
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* The most nodes that a residual condition may have. A policy tree may use a
|
|
1433
|
+
* condition of a child two times, in its allow condition and in its deny
|
|
1434
|
+
* condition, so a small policy may give a very large condition. Past this
|
|
1435
|
+
* size, `residual` throws an error.
|
|
1436
|
+
*/
|
|
1437
|
+
const maxResidualSize = 10_000
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The most levels that a residual condition may have. The rules of an ordered
|
|
1441
|
+
* algorithm, such as `firstApplicable`, give one level for each run of rules
|
|
1442
|
+
* with one tag. A walk over a condition calls itself for each level, so a much
|
|
1443
|
+
* deeper condition could fill the stack. Past this depth, `residual` throws an
|
|
1444
|
+
* error.
|
|
1445
|
+
*/
|
|
1446
|
+
const maxResidualDepth = 500
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* A residual condition is too large. See {@link maxResidualSize} and
|
|
1450
|
+
* {@link maxResidualDepth}.
|
|
1451
|
+
*/
|
|
1452
|
+
class TooLarge {}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Makes conditions for one residual, with no types. Each step simplifies the
|
|
1456
|
+
* condition, and it stops the work when the condition is too large. The
|
|
1457
|
+
* steps keep their work for the parts they have seen, for this residual only.
|
|
1458
|
+
*/
|
|
1459
|
+
interface Build {
|
|
1460
|
+
// A list goes in as an array, not as arguments: a call may take only so
|
|
1461
|
+
// many arguments, and a policy may have more rules.
|
|
1462
|
+
readonly and: (values: ReadonlyArray<Ast>) => Ast
|
|
1463
|
+
readonly or: (values: ReadonlyArray<Ast>) => Ast
|
|
1464
|
+
readonly not: (value: Ast) => Ast
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const makeBuild = (): Build => {
|
|
1468
|
+
const simplify = Condition.simplifier()
|
|
1469
|
+
const sizes = new Map<Ast, number>()
|
|
1470
|
+
/** Gives the number of nodes in a condition, as JSON would have them. */
|
|
1471
|
+
const sizeOf = (ast: Ast): number =>
|
|
1472
|
+
remember(sizes, ast, () => {
|
|
1473
|
+
switch (ast._tag) {
|
|
1474
|
+
case "And":
|
|
1475
|
+
case "Or":
|
|
1476
|
+
return Arr.reduce(
|
|
1477
|
+
ast.conditions,
|
|
1478
|
+
1,
|
|
1479
|
+
(size, child) => size + sizeOf(child),
|
|
1480
|
+
)
|
|
1481
|
+
case "Not":
|
|
1482
|
+
return 1 + sizeOf(ast.condition)
|
|
1483
|
+
default:
|
|
1484
|
+
return 1
|
|
1485
|
+
}
|
|
1486
|
+
})
|
|
1487
|
+
const depths = new Map<Ast, number>()
|
|
1488
|
+
/**
|
|
1489
|
+
* Gives the number of levels in a condition. Each child went through
|
|
1490
|
+
* `limit`, so this walk is not deeper than `maxResidualDepth`.
|
|
1491
|
+
*/
|
|
1492
|
+
const depthOf = (ast: Ast): number =>
|
|
1493
|
+
remember(depths, ast, () => {
|
|
1494
|
+
switch (ast._tag) {
|
|
1495
|
+
case "And":
|
|
1496
|
+
case "Or":
|
|
1497
|
+
return (
|
|
1498
|
+
1 +
|
|
1499
|
+
Arr.reduce(ast.conditions, 0, (top, child) =>
|
|
1500
|
+
Math.max(top, depthOf(child)),
|
|
1501
|
+
)
|
|
1502
|
+
)
|
|
1503
|
+
case "Not":
|
|
1504
|
+
return 1 + depthOf(ast.condition)
|
|
1505
|
+
default:
|
|
1506
|
+
return 1
|
|
1507
|
+
}
|
|
1508
|
+
})
|
|
1509
|
+
const limit = (ast: Ast): Ast => {
|
|
1510
|
+
if (sizeOf(ast) > maxResidualSize || depthOf(ast) > maxResidualDepth) {
|
|
1511
|
+
throw new TooLarge()
|
|
1512
|
+
}
|
|
1513
|
+
return ast
|
|
1514
|
+
}
|
|
1515
|
+
// These steps work on nodes with no types, so they leave out the type
|
|
1516
|
+
// field of a condition.
|
|
1517
|
+
return {
|
|
1518
|
+
and: (values) =>
|
|
1519
|
+
limit(simplify(Condition.andAll(values as ReadonlyArray<never>))),
|
|
1520
|
+
or: (values) =>
|
|
1521
|
+
limit(simplify(Condition.orAll(values as ReadonlyArray<never>))),
|
|
1522
|
+
not: (value) => limit(simplify(Condition.not(value as never))),
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** The two residual conditions of a node, with no types. */
|
|
1527
|
+
interface Pair {
|
|
1528
|
+
readonly allow: Ast
|
|
1529
|
+
readonly deny: Ast
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
interface ResidualContext {
|
|
1533
|
+
readonly action: string
|
|
1534
|
+
readonly kind: string
|
|
1535
|
+
readonly field: string | undefined
|
|
1536
|
+
readonly input: {
|
|
1537
|
+
readonly principal: object
|
|
1538
|
+
readonly path: Condition.Input["path"]
|
|
1539
|
+
}
|
|
1540
|
+
/** The resource of the kind, for the fields that a rule may grant. */
|
|
1541
|
+
readonly descriptor: Resource.Descriptor
|
|
1542
|
+
/**
|
|
1543
|
+
* The work done on each node. One node may be a child of many nodes, so
|
|
1544
|
+
* the work on it is done only one time.
|
|
1545
|
+
*/
|
|
1546
|
+
readonly pairs: Map<Node, Pair>
|
|
1547
|
+
readonly predicates: Map<Node, boolean>
|
|
1548
|
+
readonly build: Build
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const ruleCondition = (value: Rule.Any, context: ResidualContext): Ast =>
|
|
1552
|
+
!Rule.matchesTarget(value, context)
|
|
1553
|
+
? never
|
|
1554
|
+
: value.when === undefined
|
|
1555
|
+
? always
|
|
1556
|
+
: Condition.partial(value.when as Condition.Any, context.input)
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Gives the conditions for "the first rule that applies is an allow rule" and
|
|
1560
|
+
* "the first rule that applies is a deny rule".
|
|
1561
|
+
*/
|
|
1562
|
+
const firstApplicableResidual = (
|
|
1563
|
+
values: ReadonlyArray<Rule.Any>,
|
|
1564
|
+
context: ResidualContext,
|
|
1565
|
+
): Pair => {
|
|
1566
|
+
const { and, not, or } = context.build
|
|
1567
|
+
// The rules go in runs of one tag, from the last run to the first. The
|
|
1568
|
+
// conditions of a run go into one node at once. With one node for each
|
|
1569
|
+
// rule, each step would flatten the whole condition again.
|
|
1570
|
+
const runs = Arr.isReadonlyArrayNonEmpty(values)
|
|
1571
|
+
? Arr.groupWith(values, (a, b) => a._tag === b._tag)
|
|
1572
|
+
: []
|
|
1573
|
+
return Arr.reduceRight(
|
|
1574
|
+
runs,
|
|
1575
|
+
{ allow: never, deny: never },
|
|
1576
|
+
(rest: Pair, run): Pair => {
|
|
1577
|
+
const applies = or(Arr.map(run, (value) => ruleCondition(value, context)))
|
|
1578
|
+
return run[0]._tag === "Allow"
|
|
1579
|
+
? {
|
|
1580
|
+
allow: or([applies, rest.allow]),
|
|
1581
|
+
deny: and([not(applies), rest.deny]),
|
|
1582
|
+
}
|
|
1583
|
+
: {
|
|
1584
|
+
allow: and([not(applies), rest.allow]),
|
|
1585
|
+
deny: or([applies, rest.deny]),
|
|
1586
|
+
}
|
|
1587
|
+
},
|
|
1588
|
+
)
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const residualNode = (node: Node, context: ResidualContext): Pair =>
|
|
1592
|
+
remember(context.pairs, node, () => residualOnce(node, context))
|
|
1593
|
+
|
|
1594
|
+
const residualOnce = (node: Node, context: ResidualContext): Pair => {
|
|
1595
|
+
const { and, not, or } = context.build
|
|
1596
|
+
switch (node._tag) {
|
|
1597
|
+
case "Rules": {
|
|
1598
|
+
switch (node.algorithm) {
|
|
1599
|
+
case "denyOverrides": {
|
|
1600
|
+
const deny = or(
|
|
1601
|
+
node.rules
|
|
1602
|
+
.filter((value) => value._tag === "Deny")
|
|
1603
|
+
.map((value) => ruleCondition(value, context)),
|
|
1604
|
+
)
|
|
1605
|
+
const allow = and([
|
|
1606
|
+
not(deny),
|
|
1607
|
+
or(
|
|
1608
|
+
node.rules
|
|
1609
|
+
.filter((value) => value._tag === "Allow")
|
|
1610
|
+
.map((value) => ruleCondition(value, context)),
|
|
1611
|
+
),
|
|
1612
|
+
])
|
|
1613
|
+
return { allow, deny }
|
|
1614
|
+
}
|
|
1615
|
+
case "permitOverrides": {
|
|
1616
|
+
const allow = or(
|
|
1617
|
+
node.rules
|
|
1618
|
+
.filter((value) => value._tag === "Allow")
|
|
1619
|
+
.map((value) => ruleCondition(value, context)),
|
|
1620
|
+
)
|
|
1621
|
+
const deny = and([
|
|
1622
|
+
not(allow),
|
|
1623
|
+
or(
|
|
1624
|
+
node.rules
|
|
1625
|
+
.filter((value) => value._tag === "Deny")
|
|
1626
|
+
.map((value) => ruleCondition(value, context)),
|
|
1627
|
+
),
|
|
1628
|
+
])
|
|
1629
|
+
return { allow, deny }
|
|
1630
|
+
}
|
|
1631
|
+
case "firstApplicable":
|
|
1632
|
+
return firstApplicableResidual(node.rules, context)
|
|
1633
|
+
case "lastApplicable":
|
|
1634
|
+
return firstApplicableResidual(node.rules.slice().reverse(), context)
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
// falls through: every algorithm returns above
|
|
1638
|
+
case "All": {
|
|
1639
|
+
if (node.policies.length === 0) {
|
|
1640
|
+
return { allow: never, deny: never }
|
|
1641
|
+
}
|
|
1642
|
+
const pairs = node.policies.map((child) => residualNode(child, context))
|
|
1643
|
+
const deny = or(pairs.map((pair) => pair.deny))
|
|
1644
|
+
const allow = and([...pairs.map((pair) => pair.allow), not(deny)])
|
|
1645
|
+
return { allow, deny }
|
|
1646
|
+
}
|
|
1647
|
+
case "Any": {
|
|
1648
|
+
const pairs = node.policies.map((child) => residualNode(child, context))
|
|
1649
|
+
const allow = or(pairs.map((pair) => pair.allow))
|
|
1650
|
+
const deny = and([not(allow), or(pairs.map((pair) => pair.deny))])
|
|
1651
|
+
return { allow, deny }
|
|
1652
|
+
}
|
|
1653
|
+
case "FirstOf": {
|
|
1654
|
+
// The first child that gives an answer wins.
|
|
1655
|
+
return Arr.reduceRight(
|
|
1656
|
+
node.policies,
|
|
1657
|
+
{ allow: never, deny: never },
|
|
1658
|
+
(rest: Pair, child): Pair => {
|
|
1659
|
+
const pair = residualNode(child, context)
|
|
1660
|
+
return {
|
|
1661
|
+
allow: or([pair.allow, and([not(pair.deny), rest.allow])]),
|
|
1662
|
+
deny: or([pair.deny, and([not(pair.allow), rest.deny])]),
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
)
|
|
1666
|
+
}
|
|
1667
|
+
case "Ceiling": {
|
|
1668
|
+
const cap = residualNode(node.cap, context)
|
|
1669
|
+
const inner = residualNode(node.policy, context)
|
|
1670
|
+
return {
|
|
1671
|
+
allow: and([not(cap.deny), inner.allow]),
|
|
1672
|
+
deny: or([cap.deny, inner.deny]),
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** The name of the predicate of a residual condition that runs `decide`. */
|
|
1679
|
+
const policyPredicate = "policy"
|
|
1680
|
+
|
|
1681
|
+
/** Tests if a rule of a policy tree has a predicate. */
|
|
1682
|
+
const nodeHasPredicate = (
|
|
1683
|
+
node: Node,
|
|
1684
|
+
memo: Map<Node, boolean> = new Map(),
|
|
1685
|
+
): boolean =>
|
|
1686
|
+
remember(memo, node, () => {
|
|
1687
|
+
const test = (child: Node) => nodeHasPredicate(child, memo)
|
|
1688
|
+
switch (node._tag) {
|
|
1689
|
+
case "Rules":
|
|
1690
|
+
return Arr.some(
|
|
1691
|
+
node.rules,
|
|
1692
|
+
(value) =>
|
|
1693
|
+
value.when !== undefined && Condition.hasPredicate(value.when),
|
|
1694
|
+
)
|
|
1695
|
+
case "All":
|
|
1696
|
+
case "Any":
|
|
1697
|
+
case "FirstOf":
|
|
1698
|
+
return Arr.some(node.policies, test)
|
|
1699
|
+
case "Ceiling":
|
|
1700
|
+
return test(node.cap) || test(node.policy)
|
|
1701
|
+
}
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1704
|
+
/** Tests if a rule that the request may use has a predicate. */
|
|
1705
|
+
/** What {@link usesPredicate} needs: the request and a memo. */
|
|
1706
|
+
interface PredicateContext {
|
|
1707
|
+
readonly action: string
|
|
1708
|
+
readonly kind: string
|
|
1709
|
+
readonly field: string | undefined
|
|
1710
|
+
readonly descriptor: Resource.Descriptor
|
|
1711
|
+
readonly predicates: Map<Node, boolean>
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const usesPredicate = (node: Node, context: PredicateContext): boolean =>
|
|
1715
|
+
remember(context.predicates, node, () => usesPredicateOnce(node, context))
|
|
1716
|
+
|
|
1717
|
+
const usesPredicateOnce = (node: Node, context: PredicateContext): boolean => {
|
|
1718
|
+
switch (node._tag) {
|
|
1719
|
+
case "Rules":
|
|
1720
|
+
return node.rules.some(
|
|
1721
|
+
(value) =>
|
|
1722
|
+
value.when !== undefined &&
|
|
1723
|
+
Condition.hasPredicate(value.when) &&
|
|
1724
|
+
Rule.matchesTarget(value, context),
|
|
1725
|
+
)
|
|
1726
|
+
case "All":
|
|
1727
|
+
case "Any":
|
|
1728
|
+
case "FirstOf":
|
|
1729
|
+
return node.policies.some((child) => usesPredicate(child, context))
|
|
1730
|
+
case "Ceiling":
|
|
1731
|
+
return (
|
|
1732
|
+
usesPredicate(node.cap, context) || usesPredicate(node.policy, context)
|
|
1733
|
+
)
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const isScopeValue = (operand: Condition.Operand): boolean =>
|
|
1738
|
+
operand._tag === "Principal" ||
|
|
1739
|
+
operand.value === null ||
|
|
1740
|
+
typeof operand.value === "string" ||
|
|
1741
|
+
typeof operand.value === "number" ||
|
|
1742
|
+
typeof operand.value === "boolean"
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Gives the problem of a scope that is not plain data, or `undefined` when it
|
|
1746
|
+
* is plain data. A predicate, or a value that a condition can not hold, such
|
|
1747
|
+
* as `undefined` from a route parameter, is a defect: such a scope could make
|
|
1748
|
+
* a check pass on the wrong values. A shared node is tested one time.
|
|
1749
|
+
*/
|
|
1750
|
+
const scopeProblem = (
|
|
1751
|
+
ast: Ast,
|
|
1752
|
+
memo: Map<Ast, string | null> = new Map(),
|
|
1753
|
+
): string | undefined =>
|
|
1754
|
+
remember(memo, ast, (): string | null => {
|
|
1755
|
+
switch (ast._tag) {
|
|
1756
|
+
case "Predicate":
|
|
1757
|
+
return "can not have a predicate"
|
|
1758
|
+
case "Compare":
|
|
1759
|
+
return isScopeValue(ast.value)
|
|
1760
|
+
? null
|
|
1761
|
+
: `has a value for "${ast.field}" that is not a scalar`
|
|
1762
|
+
case "In":
|
|
1763
|
+
return Arr.every(ast.values, isScopeValue)
|
|
1764
|
+
? null
|
|
1765
|
+
: `has a value for "${ast.field}" that is not a scalar`
|
|
1766
|
+
case "Under":
|
|
1767
|
+
return Arr.every(ast.prefix, isScopeValue)
|
|
1768
|
+
? null
|
|
1769
|
+
: "has a path part that is not a scalar"
|
|
1770
|
+
case "And":
|
|
1771
|
+
case "Or":
|
|
1772
|
+
return (
|
|
1773
|
+
Option.getOrNull(
|
|
1774
|
+
Arr.findFirst(
|
|
1775
|
+
Arr.map(ast.conditions, (child) => scopeProblem(child, memo)),
|
|
1776
|
+
(problem) => problem !== undefined,
|
|
1777
|
+
),
|
|
1778
|
+
) ?? null
|
|
1779
|
+
)
|
|
1780
|
+
case "Not":
|
|
1781
|
+
return scopeProblem(ast.condition, memo) ?? null
|
|
1782
|
+
default:
|
|
1783
|
+
return null
|
|
1784
|
+
}
|
|
1785
|
+
}) ?? undefined
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Gives the condition of a scope, with the principal and the path in it.
|
|
1789
|
+
* With no scope, it is `True`.
|
|
1790
|
+
*/
|
|
1791
|
+
/**
|
|
1792
|
+
* The fields that a scope reads, before it is made smaller. A smaller scope
|
|
1793
|
+
* may leave out a read that fails, such as a getter.
|
|
1794
|
+
*/
|
|
1795
|
+
const scopeFields = (within: unknown): ReadonlyArray<string> =>
|
|
1796
|
+
within === undefined
|
|
1797
|
+
? []
|
|
1798
|
+
: Condition.fields(
|
|
1799
|
+
(within as (builder: unknown) => Ast)(Condition.builder()),
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
const scopeOf = (within: unknown, input: ResidualContext["input"]): Ast => {
|
|
1803
|
+
if (within === undefined) {
|
|
1804
|
+
return always
|
|
1805
|
+
}
|
|
1806
|
+
const ast = (within as (builder: unknown) => Ast)(Condition.builder())
|
|
1807
|
+
const problem = scopeProblem(ast)
|
|
1808
|
+
if (problem !== undefined) {
|
|
1809
|
+
throw new globalThis.Error(`A scope ${problem}`)
|
|
1810
|
+
}
|
|
1811
|
+
return Condition.partial(ast as Condition.Condition, input)
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** A request about a kind with no types, with the scope of a target. */
|
|
1815
|
+
interface ScopedRequest {
|
|
1816
|
+
readonly principal: object
|
|
1817
|
+
readonly action: string
|
|
1818
|
+
readonly kind: string
|
|
1819
|
+
readonly field?: string | undefined
|
|
1820
|
+
readonly within?: unknown
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** The scope of a request, and the residual conditions inside it. */
|
|
1824
|
+
interface Scoped extends Pair {
|
|
1825
|
+
readonly scope: Ast
|
|
1826
|
+
readonly schema: Schema.Top | undefined
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
const residualsAny = (policy: Any, request: ScopedRequest): Pair =>
|
|
1830
|
+
scopedResiduals(policy, request)
|
|
1831
|
+
|
|
1832
|
+
const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
|
|
1833
|
+
const descriptor = Domain.descriptorOf(policy.domain, request.kind)
|
|
1834
|
+
if (descriptor === undefined || !fitsKind(descriptor, request)) {
|
|
1835
|
+
return { scope: never, allow: never, deny: never, schema: undefined }
|
|
1836
|
+
}
|
|
1837
|
+
// A scope is made smaller below, and that may leave out a read of a
|
|
1838
|
+
// getter that fails. So such a scope has no residual.
|
|
1839
|
+
if (scopeReadsGetter(request, descriptor)) {
|
|
1840
|
+
throw new globalThis.Error(
|
|
1841
|
+
"The scope of this request reads a getter, so it has no residual condition",
|
|
1842
|
+
)
|
|
1843
|
+
}
|
|
1844
|
+
const input = { principal: request.principal, path: descriptor.path }
|
|
1845
|
+
const scope = scopeOf(request.within, input)
|
|
1846
|
+
const build = makeBuild()
|
|
1847
|
+
const and = build.and
|
|
1848
|
+
const inScope = (pair: Pair): Scoped => ({
|
|
1849
|
+
scope,
|
|
1850
|
+
allow: and([scope, pair.allow]),
|
|
1851
|
+
deny: and([scope, pair.deny]),
|
|
1852
|
+
schema: descriptor.schema,
|
|
1853
|
+
})
|
|
1854
|
+
if (scope._tag === "False") {
|
|
1855
|
+
return inScope({ allow: never, deny: never })
|
|
1856
|
+
}
|
|
1857
|
+
const context: ResidualContext = {
|
|
1858
|
+
action: request.action,
|
|
1859
|
+
kind: request.kind,
|
|
1860
|
+
field: request.field,
|
|
1861
|
+
input,
|
|
1862
|
+
descriptor,
|
|
1863
|
+
pairs: new Map(),
|
|
1864
|
+
predicates: new Map(),
|
|
1865
|
+
build,
|
|
1866
|
+
}
|
|
1867
|
+
if (!usesPredicate(nodeOf(policy), context)) {
|
|
1868
|
+
// A read of a getter may fail when `decide` runs, and a smaller
|
|
1869
|
+
// condition may leave the read out. So a plain residual can not agree
|
|
1870
|
+
// with `decide`, and there is none.
|
|
1871
|
+
if (readsGetter(policy, request, descriptor)) {
|
|
1872
|
+
throw new globalThis.Error(
|
|
1873
|
+
"A rule of this request reads a getter, so it has no residual condition",
|
|
1874
|
+
)
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
return inScope(residualNode(nodeOf(policy), context))
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
// The type of the residual of a plain policy says that it has no
|
|
1880
|
+
// predicate, so it can not be the predicate below. The policy is too
|
|
1881
|
+
// large for a residual, and that is a defect.
|
|
1882
|
+
if (error instanceof TooLarge) {
|
|
1883
|
+
throw new globalThis.Error(
|
|
1884
|
+
`The residual condition of this request would have more than ${String(maxResidualSize)} nodes or ${String(maxResidualDepth)} levels`,
|
|
1885
|
+
)
|
|
1886
|
+
}
|
|
1887
|
+
throw error
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
// A predicate may fail, and it may give another answer each time it runs.
|
|
1891
|
+
// A tree of `And` and `Or` nodes can not run the predicates in the same
|
|
1892
|
+
// order and the same number of times as `decide`. So the residual is one
|
|
1893
|
+
// predicate that runs `decide` for each value.
|
|
1894
|
+
const decision = (value: object) =>
|
|
1895
|
+
decideNode(
|
|
1896
|
+
policy.node,
|
|
1897
|
+
{
|
|
1898
|
+
action: request.action,
|
|
1899
|
+
kind: request.kind,
|
|
1900
|
+
field: request.field,
|
|
1901
|
+
input: {
|
|
1902
|
+
value,
|
|
1903
|
+
principal: request.principal,
|
|
1904
|
+
path: descriptor.path,
|
|
1905
|
+
},
|
|
1906
|
+
descriptor,
|
|
1907
|
+
trace: undefined,
|
|
1908
|
+
predicates: new Map(),
|
|
1909
|
+
decisions: new Map(),
|
|
1910
|
+
},
|
|
1911
|
+
[],
|
|
1912
|
+
)
|
|
1913
|
+
// A value of another kind is not a value that the residual is about, so
|
|
1914
|
+
// the rules of this kind must not run on it.
|
|
1915
|
+
const test = (tag: Decision.Decision["_tag"]): Ast =>
|
|
1916
|
+
Condition.builder<any, any, any>().predicate(policyPredicate, (value) =>
|
|
1917
|
+
Resource.kindOf(value) === request.kind
|
|
1918
|
+
? Effect.map(decision(value), (result) => result._tag === tag)
|
|
1919
|
+
: Effect.succeed(false),
|
|
1920
|
+
)
|
|
1921
|
+
// The scope comes first in each `And`, so the policy runs only on values
|
|
1922
|
+
// in the scope.
|
|
1923
|
+
return inScope({ allow: test("Allow"), deny: test("Deny") })
|
|
1924
|
+
}
|
|
471
1925
|
|
|
472
1926
|
/**
|
|
473
1927
|
* Gives the condition on resource values for which the policy allows a
|
|
@@ -475,41 +1929,354 @@ export const explain: {
|
|
|
475
1929
|
* to `Condition.fold`. It is `True` when the policy allows every value of the
|
|
476
1930
|
* kind, and `False` when it allows none.
|
|
477
1931
|
*
|
|
478
|
-
*
|
|
1932
|
+
* With a scope (`within`), the condition is the scope and the residual
|
|
1933
|
+
* together, so it is false for every value outside the scope. Give the policy
|
|
1934
|
+
* first to use a scope. The condition comes from its parts, with no search. To
|
|
1935
|
+
* know if it allows all of the scope or none of it, use {@link coverage}.
|
|
1936
|
+
*
|
|
1937
|
+
* When a rule that the request may use has a predicate, the condition is one
|
|
1938
|
+
* predicate with the name `"policy"`. It runs the whole policy for each value,
|
|
1939
|
+
* so its answers and its failures are the same as those of {@link decide}. It
|
|
1940
|
+
* takes the principal with it. Give it resource values with their `_tag`. It
|
|
1941
|
+
* is false for a value of another kind.
|
|
1942
|
+
*
|
|
1943
|
+
* A policy tree may use the condition of a child two times, so a small policy
|
|
1944
|
+
* may give a very large condition. When the condition would have more than
|
|
1945
|
+
* 10000 nodes or 500 levels, this function throws an error. In an
|
|
1946
|
+
* `Authorizer`, the error is
|
|
1947
|
+
* a defect, so the check fails.
|
|
479
1948
|
*
|
|
480
1949
|
* @stability stable
|
|
481
1950
|
* @category evaluation
|
|
482
1951
|
* @since 0.3.0
|
|
483
1952
|
*/
|
|
484
1953
|
export const residual: {
|
|
485
|
-
<
|
|
486
|
-
request:
|
|
487
|
-
): <
|
|
488
|
-
|
|
1954
|
+
<const Req extends AnyKindRequest>(
|
|
1955
|
+
request: Req,
|
|
1956
|
+
): <D extends Domain.Any, E, R, O>(
|
|
1957
|
+
policy: Policy<D, E, R, O> & Fits<Req, KindRequest<D>>,
|
|
1958
|
+
) => Condition.Condition<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
1959
|
+
<D extends Domain.Any, E, R, O, const Req extends KindRequest<NoInfer<D>>>(
|
|
489
1960
|
policy: Policy<D, E, R, O>,
|
|
490
|
-
request:
|
|
491
|
-
): Condition.Condition<E, R, O
|
|
492
|
-
} =
|
|
1961
|
+
request: Req,
|
|
1962
|
+
): Condition.Condition<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
1963
|
+
} = dual(
|
|
1964
|
+
2,
|
|
1965
|
+
(policy: Any, request: KindRequest<Domain.Any>): Ast =>
|
|
1966
|
+
residualsAny(policy, request).allow,
|
|
1967
|
+
) as never
|
|
493
1968
|
|
|
494
1969
|
/**
|
|
495
|
-
* Gives
|
|
496
|
-
*
|
|
1970
|
+
* Gives both residual conditions of a request: the values that the policy
|
|
1971
|
+
* allows, and the values that it denies. A value in neither is not
|
|
1972
|
+
* applicable. See {@link residual}.
|
|
497
1973
|
*
|
|
498
1974
|
* @stability stable
|
|
499
1975
|
* @category evaluation
|
|
500
|
-
* @since 0.
|
|
1976
|
+
* @since 1.0.0
|
|
501
1977
|
*/
|
|
502
|
-
export const
|
|
503
|
-
<
|
|
504
|
-
request:
|
|
505
|
-
): <E, R, O>(
|
|
1978
|
+
export const residuals: {
|
|
1979
|
+
<const Req extends AnyKindRequest>(
|
|
1980
|
+
request: Req,
|
|
1981
|
+
): <D extends Domain.Any, E, R, O>(
|
|
1982
|
+
policy: Policy<D, E, R, O> & Fits<Req, KindRequest<D>>,
|
|
1983
|
+
) => Residuals<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
1984
|
+
<D extends Domain.Any, E, R, O, const Req extends KindRequest<NoInfer<D>>>(
|
|
506
1985
|
policy: Policy<D, E, R, O>,
|
|
507
|
-
|
|
508
|
-
<
|
|
1986
|
+
request: Req,
|
|
1987
|
+
): Residuals<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
1988
|
+
} = dual(2, residualsAny) as never
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* How much of a scope a policy allows. In each case, `condition` is the exact
|
|
1992
|
+
* condition on the values in the scope that the policy allows: give it to
|
|
1993
|
+
* `Condition.fold` for a list query.
|
|
1994
|
+
* - `All`: the policy allows every value in the scope. `condition` is the
|
|
1995
|
+
* scope (`True` with no scope).
|
|
1996
|
+
* - `None`: the policy allows no value in the scope. `condition` is `False`.
|
|
1997
|
+
* An empty scope is `None` too, so a check on a scope that has a mistake,
|
|
1998
|
+
* such as a principal field with no value, never passes.
|
|
1999
|
+
* - `Some`: the policy allows some values in the scope and not others.
|
|
2000
|
+
* `allowed` is an example of a value that it allows, and `refused` of a
|
|
2001
|
+
* value in the scope that it does not allow.
|
|
2002
|
+
* - `Unknown`: the answer needs a predicate, or the search went past its
|
|
2003
|
+
* budget.
|
|
2004
|
+
*
|
|
2005
|
+
* @stability stable
|
|
2006
|
+
* @category models
|
|
2007
|
+
* @since 1.0.0
|
|
2008
|
+
*/
|
|
2009
|
+
export type Coverage<
|
|
2010
|
+
E = never,
|
|
2011
|
+
R = never,
|
|
2012
|
+
O = never,
|
|
2013
|
+
I = unknown,
|
|
2014
|
+
> = Data.TaggedEnum<{
|
|
2015
|
+
All: { readonly condition: Condition.Condition<E, R, O, I> }
|
|
2016
|
+
None: { readonly condition: Condition.Condition<E, R, O, I> }
|
|
2017
|
+
Some: {
|
|
2018
|
+
readonly condition: Condition.Condition<E, R, O, I>
|
|
2019
|
+
readonly allowed: Condition.Example
|
|
2020
|
+
readonly refused: Condition.Example
|
|
2021
|
+
}
|
|
2022
|
+
Unknown: {
|
|
2023
|
+
readonly condition: Condition.Condition<E, R, O, I>
|
|
2024
|
+
readonly reason: "predicate" | "budget" | "schema"
|
|
2025
|
+
}
|
|
2026
|
+
}>
|
|
2027
|
+
|
|
2028
|
+
/** A {@link Coverage} with no types. */
|
|
2029
|
+
type AnyCoverage = Data.TaggedEnum<{
|
|
2030
|
+
All: { readonly condition: Ast }
|
|
2031
|
+
None: { readonly condition: Ast }
|
|
2032
|
+
Some: {
|
|
2033
|
+
readonly condition: Ast
|
|
2034
|
+
readonly allowed: Condition.Example
|
|
2035
|
+
readonly refused: Condition.Example
|
|
2036
|
+
}
|
|
2037
|
+
Unknown: {
|
|
2038
|
+
readonly condition: Ast
|
|
2039
|
+
readonly reason: "predicate" | "budget" | "schema"
|
|
2040
|
+
}
|
|
2041
|
+
}>
|
|
2042
|
+
|
|
2043
|
+
const coverages = Data.taggedEnum<AnyCoverage>()
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Tests if a rule that the request may use, or its scope, reads a getter of
|
|
2047
|
+
* a class, in any branch of a union.
|
|
2048
|
+
*/
|
|
2049
|
+
const readsGetter = (
|
|
2050
|
+
policy: Any,
|
|
2051
|
+
request: ScopedRequest,
|
|
2052
|
+
descriptor: Resource.Descriptor,
|
|
2053
|
+
): boolean =>
|
|
2054
|
+
scopeReadsGetter(request, descriptor) ||
|
|
2055
|
+
!readsOnly(
|
|
2056
|
+
nodeOf(policy),
|
|
2057
|
+
targetOf(request, descriptor),
|
|
2058
|
+
(field) => !readsGetterAt(descriptor.schema, field),
|
|
2059
|
+
new Map(),
|
|
2060
|
+
)
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* Tests if a read of a field may read a getter of a class: at the field, or
|
|
2064
|
+
* at a field on the way to it, in any branch of a union.
|
|
2065
|
+
*/
|
|
2066
|
+
const readsGetterAt = (schema: Schema.Top, field: string): boolean => {
|
|
2067
|
+
const parts = field.split(".")
|
|
2068
|
+
return Arr.some(Arr.range(1, parts.length), (count) =>
|
|
2069
|
+
Resource.hasGetter(schema, parts.slice(0, count).join(".")),
|
|
2070
|
+
)
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Tests if the scope of a request reads a getter of a class. A scope is made
|
|
2075
|
+
* smaller in each residual, also next to a predicate that runs `decide`, so
|
|
2076
|
+
* such a scope has no residual.
|
|
2077
|
+
*/
|
|
2078
|
+
const scopeReadsGetter = (
|
|
2079
|
+
request: ScopedRequest,
|
|
2080
|
+
descriptor: Resource.Descriptor,
|
|
2081
|
+
): boolean =>
|
|
2082
|
+
!Arr.every(
|
|
2083
|
+
scopeFields(request.within),
|
|
2084
|
+
(field) => !readsGetterAt(descriptor.schema, field),
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Tests if a request has no residual: its scope reads a getter, or it has a
|
|
2089
|
+
* plain residual and a rule that it may use reads a getter. A residual with
|
|
2090
|
+
* a predicate runs `decide`, so the reads of its rules stay.
|
|
2091
|
+
*/
|
|
2092
|
+
const hasNoResidual = (
|
|
2093
|
+
policy: Any,
|
|
2094
|
+
request: ScopedRequest,
|
|
2095
|
+
descriptor: Resource.Descriptor,
|
|
2096
|
+
): boolean =>
|
|
2097
|
+
scopeReadsGetter(request, descriptor) ||
|
|
2098
|
+
(!usesPredicate(nodeOf(policy), targetOf(request, descriptor)) &&
|
|
2099
|
+
readsGetter(policy, request, descriptor))
|
|
2100
|
+
|
|
2101
|
+
/** The parts of a request that tell which rules it may use. */
|
|
2102
|
+
const targetOf = (
|
|
2103
|
+
request: ScopedRequest,
|
|
2104
|
+
descriptor: Resource.Descriptor,
|
|
2105
|
+
): PredicateContext => ({
|
|
2106
|
+
action: request.action,
|
|
2107
|
+
kind: request.kind,
|
|
2108
|
+
field: request.field,
|
|
2109
|
+
descriptor,
|
|
2110
|
+
predicates: new Map(),
|
|
2111
|
+
})
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Tests if each field that a rule that the request may use reads passes
|
|
2115
|
+
* `test`. It looks at the conditions before they are made smaller. A smaller
|
|
2116
|
+
* condition may leave out a field, but a read of that field, such as a
|
|
2117
|
+
* getter, may fail when `decide` runs. A shared node is visited one time.
|
|
2118
|
+
*/
|
|
2119
|
+
const readsOnly = (
|
|
2120
|
+
node: Node,
|
|
2121
|
+
target: PredicateContext,
|
|
2122
|
+
test: (field: string) => boolean,
|
|
2123
|
+
memo: Map<Node, boolean>,
|
|
2124
|
+
): boolean =>
|
|
2125
|
+
remember(memo, node, () => {
|
|
2126
|
+
switch (node._tag) {
|
|
2127
|
+
case "Rules":
|
|
2128
|
+
return Arr.every(
|
|
2129
|
+
node.rules,
|
|
2130
|
+
(value) =>
|
|
2131
|
+
value.when === undefined ||
|
|
2132
|
+
!Rule.matchesTarget(value, target) ||
|
|
2133
|
+
Arr.every(Condition.fields(value.when as Ast), test),
|
|
2134
|
+
)
|
|
2135
|
+
case "All":
|
|
2136
|
+
case "Any":
|
|
2137
|
+
case "FirstOf":
|
|
2138
|
+
return Arr.every(node.policies, (child) =>
|
|
2139
|
+
readsOnly(child, target, test, memo),
|
|
2140
|
+
)
|
|
2141
|
+
case "Ceiling":
|
|
2142
|
+
return (
|
|
2143
|
+
readsOnly(node.cap, target, test, memo) &&
|
|
2144
|
+
readsOnly(node.policy, target, test, memo)
|
|
2145
|
+
)
|
|
2146
|
+
}
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
const coverageAny = (policy: Any, request: ScopedRequest): AnyCoverage => {
|
|
2150
|
+
const descriptor = Domain.descriptorOf(policy.domain, request.kind)
|
|
2151
|
+
// A request with no residual has the condition `False`.
|
|
2152
|
+
if (descriptor !== undefined && hasNoResidual(policy, request, descriptor)) {
|
|
2153
|
+
return coverages.Unknown({ condition: never, reason: "schema" })
|
|
2154
|
+
}
|
|
2155
|
+
const scoped = scopedResiduals(policy, request)
|
|
2156
|
+
const allowed = scoped.allow
|
|
2157
|
+
// Each field that a rule or the scope reads must be plain, also a field
|
|
2158
|
+
// that a smaller condition leaves out. The condition is the residual.
|
|
2159
|
+
if (descriptor !== undefined) {
|
|
2160
|
+
const plain = (field: string) =>
|
|
2161
|
+
Resource.isPlainField(descriptor.schema, field)
|
|
2162
|
+
if (
|
|
2163
|
+
readsGetter(policy, request, descriptor) ||
|
|
2164
|
+
!Arr.every(scopeFields(request.within), plain) ||
|
|
2165
|
+
!readsOnly(
|
|
2166
|
+
nodeOf(policy),
|
|
2167
|
+
targetOf(request, descriptor),
|
|
2168
|
+
plain,
|
|
2169
|
+
new Map(),
|
|
2170
|
+
)
|
|
2171
|
+
) {
|
|
2172
|
+
return coverages.Unknown({ condition: allowed, reason: "schema" })
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
const options = { schema: scoped.schema }
|
|
2177
|
+
const some =
|
|
2178
|
+
allowed._tag === "False"
|
|
2179
|
+
? undefined
|
|
2180
|
+
: Condition.satisfiable(allowed as Condition.Condition, options)
|
|
2181
|
+
if (some === undefined || some._tag === "Unsatisfiable") {
|
|
2182
|
+
return coverages.None({ condition: never })
|
|
2183
|
+
}
|
|
2184
|
+
if (some._tag === "Unknown") {
|
|
2185
|
+
return coverages.Unknown({ condition: allowed, reason: some.reason })
|
|
2186
|
+
}
|
|
2187
|
+
// Only two nodes more than `allowed`, so it needs no size limit.
|
|
2188
|
+
const refused: Ast = Condition.simplify(
|
|
2189
|
+
Condition.and(
|
|
2190
|
+
scoped.scope as Condition.Condition,
|
|
2191
|
+
Condition.not(allowed as Condition.Condition),
|
|
2192
|
+
),
|
|
2193
|
+
)
|
|
2194
|
+
const missing =
|
|
2195
|
+
refused._tag === "False"
|
|
2196
|
+
? undefined
|
|
2197
|
+
: Condition.satisfiable(refused as Condition.Condition, options)
|
|
2198
|
+
if (missing === undefined || missing._tag === "Unsatisfiable") {
|
|
2199
|
+
return coverages.All({ condition: scoped.scope })
|
|
2200
|
+
}
|
|
2201
|
+
if (missing._tag === "Unknown") {
|
|
2202
|
+
return coverages.Unknown({ condition: allowed, reason: missing.reason })
|
|
2203
|
+
}
|
|
2204
|
+
return coverages.Some({
|
|
2205
|
+
condition: allowed,
|
|
2206
|
+
allowed: some.example,
|
|
2207
|
+
refused: missing.example,
|
|
2208
|
+
})
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Tests if the scope of a request has a value of the Schema of its kind. The
|
|
2213
|
+
* answer is `true` only when the search finds such a value. When the search
|
|
2214
|
+
* can not say, the answer is `false`. So a "yes" from it is safe to act on,
|
|
2215
|
+
* as for the question "may the decider answer for this scope?".
|
|
2216
|
+
*
|
|
2217
|
+
* @stability stable
|
|
2218
|
+
* @category evaluation
|
|
2219
|
+
* @since 1.0.0
|
|
2220
|
+
*/
|
|
2221
|
+
export const scopeHasValue = (policy: Any, request: ScopedRequest): boolean => {
|
|
2222
|
+
const scoped = scopedResiduals(policy, request)
|
|
2223
|
+
return (
|
|
2224
|
+
Condition.satisfiable(scoped.scope as Condition.Any, {
|
|
2225
|
+
schema: scoped.schema,
|
|
2226
|
+
})._tag === "Satisfiable"
|
|
2227
|
+
)
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* Tests if the scope of a request has no value of the Schema of its kind. A
|
|
2232
|
+
* scope with a mistake, such as two values for one field, is empty. With no
|
|
2233
|
+
* scope, the answer is `false` when the kind has a value. When the search can
|
|
2234
|
+
* not say, the answer is `false`.
|
|
2235
|
+
*
|
|
2236
|
+
* @stability stable
|
|
2237
|
+
* @category evaluation
|
|
2238
|
+
* @since 1.0.0
|
|
2239
|
+
*/
|
|
2240
|
+
export const scopeIsEmpty = (policy: Any, request: ScopedRequest): boolean => {
|
|
2241
|
+
const scoped = scopedResiduals(policy, request)
|
|
2242
|
+
return (
|
|
2243
|
+
Condition.satisfiable(scoped.scope as Condition.Any, {
|
|
2244
|
+
schema: scoped.schema,
|
|
2245
|
+
})._tag === "Unsatisfiable"
|
|
2246
|
+
)
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Tells how much of a scope the policy allows: all of it, none of it, or some
|
|
2251
|
+
* of it. See {@link Coverage}. With no scope (`within`), the scope is every
|
|
2252
|
+
* value of the kind.
|
|
2253
|
+
*
|
|
2254
|
+
* The answer comes from {@link Condition.satisfiable} with the Schema of the
|
|
2255
|
+
* resource, so it is exact for the values of the Schema. When a condition
|
|
2256
|
+
* tests a field that is not a plain scalar (see `Resource.isPlainField`),
|
|
2257
|
+
* such as a field with a check other than a finite or integer check, the
|
|
2258
|
+
* answer is `Unknown`, and a check on the kind says no.
|
|
2259
|
+
*
|
|
2260
|
+
* `Some` means that some value of the Schema is allowed. It does not mean that
|
|
2261
|
+
* such a value is in your database. To know that, run `condition` as a query.
|
|
2262
|
+
*
|
|
2263
|
+
* @stability stable
|
|
2264
|
+
* @category evaluation
|
|
2265
|
+
* @since 1.0.0
|
|
2266
|
+
*/
|
|
2267
|
+
export const coverage: {
|
|
2268
|
+
<const Req extends AnyKindRequest>(
|
|
2269
|
+
request: Req,
|
|
2270
|
+
): <D extends Domain.Any, E, R, O>(
|
|
2271
|
+
policy: Policy<D, E, R, O> & Fits<Req, KindRequest<D>>,
|
|
2272
|
+
) => Coverage<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
2273
|
+
<D extends Domain.Any, E, R, O, const Req extends KindRequest<NoInfer<D>>>(
|
|
509
2274
|
policy: Policy<D, E, R, O>,
|
|
510
|
-
request:
|
|
511
|
-
):
|
|
512
|
-
} =
|
|
2275
|
+
request: Req,
|
|
2276
|
+
): Coverage<E, R, O, ResidualReads<D, Req["kind"]>>
|
|
2277
|
+
} = dual(2, coverageAny) as never
|
|
2278
|
+
|
|
2279
|
+
// Schema
|
|
513
2280
|
|
|
514
2281
|
/**
|
|
515
2282
|
* The JSON form of a rule.
|
|
@@ -552,24 +2319,997 @@ export type EncodedNode =
|
|
|
552
2319
|
readonly policy: EncodedNode
|
|
553
2320
|
}
|
|
554
2321
|
|
|
2322
|
+
const AlgorithmSchema = Schema.Literals([
|
|
2323
|
+
"denyOverrides",
|
|
2324
|
+
"permitOverrides",
|
|
2325
|
+
"firstApplicable",
|
|
2326
|
+
"lastApplicable",
|
|
2327
|
+
])
|
|
2328
|
+
|
|
2329
|
+
const encodedRuleSchema = (meta: Schema.Top | undefined) =>
|
|
2330
|
+
Schema.Struct({
|
|
2331
|
+
_tag: Schema.Literals(["Allow", "Deny"]),
|
|
2332
|
+
actions: Schema.Array(Schema.String),
|
|
2333
|
+
resources: Schema.Array(Schema.String),
|
|
2334
|
+
when: Schema.optionalKey(Condition.schema),
|
|
2335
|
+
fields: Schema.String.pipe(Schema.Array, Schema.optionalKey),
|
|
2336
|
+
reason: Schema.optionalKey(Schema.String),
|
|
2337
|
+
id: Schema.optionalKey(Schema.String),
|
|
2338
|
+
meta: Schema.optionalKey(
|
|
2339
|
+
(meta ?? Schema.Never) as Schema.Codec<unknown, unknown>,
|
|
2340
|
+
),
|
|
2341
|
+
})
|
|
2342
|
+
|
|
555
2343
|
/**
|
|
556
|
-
* The
|
|
557
|
-
*
|
|
558
|
-
*
|
|
2344
|
+
* The most levels of objects and arrays in the JSON form of a policy. The
|
|
2345
|
+
* Schema of a policy calls itself for each level, so a much deeper input
|
|
2346
|
+
* could fill the stack before the checks of the policy run. A policy tree of
|
|
2347
|
+
* `maxDepth` levels with deep conditions stays well below this.
|
|
2348
|
+
*/
|
|
2349
|
+
const maxJsonDepth = 1000
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* The most objects and arrays in the JSON form of an input, with a shared one
|
|
2353
|
+
* counted for each place. The decoder writes out a shared one each time, so
|
|
2354
|
+
* an input with few objects may give a very large tree.
|
|
2355
|
+
*/
|
|
2356
|
+
const maxInputNodes = 1_000_000
|
|
2357
|
+
|
|
2358
|
+
/** The size and the height of a value, in objects and arrays. */
|
|
2359
|
+
interface Measure {
|
|
2360
|
+
readonly size: number
|
|
2361
|
+
readonly height: number
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Measures a value, or gives `undefined` when it is higher than `levels`. A
|
|
2366
|
+
* shared object is measured one time. The walk stops at that height, so it
|
|
2367
|
+
* does not fill the stack itself. The memo is a native `Map`, because it
|
|
2368
|
+
* must find an object by the object itself.
|
|
2369
|
+
*/
|
|
2370
|
+
const measure = (
|
|
2371
|
+
input: unknown,
|
|
2372
|
+
levels: number,
|
|
2373
|
+
memo: Map<object, Measure | { readonly failed: number }>,
|
|
2374
|
+
): Measure | undefined => {
|
|
2375
|
+
if (typeof input !== "object" || input === null) {
|
|
2376
|
+
return { size: 0, height: 0 }
|
|
2377
|
+
}
|
|
2378
|
+
const cached = memo.get(input)
|
|
2379
|
+
// A value that was too high for some levels is too high for fewer.
|
|
2380
|
+
if (
|
|
2381
|
+
cached !== undefined &&
|
|
2382
|
+
("failed" in cached ? levels <= cached.failed : true)
|
|
2383
|
+
) {
|
|
2384
|
+
return "failed" in cached || cached.height > levels ? undefined : cached
|
|
2385
|
+
}
|
|
2386
|
+
// After the first child that is too high, the fold measures no more
|
|
2387
|
+
// children. The walk has few frames for each level, so it does not fill
|
|
2388
|
+
// the stack at the most levels.
|
|
2389
|
+
// An input with a key that a loop over the keys does not see, or with a
|
|
2390
|
+
// getter, may give the decoder more than the measure sees. Such an input
|
|
2391
|
+
// is not plain data, so it fails.
|
|
2392
|
+
const result =
|
|
2393
|
+
levels === 0 || !isPlainData(input)
|
|
2394
|
+
? undefined
|
|
2395
|
+
: Arr.reduce(
|
|
2396
|
+
Object.values(input),
|
|
2397
|
+
{ size: 1, height: 1 } as Measure | undefined,
|
|
2398
|
+
(total, child) => {
|
|
2399
|
+
const measured =
|
|
2400
|
+
total === undefined ? undefined : measure(child, levels - 1, memo)
|
|
2401
|
+
return total === undefined || measured === undefined
|
|
2402
|
+
? undefined
|
|
2403
|
+
: {
|
|
2404
|
+
size: total.size + measured.size,
|
|
2405
|
+
height: Math.max(total.height, 1 + measured.height),
|
|
2406
|
+
}
|
|
2407
|
+
},
|
|
2408
|
+
)
|
|
2409
|
+
memo.set(input, result ?? { failed: levels })
|
|
2410
|
+
return result
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* Tests if each own key of an object is a plain data key that a loop over the
|
|
2415
|
+
* keys sees. The `length` of an array is the one key that is not seen. The
|
|
2416
|
+
* decoder reads keys of the prototypes too, so a prototype other than the
|
|
2417
|
+
* one of a plain object or an array may have only methods, as the prototype
|
|
2418
|
+
* of a class has.
|
|
2419
|
+
*/
|
|
2420
|
+
const isPlainData = (input: object): boolean =>
|
|
2421
|
+
Arr.every(Reflect.ownKeys(input), (key) => {
|
|
2422
|
+
const property = Object.getOwnPropertyDescriptor(input, key)
|
|
2423
|
+
return (
|
|
2424
|
+
property !== undefined &&
|
|
2425
|
+
"value" in property &&
|
|
2426
|
+
(property.enumerable === true ||
|
|
2427
|
+
(Array.isArray(input) && key === "length"))
|
|
2428
|
+
)
|
|
2429
|
+
}) && hasOnlyMethods(Object.getPrototypeOf(input))
|
|
2430
|
+
|
|
2431
|
+
/** Tests if the prototypes from `prototype` up have only methods. */
|
|
2432
|
+
const hasOnlyMethods = (prototype: unknown): boolean =>
|
|
2433
|
+
prototype === null ||
|
|
2434
|
+
prototype === Object.prototype ||
|
|
2435
|
+
prototype === Array.prototype ||
|
|
2436
|
+
(typeof prototype === "object" &&
|
|
2437
|
+
Arr.every(Reflect.ownKeys(prototype), (key) => {
|
|
2438
|
+
const property = Object.getOwnPropertyDescriptor(prototype, key)
|
|
2439
|
+
return (
|
|
2440
|
+
typeof key === "symbol" ||
|
|
2441
|
+
key === "constructor" ||
|
|
2442
|
+
(property !== undefined &&
|
|
2443
|
+
"value" in property &&
|
|
2444
|
+
typeof property.value === "function")
|
|
2445
|
+
)
|
|
2446
|
+
}) &&
|
|
2447
|
+
hasOnlyMethods(Object.getPrototypeOf(prototype)))
|
|
2448
|
+
|
|
2449
|
+
/** Tests if a value is low and small enough to decode. */
|
|
2450
|
+
const fitsLimits = (input: unknown): string | true => {
|
|
2451
|
+
const measured = measure(input, maxJsonDepth, new Map())
|
|
2452
|
+
return measured === undefined
|
|
2453
|
+
? `A policy may have at most ${String(maxJsonDepth)} levels of JSON`
|
|
2454
|
+
: measured.size > maxInputNodes
|
|
2455
|
+
? `A policy may have at most ${String(maxInputNodes)} objects and arrays in JSON`
|
|
2456
|
+
: true
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const encodedNodeSchema = (
|
|
2460
|
+
meta: Schema.Top | undefined,
|
|
2461
|
+
): Schema.Codec<EncodedNode> => {
|
|
2462
|
+
const self: Schema.Codec<EncodedNode> = Schema.Union([
|
|
2463
|
+
Schema.TaggedStruct("Rules", {
|
|
2464
|
+
algorithm: AlgorithmSchema,
|
|
2465
|
+
rules: Schema.Array(encodedRuleSchema(meta)),
|
|
2466
|
+
}),
|
|
2467
|
+
Schema.TaggedStruct("All", {
|
|
2468
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
2469
|
+
}),
|
|
2470
|
+
Schema.TaggedStruct("Any", {
|
|
2471
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
2472
|
+
}),
|
|
2473
|
+
Schema.TaggedStruct("FirstOf", {
|
|
2474
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
2475
|
+
}),
|
|
2476
|
+
Schema.TaggedStruct("Ceiling", {
|
|
2477
|
+
cap: Schema.suspend(() => self),
|
|
2478
|
+
policy: Schema.suspend(() => self),
|
|
2479
|
+
}),
|
|
2480
|
+
]) as never
|
|
2481
|
+
return self
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
type ConditionNode = Condition.Encoded | Ast
|
|
2485
|
+
|
|
2486
|
+
const principalRefs = (ast: ConditionNode): ReadonlyArray<string> =>
|
|
2487
|
+
Array.from(refsIn(ast, new Map()))
|
|
2488
|
+
|
|
2489
|
+
/** The principal fields of a node. A shared node is visited one time. */
|
|
2490
|
+
const refsIn = (
|
|
2491
|
+
node: ConditionNode,
|
|
2492
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2493
|
+
): HashSet.HashSet<string> =>
|
|
2494
|
+
remember(memo, node, () => {
|
|
2495
|
+
const refs = (operands: ReadonlyArray<Condition.Operand>) =>
|
|
2496
|
+
HashSet.fromIterable(
|
|
2497
|
+
Arr.flatMap(operands, (operand) =>
|
|
2498
|
+
operand._tag === "Principal" ? [operand.path] : [],
|
|
2499
|
+
),
|
|
2500
|
+
)
|
|
2501
|
+
switch (node._tag) {
|
|
2502
|
+
case "Compare":
|
|
2503
|
+
return refs([node.value])
|
|
2504
|
+
case "In":
|
|
2505
|
+
return refs(node.values)
|
|
2506
|
+
case "Under":
|
|
2507
|
+
return refs(node.prefix)
|
|
2508
|
+
case "And":
|
|
2509
|
+
case "Or":
|
|
2510
|
+
return Arr.reduce(
|
|
2511
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2512
|
+
HashSet.empty<string>(),
|
|
2513
|
+
(all, child) => HashSet.union(all, refsIn(child, memo)),
|
|
2514
|
+
)
|
|
2515
|
+
case "Not":
|
|
2516
|
+
return refsIn(node.condition, memo)
|
|
2517
|
+
default:
|
|
2518
|
+
return HashSet.empty()
|
|
2519
|
+
}
|
|
2520
|
+
})
|
|
2521
|
+
|
|
2522
|
+
interface Issue {
|
|
2523
|
+
readonly path: ReadonlyArray<PropertyKey>
|
|
2524
|
+
readonly issue: string
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/** A test of a field with a fixed value, as in `eq("level", 3)`. */
|
|
2528
|
+
type LiteralTest = readonly [field: string, value: Condition.Scalar]
|
|
2529
|
+
|
|
2530
|
+
const literalTests = (node: ConditionNode): ReadonlyArray<LiteralTest> =>
|
|
2531
|
+
Arr.map(Array.from(literalTestsIn(node, new Map())), (text) => {
|
|
2532
|
+
const [field, value] = JSON.parse(text) as readonly [string, unknown]
|
|
2533
|
+
return [
|
|
2534
|
+
field,
|
|
2535
|
+
hasProperty(value, "number") ? Number(value.number) : value,
|
|
2536
|
+
] as LiteralTest
|
|
2537
|
+
})
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* The JSON text of a test. JSON has no `Infinity`, `-Infinity` or `NaN`, so
|
|
2541
|
+
* such a number goes in as its text.
|
|
2542
|
+
*/
|
|
2543
|
+
const literalText = (field: string, value: Condition.Scalar): string =>
|
|
2544
|
+
JSON.stringify([
|
|
2545
|
+
field,
|
|
2546
|
+
typeof value === "number" && !Number.isFinite(value)
|
|
2547
|
+
? { number: String(value) }
|
|
2548
|
+
: value,
|
|
2549
|
+
])
|
|
2550
|
+
|
|
2551
|
+
/**
|
|
2552
|
+
* The tests of fields with fixed values in a node, each as the JSON text of a
|
|
2553
|
+
* {@link LiteralTest}. A set of texts keeps each test one time, so a node
|
|
2554
|
+
* with many shared parts gives a small set. A shared node is visited one time.
|
|
2555
|
+
*/
|
|
2556
|
+
const literalTestsIn = (
|
|
2557
|
+
node: ConditionNode,
|
|
2558
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2559
|
+
): HashSet.HashSet<string> =>
|
|
2560
|
+
remember(memo, node, () => {
|
|
2561
|
+
const tests = (
|
|
2562
|
+
field: string,
|
|
2563
|
+
operands: ReadonlyArray<Condition.Operand>,
|
|
2564
|
+
): HashSet.HashSet<string> =>
|
|
2565
|
+
HashSet.fromIterable(
|
|
2566
|
+
Arr.flatMap(operands, (operand) =>
|
|
2567
|
+
operand._tag === "Literal" ? [literalText(field, operand.value)] : [],
|
|
2568
|
+
),
|
|
2569
|
+
)
|
|
2570
|
+
switch (node._tag) {
|
|
2571
|
+
case "Compare":
|
|
2572
|
+
return tests(node.field, [node.value])
|
|
2573
|
+
case "In":
|
|
2574
|
+
return tests(node.field, node.values)
|
|
2575
|
+
case "And":
|
|
2576
|
+
case "Or":
|
|
2577
|
+
return Arr.reduce(
|
|
2578
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2579
|
+
HashSet.empty<string>(),
|
|
2580
|
+
(all, child) => HashSet.union(all, literalTestsIn(child, memo)),
|
|
2581
|
+
)
|
|
2582
|
+
case "Not":
|
|
2583
|
+
return literalTestsIn(node.condition, memo)
|
|
2584
|
+
default:
|
|
2585
|
+
return HashSet.empty()
|
|
2586
|
+
}
|
|
2587
|
+
})
|
|
2588
|
+
|
|
2589
|
+
/** A test of a field with a field of the principal, as in `eq("ownerId", $.principal("id"))`. */
|
|
2590
|
+
type PrincipalTest = readonly [field: string, ref: string]
|
|
2591
|
+
|
|
2592
|
+
const principalTests = (node: ConditionNode): ReadonlyArray<PrincipalTest> =>
|
|
2593
|
+
Arr.map(
|
|
2594
|
+
Array.from(principalTestsIn(node, new Map())),
|
|
2595
|
+
(text) => JSON.parse(text) as PrincipalTest,
|
|
2596
|
+
)
|
|
2597
|
+
|
|
2598
|
+
/**
|
|
2599
|
+
* The tests of fields with principal fields in a node, each as the JSON text
|
|
2600
|
+
* of a {@link PrincipalTest}. See {@link literalTestsIn}.
|
|
2601
|
+
*/
|
|
2602
|
+
const principalTestsIn = (
|
|
2603
|
+
node: ConditionNode,
|
|
2604
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2605
|
+
): HashSet.HashSet<string> =>
|
|
2606
|
+
remember(memo, node, () => {
|
|
2607
|
+
const tests = (
|
|
2608
|
+
field: string,
|
|
2609
|
+
operands: ReadonlyArray<Condition.Operand>,
|
|
2610
|
+
): HashSet.HashSet<string> =>
|
|
2611
|
+
HashSet.fromIterable(
|
|
2612
|
+
Arr.flatMap(operands, (operand) =>
|
|
2613
|
+
operand._tag === "Principal"
|
|
2614
|
+
? [JSON.stringify([field, operand.path])]
|
|
2615
|
+
: [],
|
|
2616
|
+
),
|
|
2617
|
+
)
|
|
2618
|
+
switch (node._tag) {
|
|
2619
|
+
case "Compare":
|
|
2620
|
+
return tests(node.field, [node.value])
|
|
2621
|
+
case "In":
|
|
2622
|
+
return tests(node.field, node.values)
|
|
2623
|
+
case "And":
|
|
2624
|
+
case "Or":
|
|
2625
|
+
return Arr.reduce(
|
|
2626
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2627
|
+
HashSet.empty<string>(),
|
|
2628
|
+
(all, child) => HashSet.union(all, principalTestsIn(child, memo)),
|
|
2629
|
+
)
|
|
2630
|
+
case "Not":
|
|
2631
|
+
return principalTestsIn(node.condition, memo)
|
|
2632
|
+
default:
|
|
2633
|
+
return HashSet.empty()
|
|
2634
|
+
}
|
|
2635
|
+
})
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* Tests if a node has a fixed value that is not a {@link Condition.Scalar},
|
|
2639
|
+
* such as `undefined`. A shared node is visited one time.
|
|
2640
|
+
*/
|
|
2641
|
+
const hasBadLiteralIn = (
|
|
2642
|
+
node: ConditionNode,
|
|
2643
|
+
memo: Map<ConditionNode, boolean>,
|
|
2644
|
+
): boolean =>
|
|
2645
|
+
remember(memo, node, () => {
|
|
2646
|
+
const bad = (operands: ReadonlyArray<Condition.Operand>) =>
|
|
2647
|
+
Arr.some(
|
|
2648
|
+
operands,
|
|
2649
|
+
(operand) =>
|
|
2650
|
+
operand._tag === "Literal" &&
|
|
2651
|
+
operand.value !== null &&
|
|
2652
|
+
typeof operand.value !== "string" &&
|
|
2653
|
+
typeof operand.value !== "number" &&
|
|
2654
|
+
typeof operand.value !== "boolean",
|
|
2655
|
+
)
|
|
2656
|
+
switch (node._tag) {
|
|
2657
|
+
case "Compare":
|
|
2658
|
+
return bad([node.value])
|
|
2659
|
+
case "In":
|
|
2660
|
+
return bad(node.values)
|
|
2661
|
+
case "Under":
|
|
2662
|
+
return bad(node.prefix)
|
|
2663
|
+
case "And":
|
|
2664
|
+
case "Or":
|
|
2665
|
+
return Arr.some(
|
|
2666
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2667
|
+
(child) => hasBadLiteralIn(child, memo),
|
|
2668
|
+
)
|
|
2669
|
+
case "Not":
|
|
2670
|
+
return hasBadLiteralIn(node.condition, memo)
|
|
2671
|
+
default:
|
|
2672
|
+
return false
|
|
2673
|
+
}
|
|
2674
|
+
})
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* The principal fields in the prefixes of `Under` nodes, each as the JSON text
|
|
2678
|
+
* of the place in the prefix and the path of the field. See
|
|
2679
|
+
* {@link literalTestsIn}.
|
|
2680
|
+
*/
|
|
2681
|
+
/**
|
|
2682
|
+
* The fixed values in the prefixes of `Under` nodes, each as the JSON text of
|
|
2683
|
+
* the place in the prefix, the value, and the fixed strings of the whole
|
|
2684
|
+
* prefix. A number that JSON can not hold is left out: it fits no field
|
|
2685
|
+
* that a path names. See {@link underRefsIn}.
|
|
2686
|
+
*/
|
|
2687
|
+
const underLiteralsIn = (
|
|
2688
|
+
node: ConditionNode,
|
|
2689
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2690
|
+
): HashSet.HashSet<string> =>
|
|
2691
|
+
remember(memo, node, () => {
|
|
2692
|
+
switch (node._tag) {
|
|
2693
|
+
case "Under": {
|
|
2694
|
+
const strings = Arr.map(node.prefix, (operand) =>
|
|
2695
|
+
operand._tag === "Literal" && typeof operand.value === "string"
|
|
2696
|
+
? operand.value
|
|
2697
|
+
: null,
|
|
2698
|
+
)
|
|
2699
|
+
return HashSet.fromIterable(
|
|
2700
|
+
Arr.flatMap(node.prefix, (operand, index) =>
|
|
2701
|
+
operand._tag === "Literal" &&
|
|
2702
|
+
(operand.value === null ||
|
|
2703
|
+
typeof operand.value === "string" ||
|
|
2704
|
+
typeof operand.value === "boolean" ||
|
|
2705
|
+
Number.isFinite(operand.value))
|
|
2706
|
+
? [JSON.stringify([index, operand.value, strings])]
|
|
2707
|
+
: [],
|
|
2708
|
+
),
|
|
2709
|
+
)
|
|
2710
|
+
}
|
|
2711
|
+
case "And":
|
|
2712
|
+
case "Or":
|
|
2713
|
+
return Arr.reduce(
|
|
2714
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2715
|
+
HashSet.empty<string>(),
|
|
2716
|
+
(all, child) => HashSet.union(all, underLiteralsIn(child, memo)),
|
|
2717
|
+
)
|
|
2718
|
+
case "Not":
|
|
2719
|
+
return underLiteralsIn(node.condition, memo)
|
|
2720
|
+
default:
|
|
2721
|
+
return HashSet.empty()
|
|
2722
|
+
}
|
|
2723
|
+
})
|
|
2724
|
+
|
|
2725
|
+
/** A fixed value in a path test. See {@link underLiteralsIn}. */
|
|
2726
|
+
type UnderLiteral = readonly [
|
|
2727
|
+
index: number,
|
|
2728
|
+
value: Condition.Scalar,
|
|
2729
|
+
strings: ReadonlyArray<string | null>,
|
|
2730
|
+
]
|
|
2731
|
+
|
|
2732
|
+
const underLiterals = (node: ConditionNode): ReadonlyArray<UnderLiteral> =>
|
|
2733
|
+
Arr.map(
|
|
2734
|
+
Array.from(underLiteralsIn(node, new Map())),
|
|
2735
|
+
(text) => JSON.parse(text) as UnderLiteral,
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
const underRefsIn = (
|
|
2739
|
+
node: ConditionNode,
|
|
2740
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2741
|
+
): HashSet.HashSet<string> =>
|
|
2742
|
+
remember(memo, node, () => {
|
|
2743
|
+
switch (node._tag) {
|
|
2744
|
+
case "Under": {
|
|
2745
|
+
// The fixed strings of the prefix, so the check can leave out a
|
|
2746
|
+
// resource whose path can not match them.
|
|
2747
|
+
const strings = Arr.map(node.prefix, (operand) =>
|
|
2748
|
+
operand._tag === "Literal" && typeof operand.value === "string"
|
|
2749
|
+
? operand.value
|
|
2750
|
+
: null,
|
|
2751
|
+
)
|
|
2752
|
+
return HashSet.fromIterable(
|
|
2753
|
+
Arr.flatMap(node.prefix, (operand, index) =>
|
|
2754
|
+
operand._tag === "Principal"
|
|
2755
|
+
? [JSON.stringify([index, operand.path, strings])]
|
|
2756
|
+
: [],
|
|
2757
|
+
),
|
|
2758
|
+
)
|
|
2759
|
+
}
|
|
2760
|
+
case "And":
|
|
2761
|
+
case "Or":
|
|
2762
|
+
return Arr.reduce(
|
|
2763
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2764
|
+
HashSet.empty<string>(),
|
|
2765
|
+
(all, child) => HashSet.union(all, underRefsIn(child, memo)),
|
|
2766
|
+
)
|
|
2767
|
+
case "Not":
|
|
2768
|
+
return underRefsIn(node.condition, memo)
|
|
2769
|
+
default:
|
|
2770
|
+
return HashSet.empty()
|
|
2771
|
+
}
|
|
2772
|
+
})
|
|
2773
|
+
|
|
2774
|
+
/**
|
|
2775
|
+
* A principal field in a path test: its place in the prefix, its path, and
|
|
2776
|
+
* the fixed strings of the whole prefix, or `null` for another part.
|
|
2777
|
+
*/
|
|
2778
|
+
type UnderRef = readonly [
|
|
2779
|
+
index: number,
|
|
2780
|
+
ref: string,
|
|
2781
|
+
strings: ReadonlyArray<string | null>,
|
|
2782
|
+
]
|
|
2783
|
+
|
|
2784
|
+
const underRefs = (node: ConditionNode): ReadonlyArray<UnderRef> =>
|
|
2785
|
+
Arr.map(
|
|
2786
|
+
Array.from(underRefsIn(node, new Map())),
|
|
2787
|
+
(text) => JSON.parse(text) as UnderRef,
|
|
2788
|
+
)
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* Tests if the path of a resource may start with a prefix: it is as long as
|
|
2792
|
+
* the prefix, and each fixed string of the prefix is the string of the path
|
|
2793
|
+
* at its place, when the path has a string there.
|
|
2794
|
+
*/
|
|
2795
|
+
const mayStartWith = (
|
|
2796
|
+
descriptor: Resource.Descriptor,
|
|
2797
|
+
strings: ReadonlyArray<string | null>,
|
|
2798
|
+
): boolean =>
|
|
2799
|
+
// A path shorter than the prefix never matches.
|
|
2800
|
+
(descriptor.path?.length ?? 0) >= strings.length &&
|
|
2801
|
+
Arr.every(strings, (string, index) => {
|
|
2802
|
+
const segment = descriptor.path?.[index]
|
|
2803
|
+
return string === null || typeof segment !== "string" || segment === string
|
|
2804
|
+
})
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* Tests if a principal field may hold the part of a path at `index`: a string
|
|
2808
|
+
* of the path, or a value of the field that the path names there.
|
|
2809
|
+
*/
|
|
2810
|
+
const fitsPathPart = (
|
|
2811
|
+
descriptor: Resource.Descriptor,
|
|
2812
|
+
index: number,
|
|
2813
|
+
principal: Resource.FieldDomain,
|
|
2814
|
+
): boolean => {
|
|
2815
|
+
const segment = descriptor.path?.[index]
|
|
2816
|
+
return segment === undefined
|
|
2817
|
+
? false
|
|
2818
|
+
: typeof segment === "string"
|
|
2819
|
+
? fitsDomain(principal, segment)
|
|
2820
|
+
: domainsMeet(
|
|
2821
|
+
Resource.domainOf(descriptor.schema, segment.field),
|
|
2822
|
+
principal,
|
|
2823
|
+
)
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* The fields of ordered tests (`lt`, `lte`, `gt`, `gte`) in a node, each as
|
|
2828
|
+
* the JSON text of the field and the operand. See {@link literalTestsIn}.
|
|
2829
|
+
*/
|
|
2830
|
+
const orderedTestsIn = (
|
|
2831
|
+
node: ConditionNode,
|
|
2832
|
+
memo: Map<ConditionNode, HashSet.HashSet<string>>,
|
|
2833
|
+
): HashSet.HashSet<string> =>
|
|
2834
|
+
remember(memo, node, () => {
|
|
2835
|
+
switch (node._tag) {
|
|
2836
|
+
case "Compare":
|
|
2837
|
+
return node.op === "eq" || node.op === "ne"
|
|
2838
|
+
? HashSet.empty()
|
|
2839
|
+
: HashSet.make(
|
|
2840
|
+
JSON.stringify([
|
|
2841
|
+
node.field,
|
|
2842
|
+
// The type of a fixed value, not the value: JSON has no
|
|
2843
|
+
// `Infinity`.
|
|
2844
|
+
node.value._tag === "Literal"
|
|
2845
|
+
? { _tag: "Literal", type: typeof node.value.value }
|
|
2846
|
+
: node.value,
|
|
2847
|
+
]),
|
|
2848
|
+
)
|
|
2849
|
+
case "And":
|
|
2850
|
+
case "Or":
|
|
2851
|
+
return Arr.reduce(
|
|
2852
|
+
node.conditions as ReadonlyArray<ConditionNode>,
|
|
2853
|
+
HashSet.empty<string>(),
|
|
2854
|
+
(all, child) => HashSet.union(all, orderedTestsIn(child, memo)),
|
|
2855
|
+
)
|
|
2856
|
+
case "Not":
|
|
2857
|
+
return orderedTestsIn(node.condition, memo)
|
|
2858
|
+
default:
|
|
2859
|
+
return HashSet.empty()
|
|
2860
|
+
}
|
|
2861
|
+
})
|
|
2862
|
+
|
|
2863
|
+
/** The operand of an ordered test: the type of a fixed value, or a principal field. */
|
|
2864
|
+
type OrderedOperand =
|
|
2865
|
+
| { readonly _tag: "Literal"; readonly type: string }
|
|
2866
|
+
| { readonly _tag: "Principal"; readonly path: string }
|
|
2867
|
+
|
|
2868
|
+
const orderedTests = (
|
|
2869
|
+
node: ConditionNode,
|
|
2870
|
+
): ReadonlyArray<readonly [field: string, operand: OrderedOperand]> =>
|
|
2871
|
+
Arr.map(
|
|
2872
|
+
Array.from(orderedTestsIn(node, new Map())),
|
|
2873
|
+
(text) =>
|
|
2874
|
+
JSON.parse(text) as readonly [field: string, operand: OrderedOperand],
|
|
2875
|
+
)
|
|
2876
|
+
|
|
2877
|
+
/**
|
|
2878
|
+
* Tests if a field may hold a value with an order: a number or a string. An
|
|
2879
|
+
* ordered test is false for any other value.
|
|
2880
|
+
*/
|
|
2881
|
+
const isOrdered = (domain: Resource.FieldDomain): boolean =>
|
|
2882
|
+
domain.string ||
|
|
2883
|
+
domain.number !== "none" ||
|
|
2884
|
+
Arr.some(
|
|
2885
|
+
domain.literals,
|
|
2886
|
+
(value) => typeof value === "string" || typeof value === "number",
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* Tests if two fields may hold values of one ordered type: two strings or two
|
|
2891
|
+
* numbers. An ordered test of two values of other types is false.
|
|
2892
|
+
*/
|
|
2893
|
+
const orderedMeet = (
|
|
2894
|
+
left: Resource.FieldDomain,
|
|
2895
|
+
right: Resource.FieldDomain,
|
|
2896
|
+
): boolean => {
|
|
2897
|
+
const strings = (domain: Resource.FieldDomain) =>
|
|
2898
|
+
domain.string ||
|
|
2899
|
+
Arr.some(domain.literals, (value) => typeof value === "string")
|
|
2900
|
+
const numbers = (domain: Resource.FieldDomain) =>
|
|
2901
|
+
domain.number !== "none" ||
|
|
2902
|
+
Arr.some(domain.literals, (value) => typeof value === "number")
|
|
2903
|
+
return (strings(left) && strings(right)) || (numbers(left) && numbers(right))
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
/**
|
|
2907
|
+
* Tests if two fields may hold one value: a value of one type, or a single
|
|
2908
|
+
* value that the other field may hold.
|
|
2909
|
+
*/
|
|
2910
|
+
const domainsMeet = (
|
|
2911
|
+
left: Resource.FieldDomain,
|
|
2912
|
+
right: Resource.FieldDomain,
|
|
2913
|
+
): boolean =>
|
|
2914
|
+
// A principal field that is `null` is a missing value, and a test with a
|
|
2915
|
+
// missing value is false. A principal field that holds an object is not a
|
|
2916
|
+
// scalar, and it is a missing value too. So only scalars can be shared.
|
|
2917
|
+
(left.string && right.string) ||
|
|
2918
|
+
(left.boolean && right.boolean) ||
|
|
2919
|
+
(left.number !== "none" && right.number !== "none") ||
|
|
2920
|
+
Arr.some(left.literals, (value) => fitsDomain(right, value)) ||
|
|
2921
|
+
Arr.some(right.literals, (value) => fitsDomain(left, value))
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* Tests if a field with this domain may hold a value of the type of `value`.
|
|
2925
|
+
* `null` always fits: a test with `null` is a test that the field is missing.
|
|
2926
|
+
*/
|
|
2927
|
+
const fitsDomain = (
|
|
2928
|
+
domain: Resource.FieldDomain,
|
|
2929
|
+
value: Condition.Scalar,
|
|
2930
|
+
): boolean =>
|
|
2931
|
+
value === null ||
|
|
2932
|
+
domain.literals.includes(value) ||
|
|
2933
|
+
(typeof value === "string" && domain.string) ||
|
|
2934
|
+
(typeof value === "boolean" && domain.boolean) ||
|
|
2935
|
+
(typeof value === "number" && domain.number !== "none")
|
|
2936
|
+
|
|
2937
|
+
/**
|
|
2938
|
+
* Checks the names in an encoded policy against the domain. It gives one
|
|
2939
|
+
* message for each problem.
|
|
2940
|
+
*/
|
|
2941
|
+
const validate = (
|
|
2942
|
+
domain: Domain.Any,
|
|
2943
|
+
root: EncodedNode,
|
|
2944
|
+
): ReadonlyArray<Issue> => {
|
|
2945
|
+
const principalPaths = domain.principal.pipe(
|
|
2946
|
+
Resource.fieldPaths,
|
|
2947
|
+
HashSet.fromIterable,
|
|
2948
|
+
)
|
|
2949
|
+
const ruleIssues = (
|
|
2950
|
+
value: EncodedRule,
|
|
2951
|
+
at: ReadonlyArray<PropertyKey>,
|
|
2952
|
+
): ReadonlyArray<Issue> => {
|
|
2953
|
+
const all = value.resources.includes("*")
|
|
2954
|
+
// Each kind is checked one time, also when the list names it again. The
|
|
2955
|
+
// order of the kinds does not change the issues, so a set is enough.
|
|
2956
|
+
const named = Array.from(
|
|
2957
|
+
HashSet.fromIterable(Arr.filter(value.resources, (kind) => kind !== "*")),
|
|
2958
|
+
)
|
|
2959
|
+
const descriptors = Arr.flatMap(named, (kind) => {
|
|
2960
|
+
const descriptor = Domain.descriptorOf(domain, kind)
|
|
2961
|
+
return descriptor === undefined ? [] : [descriptor]
|
|
2962
|
+
})
|
|
2963
|
+
const known = all
|
|
2964
|
+
? Array.from(HashMap.values(domain.resources))
|
|
2965
|
+
: descriptors
|
|
2966
|
+
// The kinds of the rule with one of its actions. The rule never runs on
|
|
2967
|
+
// another kind, so the types of its tests are checked only on these.
|
|
2968
|
+
const applicable = Arr.filter(
|
|
2969
|
+
descriptors,
|
|
2970
|
+
(descriptor) =>
|
|
2971
|
+
value.actions.includes("*") ||
|
|
2972
|
+
Arr.some(value.actions, (action) =>
|
|
2973
|
+
descriptor.actions.includes(action),
|
|
2974
|
+
),
|
|
2975
|
+
)
|
|
2976
|
+
const usedFields = [
|
|
2977
|
+
...Arr.filter(value.fields ?? [], (field) => !field.includes("*")),
|
|
2978
|
+
...(value.when === undefined ? [] : Condition.fields(value.when as Ast)),
|
|
2979
|
+
]
|
|
2980
|
+
return [
|
|
2981
|
+
...Arr.map(
|
|
2982
|
+
Arr.filter(named, (kind) => !HashMap.has(domain.resources, kind)),
|
|
2983
|
+
(kind): Issue => ({
|
|
2984
|
+
path: [...at, "resources"],
|
|
2985
|
+
issue: `There is no resource with the kind "${kind}"`,
|
|
2986
|
+
}),
|
|
2987
|
+
),
|
|
2988
|
+
...Arr.map(
|
|
2989
|
+
Arr.filter(
|
|
2990
|
+
value.actions,
|
|
2991
|
+
(action) =>
|
|
2992
|
+
action !== "*" &&
|
|
2993
|
+
!Arr.some(known, (descriptor) =>
|
|
2994
|
+
descriptor.actions.includes(action),
|
|
2995
|
+
),
|
|
2996
|
+
),
|
|
2997
|
+
(action): Issue => ({
|
|
2998
|
+
path: [...at, "actions"],
|
|
2999
|
+
issue: `The action "${action}" is not an action of these resources`,
|
|
3000
|
+
}),
|
|
3001
|
+
),
|
|
3002
|
+
...Arr.map(
|
|
3003
|
+
Arr.filter(
|
|
3004
|
+
usedFields,
|
|
3005
|
+
(field) =>
|
|
3006
|
+
all ||
|
|
3007
|
+
descriptors.length === 0 ||
|
|
3008
|
+
!Arr.every(
|
|
3009
|
+
descriptors,
|
|
3010
|
+
(descriptor) =>
|
|
3011
|
+
descriptor.fieldPaths.includes(field) ||
|
|
3012
|
+
// A key of a record, or a field deep in a recursive
|
|
3013
|
+
// Schema, or a getter of a nested class.
|
|
3014
|
+
Resource.hasPath(descriptor.schema, field) ||
|
|
3015
|
+
Resource.hasGetter(descriptor.schema, field),
|
|
3016
|
+
),
|
|
3017
|
+
),
|
|
3018
|
+
(field): Issue => ({
|
|
3019
|
+
path: at,
|
|
3020
|
+
issue: `The field "${field}" is not a field of every resource of the rule`,
|
|
3021
|
+
}),
|
|
3022
|
+
),
|
|
3023
|
+
...Arr.map(
|
|
3024
|
+
Arr.filter(
|
|
3025
|
+
value.when === undefined ? [] : principalRefs(value.when),
|
|
3026
|
+
(ref) =>
|
|
3027
|
+
!HashSet.has(principalPaths, ref) &&
|
|
3028
|
+
!Resource.hasPath(domain.principal, ref) &&
|
|
3029
|
+
!Resource.hasGetter(domain.principal, ref),
|
|
3030
|
+
),
|
|
3031
|
+
(ref): Issue => ({
|
|
3032
|
+
path: [...at, "when"],
|
|
3033
|
+
issue: `The principal has no field "${ref}"`,
|
|
3034
|
+
}),
|
|
3035
|
+
),
|
|
3036
|
+
// A test with a value of another type than the field never matches.
|
|
3037
|
+
// The types stop it in a typed rule, so here it is a mistake, and in
|
|
3038
|
+
// a deny rule it would let values through.
|
|
3039
|
+
...Arr.map(
|
|
3040
|
+
Arr.filter(
|
|
3041
|
+
value.when === undefined ? [] : literalTests(value.when),
|
|
3042
|
+
([field, literal]) =>
|
|
3043
|
+
Arr.some(
|
|
3044
|
+
applicable,
|
|
3045
|
+
(descriptor) =>
|
|
3046
|
+
!fitsDomain(
|
|
3047
|
+
Resource.domainOf(descriptor.schema, field),
|
|
3048
|
+
literal,
|
|
3049
|
+
),
|
|
3050
|
+
),
|
|
3051
|
+
),
|
|
3052
|
+
([field, literal]): Issue => ({
|
|
3053
|
+
path: [...at, "when"],
|
|
3054
|
+
issue: `The field "${field}" can not hold the value ${JSON.stringify(literal)}`,
|
|
3055
|
+
}),
|
|
3056
|
+
),
|
|
3057
|
+
...Arr.map(
|
|
3058
|
+
Arr.filter(
|
|
3059
|
+
value.when === undefined ? [] : principalTests(value.when),
|
|
3060
|
+
([field, ref]) =>
|
|
3061
|
+
Arr.some(
|
|
3062
|
+
applicable,
|
|
3063
|
+
(descriptor) =>
|
|
3064
|
+
!domainsMeet(
|
|
3065
|
+
Resource.domainOf(descriptor.schema, field),
|
|
3066
|
+
Resource.domainOf(domain.principal, ref),
|
|
3067
|
+
),
|
|
3068
|
+
),
|
|
3069
|
+
),
|
|
3070
|
+
([field, ref]): Issue => ({
|
|
3071
|
+
path: [...at, "when"],
|
|
3072
|
+
issue: `The field "${field}" can not hold a value of the principal field "${ref}"`,
|
|
3073
|
+
}),
|
|
3074
|
+
),
|
|
3075
|
+
// A condition with a node that is not known would be skipped when it
|
|
3076
|
+
// runs, so it is not taken.
|
|
3077
|
+
...(value.when !== undefined && !Condition.isAst(value.when)
|
|
3078
|
+
? [
|
|
3079
|
+
{
|
|
3080
|
+
path: [...at, "when"],
|
|
3081
|
+
issue: "The condition is not a whole condition tree",
|
|
3082
|
+
} satisfies Issue,
|
|
3083
|
+
]
|
|
3084
|
+
: []),
|
|
3085
|
+
// A fixed value such as `undefined` is not a value of a test. Its
|
|
3086
|
+
// JSON text would be `null`, and the test would mean another thing.
|
|
3087
|
+
...(value.when !== undefined && hasBadLiteralIn(value.when, new Map())
|
|
3088
|
+
? [
|
|
3089
|
+
{
|
|
3090
|
+
path: [...at, "when"],
|
|
3091
|
+
issue:
|
|
3092
|
+
"A test has a fixed value that is not a string, a number, a boolean or null",
|
|
3093
|
+
} satisfies Issue,
|
|
3094
|
+
]
|
|
3095
|
+
: []),
|
|
3096
|
+
// A principal field in a path test must hold a value that the path
|
|
3097
|
+
// may have at that place. Else the test is always false.
|
|
3098
|
+
...Arr.map(
|
|
3099
|
+
Arr.filter(
|
|
3100
|
+
value.when === undefined ? [] : underRefs(value.when),
|
|
3101
|
+
([index, ref, strings]) =>
|
|
3102
|
+
// A rule for every resource may run on each of them that has a
|
|
3103
|
+
// part of its path at this place.
|
|
3104
|
+
Arr.some(
|
|
3105
|
+
// The rule runs only on a kind with one of its actions, and
|
|
3106
|
+
// the test can match only a path that starts with the fixed
|
|
3107
|
+
// strings of the prefix.
|
|
3108
|
+
Arr.filter(
|
|
3109
|
+
all
|
|
3110
|
+
? Arr.filter(
|
|
3111
|
+
known,
|
|
3112
|
+
(descriptor) =>
|
|
3113
|
+
descriptor.path?.[index] !== undefined &&
|
|
3114
|
+
(value.actions.includes("*") ||
|
|
3115
|
+
Arr.some(value.actions, (action) =>
|
|
3116
|
+
descriptor.actions.includes(action),
|
|
3117
|
+
)),
|
|
3118
|
+
)
|
|
3119
|
+
: applicable,
|
|
3120
|
+
(descriptor) => mayStartWith(descriptor, strings),
|
|
3121
|
+
),
|
|
3122
|
+
(descriptor) =>
|
|
3123
|
+
!fitsPathPart(
|
|
3124
|
+
descriptor,
|
|
3125
|
+
index,
|
|
3126
|
+
Resource.domainOf(domain.principal, ref),
|
|
3127
|
+
),
|
|
3128
|
+
),
|
|
3129
|
+
),
|
|
3130
|
+
([, ref]): Issue => ({
|
|
3131
|
+
path: [...at, "when"],
|
|
3132
|
+
issue: `The principal field "${ref}" can not hold a part of the path of the resource`,
|
|
3133
|
+
}),
|
|
3134
|
+
),
|
|
3135
|
+
// A fixed value in a path test at the place of a field of the path
|
|
3136
|
+
// must be a value of that field. Else the test is always false.
|
|
3137
|
+
...Arr.map(
|
|
3138
|
+
Arr.filter(
|
|
3139
|
+
value.when === undefined ? [] : underLiterals(value.when),
|
|
3140
|
+
([index, literal, strings]) =>
|
|
3141
|
+
Arr.some(
|
|
3142
|
+
Arr.filter(
|
|
3143
|
+
all
|
|
3144
|
+
? Arr.filter(
|
|
3145
|
+
known,
|
|
3146
|
+
(descriptor) =>
|
|
3147
|
+
value.actions.includes("*") ||
|
|
3148
|
+
Arr.some(value.actions, (action) =>
|
|
3149
|
+
descriptor.actions.includes(action),
|
|
3150
|
+
),
|
|
3151
|
+
)
|
|
3152
|
+
: applicable,
|
|
3153
|
+
(descriptor) => mayStartWith(descriptor, strings),
|
|
3154
|
+
),
|
|
3155
|
+
(descriptor) => {
|
|
3156
|
+
const segment = descriptor.path?.[index]
|
|
3157
|
+
return (
|
|
3158
|
+
segment !== undefined &&
|
|
3159
|
+
typeof segment !== "string" &&
|
|
3160
|
+
!fitsDomain(
|
|
3161
|
+
Resource.domainOf(descriptor.schema, segment.field),
|
|
3162
|
+
literal,
|
|
3163
|
+
)
|
|
3164
|
+
)
|
|
3165
|
+
},
|
|
3166
|
+
),
|
|
3167
|
+
),
|
|
3168
|
+
([index, literal]): Issue => ({
|
|
3169
|
+
path: [...at, "when"],
|
|
3170
|
+
issue: `The path part ${String(index)} can not hold the value ${JSON.stringify(literal)}`,
|
|
3171
|
+
}),
|
|
3172
|
+
),
|
|
3173
|
+
// An ordered test compares numbers with numbers and strings with
|
|
3174
|
+
// strings. With another value it is always false.
|
|
3175
|
+
...Arr.map(
|
|
3176
|
+
Arr.filter(
|
|
3177
|
+
value.when === undefined ? [] : orderedTests(value.when),
|
|
3178
|
+
([field, operand]) =>
|
|
3179
|
+
(operand._tag === "Literal" &&
|
|
3180
|
+
operand.type !== "string" &&
|
|
3181
|
+
operand.type !== "number") ||
|
|
3182
|
+
Arr.some(applicable, (descriptor) => {
|
|
3183
|
+
const fieldDomain = Resource.domainOf(descriptor.schema, field)
|
|
3184
|
+
// A principal field must share an ordered type with the
|
|
3185
|
+
// field: a string and a number never compare.
|
|
3186
|
+
return operand._tag === "Literal"
|
|
3187
|
+
? !isOrdered(fieldDomain)
|
|
3188
|
+
: !orderedMeet(
|
|
3189
|
+
fieldDomain,
|
|
3190
|
+
Resource.domainOf(domain.principal, operand.path),
|
|
3191
|
+
)
|
|
3192
|
+
}),
|
|
3193
|
+
),
|
|
3194
|
+
([field]): Issue => ({
|
|
3195
|
+
path: [...at, "when"],
|
|
3196
|
+
issue: `The field "${field}" has an ordered test with a value that has no order`,
|
|
3197
|
+
}),
|
|
3198
|
+
),
|
|
3199
|
+
]
|
|
3200
|
+
}
|
|
3201
|
+
const issuesOf = (
|
|
3202
|
+
node: EncodedNode,
|
|
3203
|
+
path: ReadonlyArray<PropertyKey>,
|
|
3204
|
+
level: number,
|
|
3205
|
+
): ReadonlyArray<Issue> => {
|
|
3206
|
+
if (level > maxDepth) {
|
|
3207
|
+
return [
|
|
3208
|
+
{ path, issue: `A policy tree may have at most ${maxDepth} levels` },
|
|
3209
|
+
]
|
|
3210
|
+
}
|
|
3211
|
+
switch (node._tag) {
|
|
3212
|
+
case "Rules":
|
|
3213
|
+
return Arr.flatMap(node.rules, (value, index) =>
|
|
3214
|
+
ruleIssues(value, [...path, "rules", index]),
|
|
3215
|
+
)
|
|
3216
|
+
case "All":
|
|
3217
|
+
case "Any":
|
|
3218
|
+
case "FirstOf":
|
|
3219
|
+
return Arr.flatMap(node.policies, (child, index) =>
|
|
3220
|
+
issuesOf(child, [...path, "policies", index], level + 1),
|
|
3221
|
+
)
|
|
3222
|
+
case "Ceiling":
|
|
3223
|
+
return [
|
|
3224
|
+
...issuesOf(node.cap, [...path, "cap"], level + 1),
|
|
3225
|
+
...issuesOf(node.policy, [...path, "policy"], level + 1),
|
|
3226
|
+
]
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
return issuesOf(root, [], 1)
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/**
|
|
3233
|
+
* Makes a policy tree from its JSON form. Each rule gets the domain of the
|
|
3234
|
+
* Schema, as a rule from the builder of the domain has it.
|
|
3235
|
+
*/
|
|
3236
|
+
const decodeNode = (node: EncodedNode, domain: Domain.Any): Node => {
|
|
3237
|
+
switch (node._tag) {
|
|
3238
|
+
case "Rules":
|
|
3239
|
+
return nodes.Rules({
|
|
3240
|
+
algorithm: node.algorithm,
|
|
3241
|
+
rules: node.rules.map((value) =>
|
|
3242
|
+
Rule.make({ ...(value as Rule.Shape), domain }),
|
|
3243
|
+
),
|
|
3244
|
+
})
|
|
3245
|
+
case "All":
|
|
3246
|
+
case "Any":
|
|
3247
|
+
case "FirstOf":
|
|
3248
|
+
return nodes[node._tag]({
|
|
3249
|
+
policies: node.policies.map((child) => decodeNode(child, domain)),
|
|
3250
|
+
})
|
|
3251
|
+
case "Ceiling":
|
|
3252
|
+
return nodes.Ceiling({
|
|
3253
|
+
cap: decodeNode(node.cap, domain),
|
|
3254
|
+
policy: decodeNode(node.policy, domain),
|
|
3255
|
+
})
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* Tests if a value is a policy.
|
|
559
3261
|
*
|
|
560
3262
|
* @stability stable
|
|
561
|
-
* @category
|
|
3263
|
+
* @category guards
|
|
562
3264
|
* @since 0.3.0
|
|
563
3265
|
*/
|
|
564
|
-
export const
|
|
565
|
-
domain: D,
|
|
566
|
-
) => Schema.Codec<Policy<D>, EncodedNode> = internal.schema
|
|
3266
|
+
export const isPolicy = (u: unknown): u is Any => u instanceof PolicyData
|
|
567
3267
|
|
|
568
3268
|
/**
|
|
569
|
-
*
|
|
3269
|
+
* The Schema of the policies of a domain with no predicates. Decoding checks
|
|
3270
|
+
* every kind, action, field and principal field against the domain, and the
|
|
3271
|
+
* `meta` of each rule against the `meta` Schema of the domain.
|
|
570
3272
|
*
|
|
571
3273
|
* @stability stable
|
|
572
|
-
* @category
|
|
3274
|
+
* @category schemas
|
|
573
3275
|
* @since 0.3.0
|
|
574
3276
|
*/
|
|
575
|
-
export const
|
|
3277
|
+
export const schema = <D extends Domain.Any>(
|
|
3278
|
+
domain: D,
|
|
3279
|
+
): Schema.Codec<Policy<D>, EncodedNode> =>
|
|
3280
|
+
// The input is any value until the checks of `encodedNodeSchema` run, so
|
|
3281
|
+
// this guard says yes to every object.
|
|
3282
|
+
Schema.declare(
|
|
3283
|
+
(input): input is EncodedNode =>
|
|
3284
|
+
typeof input === "object" && input !== null,
|
|
3285
|
+
).pipe(
|
|
3286
|
+
Schema.check(Schema.makeFilter(fitsLimits)),
|
|
3287
|
+
Schema.decodeTo(
|
|
3288
|
+
encodedNodeSchema(domain.meta),
|
|
3289
|
+
SchemaTransformation.passthrough(),
|
|
3290
|
+
),
|
|
3291
|
+
Schema.check(Schema.makeFilter((node) => validate(domain, node))),
|
|
3292
|
+
Schema.decodeTo(
|
|
3293
|
+
// A policy with a predicate is not a plain policy, and it can not
|
|
3294
|
+
// become JSON.
|
|
3295
|
+
Schema.declare(
|
|
3296
|
+
(u): u is Policy<D> =>
|
|
3297
|
+
isPolicy(u) && u.domain === domain && !nodeHasPredicate(u.node),
|
|
3298
|
+
),
|
|
3299
|
+
SchemaTransformation.transformEffect({
|
|
3300
|
+
decode: (node: EncodedNode) =>
|
|
3301
|
+
Effect.succeed(fromNode(domain, decodeNode(node, domain))),
|
|
3302
|
+
// A tree whose JSON form is too large fails as a Schema issue.
|
|
3303
|
+
encode: (policy: Policy<D>, options) =>
|
|
3304
|
+
encodedSize(policy.node) > maxEncodedNodes
|
|
3305
|
+
? Effect.fail(
|
|
3306
|
+
new SchemaIssue.InvalidValue(
|
|
3307
|
+
{ message: tooLargeMessage },
|
|
3308
|
+
policy,
|
|
3309
|
+
options,
|
|
3310
|
+
),
|
|
3311
|
+
)
|
|
3312
|
+
: Effect.succeed(encodeNode(policy.node)),
|
|
3313
|
+
}),
|
|
3314
|
+
),
|
|
3315
|
+
)
|