@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.320

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +9 -5
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/util/Util_AwsError.res +56 -0
  22. package/src/util/Util_AwsError.res.mjs +59 -0
  23. package/src/util/Util_ShellConfig.res +28 -0
  24. package/src/util/Util_ShellConfig.res.mjs +22 -0
  25. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  26. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  27. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  28. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  29. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  30. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  31. package/tests/AwsErrorFixtures.mjs +16 -0
  32. package/tests/ProvisionIdentityTest.res +109 -0
  33. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  34. package/tests/Util_AwsErrorTest.res +127 -0
  35. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  36. package/tests/Util_ShellConfigTest.res +51 -0
  37. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -0,0 +1,92 @@
1
+ // The active-role store's identity: what it is called, and how its rows are
2
+ // keyed. See [docs/plans/active-role-store-scoped-to-the-pool.md].
3
+ //
4
+ // 🚨 **One definition, four consumers, and that is the whole reason this file
5
+ // exists.** The deploy creates or looks the table up ([Auth_ActiveRoleStore]),
6
+ // the write door writes rows ([Auth_ActiveRoleStore_Ops]), the pre-token trigger
7
+ // reads them ([Auth_ActiveRoleTrigger_Ops]), and the provisioning script creates
8
+ // the table on a provider no stack owns (`scripts/provision-identity.mjs`). Any
9
+ // two of those disagreeing produces the same failure: a row written where nothing
10
+ // looks for it, so a role switch reports success and does nothing.
11
+ //
12
+ // Deliberately free of Pulumi and of the AWS SDK, so the script and both Lambda
13
+ // bundles can import it without dragging a deploy-time dependency into a runtime
14
+ // graph — the hazard that once broke command-handler cold starts.
15
+
16
+ /**
17
+ The store belonging to an identity provider this framework does not own.
18
+
19
+ Derived rather than configured. The objection to deriving was that two stacks
20
+ would both try to *create* the table and the second would fail or adopt a
21
+ resource it does not own — true while stacks create it, and no stack creates this
22
+ one. What derivation buys is worth more than the config key it removes: two
23
+ platforms on one provider **cannot** name different stores, so the defect stops
24
+ being something to detect and becomes something that cannot be expressed.
25
+
26
+ The provider id carries its own region (`eu-west-1_CQTwafSeX`), which is the
27
+ region the store must live in too — a pre-token-generation trigger has to sit in
28
+ its pool's region, so every stack that can attach one derives the same name in
29
+ the same place.
30
+ */
31
+ let derivedStoreName = (~identityProviderId: string): string =>
32
+ `ReventlessActiveRoleStore-${identityProviderId}`
33
+
34
+ /** The caller's Cognito `sub`. Stable across a rename, unlike the username. */
35
+ let partitionKey = "id"
36
+
37
+ /**
38
+ The app client the token was minted for.
39
+
40
+ A sort key rather than nothing, because one identity provider can serve several
41
+ platforms: each platform stack declares its own app client, so keying on the pair
42
+ gives every platform its own active role over one shared set of rows. Keyed on
43
+ the subject alone, narrowing to a role in one platform would narrow the caller's
44
+ session in every other platform on that provider — defensible as "one identity,
45
+ one session", but not what an operator wants, since a role with surfaces in one
46
+ platform and none in another leaves the second showing nothing.
47
+ */
48
+ let sortKey = "clientId"
49
+
50
+ /** The stored choice itself. */
51
+ let roleAttribute = "activeRole"
52
+
53
+ /** The key schema as DynamoDB describes one: `(attribute, keyType)` pairs.
54
+
55
+ Plain tuples rather than the SDK's `keySchemaElement` so this module keeps its
56
+ "no side effect" footer — importing the AWS SDK here would put it in both Lambda
57
+ bundles, which is the dependency leak that once broke command-handler cold
58
+ starts. Callers map their own shapes into this. */
59
+ let expectedKeySchema: array<(string, string)> = [(partitionKey, "HASH"), (sortKey, "RANGE")]
60
+
61
+ let describeKeySchema = (elements: array<(string, string)>): string =>
62
+ elements
63
+ ->Array.map(((attribute, keyType)) => `${attribute}:${keyType}`)
64
+ ->Array.toSorted(String.compare)
65
+ ->Array.join(",")
66
+
67
+ /**
68
+ Why a table that already exists cannot serve as the store, if it cannot.
69
+
70
+ 🚨 **A table under the right name with the wrong key is worse than no table.** The
71
+ deploy finds it, the handlers write into it, and every read misses — a role switch
72
+ that reports success and does nothing, which is the defect this whole store was
73
+ repaired for. So adoption checks the schema and refuses, rather than reporting
74
+ success.
75
+
76
+ The pre-`clientId` store is exactly this case: keyed on the subject alone. An
77
+ operator upgrading meets a sentence instead of a silent misbehaviour.
78
+
79
+ Order-insensitive, because `DescribeTable` does not promise one.
80
+ */
81
+ let keySchemaRefusal = (
82
+ ~tableName: string,
83
+ ~actual: array<(string, string)>,
84
+ ): option<string> => {
85
+ let actualKey = describeKeySchema(actual)
86
+ let wantedKey = describeKeySchema(expectedKeySchema)
87
+ actualKey == wantedKey
88
+ ? None
89
+ : Some(
90
+ `table "${tableName}" already exists with key schema [${actualKey}], but the active-role store needs [${wantedKey}]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`,
91
+ )
92
+ }
@@ -0,0 +1,49 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
4
+
5
+ function derivedStoreName(identityProviderId) {
6
+ return `ReventlessActiveRoleStore-` + identityProviderId;
7
+ }
8
+
9
+ let partitionKey = "id";
10
+
11
+ let sortKey = "clientId";
12
+
13
+ let expectedKeySchema = [
14
+ [
15
+ partitionKey,
16
+ "HASH"
17
+ ],
18
+ [
19
+ sortKey,
20
+ "RANGE"
21
+ ]
22
+ ];
23
+
24
+ function describeKeySchema(elements) {
25
+ return elements.map(param => param[0] + `:` + param[1]).toSorted(Primitive_string.compare).join(",");
26
+ }
27
+
28
+ function keySchemaRefusal(tableName, actual) {
29
+ let actualKey = describeKeySchema(actual);
30
+ let wantedKey = describeKeySchema(expectedKeySchema);
31
+ if (actualKey === wantedKey) {
32
+ return;
33
+ } else {
34
+ return `table "` + tableName + `" already exists with key schema [` + actualKey + `], but the active-role store needs [` + wantedKey + `]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`;
35
+ }
36
+ }
37
+
38
+ let roleAttribute = "activeRole";
39
+
40
+ export {
41
+ derivedStoreName,
42
+ partitionKey,
43
+ sortKey,
44
+ roleAttribute,
45
+ expectedKeySchema,
46
+ describeKeySchema,
47
+ keySchemaRefusal,
48
+ }
49
+ /* No side effect */
@@ -60,6 +60,11 @@ type triggerRequest = {
60
60
  groupConfiguration?: groupConfiguration,
61
61
  }
62
62
 
63
+ /** The app client this token is being minted for — the second half of the store's
64
+ row key, and what makes the active role per-platform on a provider serving
65
+ several. Cognito supplies it on every pre-token-generation event. */
66
+ type callerContext = {clientId?: string}
67
+
63
68
  type groupOverrideDetails = {
64
69
  groupsToOverride: array<string>,
65
70
  iamRolesToOverride: array<string>,
@@ -77,6 +82,7 @@ type event = {
77
82
  request?: triggerRequest,
78
83
  response?: triggerResponse,
79
84
  userName?: string,
85
+ callerContext?: callerContext,
80
86
  }
81
87
 
82
88
  // ── The decision ────────────────────────────────────────────────────────────
@@ -171,15 +177,18 @@ sign-in outright, and failing a login because a *preference* could not be read
171
177
  trades a working session for a cosmetic one. The caller lands on full membership —
172
178
  their existing privileges, not more — which is the safe direction to fail.
173
179
  */
174
- let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
180
+ let storedRoleFor = async (~sub: string, ~clientId: string, ~table: string): option<string> =>
175
181
  try {
176
182
  let out = await DynamoDb_DocumentClient.GetCommand.make({
177
183
  tableName: table,
178
- key: Dict.fromArray([("id", JSON.Encode.string(sub))]),
184
+ key: Dict.fromArray([
185
+ (Auth_ActiveRoleStore_Schema.partitionKey, JSON.Encode.string(sub)),
186
+ (Auth_ActiveRoleStore_Schema.sortKey, JSON.Encode.string(clientId)),
187
+ ]),
179
188
  })->DynamoDb_DocumentClient.GetCommand.send
180
189
  out.item
181
190
  ->Option.flatMap(JSON.Decode.object)
182
- ->Option.flatMap(o => o->Dict.get("activeRole"))
191
+ ->Option.flatMap(o => o->Dict.get(Auth_ActiveRoleStore_Schema.roleAttribute))
183
192
  ->Option.flatMap(JSON.Decode.string)
184
193
  } catch {
185
194
  | _ => None
@@ -197,12 +206,17 @@ let handler = async (event: event): event => {
197
206
  let sub =
198
207
  event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
199
208
 
200
- // No subject means no row to look up. Returning the event untouched keeps the
201
- // sign-in working on exactly the membership the pool granted.
202
- if sub == "" {
209
+ let clientId = event.callerContext->Option.flatMap(c => c.clientId)->Option.getOr("")
210
+
211
+ // Neither half of the key means no row to look up. Returning the event
212
+ // untouched keeps the sign-in working on exactly the membership the pool
213
+ // granted — the safe direction, and the same one a read failure takes. An
214
+ // absent client id is not worth failing a login over: the caller lands on their
215
+ // full membership, which is their existing privileges and not more.
216
+ if sub == "" || clientId == "" {
203
217
  event
204
218
  } else {
205
- let storedRole = await storedRoleFor(~sub, ~table=tableName())
219
+ let storedRole = await storedRoleFor(~sub, ~clientId, ~table=tableName())
206
220
  respond(~event, ~decision=decide(~membership, ~storedRole))
207
221
  }
208
222
  }
@@ -6,6 +6,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
6
  import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
7
7
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
8
  import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
9
+ import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
9
10
 
10
11
  function getEnv(k) {
11
12
  let v = process.env[k];
@@ -90,16 +91,22 @@ function respond(event, decision) {
90
91
  ]));
91
92
  }
92
93
 
93
- async function storedRoleFor(sub, table) {
94
+ async function storedRoleFor(sub, clientId, table) {
94
95
  try {
95
96
  let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
96
97
  TableName: table,
97
- Key: Object.fromEntries([[
98
- "id",
98
+ Key: Object.fromEntries([
99
+ [
100
+ Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
99
101
  sub
100
- ]])
102
+ ],
103
+ [
104
+ Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
105
+ clientId
106
+ ]
107
+ ])
101
108
  }));
102
- return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o["activeRole"]), Stdlib_JSON.Decode.string);
109
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[Auth_ActiveRoleStore_Schema$ReventlessAws.roleAttribute]), Stdlib_JSON.Decode.string);
103
110
  } catch (exn) {
104
111
  return;
105
112
  }
@@ -108,10 +115,11 @@ async function storedRoleFor(sub, table) {
108
115
  async function handler(event) {
109
116
  let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
110
117
  let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
111
- if (sub === "") {
118
+ let clientId = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.callerContext, c => c.clientId), "");
119
+ if (sub === "" || clientId === "") {
112
120
  return event;
113
121
  }
114
- let storedRole = await storedRoleFor(sub, tableName());
122
+ let storedRole = await storedRoleFor(sub, clientId, tableName());
115
123
  return respond(event, decide(membership, storedRole));
116
124
  }
117
125
 
@@ -0,0 +1,56 @@
1
+ // Classifying an AWS SDK failure, for callers that must tell one apart from the
2
+ // rest rather than treat every throw alike.
3
+ //
4
+ // 🚨 **The error's `name` carries the code; its `message` does not have to.** A
5
+ // v3 SDK `DescribeTable` on a missing table throws `name:
6
+ // "ResourceNotFoundException"` with the message "Requested resource not found:
7
+ // Table: X not found" — which does not contain the code anywhere. A guard written
8
+ // against the message alone therefore never matches, and the ordinary case it was
9
+ // meant to absorb escapes as an unhandled rejection instead.
10
+ //
11
+ // That is not hypothetical: it is why this module exists rather than the check
12
+ // living inline. A predicate inside a script that runs on import cannot be tested,
13
+ // so the version that could never match shipped.
14
+ //
15
+ // `Auth_ActiveRolePoolAttachment` keeps its own inline copy on purpose — Pulumi
16
+ // serialises a dynamic provider's whole closure into stack state, and a helper
17
+ // reached through a module import is a dependency that serialisation cannot carry.
18
+
19
+ @get @return(nullable) external name: JsExn.t => option<string> = "name"
20
+
21
+ /** Whether a failure carries this AWS error code, by `name` first and message as
22
+ a fallback for wrapped or re-thrown shapes. */
23
+ let hasCode = (exn: exn, ~code: string): bool =>
24
+ switch exn->JsExn.fromException {
25
+ | Some(jsErr) =>
26
+ switch (jsErr->name, JsExn.message(jsErr)) {
27
+ | (Some(actual), _) if actual == code => true
28
+ | (_, Some(message)) => message->String.includes(code)
29
+ | _ => false
30
+ }
31
+ | None => false
32
+ }
33
+
34
+ /** The resource named by the call does not exist — the shape both DynamoDB and
35
+ Cognito use, and normally an absence to handle rather than a failure to report. */
36
+ let isNotFound = (exn: exn): bool => exn->hasCode(~code="ResourceNotFoundException")
37
+
38
+ /**
39
+ An escaped exception as one line an operator can act on.
40
+
41
+ For the top of a CLI: without it Node reports `UnhandledPromiseRejection ...
42
+ "#<Object>"`, which names neither the call that failed nor why. Every branch
43
+ returns something, because a describer that itself throws replaces one unreadable
44
+ failure with another.
45
+ */
46
+ let describe = (exn: exn): string =>
47
+ switch exn->JsExn.fromException {
48
+ | Some(jsErr) =>
49
+ switch (jsErr->name, JsExn.message(jsErr)) {
50
+ | (Some(n), Some(m)) => `${n}: ${m}`
51
+ | (Some(n), None) => n
52
+ | (None, Some(m)) => m
53
+ | (None, None) => "an AWS call failed with no message"
54
+ }
55
+ | None => "an unexpected error escaped"
56
+ }
@@ -0,0 +1,59 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
5
+
6
+ function hasCode(exn, code) {
7
+ let jsErr = Stdlib_JsExn.fromException(exn);
8
+ if (jsErr === undefined) {
9
+ return false;
10
+ }
11
+ let jsErr$1 = Primitive_option.valFromOption(jsErr);
12
+ let match = jsErr$1.name;
13
+ let match$1 = Stdlib_JsExn.message(jsErr$1);
14
+ if (match == null) {
15
+ if (match$1 !== undefined) {
16
+ return match$1.includes(code);
17
+ } else {
18
+ return false;
19
+ }
20
+ } else if (match === code) {
21
+ return true;
22
+ } else if (match$1 !== undefined) {
23
+ return match$1.includes(code);
24
+ } else {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function isNotFound(exn) {
30
+ return hasCode(exn, "ResourceNotFoundException");
31
+ }
32
+
33
+ function describe(exn) {
34
+ let jsErr = Stdlib_JsExn.fromException(exn);
35
+ if (jsErr === undefined) {
36
+ return "an unexpected error escaped";
37
+ }
38
+ let jsErr$1 = Primitive_option.valFromOption(jsErr);
39
+ let match = jsErr$1.name;
40
+ let match$1 = Stdlib_JsExn.message(jsErr$1);
41
+ if (match == null) {
42
+ if (match$1 !== undefined) {
43
+ return match$1;
44
+ } else {
45
+ return "an AWS call failed with no message";
46
+ }
47
+ } else if (match$1 !== undefined) {
48
+ return match + `: ` + match$1;
49
+ } else {
50
+ return match;
51
+ }
52
+ }
53
+
54
+ export {
55
+ hasCode,
56
+ isNotFound,
57
+ describe,
58
+ }
59
+ /* Stdlib_JsExn Not a pure module */
@@ -31,6 +31,34 @@ let modeOptions = (mode: Platform.viewMode): array<(string, JSON.t)> =>
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ The identity fields a shell reads, under **both** spellings.
36
+
37
+ 🚨 **Both, and not as a sequenced rename.** `identityProvider*` is what these
38
+ become — the concept is not AWS-specific, so a second cloud's adapter should meet
39
+ the same names. `cognito*` is what every shipped shell reads today.
40
+
41
+ A shell reads `config.json` at runtime and CloudFront serves the previous bundle
42
+ until someone invalidates it, so switching the keys in one deploy leaves a window
43
+ where the served bundle and the served config disagree. `cognitoClientId` is read
44
+ into an `option`: a bundle that cannot find its key does not error, it gets
45
+ `None`, and login, silent refresh and token refresh all quietly fall through —
46
+ the window is a total auth outage that reports nothing.
47
+
48
+ Writing both removes the ordering dependency rather than managing it: any bundle,
49
+ old or new or stale in a CDN, finds one it understands. The `cognito*` pair goes
50
+ once the shell that prefers the other is the pinned one.
51
+
52
+ Here rather than inline at the call site so the pairing is one fact in one place,
53
+ and so the property can be asserted without a deploy.
54
+ */
55
+ let identityFields = (~providerId: string, ~clientId: string): array<(string, JSON.t)> => [
56
+ ("identityProviderId", JSON.Encode.string(providerId)),
57
+ ("identityProviderClientId", JSON.Encode.string(clientId)),
58
+ ("cognitoUserPoolId", JSON.Encode.string(providerId)),
59
+ ("cognitoClientId", JSON.Encode.string(clientId)),
60
+ ]
61
+
34
62
  /**
35
63
  The config.json field set.
36
64
 
@@ -31,6 +31,27 @@ function modeOptions(mode) {
31
31
  }
32
32
  }
33
33
 
34
+ function identityFields(providerId, clientId) {
35
+ return [
36
+ [
37
+ "identityProviderId",
38
+ providerId
39
+ ],
40
+ [
41
+ "identityProviderClientId",
42
+ clientId
43
+ ],
44
+ [
45
+ "cognitoUserPoolId",
46
+ providerId
47
+ ],
48
+ [
49
+ "cognitoClientId",
50
+ clientId
51
+ ]
52
+ ];
53
+ }
54
+
34
55
  function fields(computed, viewModes, bakedManifest, shellConfig) {
35
56
  let out = Object.fromEntries(computed);
36
57
  Stdlib_Option.forEach(bakedManifest, bake => {
@@ -70,6 +91,7 @@ export {
70
91
  Platform,
71
92
  journeyManifestsKey,
72
93
  modeOptions,
94
+ identityFields,
73
95
  fields,
74
96
  }
75
97
  /* No side effect */
@@ -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
  })