@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.69 → 2.4.0-alpha.71

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/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 2.4.0-alpha.71 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
11
+
12
+
13
+ # 2.4.0-alpha.70 (2026-08-11)
14
+
15
+ **Note:** Version bump only for package @reventlessdev/rescript-pulumi-aws
16
+
17
+
18
+
19
+
20
+
6
21
  # 2.4.0-alpha.69 (2026-08-09)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/rescript-pulumi-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "2.4.0-alpha.69",
3
+ "version": "2.4.0-alpha.71",
4
4
  "description": "ReScript bindings for @pulumi/aws",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -22,8 +22,8 @@
22
22
  "@pulumi/aws": "~7.19.0",
23
23
  "@pulumi/aws-native": "^1.62.0",
24
24
  "sury": "11.0.0-alpha.4",
25
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.6",
26
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18"
25
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
26
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.7"
27
27
  },
28
28
  "devDependencies": {
29
29
  "esbuild": "^0.25.12",
@@ -438,6 +438,15 @@ let listAllItemsConnection = (
438
438
  // rows would otherwise resolve `name`/`status`/`version` to null and violate the
439
439
  // non-null GraphQL connection schema, nulling the whole connection.
440
440
  ~requireAttribute: option<string>=?,
441
+ // The state's `@owner` field, when it declares one, plus the groups exempt from
442
+ // scoping. Baked into the generated source because this resolver runs inside
443
+ // AppSync with no Lambda in the path — there is nothing here that could read a
444
+ // configuration value at request time, so the deploy is the only chance to
445
+ // state it. Changing the elevated-group list therefore requires a redeploy,
446
+ // which is worth knowing and is why it is a deployment-level setting rather
447
+ // than a per-request one.
448
+ ~ownerField: option<string>=?,
449
+ ~elevatedGroups: array<string>=[],
441
450
  ) => {
442
451
  let requireAttributeClause = switch requireAttribute {
443
452
  | Some(attr) => `
@@ -445,6 +454,32 @@ let listAllItemsConnection = (
445
454
  parts.push('attribute_exists(#${attr})');`
446
455
  | None => ""
447
456
  }
457
+ // Mirrors `Reventless.OwnerScope.resolve`, in the one place that cannot call
458
+ // it. The branch ORDER is the part that has to match: provider first, because
459
+ // an IAM-signed service caller has no `sub` for a reason that has nothing to do
460
+ // with being anonymous, and must not be refused as though it did.
461
+ let ownerClause = switch ownerField {
462
+ | None => ""
463
+ | Some(field) =>
464
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
465
+ `
466
+ // ── owner scoping (generated) ──
467
+ // Not read from ctx.args: a predicate deciding what the caller may see must
468
+ // arrive on a channel the caller cannot name, and this field is usually absent
469
+ // from the filter surface anyway.
470
+ const _id = ctx.identity;
471
+ const _sub = _id == null ? null : _id.sub;
472
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
473
+ const _elevated = [${elevatedLiteral}];
474
+ // No identity at all, or an identity with no \`sub\`, is the IAM service caller
475
+ // the API also accepts — inside the trust boundary, and exempt.
476
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
477
+ if (!_exempt) {
478
+ names['#owner'] = '${field}';
479
+ values[':owner'] = util.dynamodb.toDynamoDB(_sub);
480
+ parts.push('#owner = :owner');
481
+ }`
482
+ }
448
483
  let filterClauses =
449
484
  filterFields
450
485
  ->Array.map(f => `
@@ -534,7 +569,7 @@ export function request(ctx) {
534
569
  return key;
535
570
  });
536
571
  parts.push('#id IN (' + placeholders.join(', ') + ')');
537
- }${filterClauses}${rangeClauses}${requireAttributeClause}
572
+ }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}
538
573
  // The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
539
574
  // DynamoDB continuation token the response side emitted (Fix 1 round-trip).
540
575
  let after = null;
@@ -360,13 +360,37 @@ export function request(ctx) {
360
360
  ` + resultResponseCode + `
361
361
  `;
362
362
 
363
- function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute) {
363
+ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute, ownerField, elevatedGroupsOpt) {
364
364
  let filterFields = filterFieldsOpt !== undefined ? filterFieldsOpt : [];
365
365
  let rangeFields = rangeFieldsOpt !== undefined ? rangeFieldsOpt : [];
366
366
  let sortFields = sortFieldsOpt !== undefined ? sortFieldsOpt : [];
367
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
367
368
  let requireAttributeClause = requireAttribute !== undefined ? `
368
369
  names['#` + requireAttribute + `'] = '` + requireAttribute + `';
369
370
  parts.push('attribute_exists(#` + requireAttribute + `)');` : "";
371
+ let ownerClause;
372
+ if (ownerField !== undefined) {
373
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
374
+ ownerClause = `
375
+ // ── owner scoping (generated) ──
376
+ // Not read from ctx.args: a predicate deciding what the caller may see must
377
+ // arrive on a channel the caller cannot name, and this field is usually absent
378
+ // from the filter surface anyway.
379
+ const _id = ctx.identity;
380
+ const _sub = _id == null ? null : _id.sub;
381
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
382
+ const _elevated = [` + elevatedLiteral + `];
383
+ // No identity at all, or an identity with no \`sub\`, is the IAM service caller
384
+ // the API also accepts — inside the trust boundary, and exempt.
385
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
386
+ if (!_exempt) {
387
+ names['#owner'] = '` + ownerField + `';
388
+ values[':owner'] = util.dynamodb.toDynamoDB(_sub);
389
+ parts.push('#owner = :owner');
390
+ }`;
391
+ } else {
392
+ ownerClause = "";
393
+ }
370
394
  let filterClauses = filterFields.map(f => `
371
395
  if (filter.` + f + `Eq !== undefined && filter.` + f + `Eq !== null && filter.` + f + `Eq !== '') {
372
396
  names['#` + f + `'] = '` + f + `';
@@ -436,7 +460,7 @@ export function request(ctx) {
436
460
  return key;
437
461
  });
438
462
  parts.push('#id IN (' + placeholders.join(', ') + ')');
439
- }` + filterClauses + rangeClauses + requireAttributeClause + `
463
+ }` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + `
440
464
  // The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
441
465
  // DynamoDB continuation token the response side emitted (Fix 1 round-trip).
442
466
  let after = null;
@@ -650,3 +650,96 @@ describe('authorizeIndexedAccess', () => {
650
650
  expect(response(ctx)).toEqual({ adminId: 'alice', data: 'secret' })
651
651
  })
652
652
  })
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // listAllItemsConnection — @owner scoping
656
+ // ---------------------------------------------------------------------------
657
+ // This is the one read path with no Lambda in it: the predicate lives in
658
+ // generated JS that AppSync runs directly, so it shares no code with the three
659
+ // paths the ReScript suites cover. Evaluating the emitted source here is the
660
+ // strongest check available short of a deployed stack, and the only one that
661
+ // can catch an APPSYNC_JS-hostile construct or a mis-ordered branch.
662
+ describe('listAllItemsConnection — owner scoping', () => {
663
+ // (labelField, filterFields, rangeFields, sortFields, requireAttribute,
664
+ // ownerField, elevatedGroups)
665
+ const scoped = () =>
666
+ evalResolver(
667
+ F.listAllItemsConnection('name', ['status'], [], [], undefined, 'customerId', ['Admin']),
668
+ )
669
+
670
+ const asUser = (sub, groups = []) => ({
671
+ username: sub,
672
+ sub,
673
+ sourceIp: [],
674
+ claims: { 'cognito:groups': groups },
675
+ })
676
+
677
+ test('a plain caller is narrowed to their own rows', () => {
678
+ const { request } = scoped()
679
+ const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
680
+ expect(r.filter.expression).toBe('#owner = :owner')
681
+ expect(r.filter.expressionNames['#owner']).toBe('customerId')
682
+ expect(r.filter.expressionValues[':owner']).toEqual({ S: 'cust-a' })
683
+ })
684
+
685
+ test('the owner clause is ANDed with a client filter, not replaced by it', () => {
686
+ const { request } = scoped()
687
+ const r = request(
688
+ makeCtx({ args: { filter: { statusEq: 'Placed' } }, identity: asUser('cust-a') }),
689
+ )
690
+ expect(r.filter.expression).toBe('#status = :statusEq AND #owner = :owner')
691
+ })
692
+
693
+ // The caller cannot address the channel the scope arrives on: naming the owner
694
+ // field in their own filter must not overwrite the value the resolver derived.
695
+ test('a client filter naming the owner field cannot displace the scope', () => {
696
+ const { request } = scoped()
697
+ const r = request(
698
+ makeCtx({ args: { filter: { customerIdEq: 'cust-b' } }, identity: asUser('cust-a') }),
699
+ )
700
+ expect(r.filter.expressionValues[':owner']).toEqual({ S: 'cust-a' })
701
+ expect(r.filter.expression).toContain('#owner = :owner')
702
+ })
703
+
704
+ test('a caller in an elevated group is not narrowed', () => {
705
+ const { request } = scoped()
706
+ const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
707
+ expect(r.filter).toBeUndefined()
708
+ })
709
+
710
+ // The IAM service caller: present, but with no `sub` and no groups. Ordering
711
+ // the group test first would classify it as non-elevated and then filter on
712
+ // `undefined`, so every service read would return nothing.
713
+ test('an IAM-shaped identity with no sub is exempt, not filtered on undefined', () => {
714
+ const { request } = scoped()
715
+ const r = request(
716
+ makeCtx({
717
+ args: {},
718
+ identity: { userArn: 'arn:aws:sts::1:assumed-role/Ingester', username: 'Ingester' },
719
+ }),
720
+ )
721
+ expect(r.filter).toBeUndefined()
722
+ })
723
+
724
+ test('a wholly absent identity is exempt rather than a crash', () => {
725
+ const { request } = scoped()
726
+ expect(() => request(makeCtx({ args: {}, identity: null }))).not.toThrow()
727
+ expect(request(makeCtx({ args: {}, identity: null })).filter).toBeUndefined()
728
+ })
729
+
730
+ // The control: without an owner field nothing changes for anyone, which is
731
+ // what keeps this from silently altering every existing read model.
732
+ test('a view with no owner field is never scoped', () => {
733
+ const { request } = evalResolver(F.listAllItemsConnection('name'))
734
+ const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
735
+ expect(r.filter).toBeUndefined()
736
+ })
737
+
738
+ test('with no elevated groups configured, an Admin is still narrowed', () => {
739
+ const { request } = evalResolver(
740
+ F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', []),
741
+ )
742
+ const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
743
+ expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
744
+ })
745
+ })