@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Runtime handler for the Cognito **pre token generation** trigger — the AWS
|
|
2
|
+
// minting point for "acting as one of the roles you hold". See
|
|
3
|
+
// [docs/plans/active-role-narrows-the-token.md] §6.
|
|
4
|
+
//
|
|
5
|
+
// Compiled, type-checked and Pulumi-free so it ships as an EntryPoint module
|
|
6
|
+
// ([Auth_ActiveRoleTrigger.res] bundles it and attaches it to the user pool).
|
|
7
|
+
//
|
|
8
|
+
// The narrowing has to happen here rather than in our own enforcement code
|
|
9
|
+
// because `@authorize(AllowGroups([...]))` compiles to `@aws_auth`, which AppSync
|
|
10
|
+
// evaluates against `cognito:groups` **before any of our code executes**. A
|
|
11
|
+
// request header we invent could scope reads correctly and still leave every
|
|
12
|
+
// group-gated mutation callable — right about the data, wrong about the writes.
|
|
13
|
+
// Overriding the group claim is the one place every enforcement point already
|
|
14
|
+
// looks.
|
|
15
|
+
//
|
|
16
|
+
// **Version 1 of the trigger, deliberately.** Group override and added ID-token
|
|
17
|
+
// claims are `V1_0` capabilities; `V2_0`/`V3_0` buy access-token customisation
|
|
18
|
+
// this does not need and are gated behind the Essentials and Plus feature plans.
|
|
19
|
+
// A deployment on the Lite tier must not be excluded from acting as a role.
|
|
20
|
+
|
|
21
|
+
open AwsSdk
|
|
22
|
+
|
|
23
|
+
// ── Environment ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let getEnv = (k: string): option<string> =>
|
|
26
|
+
switch NodeProcess.env->Dict.get(k) {
|
|
27
|
+
| Some("") | None => None
|
|
28
|
+
| Some(v) => Some(v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let tableName = (): string =>
|
|
32
|
+
switch getEnv("ACTIVE_ROLE_TABLE") {
|
|
33
|
+
| Some(t) => t
|
|
34
|
+
| None => JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Cognito pre-token-generation event (V1_0) ───────────────────────────────
|
|
38
|
+
|
|
39
|
+
type userAttributes = {sub?: string}
|
|
40
|
+
|
|
41
|
+
/** The groups the pool says the user is really in, supplied by the pool itself.
|
|
42
|
+
This is why the subset check here needs no lookup and no IAM to perform one:
|
|
43
|
+
the authority arrives in the payload, and the check is a containment test
|
|
44
|
+
against it. */
|
|
45
|
+
type groupConfiguration = {
|
|
46
|
+
groupsToOverride?: array<string>,
|
|
47
|
+
iamRolesToOverride?: array<string>,
|
|
48
|
+
preferredRole?: Nullable.t<string>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type triggerRequest = {
|
|
52
|
+
userAttributes?: userAttributes,
|
|
53
|
+
groupConfiguration?: groupConfiguration,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type groupOverrideDetails = {
|
|
57
|
+
groupsToOverride: array<string>,
|
|
58
|
+
iamRolesToOverride: array<string>,
|
|
59
|
+
preferredRole?: Nullable.t<string>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type claimsOverrideDetails = {
|
|
63
|
+
claimsToAddOrOverride?: dict<string>,
|
|
64
|
+
groupOverrideDetails?: groupOverrideDetails,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type triggerResponse = {claimsOverrideDetails?: claimsOverrideDetails}
|
|
68
|
+
|
|
69
|
+
type event = {
|
|
70
|
+
request?: triggerRequest,
|
|
71
|
+
response?: triggerResponse,
|
|
72
|
+
userName?: string,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── The decision ────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
type decision =
|
|
78
|
+
/** No stored preference — mint exactly the token that would have been minted
|
|
79
|
+
before any of this existed. This is the path every existing caller takes. */
|
|
80
|
+
| Unchanged
|
|
81
|
+
/** The stored role is one the pool says the caller holds. */
|
|
82
|
+
| Narrow({role: string, membership: array<string>})
|
|
83
|
+
/** The row outlived the membership that justified it.
|
|
84
|
+
|
|
85
|
+
Resolved by minting the full set rather than narrowing to a group the pool no
|
|
86
|
+
longer grants — but *decided* here rather than falling out of the code,
|
|
87
|
+
because the alternative reading is a token scoped to a role the caller has
|
|
88
|
+
lost. Unlike the write door, this path has no client to refuse: the trigger
|
|
89
|
+
meets a stale row on an ordinary refresh with nobody asking for anything. It
|
|
90
|
+
says so on the token instead, so a caller whose chosen role silently stopped
|
|
91
|
+
applying has something to read. */
|
|
92
|
+
| Stale({role: string, membership: array<string>})
|
|
93
|
+
|
|
94
|
+
let decide = (~membership: array<string>, ~storedRole: option<string>): decision =>
|
|
95
|
+
switch storedRole {
|
|
96
|
+
| None | Some("") => Unchanged
|
|
97
|
+
| Some(role) =>
|
|
98
|
+
membership->Array.includes(role) ? Narrow({role, membership}) : Stale({role, membership})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
Turn a decision into the trigger's response.
|
|
103
|
+
|
|
104
|
+
🚨 **`groupOverrideDetails` replaces the whole group configuration.** Supplying it
|
|
105
|
+
with only `groupsToOverride` drops the caller's `iamRolesToOverride` and
|
|
106
|
+
`preferredRole` — the same reset-by-omission hazard `UpdateUserPool` has, and just
|
|
107
|
+
as quiet. Both are echoed from the incoming configuration so the override changes
|
|
108
|
+
exactly one thing.
|
|
109
|
+
|
|
110
|
+
`Unchanged` returns the event untouched rather than an empty override: an empty
|
|
111
|
+
`claimsOverrideDetails` is not the same as none, and the regression line for this
|
|
112
|
+
whole feature is that a caller with no stored role gets byte-identical output.
|
|
113
|
+
*/
|
|
114
|
+
let respond = (~event: event, ~decision: decision): event => {
|
|
115
|
+
let incoming = event.request->Option.flatMap(r => r.groupConfiguration)
|
|
116
|
+
let iamRolesToOverride = incoming->Option.flatMap(g => g.iamRolesToOverride)->Option.getOr([])
|
|
117
|
+
let preferredRole = incoming->Option.flatMap(g => g.preferredRole)
|
|
118
|
+
|
|
119
|
+
let overrideWith = (~groups: array<string>, ~claims: dict<string>) => {
|
|
120
|
+
...event,
|
|
121
|
+
response: {
|
|
122
|
+
claimsOverrideDetails: {
|
|
123
|
+
claimsToAddOrOverride: claims,
|
|
124
|
+
groupOverrideDetails: {
|
|
125
|
+
groupsToOverride: groups,
|
|
126
|
+
iamRolesToOverride,
|
|
127
|
+
preferredRole: ?preferredRole,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
switch decision {
|
|
134
|
+
| Unchanged => event
|
|
135
|
+
| Narrow({role, membership}) =>
|
|
136
|
+
overrideWith(
|
|
137
|
+
~groups=[role],
|
|
138
|
+
~claims=Dict.fromArray([
|
|
139
|
+
(ReventlessCore.Auth_ActiveRole.activeRoleClaim, role),
|
|
140
|
+
(ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
|
|
141
|
+
]),
|
|
142
|
+
)
|
|
143
|
+
| Stale({role, membership}) =>
|
|
144
|
+
// The full set, as the pool granted it — plus the marker that explains why the
|
|
145
|
+
// stored choice did not apply.
|
|
146
|
+
overrideWith(
|
|
147
|
+
~groups=membership,
|
|
148
|
+
~claims=Dict.fromArray([
|
|
149
|
+
(ReventlessCore.Auth_ActiveRole.staleRoleClaim, role),
|
|
150
|
+
(ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
|
|
151
|
+
]),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Stored preference ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
The role this subject last chose, or `None` if they never chose one or cleared it.
|
|
160
|
+
|
|
161
|
+
A read failure resolves to `None`, not to an error. This trigger sits in the
|
|
162
|
+
critical path of every token Cognito mints for this pool: a throw here fails the
|
|
163
|
+
sign-in outright, and failing a login because a *preference* could not be read
|
|
164
|
+
trades a working session for a cosmetic one. The caller lands on full membership —
|
|
165
|
+
their existing privileges, not more — which is the safe direction to fail.
|
|
166
|
+
*/
|
|
167
|
+
let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
|
|
168
|
+
try {
|
|
169
|
+
let out = await DynamoDb_DocumentClient.GetCommand.make({
|
|
170
|
+
tableName: table,
|
|
171
|
+
key: Dict.fromArray([("id", JSON.Encode.string(sub))]),
|
|
172
|
+
})->DynamoDb_DocumentClient.GetCommand.send
|
|
173
|
+
out.item
|
|
174
|
+
->Option.flatMap(JSON.Decode.object)
|
|
175
|
+
->Option.flatMap(o => o->Dict.get("activeRole"))
|
|
176
|
+
->Option.flatMap(JSON.Decode.string)
|
|
177
|
+
} catch {
|
|
178
|
+
| _ => None
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Handler ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
let handler = async (event: event): event => {
|
|
184
|
+
let membership =
|
|
185
|
+
event.request
|
|
186
|
+
->Option.flatMap(r => r.groupConfiguration)
|
|
187
|
+
->Option.flatMap(g => g.groupsToOverride)
|
|
188
|
+
->Option.getOr([])
|
|
189
|
+
|
|
190
|
+
let sub =
|
|
191
|
+
event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
|
|
192
|
+
|
|
193
|
+
// No subject means no row to look up. Returning the event untouched keeps the
|
|
194
|
+
// sign-in working on exactly the membership the pool granted.
|
|
195
|
+
if sub == "" {
|
|
196
|
+
event
|
|
197
|
+
} else {
|
|
198
|
+
let storedRole = await storedRoleFor(~sub, ~table=tableName())
|
|
199
|
+
respond(~event, ~decision=decide(~membership, ~storedRole))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
6
|
+
import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
|
|
7
|
+
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
8
|
+
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
9
|
+
|
|
10
|
+
function getEnv(k) {
|
|
11
|
+
let v = process.env[k];
|
|
12
|
+
if (v !== undefined && v !== "") {
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tableName() {
|
|
18
|
+
let t = getEnv("ACTIVE_ROLE_TABLE");
|
|
19
|
+
if (t !== undefined) {
|
|
20
|
+
return t;
|
|
21
|
+
} else {
|
|
22
|
+
return Stdlib_JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decide(membership, storedRole) {
|
|
27
|
+
if (storedRole !== undefined && storedRole !== "") {
|
|
28
|
+
if (membership.includes(storedRole)) {
|
|
29
|
+
return {
|
|
30
|
+
TAG: "Narrow",
|
|
31
|
+
role: storedRole,
|
|
32
|
+
membership: membership
|
|
33
|
+
};
|
|
34
|
+
} else {
|
|
35
|
+
return {
|
|
36
|
+
TAG: "Stale",
|
|
37
|
+
role: storedRole,
|
|
38
|
+
membership: membership
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
return "Unchanged";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function respond(event, decision) {
|
|
47
|
+
let incoming = Stdlib_Option.flatMap(event.request, r => r.groupConfiguration);
|
|
48
|
+
let iamRolesToOverride = Stdlib_Option.getOr(Stdlib_Option.flatMap(incoming, g => g.iamRolesToOverride), []);
|
|
49
|
+
let preferredRole = Stdlib_Option.flatMap(incoming, g => g.preferredRole);
|
|
50
|
+
let overrideWith = (groups, claims) => {
|
|
51
|
+
let newrecord = {...event};
|
|
52
|
+
newrecord.response = {
|
|
53
|
+
claimsOverrideDetails: {
|
|
54
|
+
claimsToAddOrOverride: claims,
|
|
55
|
+
groupOverrideDetails: {
|
|
56
|
+
groupsToOverride: groups,
|
|
57
|
+
iamRolesToOverride: iamRolesToOverride,
|
|
58
|
+
preferredRole: preferredRole
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return newrecord;
|
|
63
|
+
};
|
|
64
|
+
if (typeof decision !== "object") {
|
|
65
|
+
return event;
|
|
66
|
+
}
|
|
67
|
+
if (decision.TAG === "Narrow") {
|
|
68
|
+
let role = decision.role;
|
|
69
|
+
return overrideWith([role], Object.fromEntries([
|
|
70
|
+
[
|
|
71
|
+
Auth_ActiveRole$ReventlessCore.activeRoleClaim,
|
|
72
|
+
role
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
Auth_ActiveRole$ReventlessCore.availableRolesClaim,
|
|
76
|
+
decision.membership.join(",")
|
|
77
|
+
]
|
|
78
|
+
]));
|
|
79
|
+
}
|
|
80
|
+
let membership = decision.membership;
|
|
81
|
+
return overrideWith(membership, Object.fromEntries([
|
|
82
|
+
[
|
|
83
|
+
Auth_ActiveRole$ReventlessCore.staleRoleClaim,
|
|
84
|
+
decision.role
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
Auth_ActiveRole$ReventlessCore.availableRolesClaim,
|
|
88
|
+
membership.join(",")
|
|
89
|
+
]
|
|
90
|
+
]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function storedRoleFor(sub, table) {
|
|
94
|
+
try {
|
|
95
|
+
let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
|
|
96
|
+
TableName: table,
|
|
97
|
+
Key: Object.fromEntries([[
|
|
98
|
+
"id",
|
|
99
|
+
sub
|
|
100
|
+
]])
|
|
101
|
+
}));
|
|
102
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o["activeRole"]), Stdlib_JSON.Decode.string);
|
|
103
|
+
} catch (exn) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handler(event) {
|
|
109
|
+
let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
|
|
110
|
+
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
|
|
111
|
+
if (sub === "") {
|
|
112
|
+
return event;
|
|
113
|
+
}
|
|
114
|
+
let storedRole = await storedRoleFor(sub, tableName());
|
|
115
|
+
return respond(event, decide(membership, storedRole));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
getEnv,
|
|
120
|
+
tableName,
|
|
121
|
+
decide,
|
|
122
|
+
respond,
|
|
123
|
+
storedRoleFor,
|
|
124
|
+
handler,
|
|
125
|
+
}
|
|
126
|
+
/* @aws-sdk/lib-dynamodb Not a pure module */
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// 🚨 The test §6 of the plan asks for, and the one that matters most in this
|
|
4
|
+
// feature: `UpdateUserPool` requires "a value for all parameters that you don't
|
|
5
|
+
// want set to a default value". An attach that sends only `LambdaConfig`
|
|
6
|
+
// silently returns every other setting on the pool to its default — on a pool
|
|
7
|
+
// the framework did not create and whose configuration it never described.
|
|
8
|
+
//
|
|
9
|
+
// So the assertions below are all the same assertion: a pool carrying
|
|
10
|
+
// non-default settings still carries them afterwards. The merge is a pure
|
|
11
|
+
// function precisely so this is checkable against a pool the test did not
|
|
12
|
+
// create, which is the case the merge exists for.
|
|
13
|
+
|
|
14
|
+
module Attachment = Auth_ActiveRolePoolAttachment
|
|
15
|
+
|
|
16
|
+
let str = JSON.Encode.string
|
|
17
|
+
|
|
18
|
+
// A pool that is nothing like a fresh one: MFA on, a deletion guard, a custom
|
|
19
|
+
// verification message, two triggers already attached, tags, and a paid tier.
|
|
20
|
+
//
|
|
21
|
+
// The key set mirrors what `DescribeUserPool` actually returns for a live pool,
|
|
22
|
+
// including `IssuerConfiguration` / `KeyConfiguration` — informational fields
|
|
23
|
+
// that are *not* members of `UpdateUserPool`. The merge carries them and the SDK
|
|
24
|
+
// drops them during serialisation, which is why they are harmless; they are here
|
|
25
|
+
// so a future edit that starts filtering on this list has a real shape to filter.
|
|
26
|
+
let describedPool = () =>
|
|
27
|
+
Dict.fromArray([
|
|
28
|
+
("Id", str("eu-west-1_abc123")),
|
|
29
|
+
("Name", str("CustomerPool")),
|
|
30
|
+
("Arn", str("arn:aws:cognito-idp:eu-west-1:1:userpool/eu-west-1_abc123")),
|
|
31
|
+
("Status", str("Enabled")),
|
|
32
|
+
("CreationDate", str("2020-01-01T00:00:00Z")),
|
|
33
|
+
("LastModifiedDate", str("2024-01-01T00:00:00Z")),
|
|
34
|
+
("SchemaAttributes", JSON.Encode.array([str("email")])),
|
|
35
|
+
("UsernameAttributes", JSON.Encode.array([str("email")])),
|
|
36
|
+
("EstimatedNumberOfUsers", JSON.Encode.int(4200)),
|
|
37
|
+
("IssuerConfiguration", Dict.fromArray([("IssuerUri", str("https://x"))])->JSON.Encode.object),
|
|
38
|
+
("KeyConfiguration", Dict.fromArray([("KmsKeyId", str("k-1"))])->JSON.Encode.object),
|
|
39
|
+
// A paid tier silently reverting to Lite would be a billing- and
|
|
40
|
+
// capability-level regression, and one nothing in the deploy would report.
|
|
41
|
+
("UserPoolTier", str("PLUS")),
|
|
42
|
+
("MfaConfiguration", str("ON")),
|
|
43
|
+
("DeletionProtection", str("ACTIVE")),
|
|
44
|
+
("EmailVerificationMessage", str("Your code is {####}, do not share it")),
|
|
45
|
+
(
|
|
46
|
+
"Policies",
|
|
47
|
+
Dict.fromArray([
|
|
48
|
+
(
|
|
49
|
+
"PasswordPolicy",
|
|
50
|
+
Dict.fromArray([("MinimumLength", JSON.Encode.int(24))])->JSON.Encode.object,
|
|
51
|
+
),
|
|
52
|
+
])->JSON.Encode.object,
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"UserPoolTags",
|
|
56
|
+
Dict.fromArray([("CostCentre", str("identity"))])->JSON.Encode.object,
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"LambdaConfig",
|
|
60
|
+
Dict.fromArray([
|
|
61
|
+
("PreSignUp", str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
|
|
62
|
+
("CustomMessage", str("arn:aws:lambda:eu-west-1:1:function:TheirCustomMessage")),
|
|
63
|
+
])->JSON.Encode.object,
|
|
64
|
+
),
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
let merged = (~preTokenGenerationArn) =>
|
|
68
|
+
Attachment.mergedUpdateInput(
|
|
69
|
+
~described=describedPool(),
|
|
70
|
+
~userPoolId="eu-west-1_abc123",
|
|
71
|
+
~preTokenGenerationArn,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
let triggerArn = "arn:aws:lambda:eu-west-1:1:function:ActiveRoleTrigger"
|
|
75
|
+
|
|
76
|
+
let lambdaConfigOf = input =>
|
|
77
|
+
input->Dict.get("LambdaConfig")->Option.flatMap(JSON.Decode.object)
|
|
78
|
+
|
|
79
|
+
describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — settings survive the attach", () => {
|
|
80
|
+
let input = merged(~preTokenGenerationArn=Some(triggerArn))
|
|
81
|
+
|
|
82
|
+
testSync("MFA stays on rather than defaulting off", () =>
|
|
83
|
+
expect(input->Dict.get("MfaConfiguration"))->toEqual(Some(str("ON")))
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
testSync("deletion protection stays active", () =>
|
|
87
|
+
expect(input->Dict.get("DeletionProtection"))->toEqual(Some(str("ACTIVE")))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
testSync("a customised verification message is not reset", () =>
|
|
91
|
+
expect(input->Dict.get("EmailVerificationMessage"))->toEqual(
|
|
92
|
+
Some(str("Your code is {####}, do not share it")),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
testSync("the password policy travels whole", () =>
|
|
97
|
+
expect(
|
|
98
|
+
input
|
|
99
|
+
->Dict.get("Policies")
|
|
100
|
+
->Option.flatMap(JSON.Decode.object)
|
|
101
|
+
->Option.flatMap(p => p->Dict.get("PasswordPolicy"))
|
|
102
|
+
->Option.flatMap(JSON.Decode.object)
|
|
103
|
+
->Option.flatMap(p => p->Dict.get("MinimumLength")),
|
|
104
|
+
)->toEqual(Some(JSON.Encode.int(24)))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
testSync("tags are not dropped", () =>
|
|
108
|
+
expect(
|
|
109
|
+
input
|
|
110
|
+
->Dict.get("UserPoolTags")
|
|
111
|
+
->Option.flatMap(JSON.Decode.object)
|
|
112
|
+
->Option.flatMap(t => t->Dict.get("CostCentre")),
|
|
113
|
+
)->toEqual(Some(str("identity")))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
testSync("a paid tier is not reverted to Lite", () =>
|
|
117
|
+
expect(input->Dict.get("UserPoolTier"))->toEqual(Some(str("PLUS")))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// The reset hazard one level down: replacing the whole `LambdaConfig` with a
|
|
121
|
+
// single-key object would silently detach every trigger the customer had.
|
|
122
|
+
testSync("the pool's existing triggers stay attached", () =>
|
|
123
|
+
expect((
|
|
124
|
+
lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreSignUp")),
|
|
125
|
+
lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("CustomMessage")),
|
|
126
|
+
))->toEqual((
|
|
127
|
+
Some(str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
|
|
128
|
+
Some(str("arn:aws:lambda:eu-west-1:1:function:TheirCustomMessage")),
|
|
129
|
+
))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
testSync("and ours is added beside them", () =>
|
|
133
|
+
expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreTokenGeneration")))->toEqual(
|
|
134
|
+
Some(str(triggerArn)),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — shaping for the API", () => {
|
|
140
|
+
let input = merged(~preTokenGenerationArn=Some(triggerArn))
|
|
141
|
+
|
|
142
|
+
testSync("UserPoolId names the pool being updated", () =>
|
|
143
|
+
expect(input->Dict.get("UserPoolId"))->toEqual(Some(str("eu-west-1_abc123")))
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// DescribeUserPool returns `Name`; UpdateUserPool takes `PoolName`. Sending
|
|
147
|
+
// `Name` is rejected, and omitting the rename would rename the pool by default.
|
|
148
|
+
testSync("Name is renamed to PoolName rather than dropped", () =>
|
|
149
|
+
expect((input->Dict.get("PoolName"), input->Dict.get("Name")))->toEqual((
|
|
150
|
+
Some(str("CustomerPool")),
|
|
151
|
+
None,
|
|
152
|
+
))
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
testSync("read-only fields UpdateUserPool rejects are not sent", () =>
|
|
156
|
+
expect(
|
|
157
|
+
["Id", "Arn", "Status", "CreationDate", "LastModifiedDate", "SchemaAttributes",
|
|
158
|
+
"UsernameAttributes", "EstimatedNumberOfUsers"]->Array.filter(k =>
|
|
159
|
+
input->Dict.get(k)->Option.isSome
|
|
160
|
+
),
|
|
161
|
+
)->toEqual([])
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — detaching", () => {
|
|
166
|
+
let input = merged(~preTokenGenerationArn=None)
|
|
167
|
+
|
|
168
|
+
// Destroy has to detach, or the pool is left pointing at a deleted function and
|
|
169
|
+
// every sign-in fails — on a pool nothing in this deployment would ever fix.
|
|
170
|
+
testSync("clearing removes our trigger", () =>
|
|
171
|
+
expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreTokenGeneration")))->toEqual(
|
|
172
|
+
None,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
testSync("clearing leaves the customer's own triggers alone", () =>
|
|
177
|
+
expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreSignUp")))->toEqual(
|
|
178
|
+
Some(str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
testSync("clearing still sends the rest of the pool back whole", () =>
|
|
183
|
+
expect(input->Dict.get("MfaConfiguration"))->toEqual(Some(str("ON")))
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — a pool with nothing set", () => {
|
|
188
|
+
testSync("a pool with no LambdaConfig gains one holding only our trigger", () => {
|
|
189
|
+
let input = Attachment.mergedUpdateInput(
|
|
190
|
+
~described=Dict.fromArray([("Name", str("Bare"))]),
|
|
191
|
+
~userPoolId="eu-west-1_bare",
|
|
192
|
+
~preTokenGenerationArn=Some(triggerArn),
|
|
193
|
+
)
|
|
194
|
+
expect(lambdaConfigOf(input))->toEqual(
|
|
195
|
+
Some(Dict.fromArray([("PreTokenGeneration", str(triggerArn))])),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe("Auth_ActiveRolePoolAttachment.attachedTrigger", () => {
|
|
201
|
+
testSync("reports the trigger a described pool carries", () =>
|
|
202
|
+
expect(Attachment.attachedTrigger(~described=merged(~preTokenGenerationArn=Some(triggerArn))))
|
|
203
|
+
->toEqual(Some(triggerArn))
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
testSync("reports none when the pool carries no trigger", () =>
|
|
207
|
+
expect(Attachment.attachedTrigger(~described=Dict.fromArray([("Name", str("Bare"))])))->toEqual(
|
|
208
|
+
None,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
})
|