@reventlessdev/reventless-aws 3.0.0-alpha.292 → 3.0.0-alpha.294

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.
@@ -0,0 +1,517 @@
1
+ /** Attaches the pre-token-generation trigger to a Cognito user pool the stack
2
+ does **not** own — the BYO case of `Platform_Stack.resolveCognitoUserPool`,
3
+ where `platform:cognitoUserPoolId` names an existing pool that is looked up
4
+ for its ARN and nothing more.
5
+
6
+ BYO cannot be the unsupported mode. It is the mode a customer with an
7
+ existing identity estate is in, and requiring the framework to own their user
8
+ pool before an operator can act as a role is a far larger ask than the
9
+ feature is worth. In **auto** mode the pool is ours and
10
+ `lambdaConfig.preTokenGeneration` is an ordinary property on a resource we
11
+ already declare — this resource is not used there.
12
+
13
+ The trigger is a property of the pool and Cognito offers no separate
14
+ attachment resource, so something has to call `UpdateUserPool` against a pool
15
+ no stack owns. That belongs in a declared resource executed at deploy time,
16
+ never a hand-run command against a live pool, which would leave the
17
+ deployment's behaviour depending on an act no source describes.
18
+
19
+ 🚨 **`UpdateUserPool` resets by omission.** The API requires "a value for all
20
+ parameters that you don't want set to a default value". An attach that sends
21
+ only `LambdaConfig` silently returns every other setting on that pool to its
22
+ default — on a pool the framework did not create and whose configuration it
23
+ never described. So every call here describes first and sends the pool's own
24
+ configuration back whole, with one field changed. See [mergedUpdateInput].
25
+
26
+ Mirrors the dynImport + hand-rolled shape of
27
+ [AppSync_SourceApiAssociation_Retrying.res]: Pulumi serialises a dynamic
28
+ provider's whole captured closure into stack state, so the SDK is imported
29
+ lazily and nothing here captures a Pulumi Output. */
30
+
31
+ let log = ReventlessCore.Logger.fromEnv()
32
+
33
+ // ── The merge ────────────────────────────────────────────────────────────────
34
+
35
+ /** Keys `DescribeUserPool` returns that `UpdateUserPool` does not accept.
36
+
37
+ A **denylist**, deliberately, and the choice matters. An allowlist that
38
+ missed a field would omit it from the update and silently reset it — exactly
39
+ the failure this whole resource exists to prevent, and one that succeeds
40
+ quietly. A denylist that misses a read-only field instead makes AWS reject
41
+ the call, which is loud, immediate, and fixable. When the two error shapes
42
+ are "a customer's pool quietly loses a setting" and "the deploy fails with a
43
+ parameter error", the second is the one to design for.
44
+
45
+ `Name` is absent from this list because it is not dropped but *renamed* —
46
+ `DescribeUserPool` returns `Name`, `UpdateUserPool` takes `PoolName`. */
47
+ let readOnlyKeys = [
48
+ "Id",
49
+ "Name",
50
+ "Arn",
51
+ "Status",
52
+ "CreationDate",
53
+ "LastModifiedDate",
54
+ "SchemaAttributes",
55
+ "AliasAttributes",
56
+ "UsernameAttributes",
57
+ "UsernameConfiguration",
58
+ "EstimatedNumberOfUsers",
59
+ "Domain",
60
+ "CustomDomain",
61
+ "SmsConfigurationFailure",
62
+ "EmailConfigurationFailure",
63
+ ]
64
+
65
+ /** The pool's own configuration, sent back whole with `PreTokenGeneration` set
66
+ (or, when `preTokenGenerationArn` is `None`, removed).
67
+
68
+ Pure and total so the property that matters — a pool carrying non-default
69
+ settings still carries them afterwards — is checkable without a pool. */
70
+ let mergedUpdateInput = (
71
+ ~described: dict<JSON.t>,
72
+ ~userPoolId: string,
73
+ ~preTokenGenerationArn: option<string>,
74
+ ): dict<JSON.t> => {
75
+ let out = Dict.make()
76
+ described->Dict.forEachWithKey((value, key) =>
77
+ if !(readOnlyKeys->Array.includes(key)) {
78
+ out->Dict.set(key, value)
79
+ }
80
+ )
81
+ described->Dict.get("Name")->Option.forEach(name => out->Dict.set("PoolName", name))
82
+ out->Dict.set("UserPoolId", JSON.Encode.string(userPoolId))
83
+
84
+ // The pool's other triggers are carried through untouched. Replacing the whole
85
+ // `LambdaConfig` with a single-key object would silently detach every trigger
86
+ // the customer already had — the reset-by-omission hazard one level down.
87
+ let lambdaConfig =
88
+ described
89
+ ->Dict.get("LambdaConfig")
90
+ ->Option.flatMap(JSON.Decode.object)
91
+ ->Option.mapOr(Dict.make(), existing => Dict.fromArray(existing->Dict.toArray))
92
+ switch preTokenGenerationArn {
93
+ | Some(arn) => lambdaConfig->Dict.set("PreTokenGeneration", JSON.Encode.string(arn))
94
+ | None => lambdaConfig->Dict.delete("PreTokenGeneration")
95
+ }
96
+ out->Dict.set("LambdaConfig", lambdaConfig->JSON.Encode.object)
97
+ out
98
+ }
99
+
100
+ /** The trigger currently attached to a described pool, if any. */
101
+ let attachedTrigger = (~described: dict<JSON.t>): option<string> =>
102
+ described
103
+ ->Dict.get("LambdaConfig")
104
+ ->Option.flatMap(JSON.Decode.object)
105
+ ->Option.flatMap(c => c->Dict.get("PreTokenGeneration"))
106
+ ->Option.flatMap(JSON.Decode.string)
107
+
108
+ // ── AWS SDK bindings (lazily imported — see AppSync_SourceApiAssociation_Retrying) ──
109
+
110
+ type cognitoClient
111
+
112
+ type describeInput = {@as("UserPoolId") userPoolId: string}
113
+ type describeResult = {@as("UserPool") userPool?: JSON.t}
114
+
115
+ type describeCmd
116
+ type updateCmd
117
+
118
+ type sdkModule = {
119
+ @as("CognitoIdentityProviderClient") clientCtor: unit => cognitoClient,
120
+ @as("DescribeUserPoolCommand") describeCtor: describeInput => describeCmd,
121
+ @as("UpdateUserPoolCommand") updateCtor: dict<JSON.t> => updateCmd,
122
+ }
123
+
124
+ // Calls a constructor with `new` — the SDK exports plain classes.
125
+ //
126
+ // `%raw` here rather than a companion `.mjs` bound through `@module`, matching
127
+ // [AppSync_SourceApiAssociation_Retrying.res]: Pulumi serialises this provider's
128
+ // whole closure into stack state, and a helper reached through a module import
129
+ // is a dependency that serialisation cannot carry. Inline source can be.
130
+ let newOf1: ('ctor, 'arg) => 'r = %raw(`(C, x) => new C(x)`)
131
+ let newOf0: 'ctor => 'r = %raw(`(C) => new C()`)
132
+
133
+ @send external sendDescribe: (cognitoClient, describeCmd) => promise<describeResult> = "send"
134
+ @send external sendUpdate: (cognitoClient, updateCmd) => promise<unit> = "send"
135
+
136
+ // Emitted as a literal `import("...")` so the Pulumi serialiser does not
137
+ // statically capture the module in the provider closure.
138
+ @val external dynImport: string => promise<sdkModule> = "import"
139
+
140
+ let _sdk: ref<option<sdkModule>> = ref(None)
141
+ let _client: ref<option<cognitoClient>> = ref(None)
142
+
143
+ let getSdk = async (): sdkModule =>
144
+ switch _sdk.contents {
145
+ | Some(m) => m
146
+ | None =>
147
+ let m = await dynImport("@aws-sdk/client-cognito-identity-provider")
148
+ _sdk.contents = Some(m)
149
+ m
150
+ }
151
+
152
+ let getClient = async (): cognitoClient =>
153
+ switch _client.contents {
154
+ | Some(c) => c
155
+ | None =>
156
+ let sdk = await getSdk()
157
+ let c = newOf0(sdk.clientCtor)
158
+ _client.contents = Some(c)
159
+ c
160
+ }
161
+
162
+ // ── Lambda bindings, for proving the trigger before attaching it ─────────────
163
+
164
+ type lambdaClient
165
+ type invokeCmd
166
+
167
+ type lambdaSdkModule = {
168
+ @as("LambdaClient") clientCtor: unit => lambdaClient,
169
+ @as("InvokeCommand") invokeCtor: dict<JSON.t> => invokeCmd,
170
+ }
171
+
172
+ type invokeResult = {
173
+ @as("FunctionError") functionError?: string,
174
+ @as("Payload") payload?: JSON.t,
175
+ }
176
+
177
+ @send external sendInvoke: (lambdaClient, invokeCmd) => promise<invokeResult> = "send"
178
+ @val external dynImportLambda: string => promise<lambdaSdkModule> = "import"
179
+
180
+ let _lambdaSdk: ref<option<lambdaSdkModule>> = ref(None)
181
+ let _lambdaClient: ref<option<lambdaClient>> = ref(None)
182
+
183
+ let getLambdaSdk = async (): lambdaSdkModule =>
184
+ switch _lambdaSdk.contents {
185
+ | Some(m) => m
186
+ | None =>
187
+ let m = await dynImportLambda("@aws-sdk/client-lambda")
188
+ _lambdaSdk.contents = Some(m)
189
+ m
190
+ }
191
+
192
+ let getLambdaClient = async (): lambdaClient =>
193
+ switch _lambdaClient.contents {
194
+ | Some(c) => c
195
+ | None =>
196
+ let sdk = await getLambdaSdk()
197
+ let c = newOf0(sdk.clientCtor)
198
+ _lambdaClient.contents = Some(c)
199
+ c
200
+ }
201
+
202
+ /** Decodes the SDK's `Payload`, which arrives as a byte array rather than a
203
+ string. */
204
+ let decodePayload: JSON.t => string = %raw(`(p) => {
205
+ if (p == null) return "";
206
+ if (typeof p === "string") return p;
207
+ try { return new TextDecoder().decode(p); } catch { return String(p); }
208
+ }`)
209
+
210
+ @get @return(nullable) external exnName: JsExn.t => option<string> = "name"
211
+
212
+ let isPoolGoneError = (jsErr: JsExn.t): bool =>
213
+ switch (jsErr->exnName, JsExn.message(jsErr)) {
214
+ | (Some("ResourceNotFoundException"), _) => true
215
+ | (_, Some(msg)) => msg->String.includes("ResourceNotFoundException")
216
+ | _ => false
217
+ }
218
+
219
+ // ── Prove the trigger before attaching it ────────────────────────────────────
220
+
221
+ /** The subject the probe presents. It is not a real user and must never match
222
+ one: the handler looks the id up in the role table, and a probe that
223
+ collided with a real row would exercise a different branch than the one it
224
+ is here to check. */
225
+ let probeSubject = "reventless-attachment-probe"
226
+
227
+ /** A minimal `V1_0` pre-token-generation event.
228
+
229
+ Empty membership on purpose — with no groups and no stored role there is
230
+ nothing to narrow, so a healthy handler returns the event unchanged. The
231
+ probe is checking that the function *runs*, not what it decides; what it
232
+ decides has unit tests, and they do not need a deployed pool. */
233
+ let probeEvent = (~userPoolId: string): JSON.t => {
234
+ let groupConfiguration = Dict.make()
235
+ groupConfiguration->Dict.set("groupsToOverride", JSON.Encode.array([]))
236
+ groupConfiguration->Dict.set("iamRolesToOverride", JSON.Encode.array([]))
237
+
238
+ let userAttributes = Dict.make()
239
+ userAttributes->Dict.set("sub", JSON.Encode.string(probeSubject))
240
+
241
+ let request = Dict.make()
242
+ request->Dict.set("userAttributes", JSON.Encode.object(userAttributes))
243
+ request->Dict.set("groupConfiguration", JSON.Encode.object(groupConfiguration))
244
+
245
+ let event = Dict.make()
246
+ event->Dict.set("version", JSON.Encode.string("1"))
247
+ event->Dict.set("triggerSource", JSON.Encode.string("TokenGeneration_Authentication"))
248
+ event->Dict.set("userPoolId", JSON.Encode.string(userPoolId))
249
+ event->Dict.set("userName", JSON.Encode.string(probeSubject))
250
+ event->Dict.set("request", JSON.Encode.object(request))
251
+ event->Dict.set("response", JSON.Encode.object(Dict.make()))
252
+ JSON.Encode.object(event)
253
+ }
254
+
255
+ /** What one probe invocation proved.
256
+
257
+ A variant rather than a bool so the two failures stay distinguishable: they
258
+ have different causes and want different sentences. */
259
+ type probeVerdict =
260
+ | Healthy
261
+ | Crashed(string)
262
+ | NotAnEvent
263
+
264
+ /** The verdict for a given invoke result.
265
+
266
+ Pure and total, so every branch is checkable without a deployed function —
267
+ the same reason [mergedUpdateInput] is. A payload that is not JSON at all
268
+ counts as not an event rather than throwing: the point here is to produce a
269
+ verdict, and a parse error escaping would fail the deploy with a message
270
+ about JSON instead of about the trigger. */
271
+ let probeVerdict = (~functionError: option<string>, ~payload: string): probeVerdict =>
272
+ switch functionError {
273
+ | Some(kind) => Crashed(kind)
274
+ | None =>
275
+ // A trigger must hand the event back for Cognito to mint anything from. A
276
+ // function that returns 200 with something else shaped is as fatal as one
277
+ // that throws, and rather harder to notice.
278
+ let hasRequest = try {
279
+ payload
280
+ ->JSON.parseOrThrow
281
+ ->JSON.Decode.object
282
+ ->Option.flatMap(o => o->Dict.get("request"))
283
+ ->Option.isSome
284
+ } catch {
285
+ | _ => false
286
+ }
287
+ hasRequest ? Healthy : NotAnEvent
288
+ }
289
+
290
+ /**
291
+ Invoke the trigger once and refuse to attach it if it cannot answer.
292
+
293
+ 🚨 **This is the check that keeps a broken trigger off a live pool.** Cognito
294
+ runs this function on every token it mints, and a function that throws fails the
295
+ sign-in — so a trigger that cannot start does not degrade the feature, it takes
296
+ authentication away from every user of the pool. Nothing downstream notices: the
297
+ deploy reports success, and the first report is a person unable to log in.
298
+
299
+ A trigger cannot make itself fail open. It dies at module load, before any
300
+ handler code runs, so no `try` inside the handler can catch it. The only place
301
+ that can refuse a broken function is the thing about to point a pool at it, and
302
+ that is here.
303
+
304
+ So: broken function → this throws → the deploy fails with the function's own
305
+ error → **the pool is never touched**. That is the same trade [readOnlyKeys]
306
+ already makes deliberately, where a loud failure beats a quiet reset.
307
+
308
+ Needs `lambda:InvokeFunction` on the deploying principal.
309
+ */
310
+ let verifyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: string): unit => {
311
+ let sdk = await getLambdaSdk()
312
+ let client = await getLambdaClient()
313
+
314
+ let input = Dict.make()
315
+ input->Dict.set("FunctionName", JSON.Encode.string(preTokenGenerationArn))
316
+ input->Dict.set("InvocationType", JSON.Encode.string("RequestResponse"))
317
+ input->Dict.set("Payload", JSON.Encode.string(probeEvent(~userPoolId)->JSON.stringify))
318
+
319
+ let result = await client->sendInvoke(newOf1(sdk.invokeCtor, input))
320
+ let payload = result.payload->Option.mapOr("", decodePayload)
321
+
322
+ // The payload carries the runtime's own error — module-resolution failures, a
323
+ // missing environment variable, an unhandled throw. Surfaced verbatim because
324
+ // it is the most useful sentence anyone debugging this will read.
325
+ switch probeVerdict(~functionError=result.functionError, ~payload) {
326
+ | Crashed(kind) =>
327
+ JsError.throwWithMessage(
328
+ `active-role trigger ${preTokenGenerationArn} failed its pre-attach check (${kind}) and was NOT attached to user pool ${userPoolId}. Attaching it would have failed every sign-in on that pool. The function reported: ${payload}`,
329
+ )
330
+ | NotAnEvent =>
331
+ JsError.throwWithMessage(
332
+ `active-role trigger ${preTokenGenerationArn} answered its pre-attach check with something that is not a pre-token-generation event, so it was NOT attached to user pool ${userPoolId}. Cognito would have failed every sign-in. It returned: ${payload}`,
333
+ )
334
+ | Healthy =>
335
+ log.info(
336
+ ~comp="Auth_ActiveRolePoolAttachment",
337
+ `trigger ${preTokenGenerationArn} answered the pre-attach check; attaching to ${userPoolId}`,
338
+ )
339
+ }
340
+ }
341
+
342
+ // ── Describe → merge → update ────────────────────────────────────────────────
343
+
344
+ let describePool = async (~userPoolId: string): option<dict<JSON.t>> => {
345
+ let sdk = await getSdk()
346
+ let client = await getClient()
347
+ let result = await client->sendDescribe(newOf1(sdk.describeCtor, {userPoolId: userPoolId}))
348
+ result.userPool->Option.flatMap(JSON.Decode.object)
349
+ }
350
+
351
+ /** Set (or clear) the pool's pre-token-generation trigger, preserving every
352
+ other setting the pool carries. */
353
+ let applyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: option<string>): unit => {
354
+ let sdk = await getSdk()
355
+ let client = await getClient()
356
+ switch await describePool(~userPoolId) {
357
+ | None =>
358
+ // Describe succeeded but returned no pool body. Sending an update built from
359
+ // nothing is precisely the reset this resource exists to avoid.
360
+ JsError.throwWithMessage(
361
+ `DescribeUserPool returned no pool for "${userPoolId}"; refusing to send an UpdateUserPool that would reset it`,
362
+ )
363
+ | Some(described) =>
364
+ let input = mergedUpdateInput(~described, ~userPoolId, ~preTokenGenerationArn)
365
+ await client->sendUpdate(newOf1(sdk.updateCtor, input))
366
+ }
367
+ }
368
+
369
+ // ── Dynamic provider ─────────────────────────────────────────────────────────
370
+
371
+ type providerInputs = {
372
+ userPoolId: string,
373
+ preTokenGenerationArn: string,
374
+ /** The deployed function's code hash.
375
+
376
+ Carried purely so a code change reaches this resource. The function ARN is
377
+ stable across deploys, so without this the attachment sees identical inputs
378
+ every time and never runs again — meaning [verifyTrigger] would prove the
379
+ function once, on the deploy that created it, and never for any version
380
+ shipped afterwards. A check that only ever runs once is not a check. */
381
+ codeHash: string,
382
+ }
383
+
384
+ type createOuts = {
385
+ userPoolId: string,
386
+ preTokenGenerationArn: string,
387
+ codeHash: string,
388
+ }
389
+
390
+ type createResult = {id: string, outs: createOuts}
391
+ type updateResult = {outs: createOuts}
392
+ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
393
+ type readResult = {id?: string, props?: createOuts}
394
+
395
+ let create = async (inputs: providerInputs): createResult => {
396
+ await verifyTrigger(
397
+ ~userPoolId=inputs.userPoolId,
398
+ ~preTokenGenerationArn=inputs.preTokenGenerationArn,
399
+ )
400
+ await applyTrigger(
401
+ ~userPoolId=inputs.userPoolId,
402
+ ~preTokenGenerationArn=Some(inputs.preTokenGenerationArn),
403
+ )
404
+ {
405
+ id: inputs.userPoolId,
406
+ outs: {
407
+ userPoolId: inputs.userPoolId,
408
+ preTokenGenerationArn: inputs.preTokenGenerationArn,
409
+ codeHash: inputs.codeHash,
410
+ },
411
+ }
412
+ }
413
+
414
+ let update = async (_id: string, _olds: createOuts, news: providerInputs): updateResult => {
415
+ // Re-proved on every update, not only on create: the usual reason this runs is
416
+ // that the function's code changed, which is exactly when it might have stopped
417
+ // working. The pool keeps the trigger it already has until this succeeds.
418
+ await verifyTrigger(
419
+ ~userPoolId=news.userPoolId,
420
+ ~preTokenGenerationArn=news.preTokenGenerationArn,
421
+ )
422
+ await applyTrigger(
423
+ ~userPoolId=news.userPoolId,
424
+ ~preTokenGenerationArn=Some(news.preTokenGenerationArn),
425
+ )
426
+ {
427
+ outs: {
428
+ userPoolId: news.userPoolId,
429
+ preTokenGenerationArn: news.preTokenGenerationArn,
430
+ codeHash: news.codeHash,
431
+ },
432
+ }
433
+ }
434
+
435
+ /** Detaching on destroy is not optional. The Lambda is torn down with the rest
436
+ of the stack; a pool left pointing at a deleted function fails **every**
437
+ sign-in, and it is a pool the framework does not own — so nothing else in
438
+ this deployment would ever put it right. A pool that has already gone is
439
+ nothing to detach from. */
440
+ let delete_ = async (_id: string, props: createOuts): unit =>
441
+ try {
442
+ await applyTrigger(~userPoolId=props.userPoolId, ~preTokenGenerationArn=None)
443
+ } catch {
444
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) =>
445
+ log.info(
446
+ ~comp="Auth_ActiveRolePoolAttachment",
447
+ `user pool ${props.userPoolId} is gone; nothing to detach`,
448
+ )
449
+ }
450
+
451
+ /** A different pool is a different attachment: the old pool must be detached
452
+ before the new one is attached, so this replaces rather than updates in
453
+ place. Changing only the function ARN is an ordinary update. */
454
+ let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
455
+ let poolChanged = olds.userPoolId != news.userPoolId
456
+ {
457
+ changes: poolChanged ||
458
+ olds.preTokenGenerationArn != news.preTokenGenerationArn ||
459
+ olds.codeHash != news.codeHash,
460
+ replaces: poolChanged ? ["userPoolId"] : [],
461
+ deleteBeforeReplace: true,
462
+ }
463
+ }
464
+
465
+ /** Live state for `pulumi refresh`: report what the pool actually carries, so a
466
+ trigger detached out of band shows as drift and the next `up` re-attaches
467
+ it. A missing pool drops the resource from state. */
468
+ let read_ = async (id: string, props: createOuts): readResult =>
469
+ try {
470
+ switch await describePool(~userPoolId=props.userPoolId) {
471
+ | None => ({}: readResult)
472
+ | Some(described) => {
473
+ id,
474
+ props: {
475
+ userPoolId: props.userPoolId,
476
+ preTokenGenerationArn: attachedTrigger(~described)->Option.getOr(""),
477
+ // The pool cannot report what code the function is running, so the
478
+ // recorded hash is carried through unchanged. Refresh answers "is the
479
+ // right function attached", which is what the pool actually knows.
480
+ codeHash: props.codeHash,
481
+ },
482
+ }
483
+ }
484
+ } catch {
485
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) => ({}: readResult)
486
+ }
487
+
488
+ // Provider as a plain JS object (no Pulumi Output captures — all state flows
489
+ // through inputs / olds / news).
490
+ let provider = {
491
+ "create": create,
492
+ "update": update,
493
+ "delete": delete_,
494
+ "diff": diff_,
495
+ "read": read_,
496
+ }
497
+
498
+ // ── Pulumi dynamic resource binding ──────────────────────────────────────────
499
+
500
+ type t = {id: Pulumi.Output.t<string>}
501
+
502
+ type constructorProps = {
503
+ userPoolId: Pulumi.Input.t<string>,
504
+ preTokenGenerationArn: Pulumi.Input.t<string>,
505
+ codeHash: Pulumi.Input.t<string>,
506
+ }
507
+
508
+ // Explicit /index.js path because @pulumi/pulumi/dynamic is a directory import
509
+ // not resolvable in ESM mode.
510
+ @module("@pulumi/pulumi/dynamic/index.js") @new
511
+ external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
512
+
513
+ let make = (
514
+ ~name: string="ActiveRolePoolAttachment",
515
+ ~props: constructorProps,
516
+ ~opts: Pulumi.CustomResourceOptions.t,
517
+ ): t => _newResource(provider, name, props, opts)