@nmnmcc/ability 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +112 -69
  2. package/dist/Authorizer.d.ts +43 -47
  3. package/dist/Authorizer.d.ts.map +1 -1
  4. package/dist/Authorizer.js +5 -290
  5. package/dist/Authorizer.js.map +1 -1
  6. package/dist/Condition.d.ts +164 -332
  7. package/dist/Condition.d.ts.map +1 -1
  8. package/dist/Condition.js +75 -890
  9. package/dist/Condition.js.map +1 -1
  10. package/dist/Decision.d.ts +13 -13
  11. package/dist/Decision.d.ts.map +1 -1
  12. package/dist/Decision.js.map +1 -1
  13. package/dist/Domain.d.ts +10 -21
  14. package/dist/Domain.d.ts.map +1 -1
  15. package/dist/Domain.js +4 -62
  16. package/dist/Domain.js.map +1 -1
  17. package/dist/HttpApi.d.ts +31 -7
  18. package/dist/HttpApi.d.ts.map +1 -1
  19. package/dist/HttpApi.js +25 -8
  20. package/dist/HttpApi.js.map +1 -1
  21. package/dist/Limits.d.ts +114 -0
  22. package/dist/Limits.d.ts.map +1 -0
  23. package/dist/Limits.js +132 -0
  24. package/dist/Limits.js.map +1 -0
  25. package/dist/Policy.d.ts +276 -269
  26. package/dist/Policy.d.ts.map +1 -1
  27. package/dist/Policy.js +73 -1177
  28. package/dist/Policy.js.map +1 -1
  29. package/dist/Resource.d.ts +4 -177
  30. package/dist/Resource.d.ts.map +1 -1
  31. package/dist/Resource.js +4 -803
  32. package/dist/Resource.js.map +1 -1
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +4 -5
  35. package/dist/internal/algebra.js +267 -0
  36. package/dist/internal/algebra.js.map +1 -0
  37. package/dist/internal/analysis.js +245 -0
  38. package/dist/internal/analysis.js.map +1 -0
  39. package/dist/internal/authorizer.d.ts +30 -0
  40. package/dist/internal/authorizer.d.ts.map +1 -0
  41. package/dist/internal/authorizer.js +221 -0
  42. package/dist/internal/authorizer.js.map +1 -0
  43. package/dist/internal/bind.js +311 -0
  44. package/dist/internal/bind.js.map +1 -0
  45. package/dist/internal/catalog.d.ts +56 -0
  46. package/dist/internal/catalog.d.ts.map +1 -0
  47. package/dist/internal/catalog.js +292 -0
  48. package/dist/internal/catalog.js.map +1 -0
  49. package/dist/internal/decide.js +166 -0
  50. package/dist/internal/decide.js.map +1 -0
  51. package/dist/internal/domain.js +32 -0
  52. package/dist/internal/domain.js.map +1 -0
  53. package/dist/internal/json.js +134 -0
  54. package/dist/internal/json.js.map +1 -0
  55. package/dist/internal/limits.d.ts +14 -0
  56. package/dist/internal/limits.d.ts.map +1 -0
  57. package/dist/internal/limits.js +43 -0
  58. package/dist/internal/limits.js.map +1 -0
  59. package/dist/internal/nodes.d.ts +121 -0
  60. package/dist/internal/nodes.d.ts.map +1 -0
  61. package/dist/internal/nodes.js +277 -0
  62. package/dist/internal/nodes.js.map +1 -0
  63. package/dist/internal/patterns.js +110 -0
  64. package/dist/internal/patterns.js.map +1 -0
  65. package/dist/internal/policy-json.js +265 -0
  66. package/dist/internal/policy-json.js.map +1 -0
  67. package/dist/internal/policy-nodes.d.ts +61 -0
  68. package/dist/internal/policy-nodes.d.ts.map +1 -0
  69. package/dist/internal/policy-nodes.js +146 -0
  70. package/dist/internal/policy-nodes.js.map +1 -0
  71. package/dist/internal/policy.d.ts +42 -0
  72. package/dist/internal/policy.d.ts.map +1 -0
  73. package/dist/internal/policy.js +124 -0
  74. package/dist/internal/policy.js.map +1 -0
  75. package/dist/internal/read.js +39 -0
  76. package/dist/internal/read.js.map +1 -0
  77. package/dist/internal/resource.js +115 -0
  78. package/dist/internal/resource.js.map +1 -0
  79. package/dist/internal/rule.d.ts +26 -0
  80. package/dist/internal/rule.d.ts.map +1 -0
  81. package/dist/internal/rule.js +103 -0
  82. package/dist/internal/rule.js.map +1 -0
  83. package/dist/internal/search.d.ts +22 -0
  84. package/dist/internal/search.d.ts.map +1 -0
  85. package/dist/internal/search.js +274 -0
  86. package/dist/internal/search.js.map +1 -0
  87. package/dist/internal/suggest.js +46 -0
  88. package/dist/internal/suggest.js.map +1 -0
  89. package/package.json +3 -4
  90. package/src/Authorizer.ts +67 -583
  91. package/src/Condition.ts +339 -2095
  92. package/src/Decision.ts +6 -5
  93. package/src/Domain.ts +12 -69
  94. package/src/HttpApi.ts +72 -10
  95. package/src/Limits.ts +132 -0
  96. package/src/Policy.ts +391 -2772
  97. package/src/Resource.ts +7 -1548
  98. package/src/index.ts +3 -9
  99. package/src/internal/algebra.ts +482 -0
  100. package/src/internal/analysis.ts +478 -0
  101. package/src/internal/authorizer.ts +418 -0
  102. package/src/internal/bind.ts +648 -0
  103. package/src/internal/catalog.ts +534 -0
  104. package/src/internal/decide.ts +402 -0
  105. package/src/internal/domain.ts +66 -0
  106. package/src/internal/json.ts +260 -0
  107. package/src/internal/limits.ts +78 -0
  108. package/src/internal/nodes.ts +404 -0
  109. package/src/internal/patterns.ts +211 -0
  110. package/src/internal/policy-json.ts +487 -0
  111. package/src/internal/policy-nodes.ts +207 -0
  112. package/src/internal/policy.ts +264 -0
  113. package/src/internal/read.ts +55 -0
  114. package/src/internal/resource.ts +180 -0
  115. package/src/internal/rule.ts +196 -0
  116. package/src/internal/search.ts +541 -0
  117. package/src/internal/suggest.ts +104 -0
  118. package/dist/Principal.d.ts +0 -35
  119. package/dist/Principal.d.ts.map +0 -1
  120. package/dist/Principal.js +0 -37
  121. package/dist/Principal.js.map +0 -1
  122. package/dist/Rule.d.ts +0 -275
  123. package/dist/Rule.d.ts.map +0 -1
  124. package/dist/Rule.js +0 -257
  125. package/dist/Rule.js.map +0 -1
  126. package/src/Principal.ts +0 -41
  127. package/src/Rule.ts +0 -683
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The values of policies: the class of a policy, the constructors, the
3
+ * combinators and the Schema. The types are in `Policy.ts`.
4
+ */
5
+ import type * as Context from "effect/Context"
6
+ import * as Data from "effect/Data"
7
+ import * as Arr from "effect/Array"
8
+ import * as Effect from "effect/Effect"
9
+ import { dual } from "effect/Function"
10
+ import * as Option from "effect/Option"
11
+ import * as Schema from "effect/Schema"
12
+ import * as SchemaIssue from "effect/SchemaIssue"
13
+ import * as SchemaTransformation from "effect/SchemaTransformation"
14
+ import * as Condition from "../Condition.ts"
15
+ import type * as Domain from "../Domain.ts"
16
+ import type * as Policy from "../Policy.ts"
17
+ import * as Bind from "./bind.ts"
18
+ import * as Nodes from "./nodes.ts"
19
+ import * as PolicyJson from "./policy-json.ts"
20
+ import * as PolicyNodes from "./policy-nodes.ts"
21
+ import * as RuleData from "./rule.ts"
22
+
23
+ type Any = Policy.Any
24
+ type Node = Policy.Node
25
+ type Options = Policy.Options
26
+
27
+ /**
28
+ * The class of every policy value. Two policies of one domain with the same
29
+ * tree are equal. As JSON, a policy is its tree.
30
+ */
31
+ export class PolicyData<
32
+ D extends Domain.Any = any,
33
+ X = never,
34
+ > extends Data.Class<{
35
+ readonly domain: D
36
+ readonly node: Node
37
+ }> {
38
+ // A private field makes the type of a policy its own: a spread of a
39
+ // policy, or an object made by hand, is not a policy. `Equal` does not
40
+ // read it.
41
+ readonly #policy = true
42
+ /** Only in the types. */
43
+ declare readonly "~Opaque": () => X
44
+ /**
45
+ * Only in the types. A policy reads the values and the principal of its
46
+ * domain, so a policy of one domain is not a policy of a wider domain.
47
+ */
48
+ declare readonly "~Domain": (domain: D) => D
49
+
50
+ toJSON(): unknown {
51
+ return PolicyJson.toJson(this.node, PolicyJson.syncMeta(this.domain.meta))
52
+ }
53
+
54
+ /** Tests if a value is a policy, by its private field. */
55
+ static is(u: unknown): u is Any {
56
+ return typeof u === "object" && u !== null && #policy in u
57
+ }
58
+ }
59
+
60
+ /** The node of a policy, with no types, for the code below. */
61
+ export const nodeOf = (policy: Any): PolicyNodes.Node => policy.node as never
62
+
63
+ export const fromNode = (domain: Domain.Any, node: PolicyNodes.Node): any =>
64
+ new PolicyData({ domain, node })
65
+
66
+ /**
67
+ * The error when a rule does not fit its domain. It has one issue for each
68
+ * problem, with its path.
69
+ */
70
+ export class PolicyError extends Schema.TaggedError<PolicyError>(
71
+ "@nmnmcc/ability/Policy/PolicyError",
72
+ )("PolicyError", {
73
+ issues: Schema.Array(
74
+ Schema.Struct({
75
+ path: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
76
+ message: Schema.String,
77
+ }),
78
+ ),
79
+ message: Schema.String,
80
+ }) {}
81
+
82
+ /** Makes a policy from a list of rules, and binds them to the domain. */
83
+ const fromRules = (
84
+ domain: Domain.Any,
85
+ values: Iterable<unknown>,
86
+ options?: Options,
87
+ ): Any => {
88
+ // The constructor takes only rules, and it stops a list past the limits.
89
+ const node = new PolicyNodes.Rules({
90
+ algorithm: options?.algorithm ?? "denyOverrides",
91
+ rules: Array.from(values) as ReadonlyArray<RuleData.Rule>,
92
+ })
93
+ // A rule from a builder of another domain is a mistake in the code, so it
94
+ // is a defect. Two domains may have the same types, so the types do not
95
+ // find it.
96
+ if (
97
+ Arr.some(node.rules, (value) => {
98
+ const origin = value.domain
99
+ return origin !== undefined && origin !== domain
100
+ })
101
+ ) {
102
+ throw new globalThis.Error(
103
+ "A rule comes from the builder of another domain",
104
+ )
105
+ }
106
+ // Every rule is bound to the domain here, also a rule from `Policy.rule`.
107
+ const issues = Bind.treeIssues(domain, node)
108
+ if (issues.length > 0) {
109
+ throw PolicyError.make({
110
+ issues: Arr.map(issues, (issue) => ({
111
+ path: Arr.map(issue.path, (part) =>
112
+ typeof part === "number" ? part : String(part),
113
+ ),
114
+ message: issue.issue,
115
+ })),
116
+ message: `A rule does not fit the domain: ${issues.map((issue) => issue.issue).join("; ")}`,
117
+ })
118
+ }
119
+ return fromNode(domain, node)
120
+ }
121
+
122
+ /** Makes a policy from a generator function that gives rules. */
123
+ export const define =
124
+ (domain: Domain.Any, options?: Options) =>
125
+ (body: (builder: unknown) => Generator<unknown, unknown, unknown>): Any => {
126
+ const iterator = body(RuleData.builder(domain))
127
+ // Each rule goes back into the generator, as `yield*` gives it.
128
+ const collected = Arr.unfold(iterator.next(), (state) =>
129
+ state.done === true
130
+ ? Option.none()
131
+ : Option.some([state.value, iterator.next(state.value)] as const),
132
+ )
133
+ return fromRules(domain, collected, options) as never
134
+ }
135
+
136
+ const sameDomain = (policies: ReadonlyArray<Any>): Domain.Any => {
137
+ // A copy of a policy made with a spread may keep the type fields of another
138
+ // policy. Only a policy value goes into a tree.
139
+ if (!Arr.every(policies, isPolicy)) {
140
+ throw new globalThis.Error("A value in the list is not a policy")
141
+ }
142
+ const domain = policies[0]!.domain
143
+ if (!Arr.every(policies, (policy) => policy.domain === domain)) {
144
+ throw new globalThis.Error(
145
+ "Policies from two domains can not be put together",
146
+ )
147
+ }
148
+ return domain
149
+ }
150
+
151
+ // The constructors of the nodes stop a tree past `policyDepth` or
152
+ // `policyNodes`.
153
+ export const combine =
154
+ (tag: "All" | "Any" | "FirstOf") =>
155
+ (policies: ReadonlyArray<Any>): any =>
156
+ fromNode(
157
+ sameDomain(policies),
158
+ new PolicyNodes[tag](Arr.map(policies, nodeOf)),
159
+ )
160
+
161
+ /** Puts a limit on a policy. A deny from `cap` always wins. */
162
+ export const ceiling = dual(2, (policy: Any, cap: Any): any =>
163
+ fromNode(
164
+ sameDomain([policy, cap]),
165
+ new PolicyNodes.Ceiling({ cap: nodeOf(cap), policy: nodeOf(policy) }),
166
+ ),
167
+ )
168
+
169
+ /**
170
+ * Gives the services to the predicates of a tree. A node with none stays. The
171
+ * conditions get the services first, from a flat list of the rules, so the
172
+ * walk does not go down the levels of the tree and of a condition at once.
173
+ */
174
+ const provideNode = (
175
+ root: PolicyNodes.Node,
176
+ context: Context.Context<never>,
177
+ ): PolicyNodes.Node => {
178
+ // A native map, because it must find a rule by the rule itself: two rules
179
+ // with the same data are `Equal`.
180
+ const provided = new Map<RuleData.Rule, RuleData.Rule>(
181
+ Arr.map(PolicyNodes.rulesOf(root), (value) => [
182
+ value,
183
+ value.when === undefined || !Nodes.toNode(value.when).opaque
184
+ ? value
185
+ : RuleData.make({
186
+ ...value,
187
+ when: Condition.provideContext(
188
+ value.when as Condition.Any,
189
+ context as never,
190
+ ),
191
+ }),
192
+ ]),
193
+ )
194
+ const provide = (node: PolicyNodes.Node): PolicyNodes.Node => {
195
+ if (!node.opaque) {
196
+ return node
197
+ }
198
+ switch (node._tag) {
199
+ case "Rules":
200
+ return new PolicyNodes.Rules({
201
+ algorithm: node.algorithm,
202
+ rules: Arr.map(node.rules, (value) => provided.get(value) ?? value),
203
+ })
204
+ case "All":
205
+ case "Any":
206
+ case "FirstOf":
207
+ return new PolicyNodes[node._tag](Arr.map(node.policies, provide))
208
+ case "Ceiling":
209
+ return new PolicyNodes.Ceiling({
210
+ cap: provide(node.cap),
211
+ policy: provide(node.policy),
212
+ })
213
+ }
214
+ }
215
+ return provide(root)
216
+ }
217
+
218
+ /** Gives the services in `context` to every predicate of a policy. */
219
+ export const provideContext = dual(
220
+ 2,
221
+ (policy: Any, context: Context.Context<never>): any =>
222
+ fromNode(policy.domain, provideNode(nodeOf(policy), context)),
223
+ )
224
+
225
+ /** Tests if a value is a policy. */
226
+ export const isPolicy = (u: unknown): u is Any => PolicyData.is(u)
227
+
228
+ /** The Schema of the policies of a domain with no predicates. */
229
+ export const schema = (domain: Domain.Any): Schema.Top =>
230
+ Schema.Unknown.pipe(
231
+ Schema.decodeTo(
232
+ // A policy with a predicate is not a plain policy, and it can not
233
+ // become JSON.
234
+ Schema.declare(
235
+ (u): u is Any =>
236
+ isPolicy(u) && u.domain === domain && !nodeOf(u).opaque,
237
+ ).check(
238
+ Schema.makeFilter((policy) => Bind.treeIssues(domain, nodeOf(policy))),
239
+ ),
240
+ SchemaTransformation.transformEffect({
241
+ decode: (input: unknown, options) =>
242
+ PolicyJson.decode(input, domain).pipe(
243
+ Effect.map((node) => fromNode(domain, node)),
244
+ Effect.mapError(
245
+ (problem) =>
246
+ new SchemaIssue.Pointer(
247
+ problem.path,
248
+ problem.issue ??
249
+ new SchemaIssue.InvalidValue(
250
+ { message: problem.message },
251
+ input,
252
+ options,
253
+ ),
254
+ ),
255
+ ),
256
+ ),
257
+ encode: (policy: Any) =>
258
+ Effect.mapError(
259
+ PolicyJson.encode(nodeOf(policy), domain.meta),
260
+ (error) => error.issue,
261
+ ),
262
+ }),
263
+ ),
264
+ ) as never
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How a condition reads a field of a value. A condition reads only plain
3
+ * data: an own data field that a loop over the keys sees. This is the data
4
+ * that a Schema describes, and that every value from Effect has, so the
5
+ * catalog of the Schema holds every value that a read can give.
6
+ */
7
+ import * as Arr from "effect/Array"
8
+ import * as Option from "effect/Option"
9
+
10
+ /** The prototypes of a value, from its own prototype up. */
11
+ const prototypesOf = (value: object): ReadonlyArray<object> =>
12
+ Arr.unfold(Object.getPrototypeOf(value) as object | null, (prototype) =>
13
+ prototype === null
14
+ ? Option.none()
15
+ : Option.some([prototype, Object.getPrototypeOf(prototype)] as const),
16
+ )
17
+
18
+ /**
19
+ * Reads one part of a value:
20
+ * - an own data field that a loop over the keys sees gives its value, with
21
+ * `null` for `undefined`;
22
+ * - a part of `null`, of a scalar, or a missing field gives `null`;
23
+ * - a name that the value has only from `Object.prototype`, such as
24
+ * `constructor`, gives `null`.
25
+ *
26
+ * Any other field, such as a getter, a hidden field, or a field from the
27
+ * prototype of a class, is a defect. Its value is not data that the Schema
28
+ * describes, and a read of it as `null` could let a value pass a deny rule.
29
+ */
30
+ export const readPart = (value: unknown, name: string): unknown => {
31
+ if (typeof value !== "object" || value === null) {
32
+ return null
33
+ }
34
+ const property = Object.getOwnPropertyDescriptor(value, name)
35
+ if (property === undefined) {
36
+ const owner = Arr.findFirst(prototypesOf(value), (prototype) =>
37
+ Object.hasOwn(prototype, name),
38
+ )
39
+ if (Option.isNone(owner) || owner.value === Object.prototype) {
40
+ return null
41
+ }
42
+ throw new Error(`The field "${name}" of the value is not plain data`)
43
+ }
44
+ if (!("value" in property) || property.enumerable !== true) {
45
+ throw new Error(`The field "${name}" of the value is not plain data`)
46
+ }
47
+ return property.value === undefined ? null : property.value
48
+ }
49
+
50
+ /** Reads a field of a value by the names of its parts. */
51
+ export const readPath = (
52
+ value: unknown,
53
+ names: ReadonlyArray<string>,
54
+ ): unknown =>
55
+ Arr.reduce(names, value, (current, name) => readPart(current, name))
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The values of resources: their descriptors, and the constructors of
3
+ * `Resource.Class` and `Resource.make`. The types are in `Resource.ts`.
4
+ */
5
+ import * as Arr from "effect/Array"
6
+ import { Class as DataClass } from "effect/Data"
7
+ import { hasProperty } from "effect/Predicate"
8
+ import * as Schema from "effect/Schema"
9
+ import type {
10
+ Any,
11
+ Descriptor,
12
+ FieldRef,
13
+ Options,
14
+ PathSegment,
15
+ } from "../Resource.ts"
16
+ import * as Catalog from "./catalog.ts"
17
+
18
+ /**
19
+ * The class of the runtime data of a resource. `isResource` uses it to
20
+ * know a resource.
21
+ */
22
+ class ResourceDescriptor extends DataClass<Descriptor> {}
23
+
24
+ /**
25
+ * Checks the names of the top fields of a resource at once, when it is made.
26
+ * The names of nested fields are checked when the descriptor is made.
27
+ */
28
+ const checkFields = (
29
+ kind: string,
30
+ options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
31
+ ): void => {
32
+ // The kind of a value comes from its `_tag`. A field with this name could
33
+ // give a value another kind, so it is a defect.
34
+ if (Object.hasOwn(options.fields, "_tag")) {
35
+ throw new Error(`The resource "${kind}" may not have a field "_tag"`)
36
+ }
37
+ Arr.map(Object.keys(options.fields), (name) => {
38
+ if (name.includes(".")) {
39
+ throw new Error(`A field name may not have a dot: "${name}"`)
40
+ }
41
+ if (name === "") {
42
+ throw new Error("A field name may not be empty")
43
+ }
44
+ })
45
+ }
46
+
47
+ /**
48
+ * Gives a value that is made on first use, and then kept. The descriptor of a
49
+ * resource reads its Schema, and a recursive Schema refers to the resource
50
+ * class, so it can not be read while the class is made. The cell belongs to
51
+ * one resource.
52
+ */
53
+ const lazy = <A>(make: () => A): (() => A) => {
54
+ const cell: { value?: A } = {}
55
+ return () => {
56
+ if (!("value" in cell)) {
57
+ cell.value = make()
58
+ }
59
+ return cell.value as A
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Checks each part of the path of a resource: a string, or a reference to a
65
+ * top-level field of the resource. A reference to another field would read
66
+ * nothing, and a rule on the path could then match a value by mistake.
67
+ */
68
+ const checkPath = (
69
+ kind: string,
70
+ fields: Schema.Struct.Fields,
71
+ path: ReadonlyArray<unknown>,
72
+ ): ReadonlyArray<PathSegment> =>
73
+ // `Array.from` gives `undefined` for a hole of a sparse array, so the check
74
+ // sees each place.
75
+ Arr.map(Array.from(path), (segment): PathSegment => {
76
+ if (typeof segment === "string") {
77
+ return segment
78
+ }
79
+ if (
80
+ hasProperty(segment, "_tag") &&
81
+ segment._tag === "Field" &&
82
+ hasProperty(segment, "field") &&
83
+ typeof segment.field === "string" &&
84
+ Object.hasOwn(fields, segment.field)
85
+ ) {
86
+ return { _tag: "Field", field: segment.field }
87
+ }
88
+ throw new globalThis.Error(
89
+ `A part of the path of "${kind}" is not a string or a field of the resource`,
90
+ )
91
+ })
92
+
93
+ /**
94
+ * Copies the options of a resource. The descriptor is made later, when it is
95
+ * first read, so a change to the objects of the caller before then must not
96
+ * change it: the Schema of the values and the descriptor must agree.
97
+ */
98
+ const snapshot = (
99
+ options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
100
+ ): Options<ReadonlyArray<string>, Schema.Struct.Fields> => ({
101
+ ...options,
102
+ actions: [...options.actions],
103
+ fields: { ...options.fields },
104
+ })
105
+
106
+ const makeDescriptor = (
107
+ kind: string,
108
+ options: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
109
+ ): Descriptor => {
110
+ const refs: Record<string, FieldRef<string>> = Object.fromEntries(
111
+ Arr.map(Object.keys(options.fields), (key) => [
112
+ key,
113
+ { _tag: "Field", field: key },
114
+ ]),
115
+ )
116
+ const struct = Schema.Struct(options.fields)
117
+ return new ResourceDescriptor({
118
+ kind,
119
+ actions: [...options.actions],
120
+ path:
121
+ options.path === undefined
122
+ ? undefined
123
+ : checkPath(kind, options.fields, options.path(refs)),
124
+ catalog: Catalog.catalogOf(struct.ast),
125
+ schema: Schema.TaggedStruct(kind, options.fields),
126
+ })
127
+ }
128
+
129
+ /** Makes the class of `Resource.Class`. */
130
+ export const makeClass =
131
+ (identifier?: string) =>
132
+ (
133
+ kind: string,
134
+ input: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
135
+ ) => {
136
+ const options = snapshot(input)
137
+ checkFields(kind, options)
138
+ const Base = Schema.TaggedClass<any>(identifier)(kind, options.fields)
139
+ const descriptor = lazy(() => makeDescriptor(kind, options))
140
+ return class extends Base {
141
+ static get descriptor() {
142
+ return descriptor()
143
+ }
144
+ static readonly kind = kind
145
+ static readonly actions = [...options.actions]
146
+ }
147
+ }
148
+
149
+ /** Makes a resource of `Resource.make`. */
150
+ export const makeStruct = (
151
+ kind: string,
152
+ input: Options<ReadonlyArray<string>, Schema.Struct.Fields>,
153
+ ) => {
154
+ const options = snapshot(input)
155
+ checkFields(kind, options)
156
+ const schema = Schema.TaggedStruct(kind, options.fields)
157
+ const descriptor = lazy(() => makeDescriptor(kind, options))
158
+ return {
159
+ get descriptor() {
160
+ return descriptor()
161
+ },
162
+ kind,
163
+ actions: [...options.actions],
164
+ schema,
165
+ make: (input: object) => schema.make(input as never),
166
+ }
167
+ }
168
+
169
+ /** Tests if a value is a resource. */
170
+ export const isResource = (u: unknown): u is Any =>
171
+ hasProperty(u, "descriptor") && u.descriptor instanceof ResourceDescriptor
172
+
173
+ /** Gets the runtime data of a resource. */
174
+ export const descriptor = (resource: Any): Descriptor => resource.descriptor
175
+
176
+ /** Gives the kind name of a resource value, from its `_tag` field. */
177
+ export const kindOf = (value: unknown): string | undefined =>
178
+ hasProperty(value, "_tag") && typeof value._tag === "string"
179
+ ? value._tag
180
+ : undefined
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The values of rules, with no types. The types are in `Policy.ts`.
3
+ *
4
+ * Rules do not change, and two rules with the same data are equal. A rule
5
+ * works in a generator: it gives itself, so `yield* _.allow(...)` gives the
6
+ * rule to `Policy.define`.
7
+ */
8
+ import * as Arr from "effect/Array"
9
+ import * as Data from "effect/Data"
10
+ import * as Condition from "../Condition.ts"
11
+ import type * as Domain from "../Domain.ts"
12
+ import type * as Resource from "../Resource.ts"
13
+ import * as Catalog from "./catalog.ts"
14
+ import * as Nodes from "./nodes.ts"
15
+ import * as ResourceData from "./resource.ts"
16
+ import * as Patterns from "./patterns.ts"
17
+
18
+ /** The data of a rule. */
19
+ export interface Shape {
20
+ readonly _tag: "Allow" | "Deny"
21
+ readonly actions: ReadonlyArray<string>
22
+ readonly resources: ReadonlyArray<string>
23
+ readonly when?: Condition.Ast
24
+ readonly fields?: ReadonlyArray<string>
25
+ readonly reason?: string
26
+ readonly id?: string
27
+ readonly meta?: unknown
28
+ /**
29
+ * The domain of the builder that made the rule. Two domains may have the
30
+ * same types but other paths, so the types can not tell them apart. It is
31
+ * not in the JSON form of a rule.
32
+ */
33
+ readonly domain?: Domain.Any
34
+ }
35
+
36
+ /** The class of every rule value. */
37
+ export class Rule extends Data.Class<Shape> {
38
+ *[Symbol.iterator](): Generator<this, this, unknown> {
39
+ yield this
40
+ return this
41
+ }
42
+ }
43
+
44
+ /** Makes a rule from its data. It does not check the data against a domain. */
45
+ export const make = (data: Shape): Rule =>
46
+ // A missing option is left out, so two rules with the same data are equal.
47
+ new Rule({
48
+ _tag: data._tag,
49
+ actions: [...data.actions],
50
+ resources: [...data.resources],
51
+ ...(data.when === undefined ? {} : { when: data.when }),
52
+ ...(data.fields === undefined ? {} : { fields: [...data.fields] }),
53
+ ...(data.reason === undefined ? {} : { reason: data.reason }),
54
+ ...(data.id === undefined ? {} : { id: data.id }),
55
+ ...(data.meta === undefined ? {} : { meta: data.meta }),
56
+ ...(data.domain === undefined ? {} : { domain: data.domain }),
57
+ })
58
+
59
+ /**
60
+ * Makes a rule from its data, as `Policy.rule` does. A condition with a
61
+ * predicate is a defect: the types of the predicate tie it to the kinds of
62
+ * the rule of a builder, and the binding can not check it.
63
+ */
64
+ export const makePlain = (data: Shape): Rule => {
65
+ if (data.when !== undefined && Nodes.isNode(data.when) && data.when.opaque) {
66
+ throw new Error(
67
+ "Policy.rule takes no condition with a predicate. Make the rule with Policy.builder.",
68
+ )
69
+ }
70
+ return make(data)
71
+ }
72
+
73
+ /** Tests if a value is a rule. */
74
+ export const isRule = (u: unknown): u is Rule => u instanceof Rule
75
+
76
+ const toArray = <A>(value: A | ReadonlyArray<A>): ReadonlyArray<A> =>
77
+ Array.isArray(value) ? value : [value as A]
78
+
79
+ const kindName = (target: unknown): string =>
80
+ ResourceData.isResource(target)
81
+ ? ResourceData.descriptor(target).kind
82
+ : (target as string)
83
+
84
+ /** The options of a rule from a builder, with no types. */
85
+ interface Options {
86
+ readonly when?: (builder: never) => Condition.Ast
87
+ readonly fields?: ReadonlyArray<string>
88
+ readonly reason?: string
89
+ readonly id?: string
90
+ readonly meta?: unknown
91
+ }
92
+
93
+ const fromOptions =
94
+ (tag: "Allow" | "Deny", domain: Domain.Any) =>
95
+ (
96
+ action: string | ReadonlyArray<string>,
97
+ resource: unknown,
98
+ options?: Options,
99
+ ): Rule =>
100
+ make({
101
+ _tag: tag,
102
+ actions: toArray(action),
103
+ resources: Arr.map(toArray(resource), kindName),
104
+ ...(options?.when === undefined
105
+ ? {}
106
+ : { when: options.when(Condition.builder() as never) }),
107
+ ...(options?.fields === undefined ? {} : { fields: options.fields }),
108
+ ...(options?.reason === undefined ? {} : { reason: options.reason }),
109
+ ...(options?.id === undefined ? {} : { id: options.id }),
110
+ ...(options?.meta === undefined ? {} : { meta: options.meta }),
111
+ domain,
112
+ })
113
+
114
+ /** Makes the rule builder of a domain. */
115
+ export const builder = (domain: Domain.Any) => ({
116
+ allow: fromOptions("Allow", domain),
117
+ deny: fromOptions("Deny", domain),
118
+ })
119
+
120
+ /**
121
+ * Tests if a field name matches a field name or a field pattern of a rule. A
122
+ * pattern matches the fields below a field that it matches too, so `settings`
123
+ * matches `settings.model`.
124
+ */
125
+ export const matchesField = (pattern: string, field: string): boolean =>
126
+ Patterns.matches(Patterns.parseBound(pattern), field.split("."))
127
+
128
+ /**
129
+ * Tests if a field pattern may match a field inside `field`, such as
130
+ * `settings.secret` inside `settings`.
131
+ */
132
+ export const matchesInside = (pattern: string, field: string): boolean =>
133
+ Patterns.matchesBelow(Patterns.parseBound(pattern), field.split("."))
134
+
135
+ /**
136
+ * Tests the parts of a rule that do not need the resource value: the action,
137
+ * the kind and the field.
138
+ *
139
+ * A rule with an empty list of fields covers no request. A rule with fields
140
+ * covers a request with no field only when it is an allow rule. So "may
141
+ * update some fields" is a yes. A deny rule for some fields does not stop a
142
+ * request for the whole value. With the `descriptor` of the kind, such an
143
+ * allow rule covers the request only when one of its patterns matches a field
144
+ * of the kind, and a deny rule for a part of a field that holds parts covers
145
+ * a request for the whole field. A grant of fields that the kind does not have is not a grant of
146
+ * some fields.
147
+ */
148
+ export const matchesTarget = (
149
+ rule: Shape,
150
+ target: {
151
+ readonly action: string
152
+ readonly kind: string
153
+ readonly field?: string | undefined
154
+ readonly descriptor?: Resource.Descriptor | undefined
155
+ },
156
+ ): boolean => {
157
+ if (!rule.actions.includes("*") && !rule.actions.includes(target.action)) {
158
+ return false
159
+ }
160
+ if (!rule.resources.includes("*") && !rule.resources.includes(target.kind)) {
161
+ return false
162
+ }
163
+ if (rule.fields === undefined) {
164
+ return true
165
+ }
166
+ // A rule for no fields is a rule for nothing. A grant with its last field
167
+ // taken away must not cover the whole value.
168
+ if (rule.fields.length === 0) {
169
+ return false
170
+ }
171
+ if (target.field === undefined) {
172
+ const descriptor = target.descriptor
173
+ return (
174
+ rule._tag === "Allow" &&
175
+ (descriptor === undefined ||
176
+ Arr.some(rule.fields, (pattern) =>
177
+ Patterns.fitsCatalog(
178
+ Patterns.parseBound(pattern),
179
+ descriptor.catalog,
180
+ ),
181
+ ))
182
+ )
183
+ }
184
+ const field = target.field
185
+ // A field that holds parts, as a record does, shows its parts too. So a
186
+ // deny rule for a part inside it, as `data.secret` inside `data`, also
187
+ // covers a request for the whole field.
188
+ const open =
189
+ rule._tag === "Deny" &&
190
+ target.descriptor !== undefined &&
191
+ (Catalog.lookup(target.descriptor.catalog, field)?.open ?? false)
192
+ return rule.fields.some(
193
+ (pattern) =>
194
+ matchesField(pattern, field) || (open && matchesInside(pattern, field)),
195
+ )
196
+ }