@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321
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 +30 -0
- package/package.json +12 -8
- package/rescript.json +4 -0
- package/run-provision-identity.mjs +3 -0
- package/scripts/ProvisionIdentity.res +303 -0
- package/scripts/ProvisionIdentity.res.mjs +376 -0
- package/src/Platform.res +54 -30
- package/src/Platform.res.mjs +34 -32
- package/src/Platform_Stack.res +102 -19
- package/src/Platform_Stack.res.mjs +43 -7
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -18
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
- package/src/util/Util_AwsError.res +56 -0
- package/src/util/Util_AwsError.res.mjs +59 -0
- package/src/util/Util_ShellConfig.res +28 -0
- package/src/util/Util_ShellConfig.res.mjs +22 -0
- package/tests/AppSync_RetirementNarrowingTest.res +10 -12
- package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
- package/tests/Auth_ActiveRoleStoreTest.res +65 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
- package/tests/AwsErrorFixtures.mjs +16 -0
- package/tests/ProvisionIdentityTest.res +109 -0
- package/tests/ProvisionIdentityTest.res.mjs +132 -0
- package/tests/QueryDbOwnerIndexTableTest.res +53 -0
- package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
- package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
- package/tests/Util_AwsErrorTest.res +127 -0
- package/tests/Util_AwsErrorTest.res.mjs +76 -0
- package/tests/Util_ShellConfigTest.res +51 -0
- package/tests/Util_ShellConfigTest.res.mjs +42 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
// The DynamoDB backend narrows retirement on every door the rule names, not
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// exists before a deploy: the predicate runs inside AppSync's JS runtime, so a
|
|
6
|
-
// unit test cannot execute it against a table, and what can be checked here is
|
|
7
|
-
// that each door carries the guard, in the half of the template its operation
|
|
8
|
-
// allows, and that a view declaring no retirement is untouched.
|
|
1
|
+
// The DynamoDB backend narrows retirement on every door the rule names, not only
|
|
2
|
+
// on the list. These assert the generated resolver source — the only artifact
|
|
3
|
+
// that exists before a deploy — for the guard, the half of the template its
|
|
4
|
+
// operation allows, and a view declaring no retirement staying untouched.
|
|
9
5
|
|
|
10
6
|
open JestGlobals
|
|
11
7
|
|
|
@@ -210,18 +206,20 @@ describe("the by-index door answers the field it is attached to", () => {
|
|
|
210
206
|
testSync("returns a Relay connection, not the raw DynamoDB result", () =>
|
|
211
207
|
expect((
|
|
212
208
|
code->String.includes("edges,"),
|
|
213
|
-
code->String.includes("hasNextPage: !!
|
|
209
|
+
code->String.includes("hasNextPage: _more || !!_next"),
|
|
214
210
|
code->String.includes("return ctx.result;"),
|
|
215
211
|
))->Expect.toEqual((true, true, false))
|
|
216
212
|
)
|
|
217
213
|
|
|
218
214
|
// `first`/`after` are what the field declares; `limit`/`nextToken` were what
|
|
219
|
-
// the template read, and nothing translated between the two.
|
|
215
|
+
// the template read, and nothing translated between the two. `limit` counts
|
|
216
|
+
// rows examined, so a filtered read looks wider than the page it serves.
|
|
220
217
|
testSync("pages on the Relay arguments the field declares", () =>
|
|
221
218
|
expect((
|
|
222
|
-
code->String.includes("
|
|
219
|
+
code->String.includes("const _first = args.first ?? 50;"),
|
|
220
|
+
code->String.includes("expression ? (_first > 1000 ? _first : 1000)"),
|
|
223
221
|
code->String.includes("util.base64Decode(args.after)"),
|
|
224
|
-
))->Expect.toEqual((true, true))
|
|
222
|
+
))->Expect.toEqual((true, true, true))
|
|
225
223
|
)
|
|
226
224
|
|
|
227
225
|
// Every declared argument has a job other than matching a column, so each one
|
|
@@ -133,7 +133,7 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
|
|
|
133
133
|
globalThis.test("returns a Relay connection, not the raw DynamoDB result", () => {
|
|
134
134
|
globalThis.expect([
|
|
135
135
|
code.includes("edges,"),
|
|
136
|
-
code.includes("hasNextPage: !!
|
|
136
|
+
code.includes("hasNextPage: _more || !!_next"),
|
|
137
137
|
code.includes("return ctx.result;")
|
|
138
138
|
]).toEqual([
|
|
139
139
|
true,
|
|
@@ -143,9 +143,11 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
|
|
|
143
143
|
});
|
|
144
144
|
globalThis.test("pages on the Relay arguments the field declares", () => {
|
|
145
145
|
globalThis.expect([
|
|
146
|
-
code.includes("
|
|
146
|
+
code.includes("const _first = args.first ?? 50;"),
|
|
147
|
+
code.includes("expression ? (_first > 1000 ? _first : 1000)"),
|
|
147
148
|
code.includes("util.base64Decode(args.after)")
|
|
148
149
|
]).toEqual([
|
|
150
|
+
true,
|
|
149
151
|
true,
|
|
150
152
|
true
|
|
151
153
|
]);
|
|
@@ -421,4 +421,179 @@ describe("Auth_ActiveRolePoolAttachment.probeEvent", () => {
|
|
|
421
421
|
->Option.flatMap(JSON.Decode.array),
|
|
422
422
|
)->toEqual(Some([]))
|
|
423
423
|
)
|
|
424
|
+
|
|
425
|
+
// 🚨 The handler keys its row on (subject, app client), and takes the
|
|
426
|
+
// "nothing to look up" branch when either is missing. A probe without a
|
|
427
|
+
// `callerContext` would return healthy without the handler ever reaching the
|
|
428
|
+
// store — proving less than the check appears to prove.
|
|
429
|
+
testSync("carries the caller context the handler keys its read on", () =>
|
|
430
|
+
expect(
|
|
431
|
+
event
|
|
432
|
+
->Option.flatMap(o => o->Dict.get("callerContext"))
|
|
433
|
+
->Option.flatMap(JSON.Decode.object)
|
|
434
|
+
->Option.flatMap(c => c->Dict.get("clientId"))
|
|
435
|
+
->Option.flatMap(JSON.Decode.string)
|
|
436
|
+
->Option.isSome,
|
|
437
|
+
)->toBe(true)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
// Both halves of the key must miss every real row, not just the subject.
|
|
441
|
+
testSync("the probe's subject and client cannot collide with a real row", () =>
|
|
442
|
+
expect(
|
|
443
|
+
Attachment.probeSubject == Attachment.probeClientId,
|
|
444
|
+
)->toBe(false)
|
|
445
|
+
)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// 🚨 Whose slot is it. Cognito allows a pool exactly one pre-token-generation
|
|
449
|
+
// trigger, so an unconditional attach is last-writer-wins — it replaces a BYO
|
|
450
|
+
// customer's own trigger silently, and it lets two platform stacks each read a
|
|
451
|
+
// store the other never writes. The describe this resource already performs is
|
|
452
|
+
// read for what is attached, and a slot held by anything else fails the deploy.
|
|
453
|
+
describe("Auth_ActiveRolePoolAttachment.attachedTriggerArn", () => {
|
|
454
|
+
let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken"
|
|
455
|
+
|
|
456
|
+
let withConfig = (~version) =>
|
|
457
|
+
Dict.fromArray([
|
|
458
|
+
(
|
|
459
|
+
"LambdaConfig",
|
|
460
|
+
Dict.fromArray([
|
|
461
|
+
(
|
|
462
|
+
"PreTokenGenerationConfig",
|
|
463
|
+
Dict.fromArray([
|
|
464
|
+
("LambdaArn", str(theirArn)),
|
|
465
|
+
("LambdaVersion", str(version)),
|
|
466
|
+
])->JSON.Encode.object,
|
|
467
|
+
),
|
|
468
|
+
])->JSON.Encode.object,
|
|
469
|
+
),
|
|
470
|
+
])
|
|
471
|
+
|
|
472
|
+
testSync("an empty pool holds nothing", () =>
|
|
473
|
+
expect(Attachment.attachedTriggerArn(~described=Dict.fromArray([("Name", str("Bare"))])))
|
|
474
|
+
->toEqual(None)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
testSync("reports the trigger a pool carries", () =>
|
|
478
|
+
expect(Attachment.attachedTriggerArn(~described=withConfig(~version="V1_0")))->toEqual(
|
|
479
|
+
Some(theirArn),
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
// 🚨 The distinction from `attachedTrigger`, and the reason both exist. A
|
|
484
|
+
// foreign trigger pinned at a version we do not implement is very much
|
|
485
|
+
// attached; a version-aware read calls it absent, and this resource would then
|
|
486
|
+
// quietly replace the very trigger it is meant to refuse.
|
|
487
|
+
testSync("a foreign trigger at another version is still occupying the slot", () =>
|
|
488
|
+
expect((
|
|
489
|
+
Attachment.attachedTriggerArn(~described=withConfig(~version="V2_0")),
|
|
490
|
+
Attachment.attachedTrigger(~described=withConfig(~version="V2_0")),
|
|
491
|
+
))->toEqual((Some(theirArn), None))
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
testSync("falls back to the legacy field on a pool carrying only that", () =>
|
|
495
|
+
expect(
|
|
496
|
+
Attachment.attachedTriggerArn(
|
|
497
|
+
~described=Dict.fromArray([
|
|
498
|
+
(
|
|
499
|
+
"LambdaConfig",
|
|
500
|
+
Dict.fromArray([("PreTokenGeneration", str(theirArn))])->JSON.Encode.object,
|
|
501
|
+
),
|
|
502
|
+
]),
|
|
503
|
+
),
|
|
504
|
+
)->toEqual(Some(theirArn))
|
|
505
|
+
)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe("Auth_ActiveRolePoolAttachment.classifySlot", () => {
|
|
509
|
+
let ours = "arn:aws:lambda:eu-west-1:1:function:OurTrigger"
|
|
510
|
+
let theirs = "arn:aws:lambda:eu-west-1:1:function:OtherStackTrigger"
|
|
511
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x"
|
|
512
|
+
|
|
513
|
+
let classify = (~attachedArn, ~attachedStore) =>
|
|
514
|
+
Attachment.classifySlot(~attachedArn, ~ourArn=ours, ~ourStore=store, ~attachedStore)
|
|
515
|
+
|
|
516
|
+
testSync("an empty slot is free to take", () =>
|
|
517
|
+
expect(classify(~attachedArn=None, ~attachedStore=None))->toEqual(Attachment.Vacant)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
// A pool can carry an empty string where a trigger was detached out of band.
|
|
521
|
+
testSync("an empty ARN is an empty slot, not a foreign trigger", () =>
|
|
522
|
+
expect(classify(~attachedArn=Some(""), ~attachedStore=None))->toEqual(Attachment.Vacant)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
testSync("our own trigger is ours to re-attach", () =>
|
|
526
|
+
expect(classify(~attachedArn=Some(ours), ~attachedStore=None))->toEqual(Attachment.Ours)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
// 🚨 The case the shared pool exists for: two platform stacks running identical
|
|
530
|
+
// code over identical rows. Whichever holds the slot serves both, so this is
|
|
531
|
+
// not a conflict and must not fail a deploy.
|
|
532
|
+
testSync("another deployment's trigger on the same store serves both", () =>
|
|
533
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some(store)))->toEqual(
|
|
534
|
+
Attachment.SharedWith(theirs),
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
// 🚨 The original defect, caught at the only moment anything can see both
|
|
539
|
+
// halves. Unreachable by configuration now that the store is derived — reachable
|
|
540
|
+
// by version skew, while an older release is still on its stack-scoped table.
|
|
541
|
+
testSync("another deployment's trigger on a different store is the defect", () =>
|
|
542
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some("ActiveRoleStore-829c96f")))
|
|
543
|
+
->toEqual(Attachment.DifferentStore({arn: theirs, theirStore: "ActiveRoleStore-829c96f"}))
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
// Reading the store rather than matching a name is what makes this a check on
|
|
547
|
+
// the invariant: a function with no ACTIVE_ROLE_TABLE is not one of ours, and
|
|
548
|
+
// neither is one we could not read.
|
|
549
|
+
testSync("a trigger with no store of ours is foreign", () =>
|
|
550
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=None))->toEqual(
|
|
551
|
+
Attachment.Foreign(theirs),
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
describe("Auth_ActiveRolePoolAttachment.refusalFor", () => {
|
|
557
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x"
|
|
558
|
+
let refusal = slot => Attachment.refusalFor(~slot, ~userPoolId="eu-west-1_x", ~ourStore=store)
|
|
559
|
+
|
|
560
|
+
testSync("the three attachable slots produce no refusal", () =>
|
|
561
|
+
expect((
|
|
562
|
+
refusal(Attachment.Vacant),
|
|
563
|
+
refusal(Attachment.Ours),
|
|
564
|
+
refusal(Attachment.SharedWith("arn:aws:lambda:eu-west-1:1:function:Other")),
|
|
565
|
+
))->toEqual((None, None, None))
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
// Both stores named, because an operator cannot act on this without knowing
|
|
569
|
+
// which two are in disagreement.
|
|
570
|
+
testSync("a disagreeing store is refused, naming both stores", () => {
|
|
571
|
+
let message =
|
|
572
|
+
refusal(
|
|
573
|
+
Attachment.DifferentStore({
|
|
574
|
+
arn: "arn:aws:lambda:eu-west-1:1:function:Other",
|
|
575
|
+
theirStore: "ActiveRoleStore-829c96f",
|
|
576
|
+
}),
|
|
577
|
+
)->Option.getOr("")
|
|
578
|
+
expect((
|
|
579
|
+
message->String.includes(store),
|
|
580
|
+
message->String.includes("ActiveRoleStore-829c96f"),
|
|
581
|
+
message->String.includes("eu-west-1_x"),
|
|
582
|
+
))->toEqual((true, true, true))
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
// 🚨 The trade this file already makes for its denylist, applied to the trigger
|
|
586
|
+
// slot: a customer's own claims-enrichment trigger is replaced silently today.
|
|
587
|
+
// Given "a customer's pool quietly loses a trigger" and "the deploy fails
|
|
588
|
+
// naming what is in the way", the second is the one to design for.
|
|
589
|
+
testSync("a foreign trigger is refused, naming what is attached", () => {
|
|
590
|
+
let arn = "arn:aws:lambda:eu-west-1:1:function:TheirClaimsEnrichment"
|
|
591
|
+
let message = refusal(Attachment.Foreign(arn))->Option.getOr("")
|
|
592
|
+
expect((
|
|
593
|
+
message->String.includes(arn),
|
|
594
|
+
// The likeliest cause of a false Foreign is a missing permission, so the
|
|
595
|
+
// sentence has to name it or an operator debugs the wrong thing.
|
|
596
|
+
message->String.includes("lambda:GetFunctionConfiguration"),
|
|
597
|
+
))->toEqual((true, true))
|
|
598
|
+
})
|
|
424
599
|
})
|
|
@@ -362,6 +362,142 @@ globalThis.describe("Auth_ActiveRolePoolAttachment.probeEvent", () => {
|
|
|
362
362
|
globalThis.test("presents no groups to narrow", () => {
|
|
363
363
|
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event, o => o["request"]), Stdlib_JSON.Decode.object), r => r["groupConfiguration"]), Stdlib_JSON.Decode.object), g => g["groupsToOverride"]), Stdlib_JSON.Decode.array)).toEqual([]);
|
|
364
364
|
});
|
|
365
|
+
globalThis.test("carries the caller context the handler keys its read on", () => {
|
|
366
|
+
globalThis.expect(Stdlib_Option.isSome(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event, o => o["callerContext"]), Stdlib_JSON.Decode.object), c => c["clientId"]), Stdlib_JSON.Decode.string))).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
globalThis.test("the probe's subject and client cannot collide with a real row", () => {
|
|
369
|
+
globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.probeSubject === Auth_ActiveRolePoolAttachment$ReventlessAws.probeClientId).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
globalThis.describe("Auth_ActiveRolePoolAttachment.attachedTriggerArn", () => {
|
|
374
|
+
let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken";
|
|
375
|
+
let withConfig = version => Object.fromEntries([[
|
|
376
|
+
"LambdaConfig",
|
|
377
|
+
Object.fromEntries([[
|
|
378
|
+
"PreTokenGenerationConfig",
|
|
379
|
+
Object.fromEntries([
|
|
380
|
+
[
|
|
381
|
+
"LambdaArn",
|
|
382
|
+
theirArn
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
"LambdaVersion",
|
|
386
|
+
version
|
|
387
|
+
]
|
|
388
|
+
])
|
|
389
|
+
]])
|
|
390
|
+
]]);
|
|
391
|
+
globalThis.test("an empty pool holds nothing", () => {
|
|
392
|
+
globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(Object.fromEntries([[
|
|
393
|
+
"Name",
|
|
394
|
+
"Bare"
|
|
395
|
+
]]))).toEqual(undefined);
|
|
396
|
+
});
|
|
397
|
+
globalThis.test("reports the trigger a pool carries", () => {
|
|
398
|
+
globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(withConfig("V1_0"))).toEqual(theirArn);
|
|
399
|
+
});
|
|
400
|
+
globalThis.test("a foreign trigger at another version is still occupying the slot", () => {
|
|
401
|
+
globalThis.expect([
|
|
402
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(withConfig("V2_0")),
|
|
403
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTrigger(withConfig("V2_0"))
|
|
404
|
+
]).toEqual([
|
|
405
|
+
theirArn,
|
|
406
|
+
undefined
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
globalThis.test("falls back to the legacy field on a pool carrying only that", () => {
|
|
410
|
+
globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(Object.fromEntries([[
|
|
411
|
+
"LambdaConfig",
|
|
412
|
+
Object.fromEntries([[
|
|
413
|
+
"PreTokenGeneration",
|
|
414
|
+
theirArn
|
|
415
|
+
]])
|
|
416
|
+
]]))).toEqual(theirArn);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
globalThis.describe("Auth_ActiveRolePoolAttachment.classifySlot", () => {
|
|
421
|
+
let ours = "arn:aws:lambda:eu-west-1:1:function:OurTrigger";
|
|
422
|
+
let theirs = "arn:aws:lambda:eu-west-1:1:function:OtherStackTrigger";
|
|
423
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x";
|
|
424
|
+
let classify = (attachedArn, attachedStore) => Auth_ActiveRolePoolAttachment$ReventlessAws.classifySlot(attachedArn, ours, store, attachedStore);
|
|
425
|
+
globalThis.test("an empty slot is free to take", () => {
|
|
426
|
+
globalThis.expect(classify(undefined, undefined)).toEqual("Vacant");
|
|
427
|
+
});
|
|
428
|
+
globalThis.test("an empty ARN is an empty slot, not a foreign trigger", () => {
|
|
429
|
+
globalThis.expect(classify("", undefined)).toEqual("Vacant");
|
|
430
|
+
});
|
|
431
|
+
globalThis.test("our own trigger is ours to re-attach", () => {
|
|
432
|
+
globalThis.expect(classify(ours, undefined)).toEqual("Ours");
|
|
433
|
+
});
|
|
434
|
+
globalThis.test("another deployment's trigger on the same store serves both", () => {
|
|
435
|
+
globalThis.expect(classify(theirs, store)).toEqual({
|
|
436
|
+
TAG: "SharedWith",
|
|
437
|
+
_0: theirs
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
globalThis.test("another deployment's trigger on a different store is the defect", () => {
|
|
441
|
+
globalThis.expect(classify(theirs, "ActiveRoleStore-829c96f")).toEqual({
|
|
442
|
+
TAG: "DifferentStore",
|
|
443
|
+
arn: theirs,
|
|
444
|
+
theirStore: "ActiveRoleStore-829c96f"
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
globalThis.test("a trigger with no store of ours is foreign", () => {
|
|
448
|
+
globalThis.expect(classify(theirs, undefined)).toEqual({
|
|
449
|
+
TAG: "Foreign",
|
|
450
|
+
_0: theirs
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
globalThis.describe("Auth_ActiveRolePoolAttachment.refusalFor", () => {
|
|
456
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x";
|
|
457
|
+
globalThis.test("the three attachable slots produce no refusal", () => {
|
|
458
|
+
globalThis.expect([
|
|
459
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor("Vacant", "eu-west-1_x", store),
|
|
460
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor("Ours", "eu-west-1_x", store),
|
|
461
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
|
|
462
|
+
TAG: "SharedWith",
|
|
463
|
+
_0: "arn:aws:lambda:eu-west-1:1:function:Other"
|
|
464
|
+
}, "eu-west-1_x", store)
|
|
465
|
+
]).toEqual([
|
|
466
|
+
undefined,
|
|
467
|
+
undefined,
|
|
468
|
+
undefined
|
|
469
|
+
]);
|
|
470
|
+
});
|
|
471
|
+
globalThis.test("a disagreeing store is refused, naming both stores", () => {
|
|
472
|
+
let message = Stdlib_Option.getOr(Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
|
|
473
|
+
TAG: "DifferentStore",
|
|
474
|
+
arn: "arn:aws:lambda:eu-west-1:1:function:Other",
|
|
475
|
+
theirStore: "ActiveRoleStore-829c96f"
|
|
476
|
+
}, "eu-west-1_x", store), "");
|
|
477
|
+
globalThis.expect([
|
|
478
|
+
message.includes(store),
|
|
479
|
+
message.includes("ActiveRoleStore-829c96f"),
|
|
480
|
+
message.includes("eu-west-1_x")
|
|
481
|
+
]).toEqual([
|
|
482
|
+
true,
|
|
483
|
+
true,
|
|
484
|
+
true
|
|
485
|
+
]);
|
|
486
|
+
});
|
|
487
|
+
globalThis.test("a foreign trigger is refused, naming what is attached", () => {
|
|
488
|
+
let arn = "arn:aws:lambda:eu-west-1:1:function:TheirClaimsEnrichment";
|
|
489
|
+
let message = Stdlib_Option.getOr(Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
|
|
490
|
+
TAG: "Foreign",
|
|
491
|
+
_0: arn
|
|
492
|
+
}, "eu-west-1_x", store), "");
|
|
493
|
+
globalThis.expect([
|
|
494
|
+
message.includes(arn),
|
|
495
|
+
message.includes("lambda:GetFunctionConfiguration")
|
|
496
|
+
]).toEqual([
|
|
497
|
+
true,
|
|
498
|
+
true
|
|
499
|
+
]);
|
|
500
|
+
});
|
|
365
501
|
});
|
|
366
502
|
|
|
367
503
|
let Attachment;
|
|
@@ -72,3 +72,68 @@ describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
|
|
|
72
72
|
expect(Ops.cognitoLookupName(~identity={}))->toEqual(None)
|
|
73
73
|
)
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
// Which store this deployment reads and writes. Two cases and no third: a stack
|
|
77
|
+
// that creates its own provider owns everything attached to it, and a stack given
|
|
78
|
+
// a provider owns none of it. See
|
|
79
|
+
// [docs/plans/active-role-store-scoped-to-the-pool.md].
|
|
80
|
+
describe("Auth_ActiveRoleStore.chooseStore", () => {
|
|
81
|
+
testSync("a stack that creates its own provider keeps its own table", () =>
|
|
82
|
+
expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=None))->toEqual(
|
|
83
|
+
Auth_ActiveRoleStore.StackScoped,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
testSync("a stack given a provider reads that provider's derived store", () =>
|
|
88
|
+
expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_CQTwafSeX")))
|
|
89
|
+
->toEqual(
|
|
90
|
+
Auth_ActiveRoleStore.ProviderScoped("ReventlessActiveRoleStore-eu-west-1_CQTwafSeX"),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// 🚨 The defect, expressed as the property that now prevents it. Two platform
|
|
95
|
+
// stacks on one provider previously deployed a table each, so the pool's single
|
|
96
|
+
// trigger read rows the other's resolver wrote — every switch succeeding and
|
|
97
|
+
// doing nothing. There is no configuration either could carry that would make
|
|
98
|
+
// them disagree.
|
|
99
|
+
testSync("two deployments on one provider cannot choose different stores", () =>
|
|
100
|
+
expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_Shared")))
|
|
101
|
+
->toEqual(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_Shared")))
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// The second half of the row key. Every other absent field in this handler has a
|
|
106
|
+
// defensible default; this one does not, and the test that matters is the one
|
|
107
|
+
// asserting it refuses rather than substituting something.
|
|
108
|
+
describe("Auth_ActiveRoleStore_Ops.appClientId", () => {
|
|
109
|
+
testSync("the app client the authorizer resolved is the one used", () =>
|
|
110
|
+
expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Value("7cl13nt")}))->toEqual(
|
|
111
|
+
Some("7cl13nt"),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// 🚨 The trigger keys its read on the client id Cognito hands it, so a write
|
|
116
|
+
// under any substitute — a constant, the subject, the empty string — is a row
|
|
117
|
+
// the trigger never finds. Refusing is the only answer that does not recreate
|
|
118
|
+
// the defect one level down.
|
|
119
|
+
testSync("an absent client id yields nothing to key a row on", () =>
|
|
120
|
+
expect(Ops.appClientId(~identity={sub: "sub-1"}))->toEqual(None)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// The resolver sends `appClientId(id)`, which returns null when the claims are
|
|
124
|
+
// not there to read — so null is the shape that actually arrives, not a missing
|
|
125
|
+
// field.
|
|
126
|
+
testSync("an explicitly null client id is not a client id", () =>
|
|
127
|
+
expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Null}))->toEqual(None)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
testSync("a blank client id is not a client id either", () =>
|
|
131
|
+
expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Value(" ")}))->toEqual(None)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// The subject is deliberately not a fallback: it would key every platform's row
|
|
135
|
+
// identically and undo the per-platform narrowing the pair key exists for.
|
|
136
|
+
testSync("the subject is never substituted for the app client", () =>
|
|
137
|
+
expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Null}))->toEqual(None)
|
|
138
|
+
)
|
|
139
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
4
|
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
5
|
+
import * as Auth_ActiveRoleStore$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore.res.mjs";
|
|
5
6
|
import * as Auth_ActiveRoleStore_Ops$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs";
|
|
6
7
|
|
|
7
8
|
globalThis.describe("Auth_ActiveRoleStore_Ops.mayActAs — the conformance table", () => {
|
|
@@ -59,6 +60,53 @@ globalThis.describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
|
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
globalThis.describe("Auth_ActiveRoleStore.chooseStore", () => {
|
|
64
|
+
globalThis.test("a stack that creates its own provider keeps its own table", () => {
|
|
65
|
+
globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore(undefined)).toEqual("StackScoped");
|
|
66
|
+
});
|
|
67
|
+
globalThis.test("a stack given a provider reads that provider's derived store", () => {
|
|
68
|
+
globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_CQTwafSeX")).toEqual({
|
|
69
|
+
TAG: "ProviderScoped",
|
|
70
|
+
_0: "ReventlessActiveRoleStore-eu-west-1_CQTwafSeX"
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
globalThis.test("two deployments on one provider cannot choose different stores", () => {
|
|
74
|
+
globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_Shared")).toEqual(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_Shared"));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
globalThis.describe("Auth_ActiveRoleStore_Ops.appClientId", () => {
|
|
79
|
+
globalThis.test("the app client the authorizer resolved is the one used", () => {
|
|
80
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
|
|
81
|
+
sub: "sub-1",
|
|
82
|
+
clientId: "7cl13nt"
|
|
83
|
+
})).toEqual("7cl13nt");
|
|
84
|
+
});
|
|
85
|
+
globalThis.test("an absent client id yields nothing to key a row on", () => {
|
|
86
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
|
|
87
|
+
sub: "sub-1"
|
|
88
|
+
})).toEqual(undefined);
|
|
89
|
+
});
|
|
90
|
+
globalThis.test("an explicitly null client id is not a client id", () => {
|
|
91
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
|
|
92
|
+
sub: "sub-1",
|
|
93
|
+
clientId: null
|
|
94
|
+
})).toEqual(undefined);
|
|
95
|
+
});
|
|
96
|
+
globalThis.test("a blank client id is not a client id either", () => {
|
|
97
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
|
|
98
|
+
sub: "sub-1",
|
|
99
|
+
clientId: " "
|
|
100
|
+
})).toEqual(undefined);
|
|
101
|
+
});
|
|
102
|
+
globalThis.test("the subject is never substituted for the app client", () => {
|
|
103
|
+
globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
|
|
104
|
+
sub: "sub-1",
|
|
105
|
+
clientId: null
|
|
106
|
+
})).toEqual(undefined);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
62
110
|
let Ops;
|
|
63
111
|
|
|
64
112
|
let Contract;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The store's identity — its name and its key — is shared by four things that
|
|
4
|
+
// cannot see each other: the deploy, the write door, the pre-token trigger, and
|
|
5
|
+
// the provisioning script an operator runs by hand. Any two of them disagreeing
|
|
6
|
+
// produces one symptom, and it is the symptom this whole area exists to end: a
|
|
7
|
+
// row written where nothing looks for it, so a role switch reports success and
|
|
8
|
+
// changes nothing.
|
|
9
|
+
//
|
|
10
|
+
// These assert the contract itself rather than any one consumer's use of it.
|
|
11
|
+
|
|
12
|
+
module Schema = Auth_ActiveRoleStore_Schema
|
|
13
|
+
|
|
14
|
+
describe("Auth_ActiveRoleStore_Schema.derivedStoreName", () => {
|
|
15
|
+
testSync("names the store after the provider whose trigger reads it", () =>
|
|
16
|
+
expect(Schema.derivedStoreName(~identityProviderId="eu-west-1_CQTwafSeX"))->toBe(
|
|
17
|
+
"ReventlessActiveRoleStore-eu-west-1_CQTwafSeX",
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// The property the whole design turns on: two platforms on one provider cannot
|
|
22
|
+
// name different stores, so the defect stops being something to detect and
|
|
23
|
+
// becomes something that cannot be expressed.
|
|
24
|
+
testSync("two deployments on one provider derive the same store", () =>
|
|
25
|
+
expect(Schema.derivedStoreName(~identityProviderId="eu-west-1_Shared"))->toBe(
|
|
26
|
+
Schema.derivedStoreName(~identityProviderId="eu-west-1_Shared"),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
testSync("different providers do not collide", () =>
|
|
31
|
+
expect(
|
|
32
|
+
Schema.derivedStoreName(~identityProviderId="eu-west-1_A") ==
|
|
33
|
+
Schema.derivedStoreName(~identityProviderId="eu-west-1_B"),
|
|
34
|
+
)->toBe(false)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// A pool id carries an underscore and a hyphen; DynamoDB accepts both, and a
|
|
38
|
+
// name that needed escaping would make the script and the deploy disagree about
|
|
39
|
+
// what "the same name" is.
|
|
40
|
+
testSync("the derived name is a legal DynamoDB table name", () => {
|
|
41
|
+
let name = Schema.derivedStoreName(~identityProviderId="eu-west-1_CQTwafSeX")
|
|
42
|
+
expect((
|
|
43
|
+
RegExp.test(RegExp.fromString("^[A-Za-z0-9_.-]+$"), name),
|
|
44
|
+
name->String.length >= 3 && name->String.length <= 255,
|
|
45
|
+
))->toEqual((true, true))
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("Auth_ActiveRoleStore_Schema.keySchemaRefusal", () => {
|
|
50
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x"
|
|
51
|
+
|
|
52
|
+
testSync("the expected pair is adopted without complaint", () =>
|
|
53
|
+
expect(
|
|
54
|
+
Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH"), ("clientId", "RANGE")]),
|
|
55
|
+
)->toEqual(None)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// DescribeTable promises no ordering, and a refusal that depended on one would
|
|
59
|
+
// reject a table that is perfectly correct.
|
|
60
|
+
testSync("order is not part of the comparison", () =>
|
|
61
|
+
expect(
|
|
62
|
+
Schema.keySchemaRefusal(~tableName=store, ~actual=[("clientId", "RANGE"), ("id", "HASH")]),
|
|
63
|
+
)->toEqual(None)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// 🚨 The upgrade case. A store keyed on the subject alone is what every
|
|
67
|
+
// deployment had before the active role became per-platform, and adopting it
|
|
68
|
+
// silently would write rows under a two-part key that nothing reads.
|
|
69
|
+
testSync("the pre-clientId store is refused rather than adopted", () =>
|
|
70
|
+
expect(
|
|
71
|
+
Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH")])->Option.isSome,
|
|
72
|
+
)->toBe(true)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
testSync("the refusal names the table, what it found, and what is needed", () => {
|
|
76
|
+
let message = Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH")])->Option.getOr("")
|
|
77
|
+
expect((
|
|
78
|
+
message->String.includes(store),
|
|
79
|
+
message->String.includes("id:HASH"),
|
|
80
|
+
message->String.includes("clientId:RANGE"),
|
|
81
|
+
))->toEqual((true, true, true))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
testSync("a table keyed on something else entirely is refused", () =>
|
|
85
|
+
expect(
|
|
86
|
+
Schema.keySchemaRefusal(
|
|
87
|
+
~tableName=store,
|
|
88
|
+
~actual=[("pk", "HASH"), ("sk", "RANGE")],
|
|
89
|
+
)->Option.isSome,
|
|
90
|
+
)->toBe(true)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Not a table anyone would build on purpose — but `DescribeTable`'s KeySchema is
|
|
94
|
+
// optional in the SDK's types, and the caller resolves an absent one to this.
|
|
95
|
+
testSync("an empty key schema is refused, not treated as a match", () =>
|
|
96
|
+
expect(Schema.keySchemaRefusal(~tableName=store, ~actual=[])->Option.isSome)->toBe(true)
|
|
97
|
+
)
|
|
98
|
+
})
|