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