@nmnmcc/ability 0.1.0 → 0.3.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 +353 -170
- package/dist/Authorizer.d.ts +216 -0
- package/dist/Authorizer.d.ts.map +1 -0
- package/dist/Authorizer.js +55 -0
- package/dist/Authorizer.js.map +1 -0
- package/dist/Condition.d.ts +536 -0
- package/dist/Condition.d.ts.map +1 -0
- package/dist/Condition.js +141 -0
- package/dist/Condition.js.map +1 -0
- package/dist/Decision.d.ts +131 -0
- package/dist/Decision.d.ts.map +1 -0
- package/dist/Decision.js +83 -0
- package/dist/Decision.js.map +1 -0
- package/dist/Domain.d.ts +148 -0
- package/dist/Domain.d.ts.map +1 -0
- package/dist/Domain.js +34 -0
- package/dist/Domain.js.map +1 -0
- package/dist/HttpApi.d.ts +129 -0
- package/dist/HttpApi.d.ts.map +1 -0
- package/dist/HttpApi.js +95 -0
- package/dist/HttpApi.js.map +1 -0
- package/dist/Policy.d.ts +429 -0
- package/dist/Policy.d.ts.map +1 -0
- package/dist/Policy.js +162 -0
- package/dist/Policy.js.map +1 -0
- package/dist/Principal.d.ts +35 -0
- package/dist/Principal.d.ts.map +1 -0
- package/dist/Principal.js +37 -0
- package/dist/Principal.js.map +1 -0
- package/dist/Resource.d.ts +213 -0
- package/dist/Resource.d.ts.map +1 -0
- package/dist/Resource.js +71 -0
- package/dist/Resource.js.map +1 -0
- package/dist/Rule.d.ts +189 -0
- package/dist/Rule.d.ts.map +1 -0
- package/dist/Rule.js +50 -0
- package/dist/Rule.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +1 -2
- package/dist/index.d.ts +9 -4
- package/dist/index.js +9 -4
- package/dist/internal/authorizer.d.ts +28 -0
- package/dist/internal/authorizer.d.ts.map +1 -0
- package/dist/internal/authorizer.js +153 -0
- package/dist/internal/authorizer.js.map +1 -0
- package/dist/internal/condition.d.ts +11 -0
- package/dist/internal/condition.d.ts.map +1 -0
- package/dist/internal/condition.js +393 -0
- package/dist/internal/condition.js.map +1 -0
- package/dist/internal/domain.js +34 -0
- package/dist/internal/domain.js.map +1 -0
- package/dist/internal/httpApi.js +41 -0
- package/dist/internal/httpApi.js.map +1 -0
- package/dist/internal/policy.js +524 -0
- package/dist/internal/policy.js.map +1 -0
- package/dist/internal/resource.js +66 -0
- package/dist/internal/resource.js.map +1 -0
- package/dist/internal/rule.js +131 -0
- package/dist/internal/rule.js.map +1 -0
- package/package.json +61 -74
- package/src/Authorizer.ts +330 -0
- package/src/Condition.ts +706 -0
- package/src/Decision.ts +117 -0
- package/src/Domain.ts +189 -0
- package/src/HttpApi.ts +148 -0
- package/src/Policy.ts +575 -0
- package/src/Principal.ts +41 -0
- package/src/Resource.ts +290 -0
- package/src/Rule.ts +265 -0
- package/src/index.ts +57 -0
- package/src/internal/authorizer.ts +271 -0
- package/src/internal/condition.ts +607 -0
- package/src/internal/domain.ts +56 -0
- package/src/internal/httpApi.ts +84 -0
- package/src/internal/policy.ts +815 -0
- package/src/internal/resource.ts +98 -0
- package/src/internal/rule.ts +203 -0
- package/dist/Ability.d.ts +0 -516
- package/dist/Ability.js +0 -211
- package/dist/AbilityExtra.d.ts +0 -79
- package/dist/AbilityExtra.js +0 -195
- package/dist/AbilityRef.d.ts +0 -110
- package/dist/AbilityRef.js +0 -160
- package/dist/internal/ability.d.ts +0 -114
- package/dist/internal/ability.js +0 -624
- package/dist/internal/serializable.js +0 -22
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
// A policy keeps the error type and the service type of its predicates only in
|
|
2
|
+
// the public types. Inside this file they are erased to `unknown`, so the
|
|
3
|
+
// language service check for `unknown` in an Effect is off here.
|
|
4
|
+
// @effect-diagnostics anyUnknownInErrorContext:off
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Equal from "effect/Equal"
|
|
7
|
+
import * as Formatter from "effect/Formatter"
|
|
8
|
+
import { dual } from "effect/Function"
|
|
9
|
+
import * as Hash from "effect/Hash"
|
|
10
|
+
import * as Inspectable from "effect/Inspectable"
|
|
11
|
+
import * as Pipeable from "effect/Pipeable"
|
|
12
|
+
import * as Predicate from "effect/Predicate"
|
|
13
|
+
import * as Schema from "effect/Schema"
|
|
14
|
+
import * as SchemaTransformation from "effect/SchemaTransformation"
|
|
15
|
+
import type * as Condition from "../Condition.ts"
|
|
16
|
+
import * as Decision from "../Decision.ts"
|
|
17
|
+
import type * as Domain from "../Domain.ts"
|
|
18
|
+
import type * as Policy from "../Policy.ts"
|
|
19
|
+
import type * as Rule from "../Rule.ts"
|
|
20
|
+
import * as condition from "./condition.ts"
|
|
21
|
+
import { getDescriptor, kindOf } from "./domain.ts"
|
|
22
|
+
import * as rule from "./rule.ts"
|
|
23
|
+
|
|
24
|
+
/** @internal */
|
|
25
|
+
export const TypeId = "~@nmnmcc/ability/Policy" as const
|
|
26
|
+
|
|
27
|
+
type Node = Policy.Node
|
|
28
|
+
type Ast = Condition.Ast
|
|
29
|
+
|
|
30
|
+
interface PolicyData {
|
|
31
|
+
readonly domain: Domain.Any
|
|
32
|
+
readonly node: Node
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @internal */
|
|
36
|
+
export const encodeNode = (node: Node): Policy.EncodedNode => {
|
|
37
|
+
switch (node._tag) {
|
|
38
|
+
case "Rules":
|
|
39
|
+
return {
|
|
40
|
+
_tag: "Rules",
|
|
41
|
+
algorithm: node.algorithm,
|
|
42
|
+
rules: node.rules.map(
|
|
43
|
+
(value) => rule.toData(value) as Policy.EncodedRule,
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
case "All":
|
|
47
|
+
case "Any":
|
|
48
|
+
case "FirstOf":
|
|
49
|
+
return { _tag: node._tag, policies: node.policies.map(encodeNode) }
|
|
50
|
+
case "Ceiling":
|
|
51
|
+
return {
|
|
52
|
+
_tag: "Ceiling",
|
|
53
|
+
cap: encodeNode(node.cap),
|
|
54
|
+
policy: encodeNode(node.policy),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const Proto = {
|
|
60
|
+
...Pipeable.Prototype,
|
|
61
|
+
[TypeId]: TypeId,
|
|
62
|
+
[Equal.symbol](this: PolicyData, that: unknown): boolean {
|
|
63
|
+
return (
|
|
64
|
+
isPolicy(that) &&
|
|
65
|
+
this.domain === (that as unknown as PolicyData).domain &&
|
|
66
|
+
Equal.equals(this.node, (that as unknown as PolicyData).node)
|
|
67
|
+
)
|
|
68
|
+
},
|
|
69
|
+
[Hash.symbol](this: PolicyData): number {
|
|
70
|
+
return Hash.hash(this.node)
|
|
71
|
+
},
|
|
72
|
+
toJSON(this: PolicyData) {
|
|
73
|
+
return encodeNode(this.node)
|
|
74
|
+
},
|
|
75
|
+
toString(this: PolicyData) {
|
|
76
|
+
return Formatter.format(encodeNode(this.node), { space: 2 })
|
|
77
|
+
},
|
|
78
|
+
[Inspectable.NodeInspectSymbol](this: PolicyData) {
|
|
79
|
+
return encodeNode(this.node)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @internal */
|
|
84
|
+
export const isPolicy = (u: unknown): u is Policy.Policy<Domain.Any> =>
|
|
85
|
+
Predicate.hasProperty(u, TypeId)
|
|
86
|
+
|
|
87
|
+
/** @internal */
|
|
88
|
+
export const fromNode = (domain: Domain.Any, node: Node): any => {
|
|
89
|
+
const self = Object.create(Proto) as { domain: Domain.Any; node: Node }
|
|
90
|
+
self.domain = domain
|
|
91
|
+
self.node = node
|
|
92
|
+
return Object.freeze(self)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const nodeOf = (policy: unknown): Node => (policy as PolicyData).node
|
|
96
|
+
const domainOf = (policy: unknown): Domain.Any => (policy as PolicyData).domain
|
|
97
|
+
|
|
98
|
+
/** @internal */
|
|
99
|
+
export const rules = (
|
|
100
|
+
domain: Domain.Any,
|
|
101
|
+
values: Iterable<Rule.Any>,
|
|
102
|
+
options?: { readonly algorithm?: Policy.Algorithm },
|
|
103
|
+
): any =>
|
|
104
|
+
fromNode(
|
|
105
|
+
domain,
|
|
106
|
+
Object.freeze({
|
|
107
|
+
_tag: "Rules",
|
|
108
|
+
algorithm: options?.algorithm ?? "denyOverrides",
|
|
109
|
+
rules: Object.freeze(Array.from(values)),
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
/** @internal */
|
|
114
|
+
export const empty = (domain: Domain.Any): any => rules(domain, [])
|
|
115
|
+
|
|
116
|
+
/** @internal */
|
|
117
|
+
export const define =
|
|
118
|
+
(domain: Domain.Any, options?: { readonly algorithm?: Policy.Algorithm }) =>
|
|
119
|
+
(body: (builder: unknown) => Generator<Rule.Any, unknown, unknown>): any => {
|
|
120
|
+
const collected: Array<Rule.Any> = []
|
|
121
|
+
const iterator = body(rule.builder(domain))
|
|
122
|
+
let state = iterator.next()
|
|
123
|
+
while (state.done !== true) {
|
|
124
|
+
collected.push(state.value)
|
|
125
|
+
state = iterator.next(state.value)
|
|
126
|
+
}
|
|
127
|
+
return rules(domain, collected, options)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const combine =
|
|
131
|
+
(tag: "All" | "Any" | "FirstOf") =>
|
|
132
|
+
(policies: ReadonlyArray<unknown>): any => {
|
|
133
|
+
const domain = domainOf(policies[0])
|
|
134
|
+
for (const policy of policies) {
|
|
135
|
+
if (domainOf(policy) !== domain) {
|
|
136
|
+
throw new Error("Policies from two domains can not be put together")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return fromNode(
|
|
140
|
+
domain,
|
|
141
|
+
Object.freeze({
|
|
142
|
+
_tag: tag,
|
|
143
|
+
policies: Object.freeze(policies.map(nodeOf)),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @internal */
|
|
149
|
+
export const all = combine("All")
|
|
150
|
+
/** @internal */
|
|
151
|
+
export const any = combine("Any")
|
|
152
|
+
/** @internal */
|
|
153
|
+
export const firstOf = combine("FirstOf")
|
|
154
|
+
|
|
155
|
+
/** @internal */
|
|
156
|
+
export const ceiling = dual(2, (policy: unknown, cap: unknown): any => {
|
|
157
|
+
if (domainOf(policy) !== domainOf(cap)) {
|
|
158
|
+
throw new Error("Policies from two domains can not be put together")
|
|
159
|
+
}
|
|
160
|
+
return fromNode(
|
|
161
|
+
domainOf(policy),
|
|
162
|
+
Object.freeze({
|
|
163
|
+
_tag: "Ceiling",
|
|
164
|
+
cap: nodeOf(cap),
|
|
165
|
+
policy: nodeOf(policy),
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Decisions
|
|
171
|
+
|
|
172
|
+
interface Context {
|
|
173
|
+
readonly principal: object
|
|
174
|
+
readonly action: string
|
|
175
|
+
readonly kind: string
|
|
176
|
+
readonly value: object
|
|
177
|
+
readonly field: string | undefined
|
|
178
|
+
readonly input: Condition.Input
|
|
179
|
+
readonly trace: Array<Policy.Step> | undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const ruleApplies = (
|
|
183
|
+
value: Rule.Any,
|
|
184
|
+
context: Context,
|
|
185
|
+
path: ReadonlyArray<number>,
|
|
186
|
+
): Effect.Effect<boolean, unknown, unknown> => {
|
|
187
|
+
if (!rule.matchesTarget(value, context.action, context.kind, context.field)) {
|
|
188
|
+
return Effect.succeed(false)
|
|
189
|
+
}
|
|
190
|
+
const test =
|
|
191
|
+
value.when === undefined
|
|
192
|
+
? Effect.succeed(true)
|
|
193
|
+
: condition.evaluateOf(value.when, context.input)
|
|
194
|
+
return context.trace === undefined
|
|
195
|
+
? test
|
|
196
|
+
: Effect.tap(test, (applies) =>
|
|
197
|
+
Effect.sync(() => {
|
|
198
|
+
context.trace!.push({ path, rule: value, applies })
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fromRule = (value: Rule.Any): Decision.Decision =>
|
|
204
|
+
value._tag === "Allow"
|
|
205
|
+
? Decision.Allow({ rule: value })
|
|
206
|
+
: value.reason === undefined
|
|
207
|
+
? Decision.Deny({ rule: value })
|
|
208
|
+
: Decision.Deny({ rule: value, reason: value.reason })
|
|
209
|
+
|
|
210
|
+
const firstApplying = Effect.fnUntraced(function* (
|
|
211
|
+
candidates: ReadonlyArray<readonly [Rule.Any, number]>,
|
|
212
|
+
context: Context,
|
|
213
|
+
path: ReadonlyArray<number>,
|
|
214
|
+
): Effect.fn.Return<Rule.Any | undefined, unknown, unknown> {
|
|
215
|
+
for (const [value, index] of candidates) {
|
|
216
|
+
if (yield* ruleApplies(value, context, [...path, index])) {
|
|
217
|
+
return value
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return undefined
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const decideRules = Effect.fnUntraced(function* (
|
|
224
|
+
node: Policy.RulesNode,
|
|
225
|
+
context: Context,
|
|
226
|
+
path: ReadonlyArray<number>,
|
|
227
|
+
): Effect.fn.Return<Decision.Decision, unknown, unknown> {
|
|
228
|
+
const indexed = node.rules.map((value, index) => [value, index] as const)
|
|
229
|
+
const denies = indexed.filter(([value]) => value._tag === "Deny")
|
|
230
|
+
const allows = indexed.filter(([value]) => value._tag === "Allow")
|
|
231
|
+
let found: Rule.Any | undefined
|
|
232
|
+
switch (node.algorithm) {
|
|
233
|
+
case "denyOverrides":
|
|
234
|
+
found =
|
|
235
|
+
(yield* firstApplying(denies, context, path)) ??
|
|
236
|
+
(yield* firstApplying(allows, context, path))
|
|
237
|
+
break
|
|
238
|
+
case "permitOverrides":
|
|
239
|
+
found =
|
|
240
|
+
(yield* firstApplying(allows, context, path)) ??
|
|
241
|
+
(yield* firstApplying(denies, context, path))
|
|
242
|
+
break
|
|
243
|
+
case "firstApplicable":
|
|
244
|
+
found = yield* firstApplying(indexed, context, path)
|
|
245
|
+
break
|
|
246
|
+
case "lastApplicable":
|
|
247
|
+
found = yield* firstApplying(indexed.slice().reverse(), context, path)
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
return found === undefined ? Decision.NotApplicable : fromRule(found)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const decideNode = (
|
|
254
|
+
node: Node,
|
|
255
|
+
context: Context,
|
|
256
|
+
path: ReadonlyArray<number>,
|
|
257
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
258
|
+
switch (node._tag) {
|
|
259
|
+
case "Rules":
|
|
260
|
+
return decideRules(node, context, path)
|
|
261
|
+
case "All":
|
|
262
|
+
return Effect.gen(function* () {
|
|
263
|
+
let firstAllow: Decision.Decision | undefined
|
|
264
|
+
let allAllow = node.policies.length > 0
|
|
265
|
+
for (const [index, child] of node.policies.entries()) {
|
|
266
|
+
const result = yield* decideNode(child, context, [...path, index])
|
|
267
|
+
if (result._tag === "Deny") {
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
if (result._tag === "Allow") {
|
|
271
|
+
firstAllow ??= result
|
|
272
|
+
} else {
|
|
273
|
+
allAllow = false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return allAllow && firstAllow !== undefined
|
|
277
|
+
? firstAllow
|
|
278
|
+
: Decision.NotApplicable
|
|
279
|
+
})
|
|
280
|
+
case "Any":
|
|
281
|
+
return Effect.gen(function* () {
|
|
282
|
+
let firstDeny: Decision.Decision | undefined
|
|
283
|
+
for (const [index, child] of node.policies.entries()) {
|
|
284
|
+
const result = yield* decideNode(child, context, [...path, index])
|
|
285
|
+
if (result._tag === "Allow") {
|
|
286
|
+
return result
|
|
287
|
+
}
|
|
288
|
+
if (result._tag === "Deny") {
|
|
289
|
+
firstDeny ??= result
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return firstDeny ?? Decision.NotApplicable
|
|
293
|
+
})
|
|
294
|
+
case "FirstOf":
|
|
295
|
+
return Effect.gen(function* () {
|
|
296
|
+
for (const [index, child] of node.policies.entries()) {
|
|
297
|
+
const result = yield* decideNode(child, context, [...path, index])
|
|
298
|
+
if (result._tag !== "NotApplicable") {
|
|
299
|
+
return result
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return Decision.NotApplicable
|
|
303
|
+
})
|
|
304
|
+
case "Ceiling":
|
|
305
|
+
return Effect.gen(function* () {
|
|
306
|
+
const capped = yield* decideNode(node.cap, context, [...path, 0])
|
|
307
|
+
if (capped._tag === "Deny") {
|
|
308
|
+
return capped
|
|
309
|
+
}
|
|
310
|
+
return yield* decideNode(node.policy, context, [...path, 1])
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const makeContext = (
|
|
316
|
+
domain: Domain.Any,
|
|
317
|
+
request: Policy.AnyRequest,
|
|
318
|
+
trace: Array<Policy.Step> | undefined,
|
|
319
|
+
): Context | undefined => {
|
|
320
|
+
const kind = kindOf(request.resource)
|
|
321
|
+
const descriptor =
|
|
322
|
+
kind === undefined ? undefined : getDescriptor(domain, kind)
|
|
323
|
+
if (kind === undefined || descriptor === undefined) {
|
|
324
|
+
return undefined
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
principal: request.principal,
|
|
328
|
+
action: request.action,
|
|
329
|
+
kind,
|
|
330
|
+
value: request.resource,
|
|
331
|
+
field: request.field,
|
|
332
|
+
input: {
|
|
333
|
+
value: request.resource,
|
|
334
|
+
principal: request.principal,
|
|
335
|
+
path: descriptor.path,
|
|
336
|
+
},
|
|
337
|
+
trace,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** @internal */
|
|
342
|
+
export const decideOf = (
|
|
343
|
+
policy: unknown,
|
|
344
|
+
request: Policy.AnyRequest,
|
|
345
|
+
): Effect.Effect<Decision.Decision, unknown, unknown> => {
|
|
346
|
+
const context = makeContext(domainOf(policy), request, undefined)
|
|
347
|
+
return context === undefined
|
|
348
|
+
? Effect.succeed(Decision.NotApplicable)
|
|
349
|
+
: decideNode(nodeOf(policy), context, [])
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** @internal */
|
|
353
|
+
export const decide = dual(2, decideOf)
|
|
354
|
+
|
|
355
|
+
/** @internal */
|
|
356
|
+
export const decideSync = dual(
|
|
357
|
+
2,
|
|
358
|
+
(policy: unknown, request: Policy.AnyRequest): Decision.Decision =>
|
|
359
|
+
Effect.runSync(
|
|
360
|
+
// The public type of `decideSync` takes only policies with no
|
|
361
|
+
// predicates, so this Effect has no error and needs no services.
|
|
362
|
+
// @effect-diagnostics-next-line unsafeEffectTypeAssertion:off
|
|
363
|
+
decideOf(policy, request) as Effect.Effect<Decision.Decision>,
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
/** @internal */
|
|
368
|
+
export const explainOf = (
|
|
369
|
+
policy: unknown,
|
|
370
|
+
request: Policy.AnyRequest,
|
|
371
|
+
): Effect.Effect<Policy.Explanation, unknown, unknown> => {
|
|
372
|
+
const trace: Array<Policy.Step> = []
|
|
373
|
+
const context = makeContext(domainOf(policy), request, trace)
|
|
374
|
+
if (context === undefined) {
|
|
375
|
+
return Effect.succeed({ decision: Decision.NotApplicable, steps: [] })
|
|
376
|
+
}
|
|
377
|
+
return Effect.map(decideNode(nodeOf(policy), context, []), (decision) => ({
|
|
378
|
+
decision,
|
|
379
|
+
steps: Object.freeze(trace),
|
|
380
|
+
}))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** @internal */
|
|
384
|
+
export const explain = dual(2, explainOf)
|
|
385
|
+
|
|
386
|
+
/** @internal */
|
|
387
|
+
export const permittedFieldsOf = Effect.fnUntraced(function* (
|
|
388
|
+
policy: unknown,
|
|
389
|
+
request: Omit<Policy.AnyRequest, "field">,
|
|
390
|
+
): Effect.fn.Return<ReadonlyArray<string>, unknown, unknown> {
|
|
391
|
+
const domain = domainOf(policy)
|
|
392
|
+
const kind = kindOf(request.resource)
|
|
393
|
+
const descriptor =
|
|
394
|
+
kind === undefined ? undefined : getDescriptor(domain, kind)
|
|
395
|
+
if (descriptor === undefined) {
|
|
396
|
+
return []
|
|
397
|
+
}
|
|
398
|
+
const output: Array<string> = []
|
|
399
|
+
for (const field of descriptor.fieldPaths) {
|
|
400
|
+
const result: Decision.Decision = yield* decideOf(policy, {
|
|
401
|
+
...request,
|
|
402
|
+
field,
|
|
403
|
+
})
|
|
404
|
+
if (result._tag === "Allow") {
|
|
405
|
+
output.push(field)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return Object.freeze(output)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
/** @internal */
|
|
412
|
+
export const permittedFields = dual(2, permittedFieldsOf)
|
|
413
|
+
|
|
414
|
+
// Residual conditions
|
|
415
|
+
|
|
416
|
+
interface Pair {
|
|
417
|
+
readonly allow: Ast
|
|
418
|
+
readonly deny: Ast
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const { always, never, simplify } = condition
|
|
422
|
+
const and = (...values: ReadonlyArray<Ast>): Ast =>
|
|
423
|
+
simplify(condition.and(...values))
|
|
424
|
+
const or = (...values: ReadonlyArray<Ast>): Ast =>
|
|
425
|
+
simplify(condition.or(...values))
|
|
426
|
+
const not = (value: Ast): Ast => simplify(condition.not(value))
|
|
427
|
+
|
|
428
|
+
interface ResidualContext {
|
|
429
|
+
readonly principal: object
|
|
430
|
+
readonly action: string
|
|
431
|
+
readonly kind: string
|
|
432
|
+
readonly field: string | undefined
|
|
433
|
+
readonly input: {
|
|
434
|
+
readonly principal: object
|
|
435
|
+
readonly path: Condition.Input["path"]
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const ruleCondition = (value: Rule.Any, context: ResidualContext): Ast =>
|
|
440
|
+
!rule.matchesTarget(value, context.action, context.kind, context.field)
|
|
441
|
+
? never
|
|
442
|
+
: value.when === undefined
|
|
443
|
+
? always
|
|
444
|
+
: condition.partialOf(value.when, context.input)
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Gives the conditions for "the first rule that applies is an allow rule" and
|
|
448
|
+
* "the first rule that applies is a deny rule".
|
|
449
|
+
*/
|
|
450
|
+
const firstApplicableResidual = (
|
|
451
|
+
values: ReadonlyArray<Rule.Any>,
|
|
452
|
+
context: ResidualContext,
|
|
453
|
+
): Pair => {
|
|
454
|
+
let allow: Ast = never
|
|
455
|
+
let deny: Ast = never
|
|
456
|
+
for (const value of values.slice().reverse()) {
|
|
457
|
+
const applies = ruleCondition(value, context)
|
|
458
|
+
if (value._tag === "Allow") {
|
|
459
|
+
allow = or(applies, allow)
|
|
460
|
+
deny = and(not(applies), deny)
|
|
461
|
+
} else {
|
|
462
|
+
deny = or(applies, deny)
|
|
463
|
+
allow = and(not(applies), allow)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return { allow, deny }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const residualNode = (node: Node, context: ResidualContext): Pair => {
|
|
470
|
+
switch (node._tag) {
|
|
471
|
+
case "Rules": {
|
|
472
|
+
switch (node.algorithm) {
|
|
473
|
+
case "denyOverrides": {
|
|
474
|
+
const deny = or(
|
|
475
|
+
...node.rules
|
|
476
|
+
.filter((value) => value._tag === "Deny")
|
|
477
|
+
.map((value) => ruleCondition(value, context)),
|
|
478
|
+
)
|
|
479
|
+
const allow = and(
|
|
480
|
+
or(
|
|
481
|
+
...node.rules
|
|
482
|
+
.filter((value) => value._tag === "Allow")
|
|
483
|
+
.map((value) => ruleCondition(value, context)),
|
|
484
|
+
),
|
|
485
|
+
not(deny),
|
|
486
|
+
)
|
|
487
|
+
return { allow, deny }
|
|
488
|
+
}
|
|
489
|
+
case "permitOverrides": {
|
|
490
|
+
const allow = or(
|
|
491
|
+
...node.rules
|
|
492
|
+
.filter((value) => value._tag === "Allow")
|
|
493
|
+
.map((value) => ruleCondition(value, context)),
|
|
494
|
+
)
|
|
495
|
+
const deny = and(
|
|
496
|
+
or(
|
|
497
|
+
...node.rules
|
|
498
|
+
.filter((value) => value._tag === "Deny")
|
|
499
|
+
.map((value) => ruleCondition(value, context)),
|
|
500
|
+
),
|
|
501
|
+
not(allow),
|
|
502
|
+
)
|
|
503
|
+
return { allow, deny }
|
|
504
|
+
}
|
|
505
|
+
case "firstApplicable":
|
|
506
|
+
return firstApplicableResidual(node.rules, context)
|
|
507
|
+
case "lastApplicable":
|
|
508
|
+
return firstApplicableResidual(node.rules.slice().reverse(), context)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// falls through: every algorithm returns above
|
|
512
|
+
case "All": {
|
|
513
|
+
if (node.policies.length === 0) {
|
|
514
|
+
return { allow: never, deny: never }
|
|
515
|
+
}
|
|
516
|
+
const pairs = node.policies.map((child) => residualNode(child, context))
|
|
517
|
+
const deny = or(...pairs.map((pair) => pair.deny))
|
|
518
|
+
const allow = and(...pairs.map((pair) => pair.allow), not(deny))
|
|
519
|
+
return { allow, deny }
|
|
520
|
+
}
|
|
521
|
+
case "Any": {
|
|
522
|
+
const pairs = node.policies.map((child) => residualNode(child, context))
|
|
523
|
+
const allow = or(...pairs.map((pair) => pair.allow))
|
|
524
|
+
const deny = and(or(...pairs.map((pair) => pair.deny)), not(allow))
|
|
525
|
+
return { allow, deny }
|
|
526
|
+
}
|
|
527
|
+
case "FirstOf": {
|
|
528
|
+
let allow: Ast = never
|
|
529
|
+
let deny: Ast = never
|
|
530
|
+
for (const child of node.policies.slice().reverse()) {
|
|
531
|
+
const pair = residualNode(child, context)
|
|
532
|
+
const nextAllow = or(pair.allow, and(not(pair.deny), allow))
|
|
533
|
+
deny = or(pair.deny, and(not(pair.allow), deny))
|
|
534
|
+
allow = nextAllow
|
|
535
|
+
}
|
|
536
|
+
return { allow, deny }
|
|
537
|
+
}
|
|
538
|
+
case "Ceiling": {
|
|
539
|
+
const cap = residualNode(node.cap, context)
|
|
540
|
+
const inner = residualNode(node.policy, context)
|
|
541
|
+
return {
|
|
542
|
+
allow: and(not(cap.deny), inner.allow),
|
|
543
|
+
deny: or(cap.deny, inner.deny),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @internal */
|
|
550
|
+
export const residualPair = (
|
|
551
|
+
policy: unknown,
|
|
552
|
+
request: Policy.KindRequest<Domain.Any>,
|
|
553
|
+
): Pair => {
|
|
554
|
+
const domain = domainOf(policy)
|
|
555
|
+
const descriptor = getDescriptor(domain, request.kind)
|
|
556
|
+
if (descriptor === undefined) {
|
|
557
|
+
return { allow: never, deny: never }
|
|
558
|
+
}
|
|
559
|
+
return residualNode(nodeOf(policy), {
|
|
560
|
+
principal: request.principal,
|
|
561
|
+
action: request.action,
|
|
562
|
+
kind: request.kind,
|
|
563
|
+
field: request.field,
|
|
564
|
+
input: { principal: request.principal, path: descriptor.path },
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** @internal */
|
|
569
|
+
export const residualOf = (
|
|
570
|
+
policy: unknown,
|
|
571
|
+
request: Policy.KindRequest<Domain.Any>,
|
|
572
|
+
): Ast => residualPair(policy, request).allow
|
|
573
|
+
|
|
574
|
+
/** @internal */
|
|
575
|
+
export const residual = dual(2, residualOf)
|
|
576
|
+
|
|
577
|
+
// Schema
|
|
578
|
+
|
|
579
|
+
const Algorithm = Schema.Literals([
|
|
580
|
+
"denyOverrides",
|
|
581
|
+
"permitOverrides",
|
|
582
|
+
"firstApplicable",
|
|
583
|
+
"lastApplicable",
|
|
584
|
+
])
|
|
585
|
+
|
|
586
|
+
const encodedRuleSchema = (meta: Schema.Top | undefined) =>
|
|
587
|
+
Schema.Struct({
|
|
588
|
+
_tag: Schema.Literals(["Allow", "Deny"]),
|
|
589
|
+
actions: Schema.Array(Schema.String),
|
|
590
|
+
resources: Schema.Array(Schema.String),
|
|
591
|
+
when: Schema.optionalKey(condition.schema),
|
|
592
|
+
fields: Schema.String.pipe(Schema.Array, Schema.optionalKey),
|
|
593
|
+
reason: Schema.optionalKey(Schema.String),
|
|
594
|
+
id: Schema.optionalKey(Schema.String),
|
|
595
|
+
meta: Schema.optionalKey(
|
|
596
|
+
(meta ?? Schema.Never) as Schema.Codec<unknown, unknown>,
|
|
597
|
+
),
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
const encodedNodeSchema = (
|
|
601
|
+
meta: Schema.Top | undefined,
|
|
602
|
+
): Schema.Codec<Policy.EncodedNode> => {
|
|
603
|
+
const self: Schema.Codec<Policy.EncodedNode> = Schema.Union([
|
|
604
|
+
Schema.TaggedStruct("Rules", {
|
|
605
|
+
algorithm: Algorithm,
|
|
606
|
+
rules: Schema.Array(encodedRuleSchema(meta)),
|
|
607
|
+
}),
|
|
608
|
+
Schema.TaggedStruct("All", {
|
|
609
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
610
|
+
}),
|
|
611
|
+
Schema.TaggedStruct("Any", {
|
|
612
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
613
|
+
}),
|
|
614
|
+
Schema.TaggedStruct("FirstOf", {
|
|
615
|
+
policies: Schema.Array(Schema.suspend(() => self)),
|
|
616
|
+
}),
|
|
617
|
+
Schema.TaggedStruct("Ceiling", {
|
|
618
|
+
cap: Schema.suspend(() => self),
|
|
619
|
+
policy: Schema.suspend(() => self),
|
|
620
|
+
}),
|
|
621
|
+
]) as never
|
|
622
|
+
return self
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const principalFieldPaths = (domain: Domain.Any): ReadonlySet<string> => {
|
|
626
|
+
const output = new Set<string>()
|
|
627
|
+
const visit = (schema: unknown, prefix: string): void => {
|
|
628
|
+
if (
|
|
629
|
+
!Predicate.hasProperty(schema, "fields") ||
|
|
630
|
+
!Predicate.isObject(schema.fields)
|
|
631
|
+
) {
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
const fields = schema.fields as Record<string, unknown>
|
|
635
|
+
for (const key of Object.keys(fields)) {
|
|
636
|
+
const path = prefix === "" ? key : `${prefix}.${key}`
|
|
637
|
+
output.add(path)
|
|
638
|
+
visit(fields[key], path)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
visit(domain.principal, "")
|
|
642
|
+
return output
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const principalRefs = (ast: Condition.Encoded | Ast): ReadonlyArray<string> => {
|
|
646
|
+
const output: Array<string> = []
|
|
647
|
+
const visitOperand = (operand: Condition.Operand) => {
|
|
648
|
+
if (operand._tag === "Principal") output.push(operand.path)
|
|
649
|
+
}
|
|
650
|
+
const visit = (node: Condition.Encoded | Ast): void => {
|
|
651
|
+
switch (node._tag) {
|
|
652
|
+
case "Compare":
|
|
653
|
+
visitOperand(node.value)
|
|
654
|
+
return
|
|
655
|
+
case "In":
|
|
656
|
+
node.values.forEach(visitOperand)
|
|
657
|
+
return
|
|
658
|
+
case "Under":
|
|
659
|
+
node.prefix.forEach(visitOperand)
|
|
660
|
+
return
|
|
661
|
+
case "And":
|
|
662
|
+
case "Or":
|
|
663
|
+
;(node.conditions as ReadonlyArray<Ast>).forEach(visit)
|
|
664
|
+
return
|
|
665
|
+
case "Not":
|
|
666
|
+
visit(node.condition)
|
|
667
|
+
return
|
|
668
|
+
default:
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
visit(ast)
|
|
673
|
+
return output
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Checks the names in an encoded policy against the domain. It gives one
|
|
678
|
+
* message for each problem.
|
|
679
|
+
*/
|
|
680
|
+
const validate = (
|
|
681
|
+
domain: Domain.Any,
|
|
682
|
+
root: Policy.EncodedNode,
|
|
683
|
+
): ReadonlyArray<Schema.FilterIssue> => {
|
|
684
|
+
const issues: Array<Schema.FilterIssue> = []
|
|
685
|
+
const principalPaths = principalFieldPaths(domain)
|
|
686
|
+
const visit = (
|
|
687
|
+
node: Policy.EncodedNode,
|
|
688
|
+
path: ReadonlyArray<PropertyKey>,
|
|
689
|
+
) => {
|
|
690
|
+
switch (node._tag) {
|
|
691
|
+
case "Rules":
|
|
692
|
+
node.rules.forEach((value, index) => {
|
|
693
|
+
const at = [...path, "rules", index]
|
|
694
|
+
const kinds = value.resources.includes("*")
|
|
695
|
+
? undefined
|
|
696
|
+
: value.resources
|
|
697
|
+
const descriptors = []
|
|
698
|
+
for (const kind of value.resources) {
|
|
699
|
+
if (kind === "*") continue
|
|
700
|
+
const descriptor = getDescriptor(domain, kind)
|
|
701
|
+
if (descriptor === undefined) {
|
|
702
|
+
issues.push({
|
|
703
|
+
path: [...at, "resources"],
|
|
704
|
+
issue: `There is no resource with the kind "${kind}"`,
|
|
705
|
+
})
|
|
706
|
+
} else {
|
|
707
|
+
descriptors.push(descriptor)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const known =
|
|
711
|
+
kinds === undefined
|
|
712
|
+
? Array.from(domain.resources.values())
|
|
713
|
+
: descriptors
|
|
714
|
+
for (const action of value.actions) {
|
|
715
|
+
if (action === "*") continue
|
|
716
|
+
if (
|
|
717
|
+
!known.some((descriptor) => descriptor.actions.includes(action))
|
|
718
|
+
) {
|
|
719
|
+
issues.push({
|
|
720
|
+
path: [...at, "actions"],
|
|
721
|
+
issue: `The action "${action}" is not an action of these resources`,
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const usedFields = [
|
|
726
|
+
...(value.fields ?? []).filter((field) => !field.includes("*")),
|
|
727
|
+
...(value.when === undefined
|
|
728
|
+
? []
|
|
729
|
+
: condition.fields(value.when as Ast)),
|
|
730
|
+
]
|
|
731
|
+
for (const field of usedFields) {
|
|
732
|
+
const everywhere =
|
|
733
|
+
kinds !== undefined &&
|
|
734
|
+
descriptors.length > 0 &&
|
|
735
|
+
descriptors.every((descriptor) =>
|
|
736
|
+
descriptor.fieldPaths.includes(field),
|
|
737
|
+
)
|
|
738
|
+
if (!everywhere) {
|
|
739
|
+
issues.push({
|
|
740
|
+
path: at,
|
|
741
|
+
issue: `The field "${field}" is not a field of every resource of the rule`,
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (value.when !== undefined) {
|
|
746
|
+
for (const ref of principalRefs(value.when)) {
|
|
747
|
+
if (!principalPaths.has(ref)) {
|
|
748
|
+
issues.push({
|
|
749
|
+
path: [...at, "when"],
|
|
750
|
+
issue: `The principal has no field "${ref}"`,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
return
|
|
757
|
+
case "All":
|
|
758
|
+
case "Any":
|
|
759
|
+
case "FirstOf":
|
|
760
|
+
node.policies.forEach((child, index) =>
|
|
761
|
+
visit(child, [...path, "policies", index]),
|
|
762
|
+
)
|
|
763
|
+
return
|
|
764
|
+
case "Ceiling":
|
|
765
|
+
visit(node.cap, [...path, "cap"])
|
|
766
|
+
visit(node.policy, [...path, "policy"])
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
visit(root, [])
|
|
771
|
+
return issues
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const decodeNode = (node: Policy.EncodedNode): Node => {
|
|
775
|
+
switch (node._tag) {
|
|
776
|
+
case "Rules":
|
|
777
|
+
return Object.freeze({
|
|
778
|
+
_tag: "Rules",
|
|
779
|
+
algorithm: node.algorithm,
|
|
780
|
+
rules: Object.freeze(
|
|
781
|
+
node.rules.map((value) => rule.make(value as rule.RuleData)),
|
|
782
|
+
),
|
|
783
|
+
})
|
|
784
|
+
case "All":
|
|
785
|
+
case "Any":
|
|
786
|
+
case "FirstOf":
|
|
787
|
+
return Object.freeze({
|
|
788
|
+
_tag: node._tag,
|
|
789
|
+
policies: Object.freeze(node.policies.map(decodeNode)),
|
|
790
|
+
})
|
|
791
|
+
case "Ceiling":
|
|
792
|
+
return Object.freeze({
|
|
793
|
+
_tag: "Ceiling",
|
|
794
|
+
cap: decodeNode(node.cap),
|
|
795
|
+
policy: decodeNode(node.policy),
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** @internal */
|
|
801
|
+
export const schema = (domain: Domain.Any): any =>
|
|
802
|
+
encodedNodeSchema(domain.meta).pipe(
|
|
803
|
+
Schema.check(Schema.makeFilter((node) => validate(domain, node))),
|
|
804
|
+
Schema.decodeTo(
|
|
805
|
+
Schema.declare(
|
|
806
|
+
(u): u is Policy.Policy<Domain.Any> =>
|
|
807
|
+
isPolicy(u) && domainOf(u) === domain,
|
|
808
|
+
),
|
|
809
|
+
SchemaTransformation.transform({
|
|
810
|
+
decode: (node: Policy.EncodedNode) =>
|
|
811
|
+
fromNode(domain, decodeNode(node)),
|
|
812
|
+
encode: (policy: Policy.Policy<Domain.Any>) => encodeNode(policy.node),
|
|
813
|
+
}),
|
|
814
|
+
),
|
|
815
|
+
)
|