@reventlessdev/reventless-aws 3.0.0-alpha.282 → 3.0.0-alpha.284

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.
@@ -11,18 +11,19 @@ open ReventlessCore
11
11
 
12
12
  @val external btoa: string => string = "btoa"
13
13
 
14
- let mk = (id, status, name) => {
14
+ let mk = (id, status, name, owner) => {
15
15
  let o = Dict.make()
16
16
  o->Dict.set("id", JSON.Encode.string(id))
17
17
  o->Dict.set("status", JSON.Encode.string(status))
18
18
  o->Dict.set("name", JSON.Encode.string(name))
19
+ o->Dict.set("owner", JSON.Encode.string(owner))
19
20
  JSON.Encode.object(o)
20
21
  }
21
22
 
22
23
  let store = Dict.fromArray([
23
- ("p-1", mk("p-1", "active", "Alpha")),
24
- ("p-2", mk("p-2", "inactive", "Bravo")),
25
- ("p-3", mk("p-3", "active", "Charlie")),
24
+ ("p-1", mk("p-1", "active", "Alpha", "u-a")),
25
+ ("p-2", mk("p-2", "inactive", "Bravo", "u-b")),
26
+ ("p-3", mk("p-3", "active", "Charlie", "u-a")),
26
27
  ])
27
28
  let allItems = () => store->Dict.valuesToArray
28
29
 
@@ -35,6 +36,11 @@ let fieldEq = (item, field, value) =>
35
36
 
36
37
  // Records whether listPage was consulted, and what it returns (sentinel or None).
37
38
  let listPageReturn: ref<option<JSON.t>> = ref(None)
39
+ // The `ownerScope` the dispatcher handed the push-down on the last list call.
40
+ // The push-down's own SQL is covered by the engine's parity harness; what this
41
+ // file has to establish is that the dispatcher computes the scope from the
42
+ // caller and actually passes it on.
43
+ let lastOwnerScope: ref<option<(string, string)>> = ref(None)
38
44
 
39
45
  let ops: QueryDb_Adapter.operations = {
40
46
  load: async id => Ok(store->Dict.get(id)->Option.mapOr([], i => [i])),
@@ -51,9 +57,17 @@ let pushdowns: PgQueryResolver_Lambda.pushdowns = {
51
57
  allItems()->Array.filter(i => fieldEq(i, field, value)),
52
58
  byIds: async (~readModelName as _, ids) =>
53
59
  ids->Array.filterMap(id => store->Dict.get(id)),
54
- listPage: async (~readModelName as _, ~argsDict as _, ~capability as _, ~labelField as _) =>
55
- listPageReturn.contents,
56
- itemsPage: async (~readModelName as _, ~subIdField as _, ~id, ~argsDict as _) =>
60
+ listPage: async (
61
+ ~readModelName as _,
62
+ ~argsDict as _,
63
+ ~capability as _,
64
+ ~labelField as _,
65
+ ~ownerScope: option<(string, string)>=?,
66
+ ) => {
67
+ lastOwnerScope := ownerScope
68
+ listPageReturn.contents
69
+ },
70
+ itemsPage: async (~readModelName as _, ~subIdField as _, ~id, ~argsDict as _, ~ownerScope as _=?) =>
57
71
  // Sentinel echoing the requested id, so the dispatch routing is observable.
58
72
  JSON.Encode.object(Dict.fromArray([("itemsFor", JSON.Encode.string(id))])),
59
73
  scanAll: async (~readModelName as _) => allItems(),
@@ -67,6 +81,7 @@ let capability: GraphQL_FragmentGenerator.serverCapability = {
67
81
  let makeBinding = (
68
82
  ~authorization=Reventless.Authorization.AllowAnonymous,
69
83
  ~subIdField=None,
84
+ ~ownerField=None,
70
85
  (),
71
86
  ): PgQueryResolver_Lambda.binding => {
72
87
  ops,
@@ -80,16 +95,28 @@ let makeBinding = (
80
95
  labelField: "name",
81
96
  includeIdParam: true,
82
97
  authorization,
98
+ ownerField,
83
99
  }
84
100
 
85
- let mkPayload = (~kind, ~index=?, ~args=JSON.Encode.object(Dict.make()), ()): PgQueryResolver_Lambda.payload => {
101
+ let mkPayload = (
102
+ ~kind,
103
+ ~index=?,
104
+ ~args=JSON.Encode.object(Dict.make()),
105
+ ~identity=Reventless.Identity.anonymous,
106
+ (),
107
+ ): PgQueryResolver_Lambda.payload => {
86
108
  readModelName: "Things",
87
109
  kind,
88
110
  ?index,
89
111
  arguments: args,
90
- identity: Reventless.Identity.anonymous,
112
+ identity,
91
113
  }
92
114
 
115
+ external asIdentity: 'a => Reventless.Identity.t = "%identity"
116
+
117
+ let cognito = (~userId, ~groups): Reventless.Identity.t =>
118
+ asIdentity({"userId": userId, "username": userId, "groups": groups, "provider": "Cognito"})
119
+
93
120
  let objArgs = pairs => JSON.Encode.object(Dict.fromArray(pairs))
94
121
  let field = (j, k) => j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))
95
122
  let str = (j, k) => j->field(k)->Option.flatMap(JSON.Decode.string)
@@ -365,3 +392,206 @@ describe("PgQueryResolver_Lambda.dispatch", () => {
365
392
  expect(threw)->toBe(true)
366
393
  })
367
394
  })
395
+
396
+ // A client holding a row reads its `id` and passes it back. On the local platform
397
+ // that `id` is a Relay global id; the typed doors used to take only the storage
398
+ // key and answered null, with no error to point at. These pin the either-form
399
+ // rule at the door, so the same client call works whichever form it holds.
400
+ describe("id form — the typed doors take either", () => {
401
+ let globalIdFor = localId => ReventlessCore.Api_Ids.encode(~typeName="Thing", ~localId)
402
+
403
+ testPromise("getById resolves a Relay global id", async () => {
404
+ let r = await PgQueryResolver_Lambda.dispatch(
405
+ ~binding=makeBinding(),
406
+ ~payload=mkPayload(
407
+ ~kind="getById",
408
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
409
+ (),
410
+ ),
411
+ )
412
+ expect(r->str("name"))->toEqual(Some("Bravo"))
413
+ })
414
+
415
+ // The row's `id` must come back as the storage key it was found under — echoing
416
+ // the argument would hand back an id nothing else accepts.
417
+ testPromise("getById reports the storage key it resolved to", async () => {
418
+ let r = await PgQueryResolver_Lambda.dispatch(
419
+ ~binding=makeBinding(),
420
+ ~payload=mkPayload(
421
+ ~kind="getById",
422
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
423
+ (),
424
+ ),
425
+ )
426
+ expect(r->str("id"))->toEqual(Some("p-2"))
427
+ })
428
+
429
+ testPromise("getById still resolves a plain storage key", async () => {
430
+ let r = await PgQueryResolver_Lambda.dispatch(
431
+ ~binding=makeBinding(),
432
+ ~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-2"))]), ()),
433
+ )
434
+ expect(r->str("name"))->toEqual(Some("Bravo"))
435
+ })
436
+
437
+ testPromise("getById on a global id for a row that does not exist is still null", async () => {
438
+ let r = await PgQueryResolver_Lambda.dispatch(
439
+ ~binding=makeBinding(),
440
+ ~payload=mkPayload(
441
+ ~kind="getById",
442
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("absent")))]),
443
+ (),
444
+ ),
445
+ )
446
+ expect(r)->toBe(JSON.Encode.null)
447
+ })
448
+
449
+ testPromise("byIds accepts the two forms mixed in one call", async () => {
450
+ let r = await PgQueryResolver_Lambda.dispatch(
451
+ ~binding=makeBinding(),
452
+ ~payload=mkPayload(
453
+ ~kind="byIds",
454
+ ~args=objArgs([
455
+ (
456
+ "ids",
457
+ JSON.Encode.array([
458
+ JSON.Encode.string("p-1"),
459
+ JSON.Encode.string(globalIdFor("p-3")),
460
+ ]),
461
+ ),
462
+ ]),
463
+ (),
464
+ ),
465
+ )
466
+ expect(r->ids)->toEqual(["p-1", "p-3"])
467
+ })
468
+
469
+ // The fallback lookup must not fire when the first pass already answered — an
470
+ // all-raw call is the common path and pays for one round trip, not two.
471
+ testPromise("byIds with every id found does not double-count", async () => {
472
+ let r = await PgQueryResolver_Lambda.dispatch(
473
+ ~binding=makeBinding(),
474
+ ~payload=mkPayload(
475
+ ~kind="byIds",
476
+ ~args=objArgs([
477
+ ("ids", JSON.Encode.array(["p-1", "p-3"]->Array.map(JSON.Encode.string))),
478
+ ]),
479
+ (),
480
+ ),
481
+ )
482
+ expect(r->ids)->toEqual(["p-1", "p-3"])
483
+ })
484
+ })
485
+
486
+ // ── Owner scoping ───────────────────────────────────────────────────────────
487
+ // The push-down's SQL is the engine's business and is covered by its parity
488
+ // harness. What has to hold HERE is that the dispatcher derives the scope from
489
+ // the caller and hands it on — a push-down that supports scoping and a
490
+ // dispatcher that never passes one produces a fully unscoped deployment while
491
+ // every SQL test stays green.
492
+ describe("owner scoping", () => {
493
+ let ownedBinding = () => makeBinding(~ownerField=Some("owner"), ())
494
+ let shopper = cognito(~userId="u-a", ~groups=["User"])
495
+
496
+ let listWith = async (~binding, ~identity) => {
497
+ lastOwnerScope := None
498
+ listPageReturn := Some(JSON.Encode.string("pushed"))
499
+ let r = await PgQueryResolver_Lambda.dispatch(
500
+ ~binding,
501
+ ~payload=mkPayload(~kind="list", ~identity, ()),
502
+ )
503
+ (r, lastOwnerScope.contents)
504
+ }
505
+
506
+ testPromise("a shopper's list is scoped to their own id", async () => {
507
+ let (_, scope) = await listWith(~binding=ownedBinding(), ~identity=shopper)
508
+ expect(scope)->toEqual(Some(("owner", "u-a")))
509
+ })
510
+
511
+ testPromise("an elevated caller's list is not scoped", async () => {
512
+ Reventless.OwnerScope.setElevatedGroups(["Admin"])
513
+ let (_, scope) = await listWith(
514
+ ~binding=ownedBinding(),
515
+ ~identity=cognito(~userId="ops-1", ~groups=["Admin"]),
516
+ )
517
+ Reventless.OwnerScope.setElevatedGroups([])
518
+ expect(scope)->toBe(None)
519
+ })
520
+
521
+ // The control that keeps this from being vacuous: a view with no `@owner`
522
+ // field must reach the push-down with no scope at all, for every caller.
523
+ testPromise("a view with no owner field is never scoped", async () => {
524
+ let (_, scope) = await listWith(~binding=makeBinding(), ~identity=shopper)
525
+ expect(scope)->toBe(None)
526
+ })
527
+
528
+ testPromise("an unidentified caller is refused before the push-down runs", async () => {
529
+ lastOwnerScope := None
530
+ listPageReturn := Some(JSON.Encode.string("pushed"))
531
+ let r = await PgQueryResolver_Lambda.dispatch(
532
+ ~binding=ownedBinding(),
533
+ ~payload=mkPayload(~kind="list", ~identity=Reventless.Identity.anonymous, ()),
534
+ )
535
+ // Not the sentinel, and the push-down was never consulted — the refusal
536
+ // happens above it rather than by handing it a value nobody holds.
537
+ expect((r->field("edges"), lastOwnerScope.contents))->toEqual((
538
+ Some(JSON.Encode.array([])),
539
+ None,
540
+ ))
541
+ })
542
+
543
+ testPromise("getById hides a row the caller does not own", async () => {
544
+ let r = await PgQueryResolver_Lambda.dispatch(
545
+ ~binding=ownedBinding(),
546
+ ~payload=mkPayload(
547
+ ~kind="getById",
548
+ ~args=objArgs([("id", JSON.Encode.string("p-2"))]),
549
+ ~identity=shopper,
550
+ (),
551
+ ),
552
+ )
553
+ expect(r)->toBe(JSON.Encode.null)
554
+ })
555
+
556
+ testPromise("getById still returns a row the caller does own", async () => {
557
+ let r = await PgQueryResolver_Lambda.dispatch(
558
+ ~binding=ownedBinding(),
559
+ ~payload=mkPayload(
560
+ ~kind="getById",
561
+ ~args=objArgs([("id", JSON.Encode.string("p-1"))]),
562
+ ~identity=shopper,
563
+ (),
564
+ ),
565
+ )
566
+ expect(r->str("id"))->toEqual(Some("p-1"))
567
+ })
568
+
569
+ testPromise("byIds drops rows the caller does not own", async () => {
570
+ let r = await PgQueryResolver_Lambda.dispatch(
571
+ ~binding=ownedBinding(),
572
+ ~payload=mkPayload(
573
+ ~kind="byIds",
574
+ ~args=objArgs([
575
+ ("ids", JSON.Encode.array(["p-1", "p-2", "p-3"]->Array.map(JSON.Encode.string))),
576
+ ]),
577
+ ~identity=shopper,
578
+ (),
579
+ ),
580
+ )
581
+ expect(r->ids)->toEqual(["p-1", "p-3"])
582
+ })
583
+
584
+ testPromise("an index query drops rows the caller does not own", async () => {
585
+ let r = await PgQueryResolver_Lambda.dispatch(
586
+ ~binding=ownedBinding(),
587
+ ~payload=mkPayload(
588
+ ~kind="index",
589
+ ~index="byStatus",
590
+ ~args=objArgs([("byStatus", JSON.Encode.string("active"))]),
591
+ ~identity=shopper,
592
+ (),
593
+ ),
594
+ )
595
+ expect(r->ids)->toEqual(["p-1", "p-3"])
596
+ })
597
+ })