@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.
- package/CHANGELOG.md +17 -0
- package/package.json +5 -5
- package/src/Platform.res +43 -4
- package/src/Platform.res.mjs +11 -4
- package/src/Platform_Stack.res +82 -0
- package/src/Platform_Stack.res.mjs +18 -2
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +517 -0
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +341 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +280 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +163 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +148 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +128 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +185 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +107 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +201 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +126 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +285 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +263 -0
- package/tests/Auth_ActiveRoleStoreTest.res +42 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +41 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res +169 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res.mjs +189 -0
|
@@ -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)
|