@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.76 → 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,22 @@
|
|
|
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
|
+
|
|
13
|
+
# 2.4.0-alpha.77 (2026-08-16)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
|
|
18
|
+
* **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
|
|
19
|
+
* **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 2.4.0-alpha.76 (2026-08-15)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @reventlessdev/rescript-pulumi-aws
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
${
|
|
318
|
+
${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups)}
|
|
214
319
|
`->Pulumi.Input.make
|
|
215
320
|
|
|
216
321
|
// ---------------------------------------------------------------------------
|
|
@@ -447,6 +552,13 @@ let listAllItemsConnection = (
|
|
|
447
552
|
// than a per-request one.
|
|
448
553
|
~ownerField: option<string>=?,
|
|
449
554
|
~elevatedGroups: array<string>=[],
|
|
555
|
+
// The state's `@retired` field, when it declares one. Baked in for the same
|
|
556
|
+
// reason `ownerField` is: there is no Lambda in this path to read it at
|
|
557
|
+
// request time.
|
|
558
|
+
~retiredField: option<string>=?,
|
|
559
|
+
// The states that retire the row, for the state form of the annotation.
|
|
560
|
+
// Absent is the boolean form, where the value is always `true`.
|
|
561
|
+
~retiredValues: option<array<string>>=?,
|
|
450
562
|
) => {
|
|
451
563
|
let requireAttributeClause = switch requireAttribute {
|
|
452
564
|
| Some(attr) => `
|
|
@@ -480,6 +592,70 @@ let listAllItemsConnection = (
|
|
|
480
592
|
parts.push('#owner = :owner');
|
|
481
593
|
}`
|
|
482
594
|
}
|
|
595
|
+
// ── retirement narrowing (generated) ──
|
|
596
|
+
// Reuses `_exempt` when the owner clause already computed it, and computes its
|
|
597
|
+
// own when it did not — the two clauses are independently optional and either
|
|
598
|
+
// may be the only one present.
|
|
599
|
+
//
|
|
600
|
+
// `includeRetired` IS read from ctx.args, unlike the owner predicate, and the
|
|
601
|
+
// difference is deliberate: this argument does not say which rows the caller
|
|
602
|
+
// wants, it asks to lift a restriction, and it is honoured only inside the
|
|
603
|
+
// `_exempt` branch. A non-exempt caller passing it changes nothing.
|
|
604
|
+
//
|
|
605
|
+
// `attribute_not_exists OR = false` rather than `<> true`: a row written before
|
|
606
|
+
// the annotation existed carries no such attribute, and DynamoDB's `<>` does
|
|
607
|
+
// not match a missing one — the whole view would come back empty on the day
|
|
608
|
+
// the annotation lands.
|
|
609
|
+
//
|
|
610
|
+
// The state form compares `<>` against the retiring state instead, under the
|
|
611
|
+
// same `attribute_not_exists` guard and for the same reason. An equality
|
|
612
|
+
// predicate over an enum-valued attribute indexes exactly as a boolean one
|
|
613
|
+
// does, so the warning about an unindexed retirement field carries over
|
|
614
|
+
// unchanged.
|
|
615
|
+
let retiredClause = switch retiredField {
|
|
616
|
+
| None => ""
|
|
617
|
+
| Some(field) =>
|
|
618
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
619
|
+
let exemptPrelude = switch ownerField {
|
|
620
|
+
| Some(_) => ""
|
|
621
|
+
| None => `
|
|
622
|
+
const _id = ctx.identity;
|
|
623
|
+
const _sub = _id == null ? null : _id.sub;
|
|
624
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
625
|
+
const _elevated = [${elevatedLiteral}];
|
|
626
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
|
|
627
|
+
}
|
|
628
|
+
`${exemptPrelude}
|
|
629
|
+
// ── retirement narrowing (generated) ──
|
|
630
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
631
|
+
if (!_wantsRetired) {
|
|
632
|
+
names['#retired'] = '${field}';
|
|
633
|
+
${switch retiredValues {
|
|
634
|
+
| None => ` values[':retiredFalse'] = util.dynamodb.toDynamoDB(false);
|
|
635
|
+
parts.push('(attribute_not_exists(#retired) OR #retired = :retiredFalse)');`
|
|
636
|
+
// One `<>` per state, ANDed, rather than `NOT IN`: DynamoDB's `IN` needs a
|
|
637
|
+
// parenthesised operand list built from the same placeholders anyway, and
|
|
638
|
+
// the conjunction keeps `attribute_not_exists` covering the absent case
|
|
639
|
+
// once for the whole clause — a row that states no lifecycle is not
|
|
640
|
+
// retired, the same reading every other adapter takes.
|
|
641
|
+
| Some(states) =>
|
|
642
|
+
let placeholders = states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
|
|
643
|
+
let assignments =
|
|
644
|
+
placeholders
|
|
645
|
+
->Array.map(((ph, state)) => ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`)
|
|
646
|
+
->Array.join("\n")
|
|
647
|
+
let comparisons =
|
|
648
|
+
placeholders->Array.map(((ph, _)) => `#retired <> ${ph}`)->Array.join(" AND ")
|
|
649
|
+
// A set naming nothing withdraws nothing, so it pushes no predicate down.
|
|
650
|
+
switch placeholders {
|
|
651
|
+
| [] => ""
|
|
652
|
+
| _ =>
|
|
653
|
+
`${assignments}
|
|
654
|
+
parts.push('(attribute_not_exists(#retired) OR (${comparisons}))');`
|
|
655
|
+
}
|
|
656
|
+
}}
|
|
657
|
+
}`
|
|
658
|
+
}
|
|
483
659
|
let filterClauses =
|
|
484
660
|
filterFields
|
|
485
661
|
->Array.map(f => `
|
|
@@ -569,7 +745,7 @@ export function request(ctx) {
|
|
|
569
745
|
return key;
|
|
570
746
|
});
|
|
571
747
|
parts.push('#id IN (' + placeholders.join(', ') + ')');
|
|
572
|
-
}${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}
|
|
748
|
+
}${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}${retiredClause}
|
|
573
749
|
// The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
|
|
574
750
|
// DynamoDB continuation token the response side emitted (Fix 1 round-trip).
|
|
575
751
|
let after = null;
|
|
@@ -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
|
-
|
|
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
|
-
` +
|
|
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
|
-
` +
|
|
247
|
+
` + ownerScopedFirstResultResponse(ownerField, elevatedGroups) + `
|
|
183
248
|
`;
|
|
184
249
|
}
|
|
185
250
|
|
|
@@ -360,7 +425,7 @@ export function request(ctx) {
|
|
|
360
425
|
` + resultResponseCode + `
|
|
361
426
|
`;
|
|
362
427
|
|
|
363
|
-
function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute, ownerField, elevatedGroupsOpt) {
|
|
428
|
+
function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sortFieldsOpt, requireAttribute, ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
|
|
364
429
|
let filterFields = filterFieldsOpt !== undefined ? filterFieldsOpt : [];
|
|
365
430
|
let rangeFields = rangeFieldsOpt !== undefined ? rangeFieldsOpt : [];
|
|
366
431
|
let sortFields = sortFieldsOpt !== undefined ? sortFieldsOpt : [];
|
|
@@ -391,6 +456,39 @@ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sor
|
|
|
391
456
|
} else {
|
|
392
457
|
ownerClause = "";
|
|
393
458
|
}
|
|
459
|
+
let retiredClause;
|
|
460
|
+
if (retiredField !== undefined) {
|
|
461
|
+
let elevatedLiteral$1 = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
462
|
+
let exemptPrelude = ownerField !== undefined ? "" : `
|
|
463
|
+
const _id = ctx.identity;
|
|
464
|
+
const _sub = _id == null ? null : _id.sub;
|
|
465
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
466
|
+
const _elevated = [` + elevatedLiteral$1 + `];
|
|
467
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`;
|
|
468
|
+
let tmp;
|
|
469
|
+
if (retiredValues !== undefined) {
|
|
470
|
+
let placeholders = retiredValues.map((state, i) => [
|
|
471
|
+
`:retiredValue` + i.toString(),
|
|
472
|
+
state
|
|
473
|
+
]);
|
|
474
|
+
let assignments = placeholders.map(param => ` values['` + param[0] + `'] = util.dynamodb.toDynamoDB('` + param[1] + `');`).join("\n");
|
|
475
|
+
let comparisons = placeholders.map(param => `#retired <> ` + param[0]).join(" AND ");
|
|
476
|
+
tmp = placeholders.length !== 0 ? assignments + `
|
|
477
|
+
parts.push('(attribute_not_exists(#retired) OR (` + comparisons + `))');` : "";
|
|
478
|
+
} else {
|
|
479
|
+
tmp = ` values[':retiredFalse'] = util.dynamodb.toDynamoDB(false);
|
|
480
|
+
parts.push('(attribute_not_exists(#retired) OR #retired = :retiredFalse)');`;
|
|
481
|
+
}
|
|
482
|
+
retiredClause = exemptPrelude + `
|
|
483
|
+
// ── retirement narrowing (generated) ──
|
|
484
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
485
|
+
if (!_wantsRetired) {
|
|
486
|
+
names['#retired'] = '` + retiredField + `';
|
|
487
|
+
` + tmp + `
|
|
488
|
+
}`;
|
|
489
|
+
} else {
|
|
490
|
+
retiredClause = "";
|
|
491
|
+
}
|
|
394
492
|
let filterClauses = filterFields.map(f => `
|
|
395
493
|
if (filter.` + f + `Eq !== undefined && filter.` + f + `Eq !== null && filter.` + f + `Eq !== '') {
|
|
396
494
|
names['#` + f + `'] = '` + f + `';
|
|
@@ -460,7 +558,7 @@ export function request(ctx) {
|
|
|
460
558
|
return key;
|
|
461
559
|
});
|
|
462
560
|
parts.push('#id IN (' + placeholders.join(', ') + ')');
|
|
463
|
-
}` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + `
|
|
561
|
+
}` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + retiredClause + `
|
|
464
562
|
// The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
|
|
465
563
|
// DynamoDB continuation token the response side emitted (Fix 1 round-trip).
|
|
466
564
|
let after = null;
|
|
@@ -920,6 +1018,9 @@ export {
|
|
|
920
1018
|
firstResultResponseCode,
|
|
921
1019
|
resultListResponseCode,
|
|
922
1020
|
importUtil,
|
|
1021
|
+
ownerGuardPreamble,
|
|
1022
|
+
ownerScopedResultResponse,
|
|
1023
|
+
ownerScopedFirstResultResponse,
|
|
923
1024
|
pipelinePassThrough,
|
|
924
1025
|
nodeDecodeGlobalId,
|
|
925
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
|
+
})
|