@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.77 → 2.4.0-alpha.78

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,13 @@
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.78 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** scope by-key reads to the owner, not only lists ([8232fd4](https://github.com/ReventlessDev/reventless-core/commit/8232fd4c09c1098c7265e4a17882ee44884f3bec))
11
+
12
+
6
13
  # 2.4.0-alpha.77 (2026-08-16)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "2.4.0-alpha.77",
3
+ "version": "2.4.0-alpha.78",
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-pulumi-pulumi": "2.3.0-alpha.19",
26
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11"
25
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
26
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19"
27
27
  },
28
28
  "devDependencies": {
29
29
  "esbuild": "^0.25.12",
@@ -29,6 +29,69 @@ export function response(ctx) {
29
29
 
30
30
  let importUtil = `import { util } from '@aws-appsync/utils';`
31
31
 
32
+ /**
33
+ The caller-is-exempt test, emitted into a resolver's response.
34
+
35
+ Mirrors `Reventless.OwnerScope.resolve` — the same branch order for the same
36
+ reason the list predicate gives: an IAM-signed service caller has no `sub`
37
+ because it is inside the trust boundary, not because it is anonymous, so the
38
+ provider question is answered before the identity one.
39
+
40
+ In the RESPONSE rather than the request, because a `GetItem` has no
41
+ FilterExpression to carry a predicate: the row is fetched by key and the
42
+ decision is made on what came back. A `Query` could filter server-side, and
43
+ deliberately does not — a single-row read that filtered in one place and
44
+ guarded in another would have two implementations of one rule, and the cheaper
45
+ one is the one nobody would remember to change.
46
+ */
47
+ let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) => {
48
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
49
+ `
50
+ const _id = ctx.identity;
51
+ const _sub = _id == null ? null : _id.sub;
52
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
53
+ const _elevated = [${elevatedLiteral}];
54
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
55
+ const _owns = (row) => row == null || _exempt || row['${ownerField}'] === _sub;`
56
+ }
57
+
58
+ /**
59
+ A by-key read's response, refusing a row the caller does not own.
60
+
61
+ **Null, not an error** — which is the opposite of what a first reading suggests,
62
+ since "you may not read this" and "there is nothing here" are different answers
63
+ and only one of them is true. Two things settle it. The in-process platform
64
+ already answers `null` here, and a rule enforced differently per transport is
65
+ the failure mode owner scoping exists to avoid. And an error would confirm the
66
+ row exists to a caller who may not read it, which is a worse leak than the
67
+ ambiguity it removes.
68
+ */
69
+ let ownerScopedResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
70
+ switch ownerField {
71
+ | None => resultResponseCode
72
+ | Some(field) =>
73
+ `
74
+ export function response(ctx) {
75
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
76
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
77
+ return _owns(ctx.result) ? ctx.result : null;
78
+ }`
79
+ }
80
+
81
+ /** The `queryByIdSort` counterpart — same rule, over the first row of a Query. */
82
+ let ownerScopedFirstResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
83
+ switch ownerField {
84
+ | None => firstResultResponseCode
85
+ | Some(field) =>
86
+ `
87
+ export function response(ctx) {
88
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
89
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
90
+ const _row = ctx.result.items[0] ?? null;
91
+ return _owns(_row) ? _row : null;
92
+ }`
93
+ }
94
+
32
95
  // ---------------------------------------------------------------------------
33
96
  // Pipeline resolver pass-through (no before/after processing)
34
97
  // ---------------------------------------------------------------------------
@@ -89,7 +152,12 @@ export function response(ctx) {
89
152
  // DynamoDB read — by primary key
90
153
  // ---------------------------------------------------------------------------
91
154
 
92
- let getItemById =
155
+ // `~ownerField` / `~elevatedGroups` carry the same meaning as on
156
+ // `listAllItemsConnection`, and are optional for the same reason: a state that
157
+ // declares no owner emits exactly the source it emitted before scoping existed.
158
+ // A list that scopes beside a by-id read that does not is not a partial
159
+ // delivery — it is a hole, reachable by anyone who can name a row.
160
+ let getItemById = (~ownerField: option<string>=?, ~elevatedGroups: array<string>=[]) =>
93
161
  `${importUtil}
94
162
  export function request(ctx) {
95
163
  return {
@@ -97,7 +165,7 @@ export function request(ctx) {
97
165
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
98
166
  };
99
167
  }
100
- ${resultResponseCode}
168
+ ${ownerScopedResultResponse(~ownerField, ~elevatedGroups)}
101
169
  `->Pulumi.Input.make
102
170
 
103
171
  let queryById =
@@ -119,7 +187,39 @@ ${resultResponseCode}
119
187
  Relay pagination: `first`/`after` (forward) or `last`/`before` (backward).
120
188
  Cursor is base64 of the sort key value.
121
189
  Returns a Relay `{ edges, pageInfo }` shape reusing the entity's `Connection` type. */
122
- let queryItemsWithSortConditions = (sortField: string) =>
190
+ // A list in everything but its name, so it scopes the way `listAllItemsConnection`
191
+ // does — a FilterExpression on the request, not a guard on the response. The
192
+ // response is where the page is cut, and narrowing after that cut would report
193
+ // `hasNextPage` from a count the caller was never allowed to see.
194
+ let queryItemsWithSortConditions = (
195
+ sortField: string,
196
+ ~ownerField: option<string>=?,
197
+ ~elevatedGroups: array<string>=[],
198
+ ) => {
199
+ let ownerFilter = switch ownerField {
200
+ | None => ""
201
+ | Some(field) =>
202
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
203
+ `
204
+ // ── owner scoping (generated) ──
205
+ // Not read from ctx.args, for the reason the list resolver gives: a predicate
206
+ // deciding what the caller may see must arrive on a channel they cannot name.
207
+ const _id = ctx.identity;
208
+ const _sub = _id == null ? null : _id.sub;
209
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
210
+ const _elevated = [${elevatedLiteral}];
211
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
212
+ const _ownerFilter = _exempt ? undefined : {
213
+ expression: '#owner = :owner',
214
+ expressionNames: { '#owner': '${field}' },
215
+ expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
216
+ };`
217
+ }
218
+ let ownerFilterField = switch ownerField {
219
+ | None => ""
220
+ | Some(_) => `
221
+ filter: _ownerFilter,`
222
+ }
123
223
  `${importUtil}
124
224
  const encodeCursor = (skValue) => util.base64Encode(skValue);
125
225
  const decodeCursor = (cursor) => util.base64Decode(cursor);
@@ -162,10 +262,10 @@ export function request(ctx) {
162
262
  const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
163
263
  const orderDesc = filter.order === 'DESC';
164
264
  const scanForward = isBackward ? orderDesc : !orderDesc;
165
- const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);
265
+ const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);${ownerFilter}
166
266
  return {
167
267
  operation: 'Query',
168
- query: { expression, expressionNames, expressionValues },
268
+ query: { expression, expressionNames, expressionValues },${ownerFilterField}
169
269
  scanIndexForward: scanForward,
170
270
  limit: pageSize + 1,
171
271
  };
@@ -194,8 +294,13 @@ export function response(ctx) {
194
294
  };
195
295
  }
196
296
  `->Pulumi.Input.make
297
+ }
197
298
 
198
- let queryByIdSort = (sortField: string) =>
299
+ let queryByIdSort = (
300
+ sortField: string,
301
+ ~ownerField: option<string>=?,
302
+ ~elevatedGroups: array<string>=[],
303
+ ) =>
199
304
  `${importUtil}
200
305
  export function request(ctx) {
201
306
  return {
@@ -210,7 +315,7 @@ export function request(ctx) {
210
315
  }
211
316
  };
212
317
  }
213
- ${firstResultResponseCode}
318
+ ${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups)}
214
319
  `->Pulumi.Input.make
215
320
 
216
321
  // ---------------------------------------------------------------------------
@@ -21,6 +21,44 @@ export function response(ctx) {
21
21
 
22
22
  let importUtil = `import { util } from '@aws-appsync/utils';`;
23
23
 
24
+ function ownerGuardPreamble(ownerField, elevatedGroups) {
25
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
26
+ return `
27
+ const _id = ctx.identity;
28
+ const _sub = _id == null ? null : _id.sub;
29
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
30
+ const _elevated = [` + elevatedLiteral + `];
31
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
32
+ const _owns = (row) => row == null || _exempt || row['` + ownerField + `'] === _sub;`;
33
+ }
34
+
35
+ function ownerScopedResultResponse(ownerField, elevatedGroups) {
36
+ if (ownerField !== undefined) {
37
+ return `
38
+ export function response(ctx) {
39
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
40
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
41
+ return _owns(ctx.result) ? ctx.result : null;
42
+ }`;
43
+ } else {
44
+ return resultResponseCode;
45
+ }
46
+ }
47
+
48
+ function ownerScopedFirstResultResponse(ownerField, elevatedGroups) {
49
+ if (ownerField !== undefined) {
50
+ return `
51
+ export function response(ctx) {
52
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
53
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
54
+ const _row = ctx.result.items[0] ?? null;
55
+ return _owns(_row) ? _row : null;
56
+ }`;
57
+ } else {
58
+ return firstResultResponseCode;
59
+ }
60
+ }
61
+
24
62
  let pipelinePassThrough = importUtil + `
25
63
  export function request(ctx) { return {}; }
26
64
  ` + resultResponseCode + `
@@ -64,15 +102,18 @@ export function response(ctx) {
64
102
  `;
65
103
  }
66
104
 
67
- let getItemById = importUtil + `
105
+ function getItemById(ownerField, elevatedGroupsOpt) {
106
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
107
+ return importUtil + `
68
108
  export function request(ctx) {
69
109
  return {
70
110
  operation: 'GetItem',
71
111
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
72
112
  };
73
113
  }
74
- ` + resultResponseCode + `
114
+ ` + ownerScopedResultResponse(ownerField, elevatedGroups) + `
75
115
  `;
116
+ }
76
117
 
77
118
  let queryById = importUtil + `
78
119
  export function request(ctx) {
@@ -87,7 +128,30 @@ export function request(ctx) {
87
128
  ` + resultResponseCode + `
88
129
  `;
89
130
 
90
- function queryItemsWithSortConditions(sortField) {
131
+ function queryItemsWithSortConditions(sortField, ownerField, elevatedGroupsOpt) {
132
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
133
+ let ownerFilter;
134
+ if (ownerField !== undefined) {
135
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
136
+ ownerFilter = `
137
+ // ── owner scoping (generated) ──
138
+ // Not read from ctx.args, for the reason the list resolver gives: a predicate
139
+ // deciding what the caller may see must arrive on a channel they cannot name.
140
+ const _id = ctx.identity;
141
+ const _sub = _id == null ? null : _id.sub;
142
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
143
+ const _elevated = [` + elevatedLiteral + `];
144
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
145
+ const _ownerFilter = _exempt ? undefined : {
146
+ expression: '#owner = :owner',
147
+ expressionNames: { '#owner': '` + ownerField + `' },
148
+ expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
149
+ };`;
150
+ } else {
151
+ ownerFilter = "";
152
+ }
153
+ let ownerFilterField = ownerField !== undefined ? `
154
+ filter: _ownerFilter,` : "";
91
155
  return importUtil + `
92
156
  const encodeCursor = (skValue) => util.base64Encode(skValue);
93
157
  const decodeCursor = (cursor) => util.base64Decode(cursor);
@@ -130,10 +194,10 @@ export function request(ctx) {
130
194
  const expression = skCondition ? \`#id = :id AND \${skCondition}\` : '#id = :id';
131
195
  const orderDesc = filter.order === 'DESC';
132
196
  const scanForward = isBackward ? orderDesc : !orderDesc;
133
- const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);
197
+ const pageSize = isBackward ? (args.last ?? 50) : (args.first ?? 50);` + ownerFilter + `
134
198
  return {
135
199
  operation: 'Query',
136
- query: { expression, expressionNames, expressionValues },
200
+ query: { expression, expressionNames, expressionValues },` + ownerFilterField + `
137
201
  scanIndexForward: scanForward,
138
202
  limit: pageSize + 1,
139
203
  };
@@ -164,7 +228,8 @@ export function response(ctx) {
164
228
  `;
165
229
  }
166
230
 
167
- function queryByIdSort(sortField) {
231
+ function queryByIdSort(sortField, ownerField, elevatedGroupsOpt) {
232
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
168
233
  return importUtil + `
169
234
  export function request(ctx) {
170
235
  return {
@@ -179,7 +244,7 @@ export function request(ctx) {
179
244
  }
180
245
  };
181
246
  }
182
- ` + firstResultResponseCode + `
247
+ ` + ownerScopedFirstResultResponse(ownerField, elevatedGroups) + `
183
248
  `;
184
249
  }
185
250
 
@@ -953,6 +1018,9 @@ export {
953
1018
  firstResultResponseCode,
954
1019
  resultListResponseCode,
955
1020
  importUtil,
1021
+ ownerGuardPreamble,
1022
+ ownerScopedResultResponse,
1023
+ ownerScopedFirstResultResponse,
956
1024
  pipelinePassThrough,
957
1025
  nodeDecodeGlobalId,
958
1026
  nodeGetItemForType,
@@ -16,7 +16,7 @@ describe('Resolver code structure', () => {
16
16
  // Labeled ReScript args compile to positional JS args in declaration order.
17
17
  const codeValues = [
18
18
  ['pipelinePassThrough', F.pipelinePassThrough],
19
- ['getItemById', F.getItemById],
19
+ ['getItemById', F.getItemById(undefined, undefined)],
20
20
  ['queryById', F.queryById],
21
21
  ['queryByIdSort(sortField)', F.queryByIdSort('status')],
22
22
  ['queryByIndex(index)', F.queryByIndex('userId')],
@@ -54,7 +54,7 @@ describe('Resolver code structure', () => {
54
54
  // getItemById
55
55
  // ---------------------------------------------------------------------------
56
56
  describe('getItemById', () => {
57
- const { request, response } = evalResolver(F.getItemById)
57
+ const { request, response } = evalResolver(F.getItemById(undefined, undefined))
58
58
 
59
59
  test('request returns GetItem with id key', () => {
60
60
  const ctx = makeCtx({ args: { id: 'abc123' } })
@@ -743,3 +743,80 @@ describe('listAllItemsConnection — owner scoping', () => {
743
743
  expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
744
744
  })
745
745
  })
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // By-key reads — owner scoping
749
+ // ---------------------------------------------------------------------------
750
+ // The list resolver above carried the predicate while these did not, so a caller
751
+ // narrowed to their own rows could still read any row they could name. These
752
+ // cases pin the guard in the place it has to live: the response, since a GetItem
753
+ // has no FilterExpression, and null rather than an error, which is what the
754
+ // in-process platform answers and what does not confirm the row exists.
755
+ describe('getItemById / queryByIdSort — owner scoping', () => {
756
+ const asUser = (sub, groups = []) => ({
757
+ username: sub,
758
+ sub,
759
+ sourceIp: [],
760
+ claims: { 'cognito:groups': groups },
761
+ })
762
+ const row = { id: 'ord-1', customerId: 'cust-a', total: 10 }
763
+
764
+ const getScoped = () => evalResolver(F.getItemById('customerId', ['Admin']))
765
+ const sortScoped = () => evalResolver(F.queryByIdSort('status', 'customerId', ['Admin']))
766
+
767
+ test('an owner reads their own row', () => {
768
+ const { response } = getScoped()
769
+ expect(response(makeCtx({ result: row, identity: asUser('cust-a') }))).toEqual(row)
770
+ })
771
+
772
+ test('a foreign row reads as null, not as an error', () => {
773
+ const { response } = getScoped()
774
+ expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toBeNull()
775
+ })
776
+
777
+ test('an elevated caller reads any row', () => {
778
+ const { response } = getScoped()
779
+ expect(response(makeCtx({ result: row, identity: asUser('ops-1', ['Admin']) }))).toEqual(row)
780
+ })
781
+
782
+ test('an IAM-shaped identity with no sub is exempt, not compared against undefined', () => {
783
+ const { response } = getScoped()
784
+ const iam = { username: 'svc', userArn: 'arn:aws:iam::1:role/r', sourceIp: [] }
785
+ expect(response(makeCtx({ result: row, identity: iam }))).toEqual(row)
786
+ })
787
+
788
+ test('a wholly absent identity is exempt rather than a crash', () => {
789
+ const { response } = getScoped()
790
+ expect(response(makeCtx({ result: row, identity: null }))).toEqual(row)
791
+ })
792
+
793
+ test('a missing row stays null and does not fall into the ownership branch', () => {
794
+ const { response } = getScoped()
795
+ expect(response(makeCtx({ result: null, identity: asUser('cust-b') }))).toBeNull()
796
+ })
797
+
798
+ test('a view with no owner field is never scoped', () => {
799
+ const { response } = evalResolver(F.getItemById(undefined, []))
800
+ expect(response(makeCtx({ result: row, identity: asUser('cust-b') }))).toEqual(row)
801
+ })
802
+
803
+ test('queryByIdSort narrows its first row the same way', () => {
804
+ const { response } = sortScoped()
805
+ const ctxFor = sub => makeCtx({ result: { items: [row] }, identity: asUser(sub) })
806
+ expect(response(ctxFor('cust-a'))).toEqual(row)
807
+ expect(response(ctxFor('cust-b'))).toBeNull()
808
+ })
809
+
810
+ test('queryItemsWithSortConditions scopes in the REQUEST, so the page is cut after narrowing', () => {
811
+ const { request } = evalResolver(
812
+ F.queryItemsWithSortConditions('createdAt', 'customerId', ['Admin']),
813
+ )
814
+ const scoped = request(makeCtx({ args: { id: 'ord-1' }, identity: asUser('cust-a') }))
815
+ expect(scoped.filter.expression).toBe('#owner = :owner')
816
+ expect(scoped.filter.expressionValues[':owner']).toEqual({ S: 'cust-a' })
817
+ const elevated = request(
818
+ makeCtx({ args: { id: 'ord-1' }, identity: asUser('ops-1', ['Admin']) }),
819
+ )
820
+ expect(elevated.filter).toBeUndefined()
821
+ })
822
+ })