@reventlessdev/reventless-aws 3.0.0-alpha.291 → 3.0.0-alpha.293
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 +15 -0
- package/package.json +9 -9
- package/src/Platform.res +85 -5
- package/src/Platform.res.mjs +62 -6
- package/src/Platform_Stack.res +78 -0
- package/src/Platform_Stack.res.mjs +17 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +10 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +8 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +89 -12
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +55 -4
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +309 -0
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +230 -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 +180 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +105 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +201 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +126 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res +16 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +6 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +211 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +232 -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
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +110 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +87 -0
|
@@ -216,7 +216,18 @@ let resolveStructure = (
|
|
|
216
216
|
->Option.flatMap(JSON.Decode.string) {
|
|
217
217
|
| None => Promise.resolve(item)
|
|
218
218
|
| Some(key) =>
|
|
219
|
-
fetch(key)
|
|
219
|
+
fetch(key)
|
|
220
|
+
// The bucket answers for the whole platform, so a failure here says which
|
|
221
|
+
// plugin's ref could not be read — otherwise the S3 error names the key
|
|
222
|
+
// and the bucket, and finding the row it came from is a table scan by hand.
|
|
223
|
+
->Promise.catch(e => {
|
|
224
|
+
let plugin = item->str("name")->Option.getOr("<unnamed>")
|
|
225
|
+
JsError.throwWithMessage(
|
|
226
|
+
`offloaded structure for plugin ${plugin} is unreadable at ${key}: ` ++
|
|
227
|
+
e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error"),
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
->Promise.then(bytes => {
|
|
220
231
|
let resolved = Dict.fromArray(item->Dict.toArray)
|
|
221
232
|
resolved->Dict.set("structure", JSON.parseOrThrow(bytes))
|
|
222
233
|
Promise.resolve(resolved)
|
|
@@ -337,6 +348,58 @@ let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
|
|
|
337
348
|
}
|
|
338
349
|
)
|
|
339
350
|
|
|
351
|
+
// ── Registration freshness ───────────────────────────────────────────────────
|
|
352
|
+
// The read model this bake scans is updated asynchronously: the plugin stack
|
|
353
|
+
// publishes a re-detect, the plugin answers with its definition, the projection
|
|
354
|
+
// lands. Invoked seconds after the last stack finished, the scan can still
|
|
355
|
+
// describe the deploy before it — and a manifest baked from that is wrong in the
|
|
356
|
+
// one way nothing downstream can detect, because it is a perfectly well-formed
|
|
357
|
+
// description of the wrong deployment.
|
|
358
|
+
//
|
|
359
|
+
// So the invocation carries the structure key each plugin stack just wrote. That
|
|
360
|
+
// is an equality check rather than an inference from timestamps, and it costs
|
|
361
|
+
// nothing for a plugin that was not redeployed: its key already matches. A caller
|
|
362
|
+
// that supplies no expectations bakes whatever is current — the query paths never
|
|
363
|
+
// send any, and a hand-run bake should not need to.
|
|
364
|
+
let bakeExpectations = (event: JSON.t): dict<string> =>
|
|
365
|
+
event
|
|
366
|
+
->JSON.Decode.object
|
|
367
|
+
->Option.flatMap(o => o->Dict.get("expect"))
|
|
368
|
+
->Option.flatMap(JSON.Decode.object)
|
|
369
|
+
->Option.mapOr(Dict.make(), o =>
|
|
370
|
+
o
|
|
371
|
+
->Dict.toArray
|
|
372
|
+
->Array.filterMap(((plugin, key)) => key->JSON.Decode.string->Option.map(k => (plugin, k)))
|
|
373
|
+
->Dict.fromArray
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
// The offload key a scanned row carries. None for a structure held inline, which
|
|
377
|
+
// on a deployed platform means the row predates offloading — it cannot match an
|
|
378
|
+
// expectation, and saying so beats baking it.
|
|
379
|
+
let structureRefKey = (item: dict<JSON.t>): option<string> =>
|
|
380
|
+
item
|
|
381
|
+
->Dict.get("structure")
|
|
382
|
+
->Option.flatMap(JSON.Decode.object)
|
|
383
|
+
->Option.flatMap(o => o->Dict.get(Reventless.Offload.sentinelKey))
|
|
384
|
+
->Option.flatMap(JSON.Decode.object)
|
|
385
|
+
->Option.flatMap(r => r->Dict.get("key"))
|
|
386
|
+
->Option.flatMap(JSON.Decode.string)
|
|
387
|
+
|
|
388
|
+
// Compared against the collapsed latest version per plugin, the same view the
|
|
389
|
+
// bake itself takes — an older version's row lingering on the table is not the
|
|
390
|
+
// registration anyone is waiting for.
|
|
391
|
+
let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
|
|
392
|
+
let current =
|
|
393
|
+
Platform_AdminScan_Ops.latestByName(
|
|
394
|
+
items,
|
|
395
|
+
~nameVersionOf=item => item->str("name"),
|
|
396
|
+
~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
|
|
397
|
+
)->Dict.fromArray
|
|
398
|
+
expect
|
|
399
|
+
->Dict.toArray
|
|
400
|
+
->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
|
|
401
|
+
}
|
|
402
|
+
|
|
340
403
|
// Every failure mode here is the deployment's own mistake — a name matching no
|
|
341
404
|
// component, a structure too old to read, a bucket the function may not write —
|
|
342
405
|
// and every one of them produces the same symptom if swallowed: a shop that
|
|
@@ -404,22 +467,36 @@ let handler = async (event: JSON.t): array<JSON.t> => {
|
|
|
404
467
|
let fetch = Reventless.Offload.cachedFetch(key =>
|
|
405
468
|
AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
|
|
406
469
|
)
|
|
407
|
-
let
|
|
470
|
+
let resolveAll = () => Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
|
|
408
471
|
switch bakeTarget {
|
|
409
472
|
| Some(target) =>
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
473
|
+
// Checked on the raw rows: the refs are what the deploy can predict, and a
|
|
474
|
+
// row that is behind should not have its structure fetched at all.
|
|
475
|
+
switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
|
|
476
|
+
| [] =>
|
|
477
|
+
// The built-in admin entry is deliberately absent: it never enters the
|
|
478
|
+
// Plugin read model, and the in-memory bake curates the composed plugins
|
|
479
|
+
// only. A deployment naming it gets `UnknownPlugin`, on both platforms.
|
|
480
|
+
let structures = Platform_AdminScan_Ops.latestByName(
|
|
481
|
+
await resolveAll(),
|
|
482
|
+
~nameVersionOf=item => item->str("name"),
|
|
483
|
+
~toEntry=structureOf,
|
|
484
|
+
)
|
|
485
|
+
await runBake(~target, ~structures)
|
|
486
|
+
| pending =>
|
|
487
|
+
// Not an error — the deploy just has not finished arriving. Reported so the
|
|
488
|
+
// caller can invoke again rather than bake the previous deployment.
|
|
489
|
+
[
|
|
490
|
+
Dict.fromArray([
|
|
491
|
+
("baked", JSON.Encode.bool(false)),
|
|
492
|
+
("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
|
|
493
|
+
])->JSON.Encode.object,
|
|
494
|
+
]
|
|
495
|
+
}
|
|
419
496
|
| None =>
|
|
420
497
|
let userEntries =
|
|
421
498
|
Platform_AdminScan_Ops.latestByName(
|
|
422
|
-
|
|
499
|
+
await resolveAll(),
|
|
423
500
|
~nameVersionOf=item => item->str("name"),
|
|
424
501
|
~toEntry,
|
|
425
502
|
)
|
|
@@ -6,8 +6,11 @@ import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
|
|
|
6
6
|
import * as Nodepath from "node:path";
|
|
7
7
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
8
8
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
9
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
9
10
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
10
11
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
12
|
+
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
13
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
11
14
|
import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
|
|
12
15
|
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
13
16
|
import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
|
|
@@ -216,7 +219,10 @@ function resolveStructure(fetch, item) {
|
|
|
216
219
|
}
|
|
217
220
|
let key = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(refJson), r => r["key"]), Stdlib_JSON.Decode.string);
|
|
218
221
|
if (key !== undefined) {
|
|
219
|
-
return fetch(key)
|
|
222
|
+
return Stdlib_Promise.$$catch(fetch(key), e => {
|
|
223
|
+
let plugin = Stdlib_Option.getOr(str(item, "name"), "<unnamed>");
|
|
224
|
+
return Stdlib_JsError.throwWithMessage(`offloaded structure for plugin ` + plugin + ` is unreadable at ` + key + `: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "unknown error"));
|
|
225
|
+
}).then(bytes => {
|
|
220
226
|
let resolved = Object.fromEntries(Object.entries(item));
|
|
221
227
|
resolved["structure"] = JSON.parse(bytes);
|
|
222
228
|
return Promise.resolve(resolved);
|
|
@@ -295,6 +301,35 @@ function bakeTargetOf(event) {
|
|
|
295
301
|
});
|
|
296
302
|
}
|
|
297
303
|
|
|
304
|
+
function bakeExpectations(event) {
|
|
305
|
+
return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["expect"]), Stdlib_JSON.Decode.object), {}, o => Object.fromEntries(Stdlib_Array.filterMap(Object.entries(o), param => {
|
|
306
|
+
let plugin = param[0];
|
|
307
|
+
return Stdlib_Option.map(Stdlib_JSON.Decode.string(param[1]), k => [
|
|
308
|
+
plugin,
|
|
309
|
+
k
|
|
310
|
+
]);
|
|
311
|
+
})));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function structureRefKey(item) {
|
|
315
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), o => o[Offload$Reventless.sentinelKey]), Stdlib_JSON.Decode.object), r => r["key"]), Stdlib_JSON.Decode.string);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function pendingRegistrations(items, expect) {
|
|
319
|
+
let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
|
|
320
|
+
name,
|
|
321
|
+
key
|
|
322
|
+
])));
|
|
323
|
+
return Stdlib_Array.filterMap(Object.entries(expect), param => {
|
|
324
|
+
let plugin = param[0];
|
|
325
|
+
if (Primitive_object.equal(current[plugin], param[1])) {
|
|
326
|
+
return;
|
|
327
|
+
} else {
|
|
328
|
+
return plugin;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
298
333
|
async function runBake(target, structures) {
|
|
299
334
|
let selections = bakeSelections();
|
|
300
335
|
if (selections.length === 0) {
|
|
@@ -351,12 +386,25 @@ async function handler(event) {
|
|
|
351
386
|
]]));
|
|
352
387
|
let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
|
|
353
388
|
let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
|
|
354
|
-
let
|
|
389
|
+
let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
|
|
355
390
|
if (bakeTarget !== undefined) {
|
|
356
|
-
let
|
|
391
|
+
let pending = pendingRegistrations(rawItems, bakeExpectations(event));
|
|
392
|
+
if (pending.length !== 0) {
|
|
393
|
+
return [Object.fromEntries([
|
|
394
|
+
[
|
|
395
|
+
"baked",
|
|
396
|
+
false
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"pending",
|
|
400
|
+
pending.map(prim => prim)
|
|
401
|
+
]
|
|
402
|
+
])];
|
|
403
|
+
}
|
|
404
|
+
let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
|
|
357
405
|
return await runBake(bakeTarget, structures);
|
|
358
406
|
}
|
|
359
|
-
let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(
|
|
407
|
+
let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
|
|
360
408
|
return admin.concat(userEntries);
|
|
361
409
|
}
|
|
362
410
|
console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
|
|
@@ -386,6 +434,9 @@ export {
|
|
|
386
434
|
bakeSelection,
|
|
387
435
|
bakeSelections,
|
|
388
436
|
bakeTargetOf,
|
|
437
|
+
bakeExpectations,
|
|
438
|
+
structureRefKey,
|
|
439
|
+
pendingRegistrations,
|
|
389
440
|
runBake,
|
|
390
441
|
handler,
|
|
391
442
|
}
|
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
@get @return(nullable) external exnName: JsExn.t => option<string> = "name"
|
|
163
|
+
|
|
164
|
+
let isPoolGoneError = (jsErr: JsExn.t): bool =>
|
|
165
|
+
switch (jsErr->exnName, JsExn.message(jsErr)) {
|
|
166
|
+
| (Some("ResourceNotFoundException"), _) => true
|
|
167
|
+
| (_, Some(msg)) => msg->String.includes("ResourceNotFoundException")
|
|
168
|
+
| _ => false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Describe → merge → update ────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
let describePool = async (~userPoolId: string): option<dict<JSON.t>> => {
|
|
174
|
+
let sdk = await getSdk()
|
|
175
|
+
let client = await getClient()
|
|
176
|
+
let result = await client->sendDescribe(newOf1(sdk.describeCtor, {userPoolId: userPoolId}))
|
|
177
|
+
result.userPool->Option.flatMap(JSON.Decode.object)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Set (or clear) the pool's pre-token-generation trigger, preserving every
|
|
181
|
+
other setting the pool carries. */
|
|
182
|
+
let applyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: option<string>): unit => {
|
|
183
|
+
let sdk = await getSdk()
|
|
184
|
+
let client = await getClient()
|
|
185
|
+
switch await describePool(~userPoolId) {
|
|
186
|
+
| None =>
|
|
187
|
+
// Describe succeeded but returned no pool body. Sending an update built from
|
|
188
|
+
// nothing is precisely the reset this resource exists to avoid.
|
|
189
|
+
JsError.throwWithMessage(
|
|
190
|
+
`DescribeUserPool returned no pool for "${userPoolId}"; refusing to send an UpdateUserPool that would reset it`,
|
|
191
|
+
)
|
|
192
|
+
| Some(described) =>
|
|
193
|
+
let input = mergedUpdateInput(~described, ~userPoolId, ~preTokenGenerationArn)
|
|
194
|
+
await client->sendUpdate(newOf1(sdk.updateCtor, input))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Dynamic provider ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
type providerInputs = {
|
|
201
|
+
userPoolId: string,
|
|
202
|
+
preTokenGenerationArn: string,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type createOuts = {
|
|
206
|
+
userPoolId: string,
|
|
207
|
+
preTokenGenerationArn: string,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type createResult = {id: string, outs: createOuts}
|
|
211
|
+
type updateResult = {outs: createOuts}
|
|
212
|
+
type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
|
|
213
|
+
type readResult = {id?: string, props?: createOuts}
|
|
214
|
+
|
|
215
|
+
let create = async (inputs: providerInputs): createResult => {
|
|
216
|
+
await applyTrigger(
|
|
217
|
+
~userPoolId=inputs.userPoolId,
|
|
218
|
+
~preTokenGenerationArn=Some(inputs.preTokenGenerationArn),
|
|
219
|
+
)
|
|
220
|
+
{
|
|
221
|
+
id: inputs.userPoolId,
|
|
222
|
+
outs: {userPoolId: inputs.userPoolId, preTokenGenerationArn: inputs.preTokenGenerationArn},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let update = async (_id: string, _olds: createOuts, news: providerInputs): updateResult => {
|
|
227
|
+
await applyTrigger(
|
|
228
|
+
~userPoolId=news.userPoolId,
|
|
229
|
+
~preTokenGenerationArn=Some(news.preTokenGenerationArn),
|
|
230
|
+
)
|
|
231
|
+
{outs: {userPoolId: news.userPoolId, preTokenGenerationArn: news.preTokenGenerationArn}}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Detaching on destroy is not optional. The Lambda is torn down with the rest
|
|
235
|
+
of the stack; a pool left pointing at a deleted function fails **every**
|
|
236
|
+
sign-in, and it is a pool the framework does not own — so nothing else in
|
|
237
|
+
this deployment would ever put it right. A pool that has already gone is
|
|
238
|
+
nothing to detach from. */
|
|
239
|
+
let delete_ = async (_id: string, props: createOuts): unit =>
|
|
240
|
+
try {
|
|
241
|
+
await applyTrigger(~userPoolId=props.userPoolId, ~preTokenGenerationArn=None)
|
|
242
|
+
} catch {
|
|
243
|
+
| exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) =>
|
|
244
|
+
log.info(
|
|
245
|
+
~comp="Auth_ActiveRolePoolAttachment",
|
|
246
|
+
`user pool ${props.userPoolId} is gone; nothing to detach`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A different pool is a different attachment: the old pool must be detached
|
|
251
|
+
before the new one is attached, so this replaces rather than updates in
|
|
252
|
+
place. Changing only the function ARN is an ordinary update. */
|
|
253
|
+
let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
|
|
254
|
+
let poolChanged = olds.userPoolId != news.userPoolId
|
|
255
|
+
{
|
|
256
|
+
changes: poolChanged || olds.preTokenGenerationArn != news.preTokenGenerationArn,
|
|
257
|
+
replaces: poolChanged ? ["userPoolId"] : [],
|
|
258
|
+
deleteBeforeReplace: true,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Live state for `pulumi refresh`: report what the pool actually carries, so a
|
|
263
|
+
trigger detached out of band shows as drift and the next `up` re-attaches
|
|
264
|
+
it. A missing pool drops the resource from state. */
|
|
265
|
+
let read_ = async (id: string, props: createOuts): readResult =>
|
|
266
|
+
try {
|
|
267
|
+
switch await describePool(~userPoolId=props.userPoolId) {
|
|
268
|
+
| None => ({}: readResult)
|
|
269
|
+
| Some(described) => {
|
|
270
|
+
id,
|
|
271
|
+
props: {
|
|
272
|
+
userPoolId: props.userPoolId,
|
|
273
|
+
preTokenGenerationArn: attachedTrigger(~described)->Option.getOr(""),
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
| exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) => ({}: readResult)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Provider as a plain JS object (no Pulumi Output captures — all state flows
|
|
282
|
+
// through inputs / olds / news).
|
|
283
|
+
let provider = {
|
|
284
|
+
"create": create,
|
|
285
|
+
"update": update,
|
|
286
|
+
"delete": delete_,
|
|
287
|
+
"diff": diff_,
|
|
288
|
+
"read": read_,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Pulumi dynamic resource binding ──────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
type t = {id: Pulumi.Output.t<string>}
|
|
294
|
+
|
|
295
|
+
type constructorProps = {
|
|
296
|
+
userPoolId: Pulumi.Input.t<string>,
|
|
297
|
+
preTokenGenerationArn: Pulumi.Input.t<string>,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Explicit /index.js path because @pulumi/pulumi/dynamic is a directory import
|
|
301
|
+
// not resolvable in ESM mode.
|
|
302
|
+
@module("@pulumi/pulumi/dynamic/index.js") @new
|
|
303
|
+
external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
|
|
304
|
+
|
|
305
|
+
let make = (
|
|
306
|
+
~name: string="ActiveRolePoolAttachment",
|
|
307
|
+
~props: constructorProps,
|
|
308
|
+
~opts: Pulumi.CustomResourceOptions.t,
|
|
309
|
+
): t => _newResource(provider, name, props, opts)
|