@nmnmcc/ability 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -69
- package/dist/Authorizer.d.ts +43 -47
- package/dist/Authorizer.d.ts.map +1 -1
- package/dist/Authorizer.js +5 -290
- package/dist/Authorizer.js.map +1 -1
- package/dist/Condition.d.ts +164 -332
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +75 -890
- package/dist/Condition.js.map +1 -1
- package/dist/Decision.d.ts +13 -13
- package/dist/Decision.d.ts.map +1 -1
- package/dist/Decision.js.map +1 -1
- package/dist/Domain.d.ts +10 -21
- package/dist/Domain.d.ts.map +1 -1
- package/dist/Domain.js +4 -62
- package/dist/Domain.js.map +1 -1
- package/dist/HttpApi.d.ts +31 -7
- package/dist/HttpApi.d.ts.map +1 -1
- package/dist/HttpApi.js +25 -8
- package/dist/HttpApi.js.map +1 -1
- package/dist/Limits.d.ts +114 -0
- package/dist/Limits.d.ts.map +1 -0
- package/dist/Limits.js +132 -0
- package/dist/Limits.js.map +1 -0
- package/dist/Policy.d.ts +271 -269
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +68 -1177
- package/dist/Policy.js.map +1 -1
- package/dist/Resource.d.ts +4 -177
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +4 -803
- package/dist/Resource.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +4 -5
- package/dist/internal/algebra.js +267 -0
- package/dist/internal/algebra.js.map +1 -0
- package/dist/internal/analysis.js +245 -0
- package/dist/internal/analysis.js.map +1 -0
- package/dist/internal/authorizer.d.ts +30 -0
- package/dist/internal/authorizer.d.ts.map +1 -0
- package/dist/internal/authorizer.js +221 -0
- package/dist/internal/authorizer.js.map +1 -0
- package/dist/internal/bind.js +286 -0
- package/dist/internal/bind.js.map +1 -0
- package/dist/internal/catalog.d.ts +56 -0
- package/dist/internal/catalog.d.ts.map +1 -0
- package/dist/internal/catalog.js +292 -0
- package/dist/internal/catalog.js.map +1 -0
- package/dist/internal/decide.js +166 -0
- package/dist/internal/decide.js.map +1 -0
- package/dist/internal/domain.js +32 -0
- package/dist/internal/domain.js.map +1 -0
- package/dist/internal/json.js +134 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/limits.d.ts +14 -0
- package/dist/internal/limits.d.ts.map +1 -0
- package/dist/internal/limits.js +43 -0
- package/dist/internal/limits.js.map +1 -0
- package/dist/internal/nodes.d.ts +121 -0
- package/dist/internal/nodes.d.ts.map +1 -0
- package/dist/internal/nodes.js +277 -0
- package/dist/internal/nodes.js.map +1 -0
- package/dist/internal/patterns.js +110 -0
- package/dist/internal/patterns.js.map +1 -0
- package/dist/internal/policy-json.js +255 -0
- package/dist/internal/policy-json.js.map +1 -0
- package/dist/internal/policy-nodes.d.ts +61 -0
- package/dist/internal/policy-nodes.d.ts.map +1 -0
- package/dist/internal/policy-nodes.js +146 -0
- package/dist/internal/policy-nodes.js.map +1 -0
- package/dist/internal/policy.d.ts +42 -0
- package/dist/internal/policy.d.ts.map +1 -0
- package/dist/internal/policy.js +124 -0
- package/dist/internal/policy.js.map +1 -0
- package/dist/internal/read.js +39 -0
- package/dist/internal/read.js.map +1 -0
- package/dist/internal/resource.js +115 -0
- package/dist/internal/resource.js.map +1 -0
- package/dist/internal/rule.d.ts +26 -0
- package/dist/internal/rule.d.ts.map +1 -0
- package/dist/internal/rule.js +93 -0
- package/dist/internal/rule.js.map +1 -0
- package/dist/internal/search.d.ts +22 -0
- package/dist/internal/search.d.ts.map +1 -0
- package/dist/internal/search.js +274 -0
- package/dist/internal/search.js.map +1 -0
- package/dist/internal/suggest.js +46 -0
- package/dist/internal/suggest.js.map +1 -0
- package/package.json +3 -4
- package/src/Authorizer.ts +67 -583
- package/src/Condition.ts +339 -2095
- package/src/Decision.ts +6 -5
- package/src/Domain.ts +12 -69
- package/src/HttpApi.ts +72 -10
- package/src/Limits.ts +132 -0
- package/src/Policy.ts +386 -2772
- package/src/Resource.ts +7 -1548
- package/src/index.ts +3 -9
- package/src/internal/algebra.ts +482 -0
- package/src/internal/analysis.ts +478 -0
- package/src/internal/authorizer.ts +418 -0
- package/src/internal/bind.ts +596 -0
- package/src/internal/catalog.ts +534 -0
- package/src/internal/decide.ts +402 -0
- package/src/internal/domain.ts +66 -0
- package/src/internal/json.ts +260 -0
- package/src/internal/limits.ts +78 -0
- package/src/internal/nodes.ts +404 -0
- package/src/internal/patterns.ts +211 -0
- package/src/internal/policy-json.ts +484 -0
- package/src/internal/policy-nodes.ts +207 -0
- package/src/internal/policy.ts +264 -0
- package/src/internal/read.ts +55 -0
- package/src/internal/resource.ts +180 -0
- package/src/internal/rule.ts +181 -0
- package/src/internal/search.ts +541 -0
- package/src/internal/suggest.ts +104 -0
- package/dist/Principal.d.ts +0 -35
- package/dist/Principal.d.ts.map +0 -1
- package/dist/Principal.js +0 -37
- package/dist/Principal.js.map +0 -1
- package/dist/Rule.d.ts +0 -275
- package/dist/Rule.d.ts.map +0 -1
- package/dist/Rule.js +0 -257
- package/dist/Rule.js.map +0 -1
- package/src/Principal.ts +0 -41
- package/src/Rule.ts +0 -683
|
@@ -0,0 +1,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,181 @@
|
|
|
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 ResourceData from "./resource.ts"
|
|
15
|
+
import * as Patterns from "./patterns.ts"
|
|
16
|
+
|
|
17
|
+
/** The data of a rule. */
|
|
18
|
+
export interface Shape {
|
|
19
|
+
readonly _tag: "Allow" | "Deny"
|
|
20
|
+
readonly actions: ReadonlyArray<string>
|
|
21
|
+
readonly resources: ReadonlyArray<string>
|
|
22
|
+
readonly when?: Condition.Ast
|
|
23
|
+
readonly fields?: ReadonlyArray<string>
|
|
24
|
+
readonly reason?: string
|
|
25
|
+
readonly id?: string
|
|
26
|
+
readonly meta?: unknown
|
|
27
|
+
/**
|
|
28
|
+
* The domain of the builder that made the rule. Two domains may have the
|
|
29
|
+
* same types but other paths, so the types can not tell them apart. It is
|
|
30
|
+
* not in the JSON form of a rule.
|
|
31
|
+
*/
|
|
32
|
+
readonly domain?: Domain.Any
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The class of every rule value. */
|
|
36
|
+
export class Rule extends Data.Class<Shape> {
|
|
37
|
+
*[Symbol.iterator](): Generator<this, this, unknown> {
|
|
38
|
+
yield this
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Makes a rule from its data. It does not check the data against a domain. */
|
|
44
|
+
export const make = (data: Shape): Rule =>
|
|
45
|
+
// A missing option is left out, so two rules with the same data are equal.
|
|
46
|
+
new Rule({
|
|
47
|
+
_tag: data._tag,
|
|
48
|
+
actions: [...data.actions],
|
|
49
|
+
resources: [...data.resources],
|
|
50
|
+
...(data.when === undefined ? {} : { when: data.when }),
|
|
51
|
+
...(data.fields === undefined ? {} : { fields: [...data.fields] }),
|
|
52
|
+
...(data.reason === undefined ? {} : { reason: data.reason }),
|
|
53
|
+
...(data.id === undefined ? {} : { id: data.id }),
|
|
54
|
+
...(data.meta === undefined ? {} : { meta: data.meta }),
|
|
55
|
+
...(data.domain === undefined ? {} : { domain: data.domain }),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** Tests if a value is a rule. */
|
|
59
|
+
export const isRule = (u: unknown): u is Rule => u instanceof Rule
|
|
60
|
+
|
|
61
|
+
const toArray = <A>(value: A | ReadonlyArray<A>): ReadonlyArray<A> =>
|
|
62
|
+
Array.isArray(value) ? value : [value as A]
|
|
63
|
+
|
|
64
|
+
const kindName = (target: unknown): string =>
|
|
65
|
+
ResourceData.isResource(target)
|
|
66
|
+
? ResourceData.descriptor(target).kind
|
|
67
|
+
: (target as string)
|
|
68
|
+
|
|
69
|
+
/** The options of a rule from a builder, with no types. */
|
|
70
|
+
interface Options {
|
|
71
|
+
readonly when?: (builder: never) => Condition.Ast
|
|
72
|
+
readonly fields?: ReadonlyArray<string>
|
|
73
|
+
readonly reason?: string
|
|
74
|
+
readonly id?: string
|
|
75
|
+
readonly meta?: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fromOptions =
|
|
79
|
+
(tag: "Allow" | "Deny", domain: Domain.Any) =>
|
|
80
|
+
(
|
|
81
|
+
action: string | ReadonlyArray<string>,
|
|
82
|
+
resource: unknown,
|
|
83
|
+
options?: Options,
|
|
84
|
+
): Rule =>
|
|
85
|
+
make({
|
|
86
|
+
_tag: tag,
|
|
87
|
+
actions: toArray(action),
|
|
88
|
+
resources: Arr.map(toArray(resource), kindName),
|
|
89
|
+
...(options?.when === undefined
|
|
90
|
+
? {}
|
|
91
|
+
: { when: options.when(Condition.builder() as never) }),
|
|
92
|
+
...(options?.fields === undefined ? {} : { fields: options.fields }),
|
|
93
|
+
...(options?.reason === undefined ? {} : { reason: options.reason }),
|
|
94
|
+
...(options?.id === undefined ? {} : { id: options.id }),
|
|
95
|
+
...(options?.meta === undefined ? {} : { meta: options.meta }),
|
|
96
|
+
domain,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/** Makes the rule builder of a domain. */
|
|
100
|
+
export const builder = (domain: Domain.Any) => ({
|
|
101
|
+
allow: fromOptions("Allow", domain),
|
|
102
|
+
deny: fromOptions("Deny", domain),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tests if a field name matches a field name or a field pattern of a rule. A
|
|
107
|
+
* pattern matches the fields below a field that it matches too, so `settings`
|
|
108
|
+
* matches `settings.model`.
|
|
109
|
+
*/
|
|
110
|
+
export const matchesField = (pattern: string, field: string): boolean =>
|
|
111
|
+
Patterns.matches(Patterns.parseBound(pattern), field.split("."))
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tests if a field pattern may match a field inside `field`, such as
|
|
115
|
+
* `settings.secret` inside `settings`.
|
|
116
|
+
*/
|
|
117
|
+
export const matchesInside = (pattern: string, field: string): boolean =>
|
|
118
|
+
Patterns.matchesBelow(Patterns.parseBound(pattern), field.split("."))
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tests the parts of a rule that do not need the resource value: the action,
|
|
122
|
+
* the kind and the field.
|
|
123
|
+
*
|
|
124
|
+
* A rule with an empty list of fields covers no request. A rule with fields
|
|
125
|
+
* covers a request with no field only when it is an allow rule. So "may
|
|
126
|
+
* update some fields" is a yes. A deny rule for some fields does not stop a
|
|
127
|
+
* request for the whole value. With the `descriptor` of the kind, such an
|
|
128
|
+
* allow rule covers the request only when one of its patterns matches a field
|
|
129
|
+
* of the kind, and a deny rule for a part of a field that holds parts covers
|
|
130
|
+
* a request for the whole field. A grant of fields that the kind does not have is not a grant of
|
|
131
|
+
* some fields.
|
|
132
|
+
*/
|
|
133
|
+
export const matchesTarget = (
|
|
134
|
+
rule: Shape,
|
|
135
|
+
target: {
|
|
136
|
+
readonly action: string
|
|
137
|
+
readonly kind: string
|
|
138
|
+
readonly field?: string | undefined
|
|
139
|
+
readonly descriptor?: Resource.Descriptor | undefined
|
|
140
|
+
},
|
|
141
|
+
): boolean => {
|
|
142
|
+
if (!rule.actions.includes("*") && !rule.actions.includes(target.action)) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
if (!rule.resources.includes("*") && !rule.resources.includes(target.kind)) {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
if (rule.fields === undefined) {
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
// A rule for no fields is a rule for nothing. A grant with its last field
|
|
152
|
+
// taken away must not cover the whole value.
|
|
153
|
+
if (rule.fields.length === 0) {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
if (target.field === undefined) {
|
|
157
|
+
const descriptor = target.descriptor
|
|
158
|
+
return (
|
|
159
|
+
rule._tag === "Allow" &&
|
|
160
|
+
(descriptor === undefined ||
|
|
161
|
+
Arr.some(rule.fields, (pattern) =>
|
|
162
|
+
Patterns.fitsCatalog(
|
|
163
|
+
Patterns.parseBound(pattern),
|
|
164
|
+
descriptor.catalog,
|
|
165
|
+
),
|
|
166
|
+
))
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
const field = target.field
|
|
170
|
+
// A field that holds parts, as a record does, shows its parts too. So a
|
|
171
|
+
// deny rule for a part inside it, as `data.secret` inside `data`, also
|
|
172
|
+
// covers a request for the whole field.
|
|
173
|
+
const open =
|
|
174
|
+
rule._tag === "Deny" &&
|
|
175
|
+
target.descriptor !== undefined &&
|
|
176
|
+
(Catalog.lookup(target.descriptor.catalog, field)?.open ?? false)
|
|
177
|
+
return rule.fields.some(
|
|
178
|
+
(pattern) =>
|
|
179
|
+
matchesField(pattern, field) || (open && matchesInside(pattern, field)),
|
|
180
|
+
)
|
|
181
|
+
}
|