@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.
Files changed (66) hide show
  1. package/README.md +199 -31
  2. package/dist/Authorizer.d.ts +64 -39
  3. package/dist/Authorizer.d.ts.map +1 -1
  4. package/dist/Authorizer.js +291 -6
  5. package/dist/Authorizer.js.map +1 -1
  6. package/dist/Condition.d.ts +444 -65
  7. package/dist/Condition.d.ts.map +1 -1
  8. package/dist/Condition.js +1007 -25
  9. package/dist/Condition.js.map +1 -1
  10. package/dist/Domain.d.ts +27 -17
  11. package/dist/Domain.d.ts.map +1 -1
  12. package/dist/Domain.js +52 -10
  13. package/dist/Domain.js.map +1 -1
  14. package/dist/HttpApi.d.ts +84 -40
  15. package/dist/HttpApi.d.ts.map +1 -1
  16. package/dist/HttpApi.js +103 -13
  17. package/dist/HttpApi.js.map +1 -1
  18. package/dist/Policy.d.ts +314 -56
  19. package/dist/Policy.d.ts.map +1 -1
  20. package/dist/Policy.js +1215 -38
  21. package/dist/Policy.js.map +1 -1
  22. package/dist/Principal.js +2 -2
  23. package/dist/Principal.js.map +1 -1
  24. package/dist/Resource.d.ts +171 -20
  25. package/dist/Resource.d.ts.map +1 -1
  26. package/dist/Resource.js +788 -15
  27. package/dist/Resource.js.map +1 -1
  28. package/dist/Rule.d.ts +115 -29
  29. package/dist/Rule.d.ts.map +1 -1
  30. package/dist/Rule.js +219 -12
  31. package/dist/Rule.js.map +1 -1
  32. package/dist/index.js +4 -4
  33. package/package.json +2 -2
  34. package/src/Authorizer.ts +625 -61
  35. package/src/Condition.ts +2403 -112
  36. package/src/Domain.ts +80 -23
  37. package/src/HttpApi.ts +225 -25
  38. package/src/Policy.ts +2836 -96
  39. package/src/Principal.ts +2 -2
  40. package/src/Resource.ts +1544 -29
  41. package/src/Rule.ts +463 -45
  42. package/dist/internal/authorizer.d.ts +0 -28
  43. package/dist/internal/authorizer.d.ts.map +0 -1
  44. package/dist/internal/authorizer.js +0 -153
  45. package/dist/internal/authorizer.js.map +0 -1
  46. package/dist/internal/condition.d.ts +0 -11
  47. package/dist/internal/condition.d.ts.map +0 -1
  48. package/dist/internal/condition.js +0 -393
  49. package/dist/internal/condition.js.map +0 -1
  50. package/dist/internal/domain.js +0 -34
  51. package/dist/internal/domain.js.map +0 -1
  52. package/dist/internal/httpApi.js +0 -41
  53. package/dist/internal/httpApi.js.map +0 -1
  54. package/dist/internal/policy.js +0 -524
  55. package/dist/internal/policy.js.map +0 -1
  56. package/dist/internal/resource.js +0 -66
  57. package/dist/internal/resource.js.map +0 -1
  58. package/dist/internal/rule.js +0 -131
  59. package/dist/internal/rule.js.map +0 -1
  60. package/src/internal/authorizer.ts +0 -271
  61. package/src/internal/condition.ts +0 -607
  62. package/src/internal/domain.ts +0 -56
  63. package/src/internal/httpApi.ts +0 -84
  64. package/src/internal/policy.ts +0 -815
  65. package/src/internal/resource.ts +0 -98
  66. package/src/internal/rule.ts +0 -203
package/src/Domain.ts CHANGED
@@ -8,30 +8,19 @@
8
8
  * ```ts
9
9
  * import { Domain } from "@nmnmcc/ability"
10
10
  *
11
- * const App = Domain.make({ principal: Caller, resources: [Bot, Trigger] })
11
+ * const App = Domain.make({ principal: Member, resources: [Document, Comment] })
12
12
  * ```
13
13
  *
14
14
  * @stability stable
15
15
  * @since 0.3.0
16
16
  */
17
+ import * as Arr from "effect/Array"
18
+ import * as Data from "effect/Data"
19
+ import * as HashMap from "effect/HashMap"
20
+ import * as Option from "effect/Option"
17
21
  import type * as Schema from "effect/Schema"
18
- import * as internal from "./internal/domain.ts"
19
22
  import type * as PrincipalSchema from "./Principal.ts"
20
- import type * as Resource from "./Resource.ts"
21
-
22
- /**
23
- * @stability stable
24
- * @category type IDs
25
- * @since 0.3.0
26
- */
27
- export const TypeId: "~@nmnmcc/ability/Domain" = internal.TypeId
28
-
29
- /**
30
- * @stability stable
31
- * @category type IDs
32
- * @since 0.3.0
33
- */
34
- export type TypeId = typeof TypeId
23
+ import * as Resource from "./Resource.ts"
35
24
 
36
25
  /**
37
26
  * A domain. `P` is the type of the principal, `Resources` is the union of the
@@ -46,9 +35,9 @@ export interface Domain<
46
35
  Resources extends Resource.Any,
47
36
  Meta,
48
37
  > {
49
- readonly [TypeId]: TypeId
50
38
  readonly principal: PrincipalSchema.Any
51
- readonly resources: ReadonlyMap<string, Resource.Descriptor>
39
+ /** The descriptor of each resource, by its kind name. */
40
+ readonly resources: HashMap.HashMap<string, Resource.Descriptor>
52
41
  readonly meta: Schema.Top | undefined
53
42
  readonly "~Domain"?: {
54
43
  readonly Principal: P
@@ -125,6 +114,21 @@ export type ResourceOf<D, K extends string> =
125
114
  */
126
115
  export type ActionOf<D, K extends string> = Resource.Action<ResourceOf<D, K>>
127
116
 
117
+ /**
118
+ * The actions that every kind in `K` has. For one kind, they are its actions.
119
+ * For a union of kinds, an action of only some of them is left out, so a
120
+ * value of the union type can not get an action that its kind does not have.
121
+ *
122
+ * @stability stable
123
+ * @category utility types
124
+ * @since 1.0.0
125
+ */
126
+ export type CommonAction<D, K extends string> = (
127
+ K extends unknown ? (action: ActionOf<D, K>) => void : never
128
+ ) extends (action: infer A) => void
129
+ ? A
130
+ : never
131
+
128
132
  /**
129
133
  * All the actions of all the resources of a domain.
130
134
  *
@@ -161,9 +165,20 @@ export type ValueOf<D, K extends string> = Resource.Value<ResourceOf<D, K>>
161
165
  */
162
166
  export type AnyValue<D> = Resource.Value<Resources<D>>
163
167
 
168
+ /**
169
+ * The class of every domain value. {@link isDomain} uses it to know a domain.
170
+ */
171
+ class DomainData extends Data.Class<{
172
+ readonly principal: PrincipalSchema.Any
173
+ readonly resources: HashMap.HashMap<string, Resource.Descriptor>
174
+ readonly meta: Schema.Top | undefined
175
+ }> {}
176
+
164
177
  /**
165
178
  * Makes a domain. Each resource must have its own kind name. Give `meta` when
166
- * rules must keep extra data, such as the scope of a grant.
179
+ * rules must keep extra data, such as the scope of a grant. The `meta` Schema
180
+ * may not need services, because `Policy.schema` gives a Schema that needs
181
+ * none.
167
182
  *
168
183
  * @stability stable
169
184
  * @category constructors
@@ -172,12 +187,54 @@ export type AnyValue<D> = Resource.Value<Resources<D>>
172
187
  export const make: <
173
188
  P extends PrincipalSchema.Any,
174
189
  const Resources extends ReadonlyArray<Resource.Any>,
175
- M extends Schema.Top = Schema.Never,
190
+ M extends Schema.Codec<any, any, never, never> = Schema.Never,
176
191
  >(options: {
177
192
  readonly principal: P
178
193
  readonly resources: Resources
179
194
  readonly meta?: M
180
- }) => Domain<P["Type"], Resources[number], M["Type"]> = internal.make
195
+ }) => Domain<P["Type"], Resources[number], M["Type"]> = (options) => {
196
+ const descriptors = Arr.map(options.resources, Resource.descriptor)
197
+ const kinds = Arr.map(descriptors, (descriptor) => descriptor.kind)
198
+ // Two resources with one kind name is a mistake in the code, not in the
199
+ // data, so it is a defect.
200
+ const twice = Arr.findFirst(
201
+ kinds,
202
+ (kind, index) => kinds.indexOf(kind) !== index,
203
+ )
204
+ if (Option.isSome(twice)) {
205
+ throw new Error(`Two resources have the kind name "${twice.value}"`)
206
+ }
207
+ if (kinds.includes("*")) {
208
+ throw new Error(`A resource may not have the kind name "*"`)
209
+ }
210
+ const resources = HashMap.fromIterable(
211
+ Arr.map(
212
+ descriptors,
213
+ (descriptor) => [descriptor.kind, descriptor] as const,
214
+ ),
215
+ )
216
+ // This checks the field names of the principal. See `Resource.fieldPaths`.
217
+ Resource.fieldPaths(options.principal)
218
+ return new DomainData({
219
+ principal: options.principal,
220
+ resources,
221
+ meta: options.meta,
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Gives the descriptor of the resource with a kind name, or `undefined` when
227
+ * the domain has no such resource.
228
+ *
229
+ * @stability stable
230
+ * @category getters
231
+ * @since 1.0.0
232
+ */
233
+ export const descriptorOf = (
234
+ domain: Any,
235
+ kind: string,
236
+ ): Resource.Descriptor | undefined =>
237
+ Option.getOrUndefined(HashMap.get(domain.resources, kind))
181
238
 
182
239
  /**
183
240
  * Tests if a value is a domain.
@@ -186,4 +243,4 @@ export const make: <
186
243
  * @category guards
187
244
  * @since 0.3.0
188
245
  */
189
- export const isDomain: (u: unknown) => u is Any = internal.isDomain
246
+ export const isDomain = (u: unknown): u is Any => u instanceof DomainData
package/src/HttpApi.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Each endpoint says what it needs with the {@link Required} annotation. A
5
5
  * group can give a default with {@link GroupRequired}: one list for GET
6
6
  * requests and one list for all other methods. A middleware checks the
7
- * requirements before the handler runs. An endpoint with no annotation is a
8
- * defect, so a new endpoint is closed until you say what it needs. An empty
9
- * list means the endpoint is open.
7
+ * requirements before the handler runs, in each group that uses it. An
8
+ * endpoint with no annotation is a defect, so a new endpoint is closed until
9
+ * you say what it needs. An empty list means the endpoint is open. An endpoint
10
+ * in a group without the middleware is not checked at all.
10
11
  *
11
12
  * ```ts
12
13
  * class Authorization extends HttpApiMiddleware.Service<
@@ -14,11 +15,13 @@
14
15
  * { requires: Identity }
15
16
  * >()("app/Authorization", { error: HttpApi.errors }) {}
16
17
  *
17
- * const endpoint = HttpApiEndpoint.patch("update", "/bots/:id", { ... })
18
+ * const endpoint = HttpApiEndpoint.patch("update", "/notes/:id", { ... })
18
19
  * .annotate(HttpApi.Required, [
19
- * HttpApi.requires(Auth, "update", "Bot", (params) => Bots.find(params.id)),
20
+ * HttpApi.requires(Auth, "update", "Note", (params) => Notes.find(params.id)),
20
21
  * ])
21
22
  *
23
+ * const notes = HttpApiGroup.make("notes").add(endpoint).middleware(Authorization)
24
+ *
22
25
  * const AuthorizationLive = HttpApi.layer(Authorization, Auth)
23
26
  * ```
24
27
  *
@@ -28,19 +31,43 @@
28
31
  * @stability unstable
29
32
  * @since 0.3.0
30
33
  */
34
+ // A `load` function of a requirement is kept in an annotation, so its error
35
+ // type and its service type are erased to `unknown`. The language service
36
+ // check for `unknown` in an Effect is off here.
37
+ // @effect-diagnostics anyUnknownInErrorContext:off
31
38
  import * as Context from "effect/Context"
32
- import type * as Effect from "effect/Effect"
33
- import type * as Layer from "effect/Layer"
39
+ import * as Effect from "effect/Effect"
40
+ import * as Layer from "effect/Layer"
41
+ import * as Option from "effect/Option"
42
+ import * as Schema from "effect/Schema"
43
+ import * as SchemaTransformation from "effect/SchemaTransformation"
44
+ import { HttpRouter } from "effect/http"
34
45
  import { HttpApiSchema } from "effect/http-api"
35
46
  import * as Authorizer from "./Authorizer.ts"
36
47
  import type * as Domain from "./Domain.ts"
37
- import * as internal from "./internal/httpApi.ts"
48
+ import type * as Policy from "./Policy.ts"
49
+ import * as Resource from "./Resource.ts"
50
+
51
+ /**
52
+ * The path parameters of a request.
53
+ *
54
+ * @stability unstable
55
+ * @category models
56
+ * @since 1.0.0
57
+ */
58
+ export type Params = Readonly<Record<string, string | undefined>>
38
59
 
39
60
  /**
40
- * One check that an endpoint needs: an action on a kind of resource. With
41
- * `load`, the middleware loads the resource value from the path parameters
42
- * and checks the action on that value. Without `load`, it checks the action
43
- * on the kind.
61
+ * One check that an endpoint needs: an action on a kind of resource.
62
+ * - With `load`, the middleware loads the resource value from the path
63
+ * parameters and checks the action on that value.
64
+ * - With `within`, it makes a scope from the path parameters and checks the
65
+ * action on every value in the scope.
66
+ * - With `list`, the endpoint gives a list. The handler gets the list query
67
+ * with `Authorizer.filter`, so the middleware checks nothing, unless
68
+ * `nonEmpty` is true: then it fails when the principal may act on no value
69
+ * in the scope.
70
+ * - With none of them, it checks the action on every value of the kind.
44
71
  *
45
72
  * @stability unstable
46
73
  * @category models
@@ -49,9 +76,9 @@ import * as internal from "./internal/httpApi.ts"
49
76
  export interface Requirement {
50
77
  readonly action: string
51
78
  readonly kind: string
52
- readonly load?: (
53
- params: Readonly<Record<string, string | undefined>>,
54
- ) => Effect.Effect<object, unknown, unknown>
79
+ readonly load?: (params: Params) => Effect.Effect<object, unknown, unknown>
80
+ readonly within?: (params: Params) => unknown
81
+ readonly list?: { readonly nonEmpty: boolean }
55
82
  }
56
83
 
57
84
  /**
@@ -84,10 +111,17 @@ export class GroupRequired extends Context.Service<
84
111
 
85
112
  /**
86
113
  * Makes a {@link Requirement} with the types of an authorizer. The action must
87
- * be an action of the kind, and `load` must give a value of the kind.
114
+ * be an action of the kind, also when the kind has a union type.
88
115
  *
89
- * `load` may fail. Its errors must be errors of the middleware, and the
90
- * services that it needs must be given to {@link layer}.
116
+ * The last argument is one of:
117
+ * - a `load` function, which must give a value of the kind. It may fail. Its
118
+ * errors must be errors of the middleware, and the services that it needs
119
+ * must be given to {@link layer}.
120
+ * - `{ within }`, a function from the path parameters to a scope. Use it for
121
+ * an act on a part of the kind, such as a new value with no id yet:
122
+ * `{ within: (params) => ($) => $.under("org", params.org ?? null) }`. A path
123
+ * parameter may be `undefined`, and a scope with `undefined` in it is a
124
+ * defect, so give a value for it.
91
125
  *
92
126
  * @stability unstable
93
127
  * @category constructors
@@ -98,27 +132,193 @@ export const requires: <
98
132
  const K extends Domain.Kind<A["domain"]>,
99
133
  >(
100
134
  authorizer: A,
101
- action: Domain.ActionOf<A["domain"], K>,
135
+ action: Domain.CommonAction<A["domain"], K>,
136
+ kind: K,
137
+ how?:
138
+ | ((
139
+ params: Params,
140
+ ) => Effect.Effect<Domain.ValueOf<A["domain"], K>, unknown, unknown>)
141
+ | { readonly within: (params: Params) => Policy.Scope<A["domain"], K> },
142
+ ) => Requirement = (_authorizer, action, kind, how) =>
143
+ how === undefined
144
+ ? { action: action as string, kind }
145
+ : typeof how === "function"
146
+ ? { action: action as string, kind, load: how }
147
+ : { action: action as string, kind, within: how.within }
148
+
149
+ /**
150
+ * Makes a {@link Requirement} for an endpoint that gives a list. The middleware
151
+ * does not check that the principal may act on every value, because the list
152
+ * shows only the values that it may act on. The handler gets the list query
153
+ * with `Authorizer.filter`, with the same scope.
154
+ *
155
+ * When the principal may act on no value, the list is empty: that is not an
156
+ * error, so a client does not learn that hidden values are there. With
157
+ * `nonEmpty: true`, the middleware fails with `Forbidden` in that case. It
158
+ * also fails when the coverage is `Unknown`, because then it can not say that
159
+ * the list has a value.
160
+ *
161
+ * @stability unstable
162
+ * @category constructors
163
+ * @since 1.0.0
164
+ */
165
+ export const lists: <
166
+ A extends { readonly domain: Domain.Any },
167
+ const K extends Domain.Kind<A["domain"]>,
168
+ >(
169
+ authorizer: A,
170
+ action: Domain.CommonAction<A["domain"], K>,
102
171
  kind: K,
103
- load?: (
104
- params: Readonly<Record<string, string | undefined>>,
105
- ) => Effect.Effect<Domain.ValueOf<A["domain"], K>, unknown, unknown>,
106
- ) => Requirement = internal.requires
172
+ options?: {
173
+ readonly within?: (params: Params) => Policy.Scope<A["domain"], K>
174
+ readonly nonEmpty?: boolean
175
+ },
176
+ ) => Requirement = (_authorizer, action, kind, options) => ({
177
+ action: action as string,
178
+ kind,
179
+ list: { nonEmpty: options?.nonEmpty ?? false },
180
+ ...(options?.within === undefined ? {} : { within: options.within }),
181
+ })
182
+
183
+ /**
184
+ * The Schema of a `PolicyUnavailable` response. It encodes only the `_tag`, so
185
+ * the cause does not go to the client.
186
+ */
187
+ const PolicyUnavailableResponse = Schema.TaggedStruct(
188
+ "PolicyUnavailable",
189
+ {},
190
+ ).pipe(
191
+ Schema.decodeTo(
192
+ Schema.declare(
193
+ (u): u is Authorizer.PolicyUnavailable =>
194
+ u instanceof Authorizer.PolicyUnavailable,
195
+ ),
196
+ SchemaTransformation.transform({
197
+ decode: () => Authorizer.PolicyUnavailable.make({ cause: undefined }),
198
+ encode: () => ({ _tag: "PolicyUnavailable" as const }),
199
+ }),
200
+ ),
201
+ )
107
202
 
108
203
  /**
109
204
  * The errors of the middleware, with HTTP status codes: 403 for
110
205
  * `Forbidden`, 503 for `PolicyUnavailable`, and 409 for `Pending`.
111
206
  *
207
+ * The body of a `PolicyUnavailable` response has only its `_tag`. The cause
208
+ * may have private details, such as an error from the database, so it stays
209
+ * on the server.
210
+ *
112
211
  * @stability unstable
113
212
  * @category errors
114
213
  * @since 0.3.0
115
214
  */
116
215
  export const errors = [
117
216
  Authorizer.Forbidden.pipe(HttpApiSchema.status(403)),
118
- Authorizer.PolicyUnavailable.pipe(HttpApiSchema.status(503)),
217
+ PolicyUnavailableResponse.pipe(HttpApiSchema.status(503)),
119
218
  Authorizer.Pending.pipe(HttpApiSchema.status(409)),
120
219
  ] as const
121
220
 
221
+ const makeLayer = (
222
+ middleware: Context.ServiceClass<any, string, any>,
223
+ authorizer: Authorizer.AnyServiceClass,
224
+ ) =>
225
+ Layer.effect(
226
+ middleware,
227
+ Effect.gen(function* () {
228
+ const service = yield* authorizer
229
+ const services = yield* Effect.context<never>()
230
+ return Effect.fnUntraced(function* (
231
+ httpEffect: Effect.Effect<unknown, unknown, unknown>,
232
+ options: {
233
+ readonly endpoint: {
234
+ readonly name: string
235
+ readonly method: string
236
+ readonly annotations: Context.Context<never>
237
+ }
238
+ readonly group: {
239
+ readonly identifier: string
240
+ readonly annotations: Context.Context<never>
241
+ }
242
+ },
243
+ ) {
244
+ const requirements = Option.orElse(
245
+ Context.getOption(options.endpoint.annotations, Required),
246
+ () =>
247
+ Option.map(
248
+ Context.getOption(options.group.annotations, GroupRequired),
249
+ (rules) =>
250
+ options.endpoint.method === "GET" ? rules.read : rules.write,
251
+ ),
252
+ )
253
+ if (Option.isNone(requirements)) {
254
+ return yield* Effect.die(
255
+ new Error(
256
+ `The endpoint ${options.group.identifier}.${options.endpoint.name} does not say what it needs. Give it the HttpApi.Required annotation.`,
257
+ ),
258
+ )
259
+ }
260
+ const endpoint = `${options.group.identifier}.${options.endpoint.name}`
261
+ // The requirements are checked in order. The first one that fails
262
+ // stops the request.
263
+ yield* Effect.forEach(
264
+ requirements.value,
265
+ ({ action, kind, load, within, list }) => {
266
+ if (load !== undefined) {
267
+ return HttpRouter.params.pipe(
268
+ Effect.flatMap((params) =>
269
+ Effect.provideContext(load(params), services),
270
+ ),
271
+ // The check is for the kind of the requirement. A value of
272
+ // another kind is a mistake in `load`, and the check must
273
+ // not pass with the rules of that other kind.
274
+ Effect.filterOrElse(
275
+ (value) => Resource.kindOf(value) === kind,
276
+ () =>
277
+ Effect.die(
278
+ new Error(
279
+ `The load function of ${endpoint} gave a value that is not a ${kind}`,
280
+ ),
281
+ ),
282
+ ),
283
+ Effect.flatMap((value) =>
284
+ service.check({ action, resource: value } as never),
285
+ ),
286
+ )
287
+ }
288
+ const target =
289
+ within === undefined
290
+ ? Effect.succeed({ action, kind } as never)
291
+ : Effect.map(
292
+ HttpRouter.params,
293
+ (params) =>
294
+ ({ action, kind, within: within(params) }) as never,
295
+ )
296
+ if (list === undefined) {
297
+ return Effect.flatMap(target, (value) => service.check(value))
298
+ }
299
+ // A list endpoint may give an empty list, unless it must not.
300
+ // Only `All` and `Some` say that the list has a value. `Unknown`
301
+ // is not a "yes".
302
+ return list.nonEmpty
303
+ ? target.pipe(
304
+ Effect.flatMap((value) => service.coverage(value)),
305
+ Effect.filterOrElse(
306
+ (covered) =>
307
+ (covered as { readonly _tag: string })._tag === "All" ||
308
+ (covered as { readonly _tag: string })._tag === "Some",
309
+ () =>
310
+ Effect.fail(Authorizer.Forbidden.make({ action, kind })),
311
+ ),
312
+ )
313
+ : Effect.void
314
+ },
315
+ { discard: true },
316
+ )
317
+ return yield* httpEffect
318
+ })
319
+ }),
320
+ )
321
+
122
322
  /**
123
323
  * Makes the layer of an authorization middleware. The middleware reads the
124
324
  * {@link Required} and {@link GroupRequired} annotations and checks each
@@ -145,4 +345,4 @@ export const layer: <
145
345
  Self,
146
346
  never,
147
347
  (A extends Context.Key<infer I, any> ? I : never) | R
148
- > = internal.layer as never
348
+ > = makeLayer as never