@nmnmcc/ability 0.3.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -31
- package/dist/Authorizer.d.ts +64 -39
- package/dist/Authorizer.d.ts.map +1 -1
- package/dist/Authorizer.js +291 -6
- package/dist/Authorizer.js.map +1 -1
- package/dist/Condition.d.ts +444 -65
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js +1007 -25
- package/dist/Condition.js.map +1 -1
- package/dist/Domain.d.ts +27 -17
- package/dist/Domain.d.ts.map +1 -1
- package/dist/Domain.js +52 -10
- package/dist/Domain.js.map +1 -1
- package/dist/HttpApi.d.ts +84 -40
- package/dist/HttpApi.d.ts.map +1 -1
- package/dist/HttpApi.js +103 -13
- package/dist/HttpApi.js.map +1 -1
- package/dist/Policy.d.ts +314 -56
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +1215 -38
- package/dist/Policy.js.map +1 -1
- package/dist/Principal.js +2 -2
- package/dist/Principal.js.map +1 -1
- package/dist/Resource.d.ts +171 -20
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +788 -15
- package/dist/Resource.js.map +1 -1
- package/dist/Rule.d.ts +115 -29
- package/dist/Rule.d.ts.map +1 -1
- package/dist/Rule.js +219 -12
- package/dist/Rule.js.map +1 -1
- package/dist/index.js +4 -4
- package/package.json +2 -2
- package/src/Authorizer.ts +625 -61
- package/src/Condition.ts +2403 -112
- package/src/Domain.ts +80 -23
- package/src/HttpApi.ts +225 -25
- package/src/Policy.ts +2836 -96
- package/src/Principal.ts +2 -2
- package/src/Resource.ts +1544 -29
- package/src/Rule.ts +463 -45
- package/dist/internal/authorizer.d.ts +0 -28
- package/dist/internal/authorizer.d.ts.map +0 -1
- package/dist/internal/authorizer.js +0 -153
- package/dist/internal/authorizer.js.map +0 -1
- package/dist/internal/condition.d.ts +0 -11
- package/dist/internal/condition.d.ts.map +0 -1
- package/dist/internal/condition.js +0 -393
- package/dist/internal/condition.js.map +0 -1
- package/dist/internal/domain.js +0 -34
- package/dist/internal/domain.js.map +0 -1
- package/dist/internal/httpApi.js +0 -41
- package/dist/internal/httpApi.js.map +0 -1
- package/dist/internal/policy.js +0 -524
- package/dist/internal/policy.js.map +0 -1
- package/dist/internal/resource.js +0 -66
- package/dist/internal/resource.js.map +0 -1
- package/dist/internal/rule.js +0 -131
- package/dist/internal/rule.js.map +0 -1
- package/src/internal/authorizer.ts +0 -271
- package/src/internal/condition.ts +0 -607
- package/src/internal/domain.ts +0 -56
- package/src/internal/httpApi.ts +0 -84
- package/src/internal/policy.ts +0 -815
- package/src/internal/resource.ts +0 -98
- package/src/internal/rule.ts +0 -203
package/src/Authorizer.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Then use the service in a handler:
|
|
21
21
|
*
|
|
22
22
|
* ```ts
|
|
23
|
-
* yield* Auth.check({ action: "update", resource:
|
|
23
|
+
* yield* Auth.check({ action: "update", resource: document })
|
|
24
24
|
* ```
|
|
25
25
|
*
|
|
26
26
|
* A "no" answer goes into the error channel as {@link Forbidden}. When the
|
|
@@ -29,20 +29,31 @@
|
|
|
29
29
|
*
|
|
30
30
|
* When no rule gives an answer, the authorizer asks the decider, if there is
|
|
31
31
|
* one. A decider may answer at once, or fail with {@link Pending} when a person
|
|
32
|
-
* or
|
|
32
|
+
* or another service must answer later.
|
|
33
33
|
*
|
|
34
34
|
* @stability stable
|
|
35
35
|
* @since 0.3.0
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
// The service erases the error type and the service type of predicates,
|
|
38
|
+
// principals and deciders to `unknown`. The public types keep them, so the
|
|
39
|
+
// language service check for `unknown` in an Effect is off here.
|
|
40
|
+
// @effect-diagnostics anyUnknownInErrorContext:off
|
|
41
|
+
import * as Arr from "effect/Array"
|
|
42
|
+
import * as Cache from "effect/Cache"
|
|
43
|
+
import * as Context from "effect/Context"
|
|
38
44
|
import type * as Duration from "effect/Duration"
|
|
39
|
-
import
|
|
40
|
-
import
|
|
41
|
-
import
|
|
42
|
-
import
|
|
43
|
-
import
|
|
44
|
-
import * as
|
|
45
|
-
import
|
|
45
|
+
import * as Effect from "effect/Effect"
|
|
46
|
+
import * as Equal from "effect/Equal"
|
|
47
|
+
import * as Hash from "effect/Hash"
|
|
48
|
+
import * as Layer from "effect/Layer"
|
|
49
|
+
import * as MutableHashMap from "effect/MutableHashMap"
|
|
50
|
+
import * as Option from "effect/Option"
|
|
51
|
+
import * as Schema from "effect/Schema"
|
|
52
|
+
import * as Condition from "./Condition.ts"
|
|
53
|
+
import * as Decision from "./Decision.ts"
|
|
54
|
+
import * as Domain from "./Domain.ts"
|
|
55
|
+
import * as Policy from "./Policy.ts"
|
|
56
|
+
import * as Resource from "./Resource.ts"
|
|
46
57
|
|
|
47
58
|
/**
|
|
48
59
|
* The error when a policy says no.
|
|
@@ -51,14 +62,14 @@ import type * as Policy from "./Policy.ts"
|
|
|
51
62
|
* @category errors
|
|
52
63
|
* @since 0.3.0
|
|
53
64
|
*/
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
export class Forbidden extends Schema.TaggedError<Forbidden>(
|
|
66
|
+
"@nmnmcc/ability/Authorizer/Forbidden",
|
|
67
|
+
)("Forbidden", {
|
|
68
|
+
action: Schema.String,
|
|
69
|
+
kind: Schema.String,
|
|
70
|
+
field: Schema.optionalKey(Schema.String),
|
|
71
|
+
reason: Schema.optionalKey(Schema.String),
|
|
72
|
+
}) {}
|
|
62
73
|
|
|
63
74
|
/**
|
|
64
75
|
* The error when the policy of the principal can not be loaded. The cause is
|
|
@@ -68,15 +79,9 @@ export type Forbidden = internal.Forbidden
|
|
|
68
79
|
* @category errors
|
|
69
80
|
* @since 0.3.0
|
|
70
81
|
*/
|
|
71
|
-
export
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* @stability stable
|
|
76
|
-
* @category errors
|
|
77
|
-
* @since 0.3.0
|
|
78
|
-
*/
|
|
79
|
-
export type PolicyUnavailable = internal.PolicyUnavailable
|
|
82
|
+
export class PolicyUnavailable extends Schema.TaggedError<PolicyUnavailable>(
|
|
83
|
+
"@nmnmcc/ability/Authorizer/PolicyUnavailable",
|
|
84
|
+
)("PolicyUnavailable", { cause: Schema.Defect() }) {}
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
87
|
* The error when a decider must wait for an answer. `ticket` names the
|
|
@@ -86,14 +91,12 @@ export type PolicyUnavailable = internal.PolicyUnavailable
|
|
|
86
91
|
* @category errors
|
|
87
92
|
* @since 0.3.0
|
|
88
93
|
*/
|
|
89
|
-
export
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*/
|
|
96
|
-
export type Pending = internal.Pending
|
|
94
|
+
export class Pending extends Schema.TaggedError<Pending>(
|
|
95
|
+
"@nmnmcc/ability/Authorizer/Pending",
|
|
96
|
+
)("Pending", {
|
|
97
|
+
ticket: Schema.String,
|
|
98
|
+
reason: Schema.optionalKey(Schema.String),
|
|
99
|
+
}) {}
|
|
97
100
|
|
|
98
101
|
/**
|
|
99
102
|
* The services and the errors of the principal and the decider of an
|
|
@@ -114,7 +117,8 @@ type ErrorOf<C> = C extends { readonly error: infer E } ? E : never
|
|
|
114
117
|
/**
|
|
115
118
|
* What a check is about: an act on a resource value, or an act on a kind of
|
|
116
119
|
* resource. A check on a kind passes only when the policy allows the act on
|
|
117
|
-
* every value of the kind.
|
|
120
|
+
* every value of the kind. With a scope (`within`), it passes when the policy
|
|
121
|
+
* allows the act on every value in the scope, and the scope is not empty.
|
|
118
122
|
*
|
|
119
123
|
* @stability stable
|
|
120
124
|
* @category models
|
|
@@ -138,12 +142,25 @@ export type DeciderRequest<D> = Policy.Request<D> | Policy.KindRequest<D>
|
|
|
138
142
|
* @category models
|
|
139
143
|
* @since 0.3.0
|
|
140
144
|
*/
|
|
141
|
-
export
|
|
142
|
-
/** The action that lets the principal see the resource. The default is `"read"`. */
|
|
143
|
-
readonly read?: Domain.AnyAction<D>
|
|
145
|
+
export type GuardOptions<D, E2, K extends string = Domain.Kind<D>> = {
|
|
144
146
|
/** The error when the principal may not see the resource. */
|
|
145
147
|
readonly hide: () => E2
|
|
146
|
-
}
|
|
148
|
+
} & ("read" extends Domain.CommonAction<D, K>
|
|
149
|
+
? {
|
|
150
|
+
/**
|
|
151
|
+
* The action that lets the principal see the resource. It must be an
|
|
152
|
+
* action of every kind in `K`. The default is `"read"`.
|
|
153
|
+
*/
|
|
154
|
+
readonly read?: Domain.CommonAction<D, K>
|
|
155
|
+
}
|
|
156
|
+
: {
|
|
157
|
+
/**
|
|
158
|
+
* The action that lets the principal see the resource. It must be an
|
|
159
|
+
* action of every kind in `K`. Some kind in `K` has no `"read"`
|
|
160
|
+
* action, so you must give it.
|
|
161
|
+
*/
|
|
162
|
+
readonly read: Domain.CommonAction<D, K>
|
|
163
|
+
})
|
|
147
164
|
|
|
148
165
|
/**
|
|
149
166
|
* The service.
|
|
@@ -156,8 +173,15 @@ export interface Authorizer<D extends Domain.Any, E, R> {
|
|
|
156
173
|
readonly domain: D
|
|
157
174
|
/** The principal of the current request. */
|
|
158
175
|
readonly principal: Effect.Effect<Domain.Principal<D>, E, R>
|
|
159
|
-
/**
|
|
160
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The policy of the principal of the current request. Its predicates have
|
|
178
|
+
* the services of the layer. It may have predicates, so it is opaque.
|
|
179
|
+
*/
|
|
180
|
+
readonly policy: Effect.Effect<
|
|
181
|
+
Policy.Policy<D, E, never, Condition.Opaque>,
|
|
182
|
+
PolicyUnavailable | E,
|
|
183
|
+
R
|
|
184
|
+
>
|
|
161
185
|
/** Gives the decision of the policy. It does not ask the decider. */
|
|
162
186
|
readonly decide: (
|
|
163
187
|
target: Policy.Target<D>,
|
|
@@ -172,24 +196,48 @@ export interface Authorizer<D extends Domain.Any, E, R> {
|
|
|
172
196
|
* answer "not found" to a principal that may not know that a resource
|
|
173
197
|
* exists.
|
|
174
198
|
*/
|
|
175
|
-
readonly guard: <E2>(
|
|
176
|
-
target:
|
|
177
|
-
options: GuardOptions<D, E2>,
|
|
199
|
+
readonly guard: <const T extends Policy.Target<D>, E2>(
|
|
200
|
+
target: T,
|
|
201
|
+
options: GuardOptions<D, E2, T["resource"]["_tag"]>,
|
|
178
202
|
) => Effect.Effect<void, E2 | Forbidden | Pending | PolicyUnavailable | E, R>
|
|
179
203
|
/**
|
|
180
|
-
* Gives the condition on resource values that the principal may act on
|
|
181
|
-
* Change it into a query
|
|
204
|
+
* Gives the condition on resource values that the principal may act on,
|
|
205
|
+
* inside the scope of the target when it has one. Change it into a query
|
|
206
|
+
* with `Condition.fold`. It is `False` when the principal may act on no
|
|
207
|
+
* value, so the list is empty: it is not an error.
|
|
208
|
+
*/
|
|
209
|
+
readonly filter: <const T extends Policy.KindTarget<D>>(
|
|
210
|
+
target: T,
|
|
211
|
+
) => Effect.Effect<
|
|
212
|
+
Condition.Condition<
|
|
213
|
+
E,
|
|
214
|
+
never,
|
|
215
|
+
Condition.Opaque,
|
|
216
|
+
Policy.ResidualReads<D, T["kind"]>
|
|
217
|
+
>,
|
|
218
|
+
PolicyUnavailable | E,
|
|
219
|
+
R
|
|
220
|
+
>
|
|
221
|
+
/**
|
|
222
|
+
* Tells if the principal may act on all, none or some of the values in
|
|
223
|
+
* the scope of the target. See `Policy.coverage`. Use it to show a button
|
|
224
|
+
* only when the answer is not `None`.
|
|
182
225
|
*/
|
|
183
|
-
readonly
|
|
184
|
-
target:
|
|
226
|
+
readonly coverage: <const T extends Policy.KindTarget<D>>(
|
|
227
|
+
target: T,
|
|
185
228
|
) => Effect.Effect<
|
|
186
|
-
|
|
229
|
+
Policy.Coverage<
|
|
230
|
+
E,
|
|
231
|
+
never,
|
|
232
|
+
Condition.Opaque,
|
|
233
|
+
Policy.ResidualReads<D, T["kind"]>
|
|
234
|
+
>,
|
|
187
235
|
PolicyUnavailable | E,
|
|
188
236
|
R
|
|
189
237
|
>
|
|
190
238
|
/** Gives the fields of a value that the principal may use. */
|
|
191
239
|
readonly permittedFields: (
|
|
192
|
-
target:
|
|
240
|
+
target: Policy.FieldlessTarget<D>,
|
|
193
241
|
) => Effect.Effect<ReadonlyArray<string>, PolicyUnavailable | E, R>
|
|
194
242
|
/**
|
|
195
243
|
* Removes the cached policy of a principal, or of every principal. Call it
|
|
@@ -224,23 +272,40 @@ export interface ServiceClass<
|
|
|
224
272
|
Forbidden | Pending | PolicyUnavailable | E,
|
|
225
273
|
R | Self
|
|
226
274
|
>
|
|
227
|
-
readonly guard: <E2>(
|
|
228
|
-
target:
|
|
229
|
-
options: GuardOptions<D, E2>,
|
|
275
|
+
readonly guard: <const T extends Policy.Target<D>, E2>(
|
|
276
|
+
target: T,
|
|
277
|
+
options: GuardOptions<D, E2, T["resource"]["_tag"]>,
|
|
230
278
|
) => Effect.Effect<
|
|
231
279
|
void,
|
|
232
280
|
E2 | Forbidden | Pending | PolicyUnavailable | E,
|
|
233
281
|
R | Self
|
|
234
282
|
>
|
|
235
|
-
readonly filter: (
|
|
236
|
-
target:
|
|
283
|
+
readonly filter: <const T extends Policy.KindTarget<D>>(
|
|
284
|
+
target: T,
|
|
285
|
+
) => Effect.Effect<
|
|
286
|
+
Condition.Condition<
|
|
287
|
+
E,
|
|
288
|
+
never,
|
|
289
|
+
Condition.Opaque,
|
|
290
|
+
Policy.ResidualReads<D, T["kind"]>
|
|
291
|
+
>,
|
|
292
|
+
PolicyUnavailable | E,
|
|
293
|
+
R | Self
|
|
294
|
+
>
|
|
295
|
+
readonly coverage: <const T extends Policy.KindTarget<D>>(
|
|
296
|
+
target: T,
|
|
237
297
|
) => Effect.Effect<
|
|
238
|
-
|
|
298
|
+
Policy.Coverage<
|
|
299
|
+
E,
|
|
300
|
+
never,
|
|
301
|
+
Condition.Opaque,
|
|
302
|
+
Policy.ResidualReads<D, T["kind"]>
|
|
303
|
+
>,
|
|
239
304
|
PolicyUnavailable | E,
|
|
240
305
|
R | Self
|
|
241
306
|
>
|
|
242
307
|
readonly permittedFields: (
|
|
243
|
-
target:
|
|
308
|
+
target: Policy.FieldlessTarget<D>,
|
|
244
309
|
) => Effect.Effect<ReadonlyArray<string>, PolicyUnavailable | E, R | Self>
|
|
245
310
|
}
|
|
246
311
|
|
|
@@ -268,8 +333,33 @@ export const Service: <Self, C extends Config = {}>() => <
|
|
|
268
333
|
>(
|
|
269
334
|
id: Id,
|
|
270
335
|
domain: D,
|
|
271
|
-
) => ServiceClass<Self, Id, D, ErrorOf<C>, RequiresOf<C>> =
|
|
272
|
-
|
|
336
|
+
) => ServiceClass<Self, Id, D, ErrorOf<C>, RequiresOf<C>> = (() =>
|
|
337
|
+
(id: string, domain: Domain.Any) => {
|
|
338
|
+
const Base = Context.Service<any, any>()(id)
|
|
339
|
+
return class extends Base {
|
|
340
|
+
static readonly domain = domain
|
|
341
|
+
static readonly decide = (target: AnyTarget) =>
|
|
342
|
+
this.use((service: any) => service.decide(target))
|
|
343
|
+
static readonly check = (target: AnyTarget) =>
|
|
344
|
+
this.use((service: any) => service.check(target))
|
|
345
|
+
static readonly guard = (target: AnyTarget, options: unknown) =>
|
|
346
|
+
this.use((service: any) => service.guard(target, options))
|
|
347
|
+
static readonly filter = (target: AnyTarget) =>
|
|
348
|
+
this.use((service: any) => service.filter(target))
|
|
349
|
+
static readonly coverage = (target: AnyTarget) =>
|
|
350
|
+
this.use((service: any) => service.coverage(target))
|
|
351
|
+
static readonly permittedFields = (target: AnyTarget) =>
|
|
352
|
+
this.use((service: any) => service.permittedFields(target))
|
|
353
|
+
}
|
|
354
|
+
}) as never
|
|
355
|
+
|
|
356
|
+
type AnyTarget = {
|
|
357
|
+
readonly action: string
|
|
358
|
+
readonly resource?: object
|
|
359
|
+
readonly kind?: string
|
|
360
|
+
readonly field?: string
|
|
361
|
+
readonly within?: unknown
|
|
362
|
+
}
|
|
273
363
|
|
|
274
364
|
/**
|
|
275
365
|
* The options of {@link layer}.
|
|
@@ -299,6 +389,12 @@ export interface Options<D extends Domain.Any, E, R, EL, RL, RP> {
|
|
|
299
389
|
/**
|
|
300
390
|
* How the policies are kept. The default keeps 1000 policies for 30 seconds.
|
|
301
391
|
* Give `false` to load the policy for each check.
|
|
392
|
+
*
|
|
393
|
+
* Two principals with the same data get one policy from the cache. So the
|
|
394
|
+
* loader must decide by the data of the principal only. When it tests that
|
|
395
|
+
* an object of the principal is a given object, as `role === admin` does,
|
|
396
|
+
* give `false`. A principal with objects in an array, a set or the keys of
|
|
397
|
+
* a map is not kept, because such tests are usual there.
|
|
302
398
|
*/
|
|
303
399
|
readonly cache?:
|
|
304
400
|
| {
|
|
@@ -308,6 +404,474 @@ export interface Options<D extends Domain.Any, E, R, EL, RL, RP> {
|
|
|
308
404
|
| false
|
|
309
405
|
}
|
|
310
406
|
|
|
407
|
+
/**
|
|
408
|
+
* A cache key for a principal. Two keys are equal when they have the same
|
|
409
|
+
* text, so two principals with the same value share one policy. The key also
|
|
410
|
+
* keeps the principal, so the cache can load its policy. A plain `Data` value
|
|
411
|
+
* can not do both, so the key gives its own equality with `Equal.Equal`.
|
|
412
|
+
*/
|
|
413
|
+
class PrincipalKey implements Equal.Equal {
|
|
414
|
+
readonly principal: object
|
|
415
|
+
readonly text: string
|
|
416
|
+
constructor(principal: object, text: string) {
|
|
417
|
+
this.principal = principal
|
|
418
|
+
this.text = text
|
|
419
|
+
}
|
|
420
|
+
[Equal.symbol](that: unknown): boolean {
|
|
421
|
+
return that instanceof PrincipalKey && that.text === this.text
|
|
422
|
+
}
|
|
423
|
+
[Hash.symbol](): number {
|
|
424
|
+
return Hash.string(this.text)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Tests if an array has a plain field at each index below its length. With
|
|
430
|
+
* one more own key for `length`, it then has no other own keys.
|
|
431
|
+
*/
|
|
432
|
+
const hasEveryIndex = (value: Array<unknown>): boolean =>
|
|
433
|
+
Arr.every(
|
|
434
|
+
Array.from({ length: value.length }, (_, index) => index),
|
|
435
|
+
(index) => isPlainField(value, String(index)),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Tests if an own key of an object holds plain data that the text shows: it
|
|
440
|
+
* can be seen in a loop over the keys, and it is not a getter.
|
|
441
|
+
*/
|
|
442
|
+
const isPlainField = (value: object, key: string): boolean => {
|
|
443
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
444
|
+
return (
|
|
445
|
+
descriptor !== undefined &&
|
|
446
|
+
descriptor.enumerable === true &&
|
|
447
|
+
"value" in descriptor
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const isObjectLike = (value: unknown): boolean =>
|
|
452
|
+
(typeof value === "object" && value !== null) || typeof value === "function"
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The number of each class that a key has seen. Each layer has its own, so
|
|
456
|
+
* no state is kept for the whole program. A class is a function, and `Equal`
|
|
457
|
+
* compares functions by the function itself.
|
|
458
|
+
*/
|
|
459
|
+
type ClassIds = MutableHashMap.MutableHashMap<object, number>
|
|
460
|
+
|
|
461
|
+
/** Gives a number that is only for this class, in this layer. */
|
|
462
|
+
const classId = (classes: ClassIds, constructor: object): string => {
|
|
463
|
+
const id = Option.getOrElse(MutableHashMap.get(classes, constructor), () => {
|
|
464
|
+
const fresh = MutableHashMap.size(classes)
|
|
465
|
+
MutableHashMap.set(classes, constructor, fresh)
|
|
466
|
+
return fresh
|
|
467
|
+
})
|
|
468
|
+
return `#${String(id)}`
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The most levels of objects in a principal that the cache takes. */
|
|
472
|
+
const maxKeyDepth = 256
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Gives a text that names a principal value. Two values get one text only when
|
|
476
|
+
* they have the same types and the same data. So a string `"1"` and a bigint
|
|
477
|
+
* `1n` get two texts. The text keeps the class of each object, by a number
|
|
478
|
+
* that is its own, because two classes may have one name.
|
|
479
|
+
*
|
|
480
|
+
* It reads only the fields of plain objects and of `Schema.Class` values, and
|
|
481
|
+
* only when each own key is a plain field that a loop over the keys can see.
|
|
482
|
+
* An object of another class may keep its data where the text can not see it,
|
|
483
|
+
* as a `URL` does. So it gives `undefined` for such an object, unless it is a
|
|
484
|
+
* plain `Date`, `URL`, array, `Map` or `Set` with no extra own keys. It also
|
|
485
|
+
* gives `undefined` for a function, a symbol, or a value with one object in two
|
|
486
|
+
* places. The text can not show that two places hold the same object, and a
|
|
487
|
+
* loader may test it, as `set.has(value)` does.
|
|
488
|
+
* The private fields (`#name`) of a class can not be seen, so a principal
|
|
489
|
+
* class must not keep its data in them.
|
|
490
|
+
*
|
|
491
|
+
* It also gives `undefined` for a value with more than {@link maxKeyDepth}
|
|
492
|
+
* levels of objects. The walk calls itself for each level, so a deeper value
|
|
493
|
+
* could fill the stack. Such a principal is not cached.
|
|
494
|
+
*/
|
|
495
|
+
const keyText = (
|
|
496
|
+
value: unknown,
|
|
497
|
+
seen: Set<object>,
|
|
498
|
+
classes: ClassIds,
|
|
499
|
+
depth: number = 0,
|
|
500
|
+
): string | undefined => {
|
|
501
|
+
const plain = (text: string): string => text
|
|
502
|
+
switch (typeof value) {
|
|
503
|
+
case "string":
|
|
504
|
+
return plain(`s${JSON.stringify(value)}`)
|
|
505
|
+
case "number":
|
|
506
|
+
return plain(Object.is(value, -0) ? "n-0" : `n${String(value)}`)
|
|
507
|
+
case "bigint":
|
|
508
|
+
return plain(`b${String(value)}`)
|
|
509
|
+
case "boolean":
|
|
510
|
+
return plain(value ? "true" : "false")
|
|
511
|
+
case "undefined":
|
|
512
|
+
return plain("undefined")
|
|
513
|
+
case "object":
|
|
514
|
+
break
|
|
515
|
+
default:
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
if (value === null) {
|
|
519
|
+
return plain("null")
|
|
520
|
+
}
|
|
521
|
+
if (seen.has(value) || depth >= maxKeyDepth) {
|
|
522
|
+
return undefined
|
|
523
|
+
}
|
|
524
|
+
seen.add(value)
|
|
525
|
+
const list = (
|
|
526
|
+
items: ReadonlyArray<unknown>,
|
|
527
|
+
wrap: (texts: ReadonlyArray<string>) => string,
|
|
528
|
+
): string | undefined => {
|
|
529
|
+
const texts = Arr.map(items, (item) =>
|
|
530
|
+
keyText(item, seen, classes, depth + 1),
|
|
531
|
+
)
|
|
532
|
+
return Arr.every(texts, (text) => text !== undefined)
|
|
533
|
+
? wrap(texts as ReadonlyArray<string>)
|
|
534
|
+
: undefined
|
|
535
|
+
}
|
|
536
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
537
|
+
// The own keys of the value. A built-in value keeps its data inside, so it
|
|
538
|
+
// may have no own keys, and the elements of an array are its own keys. An
|
|
539
|
+
// extra own key is data that the text does not show.
|
|
540
|
+
const own = Reflect.ownKeys(value)
|
|
541
|
+
// The entries of a map or a set keep their order, because a loader may read
|
|
542
|
+
// the first one.
|
|
543
|
+
if (prototype === Date.prototype && own.length === 0) {
|
|
544
|
+
return `d${String((value as Date).getTime())}`
|
|
545
|
+
}
|
|
546
|
+
if (prototype === URL.prototype && own.length === 0) {
|
|
547
|
+
return `u${JSON.stringify((value as URL).href)}`
|
|
548
|
+
}
|
|
549
|
+
if (
|
|
550
|
+
prototype === Array.prototype &&
|
|
551
|
+
own.length === (value as Array<unknown>).length + 1 &&
|
|
552
|
+
hasEveryIndex(value as Array<unknown>) &&
|
|
553
|
+
// An array finds an object by the object itself, as in
|
|
554
|
+
// `roles.includes(admin)`.
|
|
555
|
+
!Arr.some(value as Array<unknown>, isObjectLike)
|
|
556
|
+
) {
|
|
557
|
+
return list(value as Array<unknown>, (texts) => `[${texts.join(",")}]`)
|
|
558
|
+
}
|
|
559
|
+
if (
|
|
560
|
+
prototype === Map.prototype &&
|
|
561
|
+
own.length === 0 &&
|
|
562
|
+
// A map finds an object key by the object itself, not by its data, as
|
|
563
|
+
// in `map.get(role)`. The text can not show which object it is.
|
|
564
|
+
!Arr.some(Array.from((value as Map<unknown, unknown>).keys()), isObjectLike)
|
|
565
|
+
) {
|
|
566
|
+
// The keys and the values go in one list, so a value that is an object
|
|
567
|
+
// does not go through the test of the elements of an array.
|
|
568
|
+
return list(
|
|
569
|
+
Arr.flatMap(Array.from(value as Map<unknown, unknown>), ([key, item]) => [
|
|
570
|
+
key,
|
|
571
|
+
item,
|
|
572
|
+
]),
|
|
573
|
+
(texts) => `Map{${texts.join(",")}}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
if (
|
|
577
|
+
prototype === Set.prototype &&
|
|
578
|
+
own.length === 0 &&
|
|
579
|
+
// A set finds an object by the object itself, as in `set.has(role)`.
|
|
580
|
+
!Arr.some(Array.from(value as Set<unknown>), isObjectLike)
|
|
581
|
+
) {
|
|
582
|
+
return list(
|
|
583
|
+
Array.from(value as Set<unknown>),
|
|
584
|
+
(texts) => `Set{${texts.join(",")}}`,
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
if (
|
|
588
|
+
(prototype === null ||
|
|
589
|
+
prototype === Object.prototype ||
|
|
590
|
+
// The prototype must be the prototype of the class itself. A value
|
|
591
|
+
// made with `Object.create(user)` has `user` as its prototype, and
|
|
592
|
+
// the data of `user` is not in the own keys. Such a prototype may
|
|
593
|
+
// have its own `constructor` key, so the test compares the
|
|
594
|
+
// prototype with the `prototype` of the class.
|
|
595
|
+
(Schema.isSchema((prototype as object).constructor) &&
|
|
596
|
+
(prototype as object).constructor.prototype === prototype)) &&
|
|
597
|
+
Arr.every(own, (key) => typeof key === "string" && isPlainField(value, key))
|
|
598
|
+
) {
|
|
599
|
+
// The keys keep their order, because a loader may read the first one.
|
|
600
|
+
const keys = own as ReadonlyArray<string>
|
|
601
|
+
const name =
|
|
602
|
+
prototype === null
|
|
603
|
+
? ""
|
|
604
|
+
: classId(classes, (prototype as object).constructor)
|
|
605
|
+
return list(
|
|
606
|
+
Arr.map(keys, (key) => (value as Record<string, unknown>)[key]),
|
|
607
|
+
(texts) =>
|
|
608
|
+
`${name}{${Arr.map(keys, (key, index) => `${JSON.stringify(key)}:${texts[index]!}`).join(",")}}`,
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
return undefined
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const forbidden = (
|
|
615
|
+
target: AnyTarget,
|
|
616
|
+
kind: string,
|
|
617
|
+
reason: string | undefined,
|
|
618
|
+
): Forbidden =>
|
|
619
|
+
Forbidden.make({
|
|
620
|
+
action: target.action,
|
|
621
|
+
kind,
|
|
622
|
+
...(target.field === undefined ? {} : { field: target.field }),
|
|
623
|
+
...(reason === undefined ? {} : { reason }),
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
const makeLayer = (
|
|
627
|
+
service: AnyServiceClass,
|
|
628
|
+
options: {
|
|
629
|
+
readonly principal: Effect.Effect<object, unknown, unknown>
|
|
630
|
+
readonly policy: (
|
|
631
|
+
principal: object,
|
|
632
|
+
) => Effect.Effect<Policy.Any, unknown, unknown>
|
|
633
|
+
readonly decider?: (
|
|
634
|
+
request: object,
|
|
635
|
+
) => Effect.Effect<Decision.Decision, unknown, unknown>
|
|
636
|
+
readonly cache?:
|
|
637
|
+
| { readonly capacity?: number; readonly timeToLive?: Duration.Input }
|
|
638
|
+
| false
|
|
639
|
+
},
|
|
640
|
+
) => {
|
|
641
|
+
const domain: Domain.Any = service.domain
|
|
642
|
+
// It gives `undefined` when the principal has no key. Such a principal does
|
|
643
|
+
// not use the cache.
|
|
644
|
+
const classes: ClassIds = MutableHashMap.empty()
|
|
645
|
+
const keyOf = (principal: object): PrincipalKey | undefined => {
|
|
646
|
+
// One set of seen objects for the walk: the walk adds each object to it,
|
|
647
|
+
// and the set goes away when the key is made.
|
|
648
|
+
const text = keyText(principal, new Set(), classes)
|
|
649
|
+
return text === undefined ? undefined : new PrincipalKey(principal, text)
|
|
650
|
+
}
|
|
651
|
+
return Layer.effect(
|
|
652
|
+
service,
|
|
653
|
+
Effect.gen(function* () {
|
|
654
|
+
// The services of the policy loader and of the predicates come from the
|
|
655
|
+
// layer, so a check does not need them.
|
|
656
|
+
const services = yield* Effect.context<never>()
|
|
657
|
+
|
|
658
|
+
// The predicates of the policy get the services of the layer, so the
|
|
659
|
+
// policy and its residual conditions need no services.
|
|
660
|
+
// A policy of another domain may have the same types, but its resources
|
|
661
|
+
// may have other paths. So it fails closed, as a policy that can not be
|
|
662
|
+
// loaded.
|
|
663
|
+
const load = (principal: object) =>
|
|
664
|
+
options.policy(principal).pipe(
|
|
665
|
+
Effect.provideContext(services),
|
|
666
|
+
Effect.filterOrFail(
|
|
667
|
+
(value) => value.domain === domain,
|
|
668
|
+
() =>
|
|
669
|
+
new globalThis.Error(
|
|
670
|
+
"The loaded policy is not a policy of this domain",
|
|
671
|
+
),
|
|
672
|
+
),
|
|
673
|
+
Effect.mapError((cause) => PolicyUnavailable.make({ cause })),
|
|
674
|
+
Effect.map((value) =>
|
|
675
|
+
Policy.provideContext(value, services as never),
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
const cache =
|
|
680
|
+
options.cache === false
|
|
681
|
+
? undefined
|
|
682
|
+
: yield* Cache.make({
|
|
683
|
+
capacity: options.cache?.capacity ?? 1000,
|
|
684
|
+
timeToLive: options.cache?.timeToLive ?? "30 seconds",
|
|
685
|
+
lookup: (key: PrincipalKey) => load(key.principal),
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
const policyFor = (principal: object) => {
|
|
689
|
+
const key = cache === undefined ? undefined : keyOf(principal)
|
|
690
|
+
if (cache === undefined || key === undefined) {
|
|
691
|
+
return load(principal)
|
|
692
|
+
}
|
|
693
|
+
// A failed lookup is kept in the cache. Remove it, so the next check
|
|
694
|
+
// tries again.
|
|
695
|
+
return Cache.get(cache, key).pipe(
|
|
696
|
+
Effect.tapError(() => Cache.invalidate(cache, key)),
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const withServices = <A, E>(
|
|
701
|
+
effect: Effect.Effect<A, E, unknown>,
|
|
702
|
+
): Effect.Effect<A, E, unknown> => Effect.provideContext(effect, services)
|
|
703
|
+
|
|
704
|
+
const current = Effect.gen(function* () {
|
|
705
|
+
const principal = yield* options.principal
|
|
706
|
+
const value = yield* policyFor(principal)
|
|
707
|
+
return { principal, policy: value }
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
// The principal and its policy, loaded one time. A check that asks
|
|
711
|
+
// the policy two times, such as `guard`, uses one snapshot for both,
|
|
712
|
+
// so the two answers come from one principal.
|
|
713
|
+
type Snapshot = Effect.Success<typeof current>
|
|
714
|
+
|
|
715
|
+
const decideWith = Effect.fnUntraced(function* (
|
|
716
|
+
{ principal, policy: value }: Snapshot,
|
|
717
|
+
target: AnyTarget,
|
|
718
|
+
) {
|
|
719
|
+
const decision: Decision.Decision = yield* withServices(
|
|
720
|
+
Policy.decide(value, { ...target, principal } as never),
|
|
721
|
+
)
|
|
722
|
+
return decision
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
const decide = (target: AnyTarget) =>
|
|
726
|
+
Effect.flatMap(current, (snapshot) => decideWith(snapshot, target))
|
|
727
|
+
|
|
728
|
+
const kindDecision = (
|
|
729
|
+
value: Policy.Any,
|
|
730
|
+
principal: object,
|
|
731
|
+
target: AnyTarget,
|
|
732
|
+
): Decision.Decision => {
|
|
733
|
+
const request = { ...target, principal } as never
|
|
734
|
+
const covered = Policy.coverage(value, request)
|
|
735
|
+
if (covered._tag === "All") {
|
|
736
|
+
return Decision.Allow({})
|
|
737
|
+
}
|
|
738
|
+
// The decider may answer for the scope only when no value in it is
|
|
739
|
+
// denied, and when no predicate may fail. Else some value may get a
|
|
740
|
+
// "no" from the policy, so the answer for the scope is "no". An
|
|
741
|
+
// unknown answer may hide a denied value, so it is a "no" too.
|
|
742
|
+
// An empty scope never passes, so the decider is not asked for it.
|
|
743
|
+
// A scope that the search can not say has a value may be empty, so
|
|
744
|
+
// the decider is not asked for it either.
|
|
745
|
+
if (
|
|
746
|
+
covered._tag !== "Unknown" &&
|
|
747
|
+
Policy.scopeHasValue(value, request)
|
|
748
|
+
) {
|
|
749
|
+
const deny: Condition.Ast = Policy.residuals(value, request).deny
|
|
750
|
+
const schema = Domain.descriptorOf(
|
|
751
|
+
value.domain,
|
|
752
|
+
target.kind ?? "",
|
|
753
|
+
)?.schema
|
|
754
|
+
if (
|
|
755
|
+
deny._tag === "False" ||
|
|
756
|
+
Condition.satisfiable(deny as Condition.Condition, { schema })
|
|
757
|
+
._tag === "Unsatisfiable"
|
|
758
|
+
) {
|
|
759
|
+
return Decision.NotApplicable
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return Decision.Deny({})
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const checkWith = Effect.fnUntraced(function* (
|
|
766
|
+
{ principal, policy: value }: Snapshot,
|
|
767
|
+
target: AnyTarget,
|
|
768
|
+
) {
|
|
769
|
+
const kind =
|
|
770
|
+
target.resource === undefined
|
|
771
|
+
? (target.kind ?? "")
|
|
772
|
+
: (Resource.kindOf(target.resource) ?? "")
|
|
773
|
+
// A request that does not fit its kind gets a "no" at once. The
|
|
774
|
+
// decider must not say yes to an action that the kind does not have.
|
|
775
|
+
const descriptor = Domain.descriptorOf(domain, kind)
|
|
776
|
+
if (descriptor === undefined || !Policy.fitsKind(descriptor, target)) {
|
|
777
|
+
return yield* forbidden(target, kind, undefined)
|
|
778
|
+
}
|
|
779
|
+
const fromPolicy: Decision.Decision =
|
|
780
|
+
target.resource === undefined
|
|
781
|
+
? kindDecision(value, principal, target)
|
|
782
|
+
: yield* withServices(
|
|
783
|
+
Policy.decide(value, { ...target, principal } as never),
|
|
784
|
+
)
|
|
785
|
+
// When no rule gives an answer, the decider gives it.
|
|
786
|
+
const decision: Decision.Decision =
|
|
787
|
+
fromPolicy._tag === "NotApplicable" && options.decider !== undefined
|
|
788
|
+
? yield* options.decider({ ...target, principal })
|
|
789
|
+
: fromPolicy
|
|
790
|
+
if (decision._tag !== "Allow") {
|
|
791
|
+
return yield* forbidden(target, kind, Decision.reason(decision))
|
|
792
|
+
}
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
const check = (target: AnyTarget) =>
|
|
796
|
+
Effect.flatMap(current, (snapshot) => checkWith(snapshot, target))
|
|
797
|
+
|
|
798
|
+
const guard = Effect.fnUntraced(function* (
|
|
799
|
+
target: AnyTarget,
|
|
800
|
+
guardOptions: {
|
|
801
|
+
readonly read?: string
|
|
802
|
+
readonly hide: () => unknown
|
|
803
|
+
},
|
|
804
|
+
) {
|
|
805
|
+
const snapshot = yield* current
|
|
806
|
+
const read = guardOptions.read ?? "read"
|
|
807
|
+
const { field: _field, ...rest } = target
|
|
808
|
+
const visible: Decision.Decision = yield* decideWith(snapshot, {
|
|
809
|
+
...rest,
|
|
810
|
+
action: read,
|
|
811
|
+
})
|
|
812
|
+
if (visible._tag !== "Allow") {
|
|
813
|
+
return yield* Effect.fail(guardOptions.hide())
|
|
814
|
+
}
|
|
815
|
+
if (target.action !== read || target.field !== undefined) {
|
|
816
|
+
yield* checkWith(snapshot, target)
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
// The filter is the condition of the coverage. When the coverage is
|
|
821
|
+
// not known, it is the residual condition in the scope, which throws
|
|
822
|
+
// an error when there is none, so the filter fails.
|
|
823
|
+
const filter = (
|
|
824
|
+
target: AnyTarget,
|
|
825
|
+
): Effect.Effect<Condition.Ast, unknown, unknown> =>
|
|
826
|
+
Effect.map(current, ({ principal, policy: value }): Condition.Ast => {
|
|
827
|
+
const request = { ...target, principal } as never
|
|
828
|
+
const covered = Policy.coverage(value, request)
|
|
829
|
+
return covered._tag === "Unknown"
|
|
830
|
+
? Policy.residual(value, request)
|
|
831
|
+
: (covered.condition as Condition.Ast)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
const coverage = (
|
|
835
|
+
target: AnyTarget,
|
|
836
|
+
): Effect.Effect<Policy.Coverage<any, any, any, any>, unknown, unknown> =>
|
|
837
|
+
Effect.map(current, ({ principal, policy: value }) =>
|
|
838
|
+
Policy.coverage(value, { ...target, principal } as never),
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
const permittedFields = Effect.fnUntraced(function* (target: AnyTarget) {
|
|
842
|
+
const { principal, policy: value } = yield* current
|
|
843
|
+
return yield* withServices(
|
|
844
|
+
Policy.permittedFields(value, { ...target, principal } as never),
|
|
845
|
+
)
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const invalidate = (principal?: object) => {
|
|
849
|
+
if (cache === undefined) {
|
|
850
|
+
return Effect.void
|
|
851
|
+
}
|
|
852
|
+
if (principal === undefined) {
|
|
853
|
+
return Cache.invalidateAll(cache)
|
|
854
|
+
}
|
|
855
|
+
const key = keyOf(principal)
|
|
856
|
+
return key === undefined ? Effect.void : Cache.invalidate(cache, key)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
domain,
|
|
861
|
+
principal: options.principal,
|
|
862
|
+
policy: Effect.map(current, ({ policy: value }) => value),
|
|
863
|
+
decide,
|
|
864
|
+
check,
|
|
865
|
+
guard,
|
|
866
|
+
filter,
|
|
867
|
+
coverage,
|
|
868
|
+
permittedFields,
|
|
869
|
+
invalidate,
|
|
870
|
+
} as never
|
|
871
|
+
}),
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
|
|
311
875
|
/**
|
|
312
876
|
* Makes the layer of an authorizer.
|
|
313
877
|
*
|
|
@@ -327,4 +891,4 @@ export const layer: <
|
|
|
327
891
|
>(
|
|
328
892
|
service: ServiceClass<Self, Id, D, E, R>,
|
|
329
893
|
options: Options<D, NoInfer<E>, NoInfer<R>, EL, RL, RP>,
|
|
330
|
-
) => Layer.Layer<Self, never, RL | RP> =
|
|
894
|
+
) => Layer.Layer<Self, never, RL | RP> = makeLayer as never
|