@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.320
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 +20 -0
- package/package.json +9 -5
- package/rescript.json +4 -0
- package/run-provision-identity.mjs +3 -0
- package/scripts/ProvisionIdentity.res +303 -0
- package/scripts/ProvisionIdentity.res.mjs +376 -0
- package/src/Platform.res +54 -30
- package/src/Platform.res.mjs +34 -32
- package/src/Platform_Stack.res +102 -19
- package/src/Platform_Stack.res.mjs +43 -7
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
- package/src/util/Util_AwsError.res +56 -0
- package/src/util/Util_AwsError.res.mjs +59 -0
- package/src/util/Util_ShellConfig.res +28 -0
- package/src/util/Util_ShellConfig.res.mjs +22 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
- package/tests/Auth_ActiveRoleStoreTest.res +65 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
- package/tests/AwsErrorFixtures.mjs +16 -0
- package/tests/ProvisionIdentityTest.res +109 -0
- package/tests/ProvisionIdentityTest.res.mjs +132 -0
- package/tests/Util_AwsErrorTest.res +127 -0
- package/tests/Util_AwsErrorTest.res.mjs +76 -0
- package/tests/Util_ShellConfigTest.res +51 -0
- package/tests/Util_ShellConfigTest.res.mjs +42 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The active-role store's identity: what it is called, and how its rows are
|
|
2
|
+
// keyed. See [docs/plans/active-role-store-scoped-to-the-pool.md].
|
|
3
|
+
//
|
|
4
|
+
// 🚨 **One definition, four consumers, and that is the whole reason this file
|
|
5
|
+
// exists.** The deploy creates or looks the table up ([Auth_ActiveRoleStore]),
|
|
6
|
+
// the write door writes rows ([Auth_ActiveRoleStore_Ops]), the pre-token trigger
|
|
7
|
+
// reads them ([Auth_ActiveRoleTrigger_Ops]), and the provisioning script creates
|
|
8
|
+
// the table on a provider no stack owns (`scripts/provision-identity.mjs`). Any
|
|
9
|
+
// two of those disagreeing produces the same failure: a row written where nothing
|
|
10
|
+
// looks for it, so a role switch reports success and does nothing.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately free of Pulumi and of the AWS SDK, so the script and both Lambda
|
|
13
|
+
// bundles can import it without dragging a deploy-time dependency into a runtime
|
|
14
|
+
// graph — the hazard that once broke command-handler cold starts.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
The store belonging to an identity provider this framework does not own.
|
|
18
|
+
|
|
19
|
+
Derived rather than configured. The objection to deriving was that two stacks
|
|
20
|
+
would both try to *create* the table and the second would fail or adopt a
|
|
21
|
+
resource it does not own — true while stacks create it, and no stack creates this
|
|
22
|
+
one. What derivation buys is worth more than the config key it removes: two
|
|
23
|
+
platforms on one provider **cannot** name different stores, so the defect stops
|
|
24
|
+
being something to detect and becomes something that cannot be expressed.
|
|
25
|
+
|
|
26
|
+
The provider id carries its own region (`eu-west-1_CQTwafSeX`), which is the
|
|
27
|
+
region the store must live in too — a pre-token-generation trigger has to sit in
|
|
28
|
+
its pool's region, so every stack that can attach one derives the same name in
|
|
29
|
+
the same place.
|
|
30
|
+
*/
|
|
31
|
+
let derivedStoreName = (~identityProviderId: string): string =>
|
|
32
|
+
`ReventlessActiveRoleStore-${identityProviderId}`
|
|
33
|
+
|
|
34
|
+
/** The caller's Cognito `sub`. Stable across a rename, unlike the username. */
|
|
35
|
+
let partitionKey = "id"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
The app client the token was minted for.
|
|
39
|
+
|
|
40
|
+
A sort key rather than nothing, because one identity provider can serve several
|
|
41
|
+
platforms: each platform stack declares its own app client, so keying on the pair
|
|
42
|
+
gives every platform its own active role over one shared set of rows. Keyed on
|
|
43
|
+
the subject alone, narrowing to a role in one platform would narrow the caller's
|
|
44
|
+
session in every other platform on that provider — defensible as "one identity,
|
|
45
|
+
one session", but not what an operator wants, since a role with surfaces in one
|
|
46
|
+
platform and none in another leaves the second showing nothing.
|
|
47
|
+
*/
|
|
48
|
+
let sortKey = "clientId"
|
|
49
|
+
|
|
50
|
+
/** The stored choice itself. */
|
|
51
|
+
let roleAttribute = "activeRole"
|
|
52
|
+
|
|
53
|
+
/** The key schema as DynamoDB describes one: `(attribute, keyType)` pairs.
|
|
54
|
+
|
|
55
|
+
Plain tuples rather than the SDK's `keySchemaElement` so this module keeps its
|
|
56
|
+
"no side effect" footer — importing the AWS SDK here would put it in both Lambda
|
|
57
|
+
bundles, which is the dependency leak that once broke command-handler cold
|
|
58
|
+
starts. Callers map their own shapes into this. */
|
|
59
|
+
let expectedKeySchema: array<(string, string)> = [(partitionKey, "HASH"), (sortKey, "RANGE")]
|
|
60
|
+
|
|
61
|
+
let describeKeySchema = (elements: array<(string, string)>): string =>
|
|
62
|
+
elements
|
|
63
|
+
->Array.map(((attribute, keyType)) => `${attribute}:${keyType}`)
|
|
64
|
+
->Array.toSorted(String.compare)
|
|
65
|
+
->Array.join(",")
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
Why a table that already exists cannot serve as the store, if it cannot.
|
|
69
|
+
|
|
70
|
+
🚨 **A table under the right name with the wrong key is worse than no table.** The
|
|
71
|
+
deploy finds it, the handlers write into it, and every read misses — a role switch
|
|
72
|
+
that reports success and does nothing, which is the defect this whole store was
|
|
73
|
+
repaired for. So adoption checks the schema and refuses, rather than reporting
|
|
74
|
+
success.
|
|
75
|
+
|
|
76
|
+
The pre-`clientId` store is exactly this case: keyed on the subject alone. An
|
|
77
|
+
operator upgrading meets a sentence instead of a silent misbehaviour.
|
|
78
|
+
|
|
79
|
+
Order-insensitive, because `DescribeTable` does not promise one.
|
|
80
|
+
*/
|
|
81
|
+
let keySchemaRefusal = (
|
|
82
|
+
~tableName: string,
|
|
83
|
+
~actual: array<(string, string)>,
|
|
84
|
+
): option<string> => {
|
|
85
|
+
let actualKey = describeKeySchema(actual)
|
|
86
|
+
let wantedKey = describeKeySchema(expectedKeySchema)
|
|
87
|
+
actualKey == wantedKey
|
|
88
|
+
? None
|
|
89
|
+
: Some(
|
|
90
|
+
`table "${tableName}" already exists with key schema [${actualKey}], but the active-role store needs [${wantedKey}]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
4
|
+
|
|
5
|
+
function derivedStoreName(identityProviderId) {
|
|
6
|
+
return `ReventlessActiveRoleStore-` + identityProviderId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let partitionKey = "id";
|
|
10
|
+
|
|
11
|
+
let sortKey = "clientId";
|
|
12
|
+
|
|
13
|
+
let expectedKeySchema = [
|
|
14
|
+
[
|
|
15
|
+
partitionKey,
|
|
16
|
+
"HASH"
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
sortKey,
|
|
20
|
+
"RANGE"
|
|
21
|
+
]
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function describeKeySchema(elements) {
|
|
25
|
+
return elements.map(param => param[0] + `:` + param[1]).toSorted(Primitive_string.compare).join(",");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function keySchemaRefusal(tableName, actual) {
|
|
29
|
+
let actualKey = describeKeySchema(actual);
|
|
30
|
+
let wantedKey = describeKeySchema(expectedKeySchema);
|
|
31
|
+
if (actualKey === wantedKey) {
|
|
32
|
+
return;
|
|
33
|
+
} else {
|
|
34
|
+
return `table "` + tableName + `" already exists with key schema [` + actualKey + `], but the active-role store needs [` + wantedKey + `]. A row written under one key is invisible to a read under the other, so role switching would report success and do nothing. Delete the table if its rows are disposable — they are preferences, and every caller re-chooses on their next switch.`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let roleAttribute = "activeRole";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
derivedStoreName,
|
|
42
|
+
partitionKey,
|
|
43
|
+
sortKey,
|
|
44
|
+
roleAttribute,
|
|
45
|
+
expectedKeySchema,
|
|
46
|
+
describeKeySchema,
|
|
47
|
+
keySchemaRefusal,
|
|
48
|
+
}
|
|
49
|
+
/* No side effect */
|
|
@@ -60,6 +60,11 @@ type triggerRequest = {
|
|
|
60
60
|
groupConfiguration?: groupConfiguration,
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** The app client this token is being minted for — the second half of the store's
|
|
64
|
+
row key, and what makes the active role per-platform on a provider serving
|
|
65
|
+
several. Cognito supplies it on every pre-token-generation event. */
|
|
66
|
+
type callerContext = {clientId?: string}
|
|
67
|
+
|
|
63
68
|
type groupOverrideDetails = {
|
|
64
69
|
groupsToOverride: array<string>,
|
|
65
70
|
iamRolesToOverride: array<string>,
|
|
@@ -77,6 +82,7 @@ type event = {
|
|
|
77
82
|
request?: triggerRequest,
|
|
78
83
|
response?: triggerResponse,
|
|
79
84
|
userName?: string,
|
|
85
|
+
callerContext?: callerContext,
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
// ── The decision ────────────────────────────────────────────────────────────
|
|
@@ -171,15 +177,18 @@ sign-in outright, and failing a login because a *preference* could not be read
|
|
|
171
177
|
trades a working session for a cosmetic one. The caller lands on full membership —
|
|
172
178
|
their existing privileges, not more — which is the safe direction to fail.
|
|
173
179
|
*/
|
|
174
|
-
let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
|
|
180
|
+
let storedRoleFor = async (~sub: string, ~clientId: string, ~table: string): option<string> =>
|
|
175
181
|
try {
|
|
176
182
|
let out = await DynamoDb_DocumentClient.GetCommand.make({
|
|
177
183
|
tableName: table,
|
|
178
|
-
key: Dict.fromArray([
|
|
184
|
+
key: Dict.fromArray([
|
|
185
|
+
(Auth_ActiveRoleStore_Schema.partitionKey, JSON.Encode.string(sub)),
|
|
186
|
+
(Auth_ActiveRoleStore_Schema.sortKey, JSON.Encode.string(clientId)),
|
|
187
|
+
]),
|
|
179
188
|
})->DynamoDb_DocumentClient.GetCommand.send
|
|
180
189
|
out.item
|
|
181
190
|
->Option.flatMap(JSON.Decode.object)
|
|
182
|
-
->Option.flatMap(o => o->Dict.get(
|
|
191
|
+
->Option.flatMap(o => o->Dict.get(Auth_ActiveRoleStore_Schema.roleAttribute))
|
|
183
192
|
->Option.flatMap(JSON.Decode.string)
|
|
184
193
|
} catch {
|
|
185
194
|
| _ => None
|
|
@@ -197,12 +206,17 @@ let handler = async (event: event): event => {
|
|
|
197
206
|
let sub =
|
|
198
207
|
event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
let clientId = event.callerContext->Option.flatMap(c => c.clientId)->Option.getOr("")
|
|
210
|
+
|
|
211
|
+
// Neither half of the key means no row to look up. Returning the event
|
|
212
|
+
// untouched keeps the sign-in working on exactly the membership the pool
|
|
213
|
+
// granted — the safe direction, and the same one a read failure takes. An
|
|
214
|
+
// absent client id is not worth failing a login over: the caller lands on their
|
|
215
|
+
// full membership, which is their existing privileges and not more.
|
|
216
|
+
if sub == "" || clientId == "" {
|
|
203
217
|
event
|
|
204
218
|
} else {
|
|
205
|
-
let storedRole = await storedRoleFor(~sub, ~table=tableName())
|
|
219
|
+
let storedRole = await storedRoleFor(~sub, ~clientId, ~table=tableName())
|
|
206
220
|
respond(~event, ~decision=decide(~membership, ~storedRole))
|
|
207
221
|
}
|
|
208
222
|
}
|
|
@@ -6,6 +6,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
|
6
6
|
import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
|
|
7
7
|
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
8
8
|
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
9
|
+
import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
|
|
9
10
|
|
|
10
11
|
function getEnv(k) {
|
|
11
12
|
let v = process.env[k];
|
|
@@ -90,16 +91,22 @@ function respond(event, decision) {
|
|
|
90
91
|
]));
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
async function storedRoleFor(sub, table) {
|
|
94
|
+
async function storedRoleFor(sub, clientId, table) {
|
|
94
95
|
try {
|
|
95
96
|
let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
|
|
96
97
|
TableName: table,
|
|
97
|
-
Key: Object.fromEntries([
|
|
98
|
-
|
|
98
|
+
Key: Object.fromEntries([
|
|
99
|
+
[
|
|
100
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
|
|
99
101
|
sub
|
|
100
|
-
]
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
|
|
105
|
+
clientId
|
|
106
|
+
]
|
|
107
|
+
])
|
|
101
108
|
}));
|
|
102
|
-
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[
|
|
109
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[Auth_ActiveRoleStore_Schema$ReventlessAws.roleAttribute]), Stdlib_JSON.Decode.string);
|
|
103
110
|
} catch (exn) {
|
|
104
111
|
return;
|
|
105
112
|
}
|
|
@@ -108,10 +115,11 @@ async function storedRoleFor(sub, table) {
|
|
|
108
115
|
async function handler(event) {
|
|
109
116
|
let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
|
|
110
117
|
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
|
|
111
|
-
|
|
118
|
+
let clientId = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.callerContext, c => c.clientId), "");
|
|
119
|
+
if (sub === "" || clientId === "") {
|
|
112
120
|
return event;
|
|
113
121
|
}
|
|
114
|
-
let storedRole = await storedRoleFor(sub, tableName());
|
|
122
|
+
let storedRole = await storedRoleFor(sub, clientId, tableName());
|
|
115
123
|
return respond(event, decide(membership, storedRole));
|
|
116
124
|
}
|
|
117
125
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Classifying an AWS SDK failure, for callers that must tell one apart from the
|
|
2
|
+
// rest rather than treat every throw alike.
|
|
3
|
+
//
|
|
4
|
+
// 🚨 **The error's `name` carries the code; its `message` does not have to.** A
|
|
5
|
+
// v3 SDK `DescribeTable` on a missing table throws `name:
|
|
6
|
+
// "ResourceNotFoundException"` with the message "Requested resource not found:
|
|
7
|
+
// Table: X not found" — which does not contain the code anywhere. A guard written
|
|
8
|
+
// against the message alone therefore never matches, and the ordinary case it was
|
|
9
|
+
// meant to absorb escapes as an unhandled rejection instead.
|
|
10
|
+
//
|
|
11
|
+
// That is not hypothetical: it is why this module exists rather than the check
|
|
12
|
+
// living inline. A predicate inside a script that runs on import cannot be tested,
|
|
13
|
+
// so the version that could never match shipped.
|
|
14
|
+
//
|
|
15
|
+
// `Auth_ActiveRolePoolAttachment` keeps its own inline copy on purpose — Pulumi
|
|
16
|
+
// serialises a dynamic provider's whole closure into stack state, and a helper
|
|
17
|
+
// reached through a module import is a dependency that serialisation cannot carry.
|
|
18
|
+
|
|
19
|
+
@get @return(nullable) external name: JsExn.t => option<string> = "name"
|
|
20
|
+
|
|
21
|
+
/** Whether a failure carries this AWS error code, by `name` first and message as
|
|
22
|
+
a fallback for wrapped or re-thrown shapes. */
|
|
23
|
+
let hasCode = (exn: exn, ~code: string): bool =>
|
|
24
|
+
switch exn->JsExn.fromException {
|
|
25
|
+
| Some(jsErr) =>
|
|
26
|
+
switch (jsErr->name, JsExn.message(jsErr)) {
|
|
27
|
+
| (Some(actual), _) if actual == code => true
|
|
28
|
+
| (_, Some(message)) => message->String.includes(code)
|
|
29
|
+
| _ => false
|
|
30
|
+
}
|
|
31
|
+
| None => false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The resource named by the call does not exist — the shape both DynamoDB and
|
|
35
|
+
Cognito use, and normally an absence to handle rather than a failure to report. */
|
|
36
|
+
let isNotFound = (exn: exn): bool => exn->hasCode(~code="ResourceNotFoundException")
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
An escaped exception as one line an operator can act on.
|
|
40
|
+
|
|
41
|
+
For the top of a CLI: without it Node reports `UnhandledPromiseRejection ...
|
|
42
|
+
"#<Object>"`, which names neither the call that failed nor why. Every branch
|
|
43
|
+
returns something, because a describer that itself throws replaces one unreadable
|
|
44
|
+
failure with another.
|
|
45
|
+
*/
|
|
46
|
+
let describe = (exn: exn): string =>
|
|
47
|
+
switch exn->JsExn.fromException {
|
|
48
|
+
| Some(jsErr) =>
|
|
49
|
+
switch (jsErr->name, JsExn.message(jsErr)) {
|
|
50
|
+
| (Some(n), Some(m)) => `${n}: ${m}`
|
|
51
|
+
| (Some(n), None) => n
|
|
52
|
+
| (None, Some(m)) => m
|
|
53
|
+
| (None, None) => "an AWS call failed with no message"
|
|
54
|
+
}
|
|
55
|
+
| None => "an unexpected error escaped"
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
4
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
5
|
+
|
|
6
|
+
function hasCode(exn, code) {
|
|
7
|
+
let jsErr = Stdlib_JsExn.fromException(exn);
|
|
8
|
+
if (jsErr === undefined) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
let jsErr$1 = Primitive_option.valFromOption(jsErr);
|
|
12
|
+
let match = jsErr$1.name;
|
|
13
|
+
let match$1 = Stdlib_JsExn.message(jsErr$1);
|
|
14
|
+
if (match == null) {
|
|
15
|
+
if (match$1 !== undefined) {
|
|
16
|
+
return match$1.includes(code);
|
|
17
|
+
} else {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
} else if (match === code) {
|
|
21
|
+
return true;
|
|
22
|
+
} else if (match$1 !== undefined) {
|
|
23
|
+
return match$1.includes(code);
|
|
24
|
+
} else {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isNotFound(exn) {
|
|
30
|
+
return hasCode(exn, "ResourceNotFoundException");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describe(exn) {
|
|
34
|
+
let jsErr = Stdlib_JsExn.fromException(exn);
|
|
35
|
+
if (jsErr === undefined) {
|
|
36
|
+
return "an unexpected error escaped";
|
|
37
|
+
}
|
|
38
|
+
let jsErr$1 = Primitive_option.valFromOption(jsErr);
|
|
39
|
+
let match = jsErr$1.name;
|
|
40
|
+
let match$1 = Stdlib_JsExn.message(jsErr$1);
|
|
41
|
+
if (match == null) {
|
|
42
|
+
if (match$1 !== undefined) {
|
|
43
|
+
return match$1;
|
|
44
|
+
} else {
|
|
45
|
+
return "an AWS call failed with no message";
|
|
46
|
+
}
|
|
47
|
+
} else if (match$1 !== undefined) {
|
|
48
|
+
return match + `: ` + match$1;
|
|
49
|
+
} else {
|
|
50
|
+
return match;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
hasCode,
|
|
56
|
+
isNotFound,
|
|
57
|
+
describe,
|
|
58
|
+
}
|
|
59
|
+
/* Stdlib_JsExn Not a pure module */
|
|
@@ -31,6 +31,34 @@ let modeOptions = (mode: Platform.viewMode): array<(string, JSON.t)> =>
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
The identity fields a shell reads, under **both** spellings.
|
|
36
|
+
|
|
37
|
+
🚨 **Both, and not as a sequenced rename.** `identityProvider*` is what these
|
|
38
|
+
become — the concept is not AWS-specific, so a second cloud's adapter should meet
|
|
39
|
+
the same names. `cognito*` is what every shipped shell reads today.
|
|
40
|
+
|
|
41
|
+
A shell reads `config.json` at runtime and CloudFront serves the previous bundle
|
|
42
|
+
until someone invalidates it, so switching the keys in one deploy leaves a window
|
|
43
|
+
where the served bundle and the served config disagree. `cognitoClientId` is read
|
|
44
|
+
into an `option`: a bundle that cannot find its key does not error, it gets
|
|
45
|
+
`None`, and login, silent refresh and token refresh all quietly fall through —
|
|
46
|
+
the window is a total auth outage that reports nothing.
|
|
47
|
+
|
|
48
|
+
Writing both removes the ordering dependency rather than managing it: any bundle,
|
|
49
|
+
old or new or stale in a CDN, finds one it understands. The `cognito*` pair goes
|
|
50
|
+
once the shell that prefers the other is the pinned one.
|
|
51
|
+
|
|
52
|
+
Here rather than inline at the call site so the pairing is one fact in one place,
|
|
53
|
+
and so the property can be asserted without a deploy.
|
|
54
|
+
*/
|
|
55
|
+
let identityFields = (~providerId: string, ~clientId: string): array<(string, JSON.t)> => [
|
|
56
|
+
("identityProviderId", JSON.Encode.string(providerId)),
|
|
57
|
+
("identityProviderClientId", JSON.Encode.string(clientId)),
|
|
58
|
+
("cognitoUserPoolId", JSON.Encode.string(providerId)),
|
|
59
|
+
("cognitoClientId", JSON.Encode.string(clientId)),
|
|
60
|
+
]
|
|
61
|
+
|
|
34
62
|
/**
|
|
35
63
|
The config.json field set.
|
|
36
64
|
|
|
@@ -31,6 +31,27 @@ function modeOptions(mode) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function identityFields(providerId, clientId) {
|
|
35
|
+
return [
|
|
36
|
+
[
|
|
37
|
+
"identityProviderId",
|
|
38
|
+
providerId
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"identityProviderClientId",
|
|
42
|
+
clientId
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"cognitoUserPoolId",
|
|
46
|
+
providerId
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"cognitoClientId",
|
|
50
|
+
clientId
|
|
51
|
+
]
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
function fields(computed, viewModes, bakedManifest, shellConfig) {
|
|
35
56
|
let out = Object.fromEntries(computed);
|
|
36
57
|
Stdlib_Option.forEach(bakedManifest, bake => {
|
|
@@ -70,6 +91,7 @@ export {
|
|
|
70
91
|
Platform,
|
|
71
92
|
journeyManifestsKey,
|
|
72
93
|
modeOptions,
|
|
94
|
+
identityFields,
|
|
73
95
|
fields,
|
|
74
96
|
}
|
|
75
97
|
/* No side effect */
|
|
@@ -421,4 +421,179 @@ describe("Auth_ActiveRolePoolAttachment.probeEvent", () => {
|
|
|
421
421
|
->Option.flatMap(JSON.Decode.array),
|
|
422
422
|
)->toEqual(Some([]))
|
|
423
423
|
)
|
|
424
|
+
|
|
425
|
+
// 🚨 The handler keys its row on (subject, app client), and takes the
|
|
426
|
+
// "nothing to look up" branch when either is missing. A probe without a
|
|
427
|
+
// `callerContext` would return healthy without the handler ever reaching the
|
|
428
|
+
// store — proving less than the check appears to prove.
|
|
429
|
+
testSync("carries the caller context the handler keys its read on", () =>
|
|
430
|
+
expect(
|
|
431
|
+
event
|
|
432
|
+
->Option.flatMap(o => o->Dict.get("callerContext"))
|
|
433
|
+
->Option.flatMap(JSON.Decode.object)
|
|
434
|
+
->Option.flatMap(c => c->Dict.get("clientId"))
|
|
435
|
+
->Option.flatMap(JSON.Decode.string)
|
|
436
|
+
->Option.isSome,
|
|
437
|
+
)->toBe(true)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
// Both halves of the key must miss every real row, not just the subject.
|
|
441
|
+
testSync("the probe's subject and client cannot collide with a real row", () =>
|
|
442
|
+
expect(
|
|
443
|
+
Attachment.probeSubject == Attachment.probeClientId,
|
|
444
|
+
)->toBe(false)
|
|
445
|
+
)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// 🚨 Whose slot is it. Cognito allows a pool exactly one pre-token-generation
|
|
449
|
+
// trigger, so an unconditional attach is last-writer-wins — it replaces a BYO
|
|
450
|
+
// customer's own trigger silently, and it lets two platform stacks each read a
|
|
451
|
+
// store the other never writes. The describe this resource already performs is
|
|
452
|
+
// read for what is attached, and a slot held by anything else fails the deploy.
|
|
453
|
+
describe("Auth_ActiveRolePoolAttachment.attachedTriggerArn", () => {
|
|
454
|
+
let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken"
|
|
455
|
+
|
|
456
|
+
let withConfig = (~version) =>
|
|
457
|
+
Dict.fromArray([
|
|
458
|
+
(
|
|
459
|
+
"LambdaConfig",
|
|
460
|
+
Dict.fromArray([
|
|
461
|
+
(
|
|
462
|
+
"PreTokenGenerationConfig",
|
|
463
|
+
Dict.fromArray([
|
|
464
|
+
("LambdaArn", str(theirArn)),
|
|
465
|
+
("LambdaVersion", str(version)),
|
|
466
|
+
])->JSON.Encode.object,
|
|
467
|
+
),
|
|
468
|
+
])->JSON.Encode.object,
|
|
469
|
+
),
|
|
470
|
+
])
|
|
471
|
+
|
|
472
|
+
testSync("an empty pool holds nothing", () =>
|
|
473
|
+
expect(Attachment.attachedTriggerArn(~described=Dict.fromArray([("Name", str("Bare"))])))
|
|
474
|
+
->toEqual(None)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
testSync("reports the trigger a pool carries", () =>
|
|
478
|
+
expect(Attachment.attachedTriggerArn(~described=withConfig(~version="V1_0")))->toEqual(
|
|
479
|
+
Some(theirArn),
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
// 🚨 The distinction from `attachedTrigger`, and the reason both exist. A
|
|
484
|
+
// foreign trigger pinned at a version we do not implement is very much
|
|
485
|
+
// attached; a version-aware read calls it absent, and this resource would then
|
|
486
|
+
// quietly replace the very trigger it is meant to refuse.
|
|
487
|
+
testSync("a foreign trigger at another version is still occupying the slot", () =>
|
|
488
|
+
expect((
|
|
489
|
+
Attachment.attachedTriggerArn(~described=withConfig(~version="V2_0")),
|
|
490
|
+
Attachment.attachedTrigger(~described=withConfig(~version="V2_0")),
|
|
491
|
+
))->toEqual((Some(theirArn), None))
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
testSync("falls back to the legacy field on a pool carrying only that", () =>
|
|
495
|
+
expect(
|
|
496
|
+
Attachment.attachedTriggerArn(
|
|
497
|
+
~described=Dict.fromArray([
|
|
498
|
+
(
|
|
499
|
+
"LambdaConfig",
|
|
500
|
+
Dict.fromArray([("PreTokenGeneration", str(theirArn))])->JSON.Encode.object,
|
|
501
|
+
),
|
|
502
|
+
]),
|
|
503
|
+
),
|
|
504
|
+
)->toEqual(Some(theirArn))
|
|
505
|
+
)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe("Auth_ActiveRolePoolAttachment.classifySlot", () => {
|
|
509
|
+
let ours = "arn:aws:lambda:eu-west-1:1:function:OurTrigger"
|
|
510
|
+
let theirs = "arn:aws:lambda:eu-west-1:1:function:OtherStackTrigger"
|
|
511
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x"
|
|
512
|
+
|
|
513
|
+
let classify = (~attachedArn, ~attachedStore) =>
|
|
514
|
+
Attachment.classifySlot(~attachedArn, ~ourArn=ours, ~ourStore=store, ~attachedStore)
|
|
515
|
+
|
|
516
|
+
testSync("an empty slot is free to take", () =>
|
|
517
|
+
expect(classify(~attachedArn=None, ~attachedStore=None))->toEqual(Attachment.Vacant)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
// A pool can carry an empty string where a trigger was detached out of band.
|
|
521
|
+
testSync("an empty ARN is an empty slot, not a foreign trigger", () =>
|
|
522
|
+
expect(classify(~attachedArn=Some(""), ~attachedStore=None))->toEqual(Attachment.Vacant)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
testSync("our own trigger is ours to re-attach", () =>
|
|
526
|
+
expect(classify(~attachedArn=Some(ours), ~attachedStore=None))->toEqual(Attachment.Ours)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
// 🚨 The case the shared pool exists for: two platform stacks running identical
|
|
530
|
+
// code over identical rows. Whichever holds the slot serves both, so this is
|
|
531
|
+
// not a conflict and must not fail a deploy.
|
|
532
|
+
testSync("another deployment's trigger on the same store serves both", () =>
|
|
533
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some(store)))->toEqual(
|
|
534
|
+
Attachment.SharedWith(theirs),
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
// 🚨 The original defect, caught at the only moment anything can see both
|
|
539
|
+
// halves. Unreachable by configuration now that the store is derived — reachable
|
|
540
|
+
// by version skew, while an older release is still on its stack-scoped table.
|
|
541
|
+
testSync("another deployment's trigger on a different store is the defect", () =>
|
|
542
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some("ActiveRoleStore-829c96f")))
|
|
543
|
+
->toEqual(Attachment.DifferentStore({arn: theirs, theirStore: "ActiveRoleStore-829c96f"}))
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
// Reading the store rather than matching a name is what makes this a check on
|
|
547
|
+
// the invariant: a function with no ACTIVE_ROLE_TABLE is not one of ours, and
|
|
548
|
+
// neither is one we could not read.
|
|
549
|
+
testSync("a trigger with no store of ours is foreign", () =>
|
|
550
|
+
expect(classify(~attachedArn=Some(theirs), ~attachedStore=None))->toEqual(
|
|
551
|
+
Attachment.Foreign(theirs),
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
describe("Auth_ActiveRolePoolAttachment.refusalFor", () => {
|
|
557
|
+
let store = "ReventlessActiveRoleStore-eu-west-1_x"
|
|
558
|
+
let refusal = slot => Attachment.refusalFor(~slot, ~userPoolId="eu-west-1_x", ~ourStore=store)
|
|
559
|
+
|
|
560
|
+
testSync("the three attachable slots produce no refusal", () =>
|
|
561
|
+
expect((
|
|
562
|
+
refusal(Attachment.Vacant),
|
|
563
|
+
refusal(Attachment.Ours),
|
|
564
|
+
refusal(Attachment.SharedWith("arn:aws:lambda:eu-west-1:1:function:Other")),
|
|
565
|
+
))->toEqual((None, None, None))
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
// Both stores named, because an operator cannot act on this without knowing
|
|
569
|
+
// which two are in disagreement.
|
|
570
|
+
testSync("a disagreeing store is refused, naming both stores", () => {
|
|
571
|
+
let message =
|
|
572
|
+
refusal(
|
|
573
|
+
Attachment.DifferentStore({
|
|
574
|
+
arn: "arn:aws:lambda:eu-west-1:1:function:Other",
|
|
575
|
+
theirStore: "ActiveRoleStore-829c96f",
|
|
576
|
+
}),
|
|
577
|
+
)->Option.getOr("")
|
|
578
|
+
expect((
|
|
579
|
+
message->String.includes(store),
|
|
580
|
+
message->String.includes("ActiveRoleStore-829c96f"),
|
|
581
|
+
message->String.includes("eu-west-1_x"),
|
|
582
|
+
))->toEqual((true, true, true))
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
// 🚨 The trade this file already makes for its denylist, applied to the trigger
|
|
586
|
+
// slot: a customer's own claims-enrichment trigger is replaced silently today.
|
|
587
|
+
// Given "a customer's pool quietly loses a trigger" and "the deploy fails
|
|
588
|
+
// naming what is in the way", the second is the one to design for.
|
|
589
|
+
testSync("a foreign trigger is refused, naming what is attached", () => {
|
|
590
|
+
let arn = "arn:aws:lambda:eu-west-1:1:function:TheirClaimsEnrichment"
|
|
591
|
+
let message = refusal(Attachment.Foreign(arn))->Option.getOr("")
|
|
592
|
+
expect((
|
|
593
|
+
message->String.includes(arn),
|
|
594
|
+
// The likeliest cause of a false Foreign is a missing permission, so the
|
|
595
|
+
// sentence has to name it or an operator debugs the wrong thing.
|
|
596
|
+
message->String.includes("lambda:GetFunctionConfiguration"),
|
|
597
|
+
))->toEqual((true, true))
|
|
598
|
+
})
|
|
424
599
|
})
|