@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.
|
|
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-
|
|
26
|
-
"@reventlessdev/rescript-
|
|
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
|
+
})
|