@nmnmcc/ability 2.0.7 → 2.1.2

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 (87) hide show
  1. package/README.md +52 -29
  2. package/dist/Authorizer.d.ts +21 -12
  3. package/dist/Authorizer.d.ts.map +1 -1
  4. package/dist/Authorizer.js.map +1 -1
  5. package/dist/Condition.d.ts +121 -19
  6. package/dist/Condition.d.ts.map +1 -1
  7. package/dist/Condition.js +32 -21
  8. package/dist/Condition.js.map +1 -1
  9. package/dist/Domain.d.ts +9 -0
  10. package/dist/Domain.d.ts.map +1 -1
  11. package/dist/Domain.js +5 -0
  12. package/dist/Domain.js.map +1 -1
  13. package/dist/Limits.d.ts +44 -13
  14. package/dist/Limits.d.ts.map +1 -1
  15. package/dist/Limits.js +52 -22
  16. package/dist/Limits.js.map +1 -1
  17. package/dist/Policy.d.ts +102 -18
  18. package/dist/Policy.d.ts.map +1 -1
  19. package/dist/Policy.js +7 -6
  20. package/dist/Policy.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/internal/algebra.js +2 -2
  23. package/dist/internal/analysis.js +81 -75
  24. package/dist/internal/analysis.js.map +1 -1
  25. package/dist/internal/authorizer.d.ts.map +1 -1
  26. package/dist/internal/authorizer.js +11 -7
  27. package/dist/internal/authorizer.js.map +1 -1
  28. package/dist/internal/bind.js +86 -64
  29. package/dist/internal/bind.js.map +1 -1
  30. package/dist/internal/catalog.d.ts +33 -10
  31. package/dist/internal/catalog.d.ts.map +1 -1
  32. package/dist/internal/catalog.js +170 -77
  33. package/dist/internal/catalog.js.map +1 -1
  34. package/dist/internal/decide.js +27 -31
  35. package/dist/internal/decide.js.map +1 -1
  36. package/dist/internal/domain.js +19 -5
  37. package/dist/internal/domain.js.map +1 -1
  38. package/dist/internal/json.js +120 -76
  39. package/dist/internal/json.js.map +1 -1
  40. package/dist/internal/limits.d.ts +28 -0
  41. package/dist/internal/limits.d.ts.map +1 -1
  42. package/dist/internal/limits.js +75 -16
  43. package/dist/internal/limits.js.map +1 -1
  44. package/dist/internal/nodes.d.ts.map +1 -1
  45. package/dist/internal/nodes.js +53 -44
  46. package/dist/internal/nodes.js.map +1 -1
  47. package/dist/internal/patterns.js +50 -15
  48. package/dist/internal/patterns.js.map +1 -1
  49. package/dist/internal/policy-json.js +169 -127
  50. package/dist/internal/policy-json.js.map +1 -1
  51. package/dist/internal/policy-nodes.d.ts.map +1 -1
  52. package/dist/internal/policy-nodes.js +16 -13
  53. package/dist/internal/policy-nodes.js.map +1 -1
  54. package/dist/internal/policy.d.ts.map +1 -1
  55. package/dist/internal/policy.js +38 -22
  56. package/dist/internal/policy.js.map +1 -1
  57. package/dist/internal/resource.js +1 -1
  58. package/dist/internal/rule.d.ts.map +1 -1
  59. package/dist/internal/rule.js +6 -5
  60. package/dist/internal/rule.js.map +1 -1
  61. package/dist/internal/search.d.ts.map +1 -1
  62. package/dist/internal/search.js +106 -92
  63. package/dist/internal/search.js.map +1 -1
  64. package/dist/internal/trampoline.js +19 -5
  65. package/dist/internal/trampoline.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/Authorizer.ts +30 -20
  68. package/src/Condition.ts +317 -86
  69. package/src/Domain.ts +9 -0
  70. package/src/Limits.ts +69 -29
  71. package/src/Policy.ts +143 -21
  72. package/src/internal/analysis.ts +154 -124
  73. package/src/internal/authorizer.ts +29 -14
  74. package/src/internal/bind.ts +193 -136
  75. package/src/internal/catalog.ts +320 -118
  76. package/src/internal/decide.ts +61 -50
  77. package/src/internal/domain.ts +39 -3
  78. package/src/internal/json.ts +212 -117
  79. package/src/internal/limits.ts +101 -31
  80. package/src/internal/nodes.ts +65 -47
  81. package/src/internal/patterns.ts +79 -25
  82. package/src/internal/policy-json.ts +266 -197
  83. package/src/internal/policy-nodes.ts +31 -23
  84. package/src/internal/policy.ts +63 -23
  85. package/src/internal/rule.ts +16 -3
  86. package/src/internal/search.ts +186 -136
  87. package/src/internal/trampoline.ts +51 -4
@@ -22,11 +22,13 @@ import { decideNode, fitsKind } from "./decide.ts"
22
22
  import * as DomainData from "./domain.ts"
23
23
  import * as Limits from "./limits.ts"
24
24
  import * as Nodes from "./nodes.ts"
25
+ import * as Place from "./place.ts"
25
26
  import * as PolicyNodes from "./policy-nodes.ts"
26
27
  import { nodeOf } from "./policy.ts"
27
28
  import * as ResourceData from "./resource.ts"
28
29
  import * as RuleData from "./rule.ts"
29
30
  import * as Search from "./search.ts"
31
+ import * as Trampoline from "./trampoline.ts"
30
32
 
31
33
  type Any = Policy.Any
32
34
 
@@ -50,25 +52,28 @@ interface Build {
50
52
  readonly not: (value: Ast) => Ast
51
53
  }
52
54
 
53
- const limit = (node: Nodes.Node): Ast => {
54
- // A residual grows by its nodes, as the conditions of the rules come
55
- // together. Its values come from the policy, which has a limit of them.
56
- if (node.size > Limits.residualNodes) {
57
- throw Limits.exceeded(
58
- "A residual condition",
59
- "residualNodes",
60
- "nodes",
61
- node.size,
62
- Limits.residualNodes,
63
- )
55
+ /** The steps of a residual for a domain with these limits. */
56
+ const buildFor = (limits: Limits.Limits): Build => {
57
+ const most = limits.residualNodes
58
+ const limit = (node: Nodes.Node): Ast => {
59
+ // A residual grows by its nodes, as the conditions of the rules come
60
+ // together. Its values come from the policy, which has a limit of them.
61
+ if (node.size > most) {
62
+ throw Limits.exceeded(
63
+ "A residual condition",
64
+ "residualNodes",
65
+ "nodes",
66
+ node.size,
67
+ most,
68
+ )
69
+ }
70
+ return node
71
+ }
72
+ return {
73
+ and: (values) => limit(Algebra.and(values as ReadonlyArray<Nodes.Node>)),
74
+ or: (values) => limit(Algebra.or(values as ReadonlyArray<Nodes.Node>)),
75
+ not: (value) => (value as Nodes.Node).pipe(Algebra.not, limit),
64
76
  }
65
- return node
66
- }
67
-
68
- const build: Build = {
69
- and: (values) => limit(Algebra.and(values as ReadonlyArray<Nodes.Node>)),
70
- or: (values) => limit(Algebra.or(values as ReadonlyArray<Nodes.Node>)),
71
- not: (value) => (value as Nodes.Node).pipe(Algebra.not, limit),
72
77
  }
73
78
 
74
79
  /** The two residual conditions of a node, with no types. */
@@ -89,6 +94,10 @@ interface ResidualContext {
89
94
  readonly descriptor: Resource.Descriptor
90
95
  /** The test of `RuleData.fitsOf` for the catalog, for this request. */
91
96
  readonly fits: (pattern: string) => boolean
97
+ /** The most parts of a field path in the domain. */
98
+ readonly parts: number
99
+ /** The steps of a residual, with the limits of the domain. */
100
+ readonly build: Build
92
101
  /**
93
102
  * The condition of each rule, with the principal in it. They are made
94
103
  * first, from a flat list of the rules, so the walk does not go down the
@@ -120,7 +129,7 @@ const firstApplicableResidual = (
120
129
  values: ReadonlyArray<RuleData.Rule>,
121
130
  context: ResidualContext,
122
131
  ): Pair => {
123
- const { and, not, or } = build
132
+ const { and, not, or } = context.build
124
133
  // The rules go in runs of one tag, from the last run to the first. The
125
134
  // conditions of a run go into one node at once. With one node for each
126
135
  // rule, each step would flatten the whole condition again.
@@ -149,90 +158,104 @@ const firstApplicableResidual = (
149
158
  * Gives the residual pair of a node. A shared child is worked again for each
150
159
  * place. The limits of the nodes and of a residual keep this work small.
151
160
  */
152
- const residualNode = (
153
- node: PolicyNodes.Node,
154
- context: ResidualContext,
155
- ): Pair => {
156
- const { and, not, or } = build
157
- switch (node._tag) {
158
- case "Rules": {
159
- switch (node.algorithm) {
160
- case "denyOverrides": {
161
- const deny = or(
162
- node.rules
163
- .filter((value) => value._tag === "Deny")
164
- .map((value) => ruleCondition(value, context)),
165
- )
166
- const allow = and([
167
- not(deny),
168
- or(
169
- node.rules
170
- .filter((value) => value._tag === "Allow")
171
- .map((value) => ruleCondition(value, context)),
161
+ const residualNode = (root: PolicyNodes.Node, context: ResidualContext): Pair =>
162
+ // The walk of the tree takes the steps of a trampoline, so a deep tree
163
+ // does not fill the stack.
164
+ Trampoline.run(root.depth, (R) => {
165
+ const { and, not, or } = context.build
166
+ const children = (nodes: ReadonlyArray<PolicyNodes.Node>) =>
167
+ R.forEach(nodes, (child) => R.suspend(() => go(child)))
168
+ const go = (node: PolicyNodes.Node): Trampoline.Step<Pair> => {
169
+ switch (node._tag) {
170
+ case "Rules":
171
+ return R.succeed(rulesResidual(node, context))
172
+ case "All":
173
+ if (node.policies.length === 0) {
174
+ return R.succeed({ allow: never, deny: never })
175
+ }
176
+ return R.map(children(node.policies), (pairs) => {
177
+ const deny = or(pairs.map((pair) => pair.deny))
178
+ const allow = and([...pairs.map((pair) => pair.allow), not(deny)])
179
+ return { allow, deny }
180
+ })
181
+ case "Any":
182
+ return R.map(children(node.policies), (pairs) => {
183
+ const allow = or(pairs.map((pair) => pair.allow))
184
+ const deny = and([not(allow), or(pairs.map((pair) => pair.deny))])
185
+ return { allow, deny }
186
+ })
187
+ case "FirstOf":
188
+ // The first child that gives an answer wins.
189
+ return R.map(children(node.policies), (pairs) =>
190
+ Arr.reduceRight(
191
+ pairs,
192
+ { allow: never, deny: never },
193
+ (rest: Pair, pair): Pair => ({
194
+ allow: or([pair.allow, and([not(pair.deny), rest.allow])]),
195
+ deny: or([pair.deny, and([not(pair.allow), rest.deny])]),
196
+ }),
172
197
  ),
173
- ])
174
- return { allow, deny }
175
- }
176
- case "permitOverrides": {
177
- const allow = or(
178
- node.rules
179
- .filter((value) => value._tag === "Allow")
180
- .map((value) => ruleCondition(value, context)),
181
198
  )
182
- const deny = and([
183
- not(allow),
184
- or(
185
- node.rules
186
- .filter((value) => value._tag === "Deny")
187
- .map((value) => ruleCondition(value, context)),
188
- ),
189
- ])
190
- return { allow, deny }
191
- }
192
- case "firstApplicable":
193
- return firstApplicableResidual(node.rules, context)
194
- case "lastApplicable":
195
- return firstApplicableResidual(node.rules.slice().reverse(), context)
196
- }
197
- }
198
- // falls through: every algorithm returns above
199
- case "All": {
200
- if (node.policies.length === 0) {
201
- return { allow: never, deny: never }
199
+ case "Ceiling":
200
+ return R.flatMap(
201
+ R.suspend(() => go(node.cap)),
202
+ (cap) =>
203
+ R.map(
204
+ R.suspend(() => go(node.policy)),
205
+ (inner) => ({
206
+ allow: and([not(cap.deny), inner.allow]),
207
+ deny: or([cap.deny, inner.deny]),
208
+ }),
209
+ ),
210
+ )
202
211
  }
203
- const pairs = node.policies.map((child) => residualNode(child, context))
204
- const deny = or(pairs.map((pair) => pair.deny))
205
- const allow = and([...pairs.map((pair) => pair.allow), not(deny)])
206
- return { allow, deny }
207
212
  }
208
- case "Any": {
209
- const pairs = node.policies.map((child) => residualNode(child, context))
210
- const allow = or(pairs.map((pair) => pair.allow))
211
- const deny = and([not(allow), or(pairs.map((pair) => pair.deny))])
213
+ return go(root)
214
+ })
215
+
216
+ /** The residual of a list of rules, by its combining algorithm. */
217
+ const rulesResidual = (
218
+ node: PolicyNodes.Rules,
219
+ context: ResidualContext,
220
+ ): Pair => {
221
+ const { and, not, or } = context.build
222
+ switch (node.algorithm) {
223
+ case "denyOverrides": {
224
+ const deny = or(
225
+ node.rules
226
+ .filter((value) => value._tag === "Deny")
227
+ .map((value) => ruleCondition(value, context)),
228
+ )
229
+ const allow = and([
230
+ not(deny),
231
+ or(
232
+ node.rules
233
+ .filter((value) => value._tag === "Allow")
234
+ .map((value) => ruleCondition(value, context)),
235
+ ),
236
+ ])
212
237
  return { allow, deny }
213
238
  }
214
- case "FirstOf": {
215
- // The first child that gives an answer wins.
216
- return Arr.reduceRight(
217
- node.policies,
218
- { allow: never, deny: never },
219
- (rest: Pair, child): Pair => {
220
- const pair = residualNode(child, context)
221
- return {
222
- allow: or([pair.allow, and([not(pair.deny), rest.allow])]),
223
- deny: or([pair.deny, and([not(pair.allow), rest.deny])]),
224
- }
225
- },
239
+ case "permitOverrides": {
240
+ const allow = or(
241
+ node.rules
242
+ .filter((value) => value._tag === "Allow")
243
+ .map((value) => ruleCondition(value, context)),
226
244
  )
245
+ const deny = and([
246
+ not(allow),
247
+ or(
248
+ node.rules
249
+ .filter((value) => value._tag === "Deny")
250
+ .map((value) => ruleCondition(value, context)),
251
+ ),
252
+ ])
253
+ return { allow, deny }
227
254
  }
228
- case "Ceiling": {
229
- const cap = residualNode(node.cap, context)
230
- const inner = residualNode(node.policy, context)
231
- return {
232
- allow: and([not(cap.deny), inner.allow]),
233
- deny: or([cap.deny, inner.deny]),
234
- }
235
- }
255
+ case "firstApplicable":
256
+ return firstApplicableResidual(node.rules, context)
257
+ case "lastApplicable":
258
+ return firstApplicableResidual(node.rules.slice().reverse(), context)
236
259
  }
237
260
  }
238
261
 
@@ -252,28 +275,15 @@ interface PredicateContext {
252
275
  const usesPredicate = (
253
276
  node: PolicyNodes.Node,
254
277
  context: PredicateContext,
255
- ): boolean => {
256
- if (!node.opaque) {
257
- return false
258
- }
259
- switch (node._tag) {
260
- case "Rules":
261
- return node.rules.some(
262
- (value) =>
263
- value.when !== undefined &&
264
- Nodes.toNode(value.when).opaque &&
265
- RuleData.matchesTarget(value, context),
266
- )
267
- case "All":
268
- case "Any":
269
- case "FirstOf":
270
- return node.policies.some((child) => usesPredicate(child, context))
271
- case "Ceiling":
272
- return (
273
- usesPredicate(node.cap, context) || usesPredicate(node.policy, context)
274
- )
275
- }
276
- }
278
+ ): boolean =>
279
+ node.opaque &&
280
+ Arr.some(
281
+ PolicyNodes.rulesOf(node),
282
+ (value) =>
283
+ value.when !== undefined &&
284
+ Nodes.toNode(value.when).opaque &&
285
+ RuleData.matchesTarget(value, context),
286
+ )
277
287
 
278
288
  /**
279
289
  * Makes the scope of a request, with the principal in it. A scope comes from
@@ -298,7 +308,7 @@ const scopeOf = (
298
308
  const issues = Bind.ruleIssues(
299
309
  domain,
300
310
  { actions: [request.action], resources: [request.kind], when: ast },
301
- () => ["within"],
311
+ Place.at(undefined, "within"),
302
312
  )
303
313
  if (issues.length > 0) {
304
314
  throw new globalThis.Error(
@@ -322,24 +332,36 @@ interface Scoped extends Pair {
322
332
  readonly scope: Ast
323
333
  /** The catalog of the kind, when the request fits the kind. */
324
334
  readonly catalog: Catalog.Catalog | undefined
335
+ /** The limits of the domain. */
336
+ readonly limits: Limits.Limits
325
337
  }
326
338
 
327
339
  export const residualsAny = (policy: Any, request: ScopedRequest): Pair =>
328
340
  scopedResiduals(policy, request)
329
341
 
330
342
  const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
343
+ const limits = policy.domain.limits
344
+ const parts = limits.fieldPathParts
331
345
  const descriptor = DomainData.descriptorOf(policy.domain, request.kind)
332
- if (descriptor === undefined || !fitsKind(descriptor, request)) {
333
- return { scope: never, allow: never, deny: never, catalog: undefined }
346
+ if (descriptor === undefined || !fitsKind(descriptor, request, parts)) {
347
+ return {
348
+ scope: never,
349
+ allow: never,
350
+ deny: never,
351
+ catalog: undefined,
352
+ limits,
353
+ }
334
354
  }
335
355
  const input = { principal: request.principal, path: descriptor.path }
336
356
  const scope = scopeOf(request, policy.domain, input)
357
+ const build = buildFor(limits)
337
358
  const and = build.and
338
359
  const inScope = (pair: Pair): Scoped => ({
339
360
  scope,
340
361
  allow: and([scope, pair.allow]),
341
362
  deny: and([scope, pair.deny]),
342
363
  catalog: descriptor.catalog,
364
+ limits,
343
365
  })
344
366
  if (scope._tag === "False") {
345
367
  return inScope({ allow: never, deny: never })
@@ -350,7 +372,9 @@ const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
350
372
  field: request.field,
351
373
  input,
352
374
  descriptor,
353
- fits: RuleData.fitsOf(descriptor.catalog),
375
+ fits: RuleData.fitsOf(descriptor.catalog, parts),
376
+ parts,
377
+ build,
354
378
  }
355
379
  const context: ResidualContext = {
356
380
  ...target,
@@ -384,10 +408,11 @@ const scopedResiduals = (policy: Any, request: ScopedRequest): Scoped => {
384
408
  path: descriptor.path,
385
409
  },
386
410
  descriptor,
387
- fits: RuleData.fitsOf(descriptor.catalog),
411
+ fits: RuleData.fitsOf(descriptor.catalog, parts),
412
+ parts,
388
413
  trace: undefined,
389
414
  },
390
- [],
415
+ undefined,
391
416
  )
392
417
  // A value of another kind is not a value that the residual is about, so
393
418
  // the rules of this kind must not run on it.
@@ -425,7 +450,12 @@ const coverages = Data.taggedEnum<AnyCoverage>()
425
450
 
426
451
  /** Searches for a value of the kind of a scope that makes a condition true. */
427
452
  const search = (scoped: Scoped, ast: Ast): Condition.Satisfiability =>
428
- Search.satisfiable(ast as Nodes.Node, Search.kindsIn(scoped.catalog))
453
+ Search.satisfiable(
454
+ ast as Nodes.Node,
455
+ Search.kindsIn(scoped.catalog, scoped.limits.fieldPathParts),
456
+ scoped.limits.searchSteps,
457
+ scoped.limits.searchFields,
458
+ )
429
459
 
430
460
  /** The values in the scope that the policy does not allow. */
431
461
  const refusedOf = (scoped: Scoped): Ast =>
@@ -6,6 +6,7 @@
6
6
  // principals and deciders to `unknown`. The public types keep them, so the
7
7
  // language service check for `unknown` in an Effect is off here.
8
8
  // @effect-diagnostics anyUnknownInErrorContext:off
9
+ import * as Arr from "effect/Array"
9
10
  import * as Cache from "effect/Cache"
10
11
  import * as Chunk from "effect/Chunk"
11
12
  import * as Context from "effect/Context"
@@ -28,6 +29,7 @@ import * as Decide from "./decide.ts"
28
29
  import * as DomainData from "./domain.ts"
29
30
  import * as Policies from "./policy.ts"
30
31
  import * as ResourceData from "./resource.ts"
32
+ import * as Trampoline from "./trampoline.ts"
31
33
 
32
34
  type AnyServiceClass = Authorizer.AnyServiceClass
33
35
 
@@ -139,14 +141,21 @@ class PrincipalKey implements Equal.Equal {
139
141
  * `Object.values` does. So two keys need the same order too. A scalar is in
140
142
  * the text with its type, so the items of a set or a map show their order.
141
143
  */
142
- const orderOf = (value: unknown): string => {
144
+ const orderOf: (value: unknown) => string = Trampoline.adaptive<
145
+ unknown,
146
+ string
147
+ >((R, child) => (value, depth) => {
143
148
  if (typeof value !== "object" || value === null) {
144
- return typeof value === "string"
145
- ? JSON.stringify(value)
146
- : `${typeof value}:${String(value)}`
149
+ return R.succeed(
150
+ typeof value === "string"
151
+ ? JSON.stringify(value)
152
+ : `${typeof value}:${String(value)}`,
153
+ )
147
154
  }
155
+ const items = (list: ReadonlyArray<unknown>) =>
156
+ R.forEach(list, (item) => child(item, depth + 1))
148
157
  if (Array.isArray(value)) {
149
- return `[${value.map(orderOf).join(",")}]`
158
+ return R.map(items(value), (texts) => `[${texts.join(",")}]`)
150
159
  }
151
160
  if (
152
161
  value instanceof Map ||
@@ -155,15 +164,18 @@ const orderOf = (value: unknown): string => {
155
164
  HashSet.isHashSet(value) ||
156
165
  Chunk.isChunk(value)
157
166
  ) {
158
- return `<${Array.from(value as Iterable<unknown>, orderOf).join(",")}>`
159
- }
160
- return `{${Object.keys(value)
161
- .map(
162
- (name) =>
163
- `${JSON.stringify(name)}:${orderOf((value as Record<string, unknown>)[name])}`,
167
+ return R.map(
168
+ items(Array.from(value as Iterable<unknown>)),
169
+ (texts) => `<${texts.join(",")}>`,
164
170
  )
165
- .join(",")}}`
166
- }
171
+ }
172
+ const names = Object.keys(value)
173
+ return R.map(
174
+ items(Arr.map(names, (name) => (value as Record<string, unknown>)[name])),
175
+ (texts) =>
176
+ `{${Arr.map(names, (name, index) => `${JSON.stringify(name)}:${texts[index]!}`).join(",")}}`,
177
+ )
178
+ })
167
179
 
168
180
  /**
169
181
  * Gives the JSON codec of a Schema, or `undefined` when the Schema has no JSON
@@ -373,7 +385,10 @@ export const makeLayer = (
373
385
  // A request that does not fit its kind gets a "no" at once. The
374
386
  // decider must not say yes to an action that the kind does not have.
375
387
  const descriptor = DomainData.descriptorOf(domain, kind)
376
- if (descriptor === undefined || !Decide.fitsKind(descriptor, target)) {
388
+ if (
389
+ descriptor === undefined ||
390
+ !Decide.fitsKind(descriptor, target, domain.limits.fieldPathParts)
391
+ ) {
377
392
  return yield* forbidden(target, kind, undefined)
378
393
  }
379
394
  const fromPolicy: Decision.Decision =