@reventlessdev/reventless-aws 3.0.0-alpha.292 → 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 +7 -0
- package/package.json +6 -6
- package/src/Platform.res +30 -4
- package/src/Platform.res.mjs +9 -4
- package/src/Platform_Stack.res +78 -0
- package/src/Platform_Stack.res.mjs +17 -2
- 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/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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.293 (2026-08-13)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.292 (2026-08-13)
|
|
7
14
|
|
|
8
15
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.293",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "3.970.0",
|
|
13
13
|
"sury": "11.0.0-alpha.4",
|
|
14
14
|
"uuid": "^13.0.0",
|
|
15
|
-
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.
|
|
16
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
17
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.73",
|
|
15
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.9",
|
|
18
16
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
17
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
19
18
|
"@reventlessdev/rescript-node": "2.0.0-alpha.5",
|
|
19
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.74",
|
|
20
20
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
21
21
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
22
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
22
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.232",
|
|
23
23
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.139",
|
|
24
24
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.31",
|
|
25
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
25
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
|
|
26
26
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.112"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
package/src/Platform.res
CHANGED
|
@@ -245,11 +245,12 @@ module MakeWithConfig = (
|
|
|
245
245
|
// unified mode carries the admin base here instead; it declares no stores in practice,
|
|
246
246
|
// so the upload resolvers below are never created there.)
|
|
247
247
|
let domainBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
|
|
248
|
-
types:
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
types: ReventlessCore.Platform_AdminApi.uploadTypes
|
|
249
|
+
->Array.concat(ReventlessCore.Platform_AdminApi.geocodeTypes)
|
|
250
|
+
->Array.concat(ReventlessCore.Platform_AdminApi.activeRoleTypes),
|
|
251
|
+
mutations: ReventlessCore.Platform_AdminApi.uploadMutationFields->Array.concat(
|
|
252
|
+
ReventlessCore.Platform_AdminApi.activeRoleMutationFields,
|
|
251
253
|
),
|
|
252
|
-
mutations: ReventlessCore.Platform_AdminApi.uploadMutationFields,
|
|
253
254
|
queries: Array.concat(
|
|
254
255
|
[" Platform_ping: String"],
|
|
255
256
|
ReventlessCore.Platform_AdminApi.geocodeQueryFields,
|
|
@@ -2106,6 +2107,31 @@ module MakeWithConfig = (
|
|
|
2106
2107
|
| _ => ()
|
|
2107
2108
|
}
|
|
2108
2109
|
|
|
2110
|
+
// Where a caller records the role they want to act as
|
|
2111
|
+
// (docs/plans/active-role-narrows-the-token.md §6). The mutation only writes
|
|
2112
|
+
// a preference; the pre-token-generation trigger reads the row and re-checks
|
|
2113
|
+
// it against real membership before narrowing anything, so this door grants
|
|
2114
|
+
// nobody anything on its own.
|
|
2115
|
+
//
|
|
2116
|
+
// The table and the trigger were provisioned with the pool — they have to
|
|
2117
|
+
// be, since the pool is declared carrying the trigger's ARN. Only the write
|
|
2118
|
+
// door waits for the API.
|
|
2119
|
+
//
|
|
2120
|
+
// Split-API only, like the upload and geocode resolvers above:
|
|
2121
|
+
// `Platform_SetActiveRole` lives on the domain base document, which a
|
|
2122
|
+
// unified-mode deployment's single API does not carry. The table and
|
|
2123
|
+
// trigger are provisioned either way — see `Platform_Stack` for why that
|
|
2124
|
+
// is not gated on this flag.
|
|
2125
|
+
if Config.splitApi {
|
|
2126
|
+
Auth_ActiveRoleStore.makeWriteDoor(
|
|
2127
|
+
~api=domainApi,
|
|
2128
|
+
~table=cognitoPool.activeRoleTable,
|
|
2129
|
+
~cognitoUserPoolId=cognitoPool.poolId->Pulumi.Output.asInput,
|
|
2130
|
+
~cognitoUserPoolArn=cognitoPool.poolArn->Pulumi.Output.asInput,
|
|
2131
|
+
~opts={},
|
|
2132
|
+
)
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2109
2135
|
// The place index itself, exported so the unattended slice path can reach the
|
|
2110
2136
|
// capability: a slice's Lambda signs an SDK call directly and needs the index
|
|
2111
2137
|
// name, not an endpoint (no proxy hop, no dependence on a public URL). The
|
package/src/Platform.res.mjs
CHANGED
|
@@ -60,6 +60,7 @@ import * as StateTopic_AppSync$ReventlessAws from "./adapter/StateTopic/StateTop
|
|
|
60
60
|
import * as AppSync_SdlDecorate$ReventlessAws from "./components/Api/AppSync_SdlDecorate.res.mjs";
|
|
61
61
|
import * as UiFragmentRegistry$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
|
|
62
62
|
import * as Util_ResourceNaming$ReventlessAws from "./util/Util_ResourceNaming.res.mjs";
|
|
63
|
+
import * as Auth_ActiveRoleStore$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleStore.res.mjs";
|
|
63
64
|
import * as ClonerRunner_Fargate$ReventlessAws from "./plugin/cloner/ClonerRunner_Fargate.res.mjs";
|
|
64
65
|
import * as QueryEngine_DynamoDb$ReventlessAws from "./adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs";
|
|
65
66
|
import * as PluginRuntime_Builder$ReventlessAws from "./plugin/runtime/PluginRuntime_Builder.res.mjs";
|
|
@@ -192,8 +193,8 @@ function MakeWithConfig(Config) {
|
|
|
192
193
|
}
|
|
193
194
|
};
|
|
194
195
|
let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
|
|
195
|
-
types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
|
|
196
|
-
mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
|
|
196
|
+
types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes).concat(Platform_AdminApi$ReventlessCore.activeRoleTypes),
|
|
197
|
+
mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields.concat(Platform_AdminApi$ReventlessCore.activeRoleMutationFields),
|
|
197
198
|
queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
|
|
198
199
|
subscriptions: [],
|
|
199
200
|
subscriptionSources: []
|
|
@@ -1130,6 +1131,9 @@ function MakeWithConfig(Config) {
|
|
|
1130
1131
|
if (index !== undefined && Config.splitApi) {
|
|
1131
1132
|
Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
|
|
1132
1133
|
}
|
|
1134
|
+
if (Config.splitApi) {
|
|
1135
|
+
Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
|
|
1136
|
+
}
|
|
1133
1137
|
let index$1 = hostUiBundle.geocoderPlaceIndex;
|
|
1134
1138
|
let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
|
|
1135
1139
|
Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
|
|
@@ -1491,8 +1495,8 @@ function Make($star) {
|
|
|
1491
1495
|
}
|
|
1492
1496
|
};
|
|
1493
1497
|
let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
|
|
1494
|
-
types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
|
|
1495
|
-
mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
|
|
1498
|
+
types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes).concat(Platform_AdminApi$ReventlessCore.activeRoleTypes),
|
|
1499
|
+
mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields.concat(Platform_AdminApi$ReventlessCore.activeRoleMutationFields),
|
|
1496
1500
|
queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
|
|
1497
1501
|
subscriptions: [],
|
|
1498
1502
|
subscriptionSources: []
|
|
@@ -2406,6 +2410,7 @@ function Make($star) {
|
|
|
2406
2410
|
if (index !== undefined) {
|
|
2407
2411
|
Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
|
|
2408
2412
|
}
|
|
2413
|
+
Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
|
|
2409
2414
|
let index$1 = hostUiBundle.geocoderPlaceIndex;
|
|
2410
2415
|
let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
|
|
2411
2416
|
Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
|
package/src/Platform_Stack.res
CHANGED
|
@@ -6,6 +6,12 @@ type cognitoUserPool = {
|
|
|
6
6
|
clientId: Pulumi.Output.t<string>,
|
|
7
7
|
poolArn: Pulumi.Output.t<string>,
|
|
8
8
|
managed: bool,
|
|
9
|
+
/** Rows holding the role each caller chose to act as, read by the pool's
|
|
10
|
+
pre-token-generation trigger. Carried on the resolved pool because the table
|
|
11
|
+
has to be provisioned *before* the pool (the trigger reads it, and the pool
|
|
12
|
+
is declared with the trigger's ARN), while the mutation that writes it is
|
|
13
|
+
provisioned after the API — see [Auth_ActiveRoleStore.res]. */
|
|
14
|
+
activeRoleTable: Auth_ActiveRoleStore.storeTable,
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -32,6 +38,11 @@ type cognitoUserPool = {
|
|
|
32
38
|
* `cognitoUserPoolArn`, `cognitoRegion`, and `cognitoUserPoolManaged` as
|
|
33
39
|
* stack outputs so downstream stacks (and Stage D AppSync wiring) can read
|
|
34
40
|
* them via `StackReference`.
|
|
41
|
+
*
|
|
42
|
+
* Also provisions the role-state table and the pre-token-generation trigger that
|
|
43
|
+
* narrows `cognito:groups` to a caller's chosen role. They belong here rather
|
|
44
|
+
* than beside the mutation that writes the table because the pool must be
|
|
45
|
+
* declared carrying the trigger's ARN — see the ordering note below.
|
|
35
46
|
*/
|
|
36
47
|
let _resolveUncached = (): cognitoUserPool => {
|
|
37
48
|
let cfg = Pulumi.Config.make(Some("platform"))
|
|
@@ -40,6 +51,33 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
40
51
|
| None => cfg->Pulumi.Config.get("cognitoUserPoolId")
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
// Provisioned ahead of the pool in both pool modes: the pre-token-generation
|
|
55
|
+
// trigger reads these rows, and in auto mode the pool is declared carrying the
|
|
56
|
+
// trigger's ARN — so table → trigger → pool is a forced order, not a choice.
|
|
57
|
+
// See [docs/plans/active-role-narrows-the-token.md] §6.
|
|
58
|
+
//
|
|
59
|
+
// Unconditional, including for a unified-API platform that mounts no
|
|
60
|
+
// `Platform_SetActiveRole` and so can never write a row.
|
|
61
|
+
//
|
|
62
|
+
// This was briefly gated on whether a write door would exist, to save a
|
|
63
|
+
// DynamoDB read per sign-in in that mode. The gate was a parameter on this
|
|
64
|
+
// function — and because the result is process-cached, it made the feature's
|
|
65
|
+
// existence depend on *which caller resolved the pool first*. `Auth_Cognito.make`
|
|
66
|
+
// reaches this through an adapter record and gets there before the platform
|
|
67
|
+
// does, so the whole feature silently vanished from the deploy: no error, no
|
|
68
|
+
// resource, nothing to notice. A local `pulumi preview` was the only thing that
|
|
69
|
+
// caught it.
|
|
70
|
+
//
|
|
71
|
+
// Saving one GetItem in a legacy mode is not worth a switch whose failure mode
|
|
72
|
+
// is a feature that quietly is not there. If the cost ever matters, the fix is
|
|
73
|
+
// a design that does not hinge on call order — not this parameter again.
|
|
74
|
+
let activeRoleTable = Auth_ActiveRoleStore.makeTable(~opts={})
|
|
75
|
+
let activeRoleTrigger = Auth_ActiveRoleTrigger.make(
|
|
76
|
+
~activeRoleTableName=activeRoleTable.name->Pulumi.Output.asInput,
|
|
77
|
+
~activeRoleTableArn=activeRoleTable.arn->Pulumi.Output.asInput,
|
|
78
|
+
~opts={},
|
|
79
|
+
)
|
|
80
|
+
|
|
43
81
|
let result = switch existingPoolId {
|
|
44
82
|
| Some(poolId) =>
|
|
45
83
|
let tokenUnits: PulumiAws.Cognito.UserPoolClient.tokenValidityUnits = {
|
|
@@ -66,11 +104,25 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
66
104
|
|
|
67
105
|
let lookup = PulumiAws.Cognito.UserPool.getUserPoolOutput(~args={userPoolId: poolId})
|
|
68
106
|
|
|
107
|
+
// BYO: we hold no handle to set `lambdaConfig` on, and Cognito offers no
|
|
108
|
+
// separate attachment resource — so the trigger is attached by a declared
|
|
109
|
+
// resource that describes the pool, merges into what it finds, and sends the
|
|
110
|
+
// result back whole. Never a hand-run command: that would leave the
|
|
111
|
+
// deployment's behaviour depending on an act no source describes.
|
|
112
|
+
let _attachment = Auth_ActiveRolePoolAttachment.make(
|
|
113
|
+
~props={
|
|
114
|
+
userPoolId: Pulumi.Input.make(poolId),
|
|
115
|
+
preTokenGenerationArn: activeRoleTrigger.functionArn->Pulumi.Output.asInput,
|
|
116
|
+
},
|
|
117
|
+
~opts={},
|
|
118
|
+
)
|
|
119
|
+
|
|
69
120
|
{
|
|
70
121
|
poolId: Pulumi.Output.make(poolId),
|
|
71
122
|
clientId: client.id,
|
|
72
123
|
poolArn: lookup->Pulumi.Output.apply(r => r.arn),
|
|
73
124
|
managed: false,
|
|
125
|
+
activeRoleTable,
|
|
74
126
|
}
|
|
75
127
|
|
|
76
128
|
| None =>
|
|
@@ -83,6 +135,11 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
83
135
|
requireUppercase: Pulumi.Input.make(true),
|
|
84
136
|
requireNumbers: Pulumi.Input.make(true),
|
|
85
137
|
}
|
|
138
|
+
// Auto: the pool is ours, so the trigger is an ordinary declared property and
|
|
139
|
+
// no out-of-band `UpdateUserPool` is involved. Declaring it here rather than
|
|
140
|
+
// attaching it afterwards also keeps Pulumi's own state the authority — an
|
|
141
|
+
// attachment made behind the resource's back would read as drift on the next
|
|
142
|
+
// refresh and be reverted.
|
|
86
143
|
let pool = PulumiAws.Cognito.UserPool.make(
|
|
87
144
|
~name="HostUiPool",
|
|
88
145
|
~args={
|
|
@@ -90,6 +147,9 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
90
147
|
usernameAttributes: Pulumi.Input.make([Pulumi.Input.make("email")]),
|
|
91
148
|
passwordPolicy: Pulumi.Input.make(pwdPolicy),
|
|
92
149
|
mfaConfiguration: Pulumi.Input.make("OFF"),
|
|
150
|
+
lambdaConfig: Pulumi.Input.make({
|
|
151
|
+
PulumiAws.Cognito.UserPool.preTokenGeneration: activeRoleTrigger.functionArn->Pulumi.Output.asInput,
|
|
152
|
+
}),
|
|
93
153
|
tags: AWS.Tags.make(
|
|
94
154
|
~name="HostUiPool",
|
|
95
155
|
~kind=ReventlessCore.ComponentType.Platform,
|
|
@@ -126,9 +186,18 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
126
186
|
clientId: client.id,
|
|
127
187
|
poolArn: pool.arn,
|
|
128
188
|
managed: true,
|
|
189
|
+
activeRoleTable,
|
|
129
190
|
}
|
|
130
191
|
}
|
|
131
192
|
|
|
193
|
+
// After the branch, because the permission is scoped to the pool's ARN and the
|
|
194
|
+
// pool is what the branch produces.
|
|
195
|
+
Auth_ActiveRoleTrigger.grantInvoke(
|
|
196
|
+
~trigger=activeRoleTrigger,
|
|
197
|
+
~cognitoUserPoolArn=result.poolArn->Pulumi.Output.asInput,
|
|
198
|
+
~opts={},
|
|
199
|
+
)
|
|
200
|
+
|
|
132
201
|
let regionStr =
|
|
133
202
|
Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")
|
|
134
203
|
|
|
@@ -149,6 +218,15 @@ let _resolveUncached = (): cognitoUserPool => {
|
|
|
149
218
|
// stack-export calls happen exactly once per Pulumi run.
|
|
150
219
|
let _cached: ref<option<cognitoUserPool>> = ref(None)
|
|
151
220
|
|
|
221
|
+
/**
|
|
222
|
+
🚨 **Takes no arguments, and must not grow any.** Everything here is provisioned
|
|
223
|
+
on the *first* call and every later caller gets that same result — so a
|
|
224
|
+
parameter would not configure the pool, it would configure whichever caller
|
|
225
|
+
happened to arrive first. `Auth_Cognito.make` reaches this through an adapter
|
|
226
|
+
record and gets here before the platform's own call does, which is not obvious
|
|
227
|
+
from any call site. Anything that needs to vary belongs in [_resolveUncached],
|
|
228
|
+
read from config or environment where every caller sees the same answer.
|
|
229
|
+
*/
|
|
152
230
|
let resolveCognitoUserPool = (): cognitoUserPool =>
|
|
153
231
|
switch _cached.contents {
|
|
154
232
|
| Some(p) => p
|
|
@@ -6,11 +6,16 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
7
7
|
import * as AWS_Tags$ReventlessAws from "./adapter/AWS_Tags.res.mjs";
|
|
8
8
|
import * as Util_LocalConfig$ReventlessAws from "./util/Util_LocalConfig.res.mjs";
|
|
9
|
+
import * as Auth_ActiveRoleStore$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleStore.res.mjs";
|
|
10
|
+
import * as Auth_ActiveRoleTrigger$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleTrigger.res.mjs";
|
|
11
|
+
import * as Auth_ActiveRolePoolAttachment$ReventlessAws from "./adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs";
|
|
9
12
|
|
|
10
13
|
function _resolveUncached() {
|
|
11
14
|
let cfg = new Pulumi.Config("platform");
|
|
12
15
|
let v = Util_LocalConfig$ReventlessAws.get("cognitoUserPoolId");
|
|
13
16
|
let existingPoolId = v !== undefined ? v : cfg.get("cognitoUserPoolId");
|
|
17
|
+
let activeRoleTable = Auth_ActiveRoleStore$ReventlessAws.makeTable(undefined, {});
|
|
18
|
+
let activeRoleTrigger = Auth_ActiveRoleTrigger$ReventlessAws.make(activeRoleTable.name, activeRoleTable.arn, undefined, {});
|
|
14
19
|
let result;
|
|
15
20
|
if (existingPoolId !== undefined) {
|
|
16
21
|
let tokenUnits_accessToken = "minutes";
|
|
@@ -37,11 +42,16 @@ function _resolveUncached() {
|
|
|
37
42
|
let lookup = Aws.cognito.getUserPoolOutput({
|
|
38
43
|
userPoolId: existingPoolId
|
|
39
44
|
});
|
|
45
|
+
Auth_ActiveRolePoolAttachment$ReventlessAws.make(undefined, {
|
|
46
|
+
userPoolId: existingPoolId,
|
|
47
|
+
preTokenGenerationArn: activeRoleTrigger.functionArn
|
|
48
|
+
}, {});
|
|
40
49
|
result = {
|
|
41
50
|
poolId: Pulumi.output(existingPoolId),
|
|
42
51
|
clientId: client.id,
|
|
43
52
|
poolArn: lookup.apply(r => r.arn),
|
|
44
|
-
managed: false
|
|
53
|
+
managed: false,
|
|
54
|
+
activeRoleTable: activeRoleTable
|
|
45
55
|
};
|
|
46
56
|
} else {
|
|
47
57
|
let adminConfig = {
|
|
@@ -58,6 +68,9 @@ function _resolveUncached() {
|
|
|
58
68
|
requireUppercase: pwdPolicy_requireUppercase
|
|
59
69
|
};
|
|
60
70
|
let pool = new (Aws.cognito.UserPool)("HostUiPool", {
|
|
71
|
+
lambdaConfig: {
|
|
72
|
+
preTokenGeneration: activeRoleTrigger.functionArn
|
|
73
|
+
},
|
|
61
74
|
adminCreateUserConfig: adminConfig,
|
|
62
75
|
usernameAttributes: ["email"],
|
|
63
76
|
passwordPolicy: pwdPolicy,
|
|
@@ -89,9 +102,11 @@ function _resolveUncached() {
|
|
|
89
102
|
poolId: pool.id,
|
|
90
103
|
clientId: client$1.id,
|
|
91
104
|
poolArn: pool.arn,
|
|
92
|
-
managed: true
|
|
105
|
+
managed: true,
|
|
106
|
+
activeRoleTable: activeRoleTable
|
|
93
107
|
};
|
|
94
108
|
}
|
|
109
|
+
Auth_ActiveRoleTrigger$ReventlessAws.grantInvoke(activeRoleTrigger, result.poolArn, undefined, {});
|
|
95
110
|
let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
|
|
96
111
|
Pulumi$Pulumi.$$export("cognitoUserPoolId", result.poolId);
|
|
97
112
|
Pulumi$Pulumi.$$export("cognitoUserPoolClientId", result.clientId);
|
|
@@ -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)
|