@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
|
@@ -59,6 +59,16 @@ function mergedUpdateInput(described, userPoolId, preTokenGenerationArn) {
|
|
|
59
59
|
return out;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function attachedTriggerArn(described) {
|
|
63
|
+
let lambdaConfig = Stdlib_Option.flatMap(described["LambdaConfig"], Stdlib_JSON.Decode.object);
|
|
64
|
+
let arn = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(lambdaConfig, c => c["PreTokenGenerationConfig"]), Stdlib_JSON.Decode.object), config => config["LambdaArn"]), Stdlib_JSON.Decode.string);
|
|
65
|
+
if (arn !== undefined) {
|
|
66
|
+
return arn;
|
|
67
|
+
} else {
|
|
68
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(lambdaConfig, c => c["PreTokenGeneration"]), Stdlib_JSON.Decode.string);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
function attachedTrigger(described) {
|
|
63
73
|
let lambdaConfig = Stdlib_Option.flatMap(described["LambdaConfig"], Stdlib_JSON.Decode.object);
|
|
64
74
|
let config = Stdlib_Option.flatMap(Stdlib_Option.flatMap(lambdaConfig, c => c["PreTokenGenerationConfig"]), Stdlib_JSON.Decode.object);
|
|
@@ -73,6 +83,48 @@ function attachedTrigger(described) {
|
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
function classifySlot(attachedArn, ourArn, ourStore, attachedStore) {
|
|
87
|
+
if (attachedArn !== undefined && attachedArn !== "") {
|
|
88
|
+
if (attachedArn === ourArn) {
|
|
89
|
+
return "Ours";
|
|
90
|
+
} else if (attachedStore !== undefined) {
|
|
91
|
+
if (attachedStore === ourStore) {
|
|
92
|
+
return {
|
|
93
|
+
TAG: "SharedWith",
|
|
94
|
+
_0: attachedArn
|
|
95
|
+
};
|
|
96
|
+
} else {
|
|
97
|
+
return {
|
|
98
|
+
TAG: "DifferentStore",
|
|
99
|
+
arn: attachedArn,
|
|
100
|
+
theirStore: attachedStore
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
return {
|
|
105
|
+
TAG: "Foreign",
|
|
106
|
+
_0: attachedArn
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
return "Vacant";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function refusalFor(slot, userPoolId, ourStore) {
|
|
115
|
+
if (typeof slot !== "object") {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
switch (slot.TAG) {
|
|
119
|
+
case "DifferentStore" :
|
|
120
|
+
return `user pool ` + userPoolId + ` already carries the active-role trigger ` + slot.arn + `, which reads "` + slot.theirStore + `" — but this deployment's Platform_SetActiveRole writes "` + ourStore + `". A pool holds one pre-token-generation trigger, so attaching would leave one of the two stacks writing rows nothing reads and every role switch silently doing nothing. Point both stacks at one store with platform:activeRoleStore, or give them separate user pools.`;
|
|
121
|
+
case "Foreign" :
|
|
122
|
+
return `user pool ` + userPoolId + ` already carries the pre-token-generation trigger ` + slot._0 + `, which is not an active-role trigger of this framework — attaching would silently replace it, and Cognito allows a pool only one. Detach it deliberately if it is obsolete, or give this deployment its own user pool. (If it *is* a Reventless trigger, this deployment could not read its configuration: the deploying principal needs lambda:GetFunctionConfiguration on it.)`;
|
|
123
|
+
default:
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
76
128
|
let newOf1 = ((C, x) => new C(x));
|
|
77
129
|
|
|
78
130
|
let newOf0 = ((C) => new C());
|
|
@@ -135,6 +187,19 @@ async function getLambdaClient() {
|
|
|
135
187
|
return c$1;
|
|
136
188
|
}
|
|
137
189
|
|
|
190
|
+
async function activeRoleStoreOf(functionArn) {
|
|
191
|
+
try {
|
|
192
|
+
let sdk = await getLambdaSdk();
|
|
193
|
+
let client = await getLambdaClient();
|
|
194
|
+
let input = {};
|
|
195
|
+
input["FunctionName"] = functionArn;
|
|
196
|
+
let result = await client.send(newOf1(sdk.GetFunctionConfigurationCommand, input));
|
|
197
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(result.Environment, Stdlib_JSON.Decode.object), env => env["Variables"]), Stdlib_JSON.Decode.object), vars => vars["ACTIVE_ROLE_TABLE"]), Stdlib_JSON.Decode.string);
|
|
198
|
+
} catch (exn) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
138
203
|
let decodePayload = ((p) => {
|
|
139
204
|
if (p == null) return "";
|
|
140
205
|
if (typeof p === "string") return p;
|
|
@@ -161,6 +226,8 @@ function isPoolGoneError(jsErr) {
|
|
|
161
226
|
|
|
162
227
|
let probeSubject = "reventless-attachment-probe";
|
|
163
228
|
|
|
229
|
+
let probeClientId = "reventless-attachment-probe-client";
|
|
230
|
+
|
|
164
231
|
function probeEvent(userPoolId) {
|
|
165
232
|
let groupConfiguration = {};
|
|
166
233
|
groupConfiguration["groupsToOverride"] = [];
|
|
@@ -170,11 +237,14 @@ function probeEvent(userPoolId) {
|
|
|
170
237
|
let request = {};
|
|
171
238
|
request["userAttributes"] = userAttributes;
|
|
172
239
|
request["groupConfiguration"] = groupConfiguration;
|
|
240
|
+
let callerContext = {};
|
|
241
|
+
callerContext["clientId"] = probeClientId;
|
|
173
242
|
let event = {};
|
|
174
243
|
event["version"] = "1";
|
|
175
244
|
event["triggerSource"] = "TokenGeneration_Authentication";
|
|
176
245
|
event["userPoolId"] = userPoolId;
|
|
177
246
|
event["userName"] = probeSubject;
|
|
247
|
+
event["callerContext"] = callerContext;
|
|
178
248
|
event["request"] = request;
|
|
179
249
|
event["response"] = {};
|
|
180
250
|
return event;
|
|
@@ -230,45 +300,77 @@ async function describePool(userPoolId) {
|
|
|
230
300
|
return Stdlib_Option.flatMap(result.UserPool, Stdlib_JSON.Decode.object);
|
|
231
301
|
}
|
|
232
302
|
|
|
233
|
-
async function
|
|
303
|
+
async function sendMerged(described, userPoolId, preTokenGenerationArn) {
|
|
234
304
|
let sdk = await getSdk();
|
|
235
305
|
let client = await getClient();
|
|
306
|
+
let input = mergedUpdateInput(described, userPoolId, preTokenGenerationArn);
|
|
307
|
+
return await client.send(newOf1(sdk.UpdateUserPoolCommand, input));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function throwOnEmptyDescribe(userPoolId) {
|
|
311
|
+
return Stdlib_JsError.throwWithMessage(`DescribeUserPool returned no pool for "` + userPoolId + `"; refusing to send an UpdateUserPool that would reset it`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function attachTrigger(userPoolId, preTokenGenerationArn, activeRoleStore) {
|
|
236
315
|
let described = await describePool(userPoolId);
|
|
237
316
|
if (described === undefined) {
|
|
238
|
-
return
|
|
317
|
+
return throwOnEmptyDescribe(userPoolId);
|
|
239
318
|
}
|
|
240
|
-
let
|
|
241
|
-
|
|
319
|
+
let attachedArn = attachedTriggerArn(described);
|
|
320
|
+
let attachedStore = attachedArn !== undefined && attachedArn !== preTokenGenerationArn && attachedArn !== "" ? await activeRoleStoreOf(attachedArn) : undefined;
|
|
321
|
+
let slot = classifySlot(attachedArn, preTokenGenerationArn, activeRoleStore, attachedStore);
|
|
322
|
+
let message = refusalFor(slot, userPoolId, activeRoleStore);
|
|
323
|
+
if (message !== undefined) {
|
|
324
|
+
Stdlib_JsError.throwWithMessage(message);
|
|
325
|
+
}
|
|
326
|
+
if (typeof slot === "object" && slot.TAG === "SharedWith") {
|
|
327
|
+
log.info("Auth_ActiveRolePoolAttachment", undefined, `user pool ` + userPoolId + ` carries active-role trigger ` + slot._0 + ` from another deployment, reading the same store "` + activeRoleStore + `" — taking the slot serves both`);
|
|
328
|
+
}
|
|
329
|
+
await verifyTrigger(userPoolId, preTokenGenerationArn);
|
|
330
|
+
return await sendMerged(described, userPoolId, preTokenGenerationArn);
|
|
242
331
|
}
|
|
243
332
|
|
|
244
333
|
async function create(inputs) {
|
|
245
|
-
await
|
|
246
|
-
await applyTrigger(inputs.userPoolId, inputs.preTokenGenerationArn);
|
|
334
|
+
await attachTrigger(inputs.userPoolId, inputs.preTokenGenerationArn, inputs.activeRoleStore);
|
|
247
335
|
return {
|
|
248
336
|
id: inputs.userPoolId,
|
|
249
337
|
outs: {
|
|
250
338
|
userPoolId: inputs.userPoolId,
|
|
251
339
|
preTokenGenerationArn: inputs.preTokenGenerationArn,
|
|
252
|
-
codeHash: inputs.codeHash
|
|
340
|
+
codeHash: inputs.codeHash,
|
|
341
|
+
activeRoleStore: inputs.activeRoleStore
|
|
253
342
|
}
|
|
254
343
|
};
|
|
255
344
|
}
|
|
256
345
|
|
|
257
346
|
async function update(_id, _olds, news) {
|
|
258
|
-
await
|
|
259
|
-
await applyTrigger(news.userPoolId, news.preTokenGenerationArn);
|
|
347
|
+
await attachTrigger(news.userPoolId, news.preTokenGenerationArn, news.activeRoleStore);
|
|
260
348
|
return {
|
|
261
349
|
outs: {
|
|
262
350
|
userPoolId: news.userPoolId,
|
|
263
351
|
preTokenGenerationArn: news.preTokenGenerationArn,
|
|
264
|
-
codeHash: news.codeHash
|
|
352
|
+
codeHash: news.codeHash,
|
|
353
|
+
activeRoleStore: news.activeRoleStore
|
|
265
354
|
}
|
|
266
355
|
};
|
|
267
356
|
}
|
|
268
357
|
|
|
269
358
|
async function delete_(_id, props) {
|
|
270
359
|
try {
|
|
271
|
-
|
|
360
|
+
let described = await describePool(props.userPoolId);
|
|
361
|
+
if (described === undefined) {
|
|
362
|
+
return throwOnEmptyDescribe(props.userPoolId);
|
|
363
|
+
}
|
|
364
|
+
let arn = attachedTriggerArn(described);
|
|
365
|
+
if (arn !== undefined) {
|
|
366
|
+
if (arn === props.preTokenGenerationArn) {
|
|
367
|
+
return await sendMerged(described, props.userPoolId, undefined);
|
|
368
|
+
} else {
|
|
369
|
+
return log.info("Auth_ActiveRolePoolAttachment", undefined, `user pool ` + props.userPoolId + ` carries ` + arn + `, not this deployment's trigger; leaving it attached`);
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
272
374
|
} catch (raw_exn) {
|
|
273
375
|
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
274
376
|
if (Stdlib_Option.mapOr(Stdlib_JsExn.fromException(exn), false, isPoolGoneError)) {
|
|
@@ -281,7 +383,7 @@ async function delete_(_id, props) {
|
|
|
281
383
|
function diff_(_id, olds, news) {
|
|
282
384
|
let poolChanged = olds.userPoolId !== news.userPoolId;
|
|
283
385
|
return {
|
|
284
|
-
changes: poolChanged || olds.preTokenGenerationArn !== news.preTokenGenerationArn || olds.codeHash !== news.codeHash,
|
|
386
|
+
changes: poolChanged || olds.preTokenGenerationArn !== news.preTokenGenerationArn || olds.codeHash !== news.codeHash || olds.activeRoleStore !== news.activeRoleStore,
|
|
285
387
|
replaces: poolChanged ? ["userPoolId"] : [],
|
|
286
388
|
deleteBeforeReplace: true
|
|
287
389
|
};
|
|
@@ -296,7 +398,8 @@ async function read_(id, props) {
|
|
|
296
398
|
props: {
|
|
297
399
|
userPoolId: props.userPoolId,
|
|
298
400
|
preTokenGenerationArn: Stdlib_Option.getOr(attachedTrigger(described), ""),
|
|
299
|
-
codeHash: props.codeHash
|
|
401
|
+
codeHash: props.codeHash,
|
|
402
|
+
activeRoleStore: props.activeRoleStore
|
|
300
403
|
}
|
|
301
404
|
};
|
|
302
405
|
} else {
|
|
@@ -329,7 +432,10 @@ export {
|
|
|
329
432
|
readOnlyKeys,
|
|
330
433
|
triggerVersion,
|
|
331
434
|
mergedUpdateInput,
|
|
435
|
+
attachedTriggerArn,
|
|
332
436
|
attachedTrigger,
|
|
437
|
+
classifySlot,
|
|
438
|
+
refusalFor,
|
|
333
439
|
newOf1,
|
|
334
440
|
newOf0,
|
|
335
441
|
_sdk,
|
|
@@ -340,14 +446,18 @@ export {
|
|
|
340
446
|
_lambdaClient,
|
|
341
447
|
getLambdaSdk,
|
|
342
448
|
getLambdaClient,
|
|
449
|
+
activeRoleStoreOf,
|
|
343
450
|
decodePayload,
|
|
344
451
|
isPoolGoneError,
|
|
345
452
|
probeSubject,
|
|
453
|
+
probeClientId,
|
|
346
454
|
probeEvent,
|
|
347
455
|
probeVerdict,
|
|
348
456
|
verifyTrigger,
|
|
349
457
|
describePool,
|
|
350
|
-
|
|
458
|
+
sendMerged,
|
|
459
|
+
throwOnEmptyDescribe,
|
|
460
|
+
attachTrigger,
|
|
351
461
|
create,
|
|
352
462
|
update,
|
|
353
463
|
delete_,
|
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
//
|
|
30
30
|
// Provisioning the table with the write door instead would close that chain into
|
|
31
31
|
// a cycle, so the table is hoisted to where the pool is resolved.
|
|
32
|
+
//
|
|
33
|
+
// 🚨 **The rows follow the identity provider, not the stack** — see
|
|
34
|
+
// [ReventlessCore.Auth_ActiveRole] for the contract and
|
|
35
|
+
// [docs/plans/active-role-store-scoped-to-the-pool.md] for the defect that
|
|
36
|
+
// produced it. A pool holds one pre-token-generation trigger, so two stacks
|
|
37
|
+
// sharing a pool with a table each have the winning trigger reading rows the
|
|
38
|
+
// serving resolver never wrote: every switch succeeds and does nothing.
|
|
39
|
+
//
|
|
40
|
+
// So there are two cases and no third. A stack that creates its own pool owns
|
|
41
|
+
// everything attached to it, this table included. A stack handed an
|
|
42
|
+
// `identityProviderId` owns none of it: the pool exists outside every stack, and
|
|
43
|
+
// so does the store, at the name [derivedStoreName] gives. See [chooseStore].
|
|
32
44
|
|
|
33
45
|
open PulumiAws
|
|
34
46
|
|
|
@@ -37,6 +49,31 @@ type storeTable = {
|
|
|
37
49
|
arn: Pulumi.Output.t<string>,
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/** Which store a deployment's trigger reads and its write door writes. */
|
|
53
|
+
type storeChoice =
|
|
54
|
+
/** The stack creates and owns the table, because it also creates and owns the
|
|
55
|
+
pool: nothing else can be attached to that pool, so there is nothing to
|
|
56
|
+
share. */
|
|
57
|
+
| StackScoped
|
|
58
|
+
/** The provider's own store, looked up at [derivedStoreName] and never created.
|
|
59
|
+
Every stack on that provider resolves the same table. */
|
|
60
|
+
| ProviderScoped(string)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
The store a resolved provider calls for.
|
|
64
|
+
|
|
65
|
+
Total and pure, so the rule is checkable without a stack — and total is the point:
|
|
66
|
+
there is no configuration here that can be wrong, because there is no second key
|
|
67
|
+
to disagree with the first. Whether the provider is ours decides the store, and
|
|
68
|
+
nothing else is consulted.
|
|
69
|
+
*/
|
|
70
|
+
let chooseStore = (~identityProviderId: option<string>): storeChoice =>
|
|
71
|
+
switch identityProviderId {
|
|
72
|
+
| None => StackScoped
|
|
73
|
+
| Some(id) =>
|
|
74
|
+
ProviderScoped(Auth_ActiveRoleStore_Schema.derivedStoreName(~identityProviderId=id))
|
|
75
|
+
}
|
|
76
|
+
|
|
40
77
|
// JS resolver code (APPSYNC_JS runtime): forward the caller's arguments and the
|
|
41
78
|
// authorizer-verified identity. `sub` is what keys the row, and it comes from
|
|
42
79
|
// here — the mutation has no `sub` argument for a client to supply.
|
|
@@ -49,14 +86,35 @@ type storeTable = {
|
|
|
49
86
|
// how the membership read used to fail for every caller on such a pool. Both
|
|
50
87
|
// fields come from the authorizer, so forwarding the second grants nothing the
|
|
51
88
|
// first did not.
|
|
89
|
+
//
|
|
90
|
+
// 🚨 **`clientId` completes the row key, and it comes from the token's own
|
|
91
|
+
// claims** — `aud` on an ID token, `client_id` on an access token, since AppSync
|
|
92
|
+
// accepts either. It is what makes the active role per-platform on a provider
|
|
93
|
+
// serving several. `aud` is a string on a Cognito ID token but an array in OIDC
|
|
94
|
+
// generally, so the first element is taken rather than assuming.
|
|
95
|
+
//
|
|
96
|
+
// Forwarded as `null` when the claims are not there to read: the handler refuses
|
|
97
|
+
// rather than inventing a key. A guessed client id would write a row under one
|
|
98
|
+
// key that the trigger reads under another — the very defect this feature was
|
|
99
|
+
// repaired for, rebuilt one level down.
|
|
52
100
|
let invokeCode: Pulumi.Input.t<string> = `import { util } from '@aws-appsync/utils';
|
|
101
|
+
function appClientId(id) {
|
|
102
|
+
const claims = id.claims;
|
|
103
|
+
if (claims == null) return null;
|
|
104
|
+
const aud = claims.aud;
|
|
105
|
+
const picked = Array.isArray(aud) ? aud[0] : aud;
|
|
106
|
+
const value = picked ?? claims.client_id;
|
|
107
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
108
|
+
}
|
|
53
109
|
export function request(ctx) {
|
|
54
110
|
const id = ctx.identity;
|
|
55
111
|
return {
|
|
56
112
|
operation: 'Invoke',
|
|
57
113
|
payload: {
|
|
58
114
|
arguments: ctx.args,
|
|
59
|
-
identity: id != null && id.sub != null
|
|
115
|
+
identity: id != null && id.sub != null
|
|
116
|
+
? { sub: id.sub, username: id.username ?? null, clientId: appClientId(id) }
|
|
117
|
+
: null
|
|
60
118
|
}
|
|
61
119
|
};
|
|
62
120
|
}
|
|
@@ -66,16 +124,22 @@ export function response(ctx) {
|
|
|
66
124
|
}
|
|
67
125
|
`->Pulumi.Input.make
|
|
68
126
|
|
|
69
|
-
/** One row per subject
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
/** One row per (subject, app client) — the key schema is
|
|
128
|
+
[Auth_ActiveRoleStore_Schema]'s, shared with both handlers and the provisioning
|
|
129
|
+
script — plus `activeRole`, the group they chose, and `updatedAt`, an
|
|
130
|
+
operational breadcrumb. A caller acts as exactly one role at a time *per
|
|
131
|
+
platform*, so the row is the whole state. */
|
|
72
132
|
let makeTable = (
|
|
73
133
|
~name: string="ActiveRoleStore",
|
|
74
134
|
~opts: Pulumi.ComponentResource.options,
|
|
75
135
|
): storeTable => {
|
|
76
136
|
let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
|
|
77
137
|
let table = Util_DynamoDb.makeTable(
|
|
78
|
-
~attributes=[
|
|
138
|
+
~attributes=[
|
|
139
|
+
{name: Auth_ActiveRoleStore_Schema.partitionKey, type_: "S"},
|
|
140
|
+
{name: Auth_ActiveRoleStore_Schema.sortKey, type_: "S"},
|
|
141
|
+
],
|
|
142
|
+
~rangeKey=Auth_ActiveRoleStore_Schema.sortKey,
|
|
79
143
|
~tags=AWS.Tags.make(
|
|
80
144
|
~name=name ++ "Table",
|
|
81
145
|
~kind=ReventlessCore.ComponentType.Platform,
|
|
@@ -88,6 +152,30 @@ let makeTable = (
|
|
|
88
152
|
{name: table.name, arn: table.arn}
|
|
89
153
|
}
|
|
90
154
|
|
|
155
|
+
/** The [ProviderScoped] table: looked up, never created.
|
|
156
|
+
|
|
157
|
+
Looked up for the same reason a BYO pool is — the operator owns the identity,
|
|
158
|
+
and these rows are part of that identity's state. Creating it here would put
|
|
159
|
+
every stack on the provider in a race to own one resource.
|
|
160
|
+
|
|
161
|
+
A name that resolves to nothing fails the deploy, which is the second half of
|
|
162
|
+
"the provider and its store must both exist". The failure names the table it
|
|
163
|
+
looked for, which is also the table the provisioning script creates. */
|
|
164
|
+
let lookupTable = (~tableName: string): storeTable => {
|
|
165
|
+
let found = DynamoDb.Table.Get.output(~args={name: tableName})
|
|
166
|
+
{
|
|
167
|
+
name: found->Pulumi.Output.apply(t => t.name),
|
|
168
|
+
arn: found->Pulumi.Output.apply(t => t.arn),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The store a resolved provider calls for — created or looked up. */
|
|
173
|
+
let resolveTable = (~choice: storeChoice, ~opts: Pulumi.ComponentResource.options): storeTable =>
|
|
174
|
+
switch choice {
|
|
175
|
+
| StackScoped => makeTable(~opts)
|
|
176
|
+
| ProviderScoped(tableName) => lookupTable(~tableName)
|
|
177
|
+
}
|
|
178
|
+
|
|
91
179
|
/** The Lambda behind `Mutation.Platform_SetActiveRole` and its resolver: an
|
|
92
180
|
execution role scoped to Logs + the one table + `AdminListGroupsForUser` on the
|
|
93
181
|
one pool, an AppSync Lambda data source, and the resolver itself. */
|
|
@@ -14,15 +14,37 @@ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/
|
|
|
14
14
|
import * as Util_DynamoDb$ReventlessAws from "../../util/Util_DynamoDb.res.mjs";
|
|
15
15
|
import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
|
|
16
16
|
import * as AppSync_Resolver_Native$ReventlessAws from "../Api/AppSync_Resolver_Native.res.mjs";
|
|
17
|
+
import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
|
|
18
|
+
|
|
19
|
+
function chooseStore(identityProviderId) {
|
|
20
|
+
if (identityProviderId !== undefined) {
|
|
21
|
+
return {
|
|
22
|
+
TAG: "ProviderScoped",
|
|
23
|
+
_0: Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName(identityProviderId)
|
|
24
|
+
};
|
|
25
|
+
} else {
|
|
26
|
+
return "StackScoped";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
let invokeCode = `import { util } from '@aws-appsync/utils';
|
|
31
|
+
function appClientId(id) {
|
|
32
|
+
const claims = id.claims;
|
|
33
|
+
if (claims == null) return null;
|
|
34
|
+
const aud = claims.aud;
|
|
35
|
+
const picked = Array.isArray(aud) ? aud[0] : aud;
|
|
36
|
+
const value = picked ?? claims.client_id;
|
|
37
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
38
|
+
}
|
|
19
39
|
export function request(ctx) {
|
|
20
40
|
const id = ctx.identity;
|
|
21
41
|
return {
|
|
22
42
|
operation: 'Invoke',
|
|
23
43
|
payload: {
|
|
24
44
|
arguments: ctx.args,
|
|
25
|
-
identity: id != null && id.sub != null
|
|
45
|
+
identity: id != null && id.sub != null
|
|
46
|
+
? { sub: id.sub, username: id.username ?? null, clientId: appClientId(id) }
|
|
47
|
+
: null
|
|
26
48
|
}
|
|
27
49
|
};
|
|
28
50
|
}
|
|
@@ -35,16 +57,40 @@ export function response(ctx) {
|
|
|
35
57
|
function makeTable(nameOpt, opts) {
|
|
36
58
|
let name = nameOpt !== undefined ? nameOpt : "ActiveRoleStore";
|
|
37
59
|
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
38
|
-
let table = Util_DynamoDb$ReventlessAws.makeTable([
|
|
39
|
-
|
|
60
|
+
let table = Util_DynamoDb$ReventlessAws.makeTable([
|
|
61
|
+
{
|
|
62
|
+
name: Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
|
|
40
63
|
type: "S"
|
|
41
|
-
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
|
|
67
|
+
type: "S"
|
|
68
|
+
}
|
|
69
|
+
], undefined, undefined, Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey, AWS_Tags$ReventlessAws.make(name + "Table", "Platform", "Auth", "Platform", undefined, undefined, undefined, undefined), opts$1, name);
|
|
42
70
|
return {
|
|
43
71
|
name: table.name,
|
|
44
72
|
arn: table.arn
|
|
45
73
|
};
|
|
46
74
|
}
|
|
47
75
|
|
|
76
|
+
function lookupTable(tableName) {
|
|
77
|
+
let found = Aws.dynamodb.getTableOutput({
|
|
78
|
+
name: tableName
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
name: found.apply(t => t.name),
|
|
82
|
+
arn: found.apply(t => t.arn)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveTable(choice, opts) {
|
|
87
|
+
if (typeof choice !== "object") {
|
|
88
|
+
return makeTable(undefined, opts);
|
|
89
|
+
} else {
|
|
90
|
+
return lookupTable(choice._0);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
function makeWriteDoor(api, table, cognitoUserPoolId, cognitoUserPoolArn, nameOpt, opts) {
|
|
49
95
|
let name = nameOpt !== undefined ? nameOpt : "ActiveRoleStore";
|
|
50
96
|
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
@@ -156,8 +202,11 @@ function makeWriteDoor(api, table, cognitoUserPoolId, cognitoUserPoolArn, nameOp
|
|
|
156
202
|
}
|
|
157
203
|
|
|
158
204
|
export {
|
|
205
|
+
chooseStore,
|
|
159
206
|
invokeCode,
|
|
160
207
|
makeTable,
|
|
208
|
+
lookupTable,
|
|
209
|
+
resolveTable,
|
|
161
210
|
makeWriteDoor,
|
|
162
211
|
}
|
|
163
212
|
/* @pulumi/aws Not a pure module */
|
|
@@ -98,7 +98,13 @@ let mayActAs = (~membership: array<string>, ~requested: string): bool =>
|
|
|
98
98
|
|
|
99
99
|
// ── AppSync resolver event / result shapes ──────────────────────────────────
|
|
100
100
|
|
|
101
|
-
type identity = {
|
|
101
|
+
type identity = {
|
|
102
|
+
sub?: string,
|
|
103
|
+
username?: Nullable.t<string>,
|
|
104
|
+
/** The app client the caller's token was minted for — the second half of the
|
|
105
|
+
row key. `Null` when the resolver could not read it from the claims. */
|
|
106
|
+
clientId?: Nullable.t<string>,
|
|
107
|
+
}
|
|
102
108
|
type activeRoleArgs = {activeRole?: Nullable.t<string>}
|
|
103
109
|
type appSyncEvent = {
|
|
104
110
|
arguments?: activeRoleArgs,
|
|
@@ -125,6 +131,28 @@ let cognitoLookupName = (~identity: identity): option<string> =>
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
/**
|
|
135
|
+
The app client this row belongs to, completing the key alongside the subject.
|
|
136
|
+
|
|
137
|
+
🚨 **No fallback, deliberately — this refuses instead of guessing.** Every other
|
|
138
|
+
absent field in this handler has a defensible default; this one does not. The
|
|
139
|
+
pre-token trigger keys its read on the client id Cognito hands it, so a write
|
|
140
|
+
under any substitute — a constant, the subject, the empty string — is a row the
|
|
141
|
+
trigger will never find: a switch that reports success and does nothing, which is
|
|
142
|
+
the exact defect this store was repaired for. A caller told "we could not
|
|
143
|
+
determine which application you are signed in to" has something to act on; a
|
|
144
|
+
caller whose switch silently fails does not.
|
|
145
|
+
|
|
146
|
+
Reachable when the authorizer omits `claims`, which happens on some APPSYNC_JS
|
|
147
|
+
invocation shapes — see `Auth_Cognito.fromAppSyncIdentity`, which documents the
|
|
148
|
+
same gap and falls back for fields where falling back is safe.
|
|
149
|
+
*/
|
|
150
|
+
let appClientId = (~identity: identity): option<string> =>
|
|
151
|
+
switch identity.clientId {
|
|
152
|
+
| Some(Value(id)) if id->String.trim != "" => Some(id)
|
|
153
|
+
| _ => None
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
let result = (~activeRole: option<string>, ~availableRoles: array<string>): JSON.t =>
|
|
129
157
|
Dict.fromArray([
|
|
130
158
|
("activeRole", activeRole->Option.mapOr(JSON.Null, JSON.Encode.string)),
|
|
@@ -146,6 +174,15 @@ let handler = async (event: appSyncEvent): JSON.t => {
|
|
|
146
174
|
| Some(name) => name
|
|
147
175
|
| None => JsError.throwWithMessage("unauthenticated")
|
|
148
176
|
}
|
|
177
|
+
// Refused rather than defaulted — see `appClientId`. The row is keyed on the
|
|
178
|
+
// pair, and half a key is not a key.
|
|
179
|
+
let clientId = switch event.identity->Option.flatMap(i => appClientId(~identity=i)) {
|
|
180
|
+
| Some(id) => id
|
|
181
|
+
| None =>
|
|
182
|
+
JsError.throwWithMessage(
|
|
183
|
+
"Cannot determine which application this session belongs to, so the active role cannot be stored where the token minter will look for it",
|
|
184
|
+
)
|
|
185
|
+
}
|
|
149
186
|
let table = tableName()
|
|
150
187
|
let membership = await membershipOf(~username=lookupName, ~poolId=userPoolId())
|
|
151
188
|
|
|
@@ -159,7 +196,15 @@ let handler = async (event: appSyncEvent): JSON.t => {
|
|
|
159
196
|
|
|
160
197
|
switch requested {
|
|
161
198
|
| None =>
|
|
162
|
-
|
|
199
|
+
// Clears this platform's preference only. A caller acting as a role in
|
|
200
|
+
// another platform on the same provider keeps it — which is the point of the
|
|
201
|
+
// pair key, and would be surprising if the clear were pool-wide.
|
|
202
|
+
let _ = await DynamoDb_DocumentClient.deleteByIdSort(
|
|
203
|
+
~tableName=table,
|
|
204
|
+
~id=sub,
|
|
205
|
+
~sortField=Auth_ActiveRoleStore_Schema.sortKey,
|
|
206
|
+
~sortKey=clientId,
|
|
207
|
+
)
|
|
163
208
|
result(~activeRole=None, ~availableRoles=membership)
|
|
164
209
|
| Some(role) =>
|
|
165
210
|
// Refused, specifically — not ignored and stored anyway. A client asking for
|
|
@@ -170,8 +215,9 @@ let handler = async (event: appSyncEvent): JSON.t => {
|
|
|
170
215
|
}
|
|
171
216
|
let item =
|
|
172
217
|
Dict.fromArray([
|
|
173
|
-
(
|
|
174
|
-
(
|
|
218
|
+
(Auth_ActiveRoleStore_Schema.partitionKey, JSON.Encode.string(sub)),
|
|
219
|
+
(Auth_ActiveRoleStore_Schema.sortKey, JSON.Encode.string(clientId)),
|
|
220
|
+
(Auth_ActiveRoleStore_Schema.roleAttribute, JSON.Encode.string(role)),
|
|
175
221
|
("updatedAt", JSON.Encode.string(Date.make()->Date.toISOString)),
|
|
176
222
|
])->JSON.Encode.object
|
|
177
223
|
let _ = await DynamoDb_DocumentClient.PutCommand.make({
|
|
@@ -7,6 +7,7 @@ import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
|
|
|
7
7
|
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
8
8
|
import * as CognitoIdentityServiceProvider$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/CognitoIdentityServiceProvider.res.mjs";
|
|
9
9
|
import * as ClientCognitoIdentityProvider from "@aws-sdk/client-cognito-identity-provider";
|
|
10
|
+
import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
|
|
10
11
|
|
|
11
12
|
function getEnv(k) {
|
|
12
13
|
let v = process.env[k];
|
|
@@ -76,6 +77,19 @@ function cognitoLookupName(identity) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function appClientId(identity) {
|
|
81
|
+
let match = identity.clientId;
|
|
82
|
+
if (match === undefined) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let id = Primitive_option.valFromOption(match);
|
|
86
|
+
if ((id == null) || id.trim() === "") {
|
|
87
|
+
return;
|
|
88
|
+
} else {
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
function result(activeRole, availableRoles) {
|
|
80
94
|
return Object.fromEntries([
|
|
81
95
|
[
|
|
@@ -96,6 +110,8 @@ async function handler(event) {
|
|
|
96
110
|
}
|
|
97
111
|
let name = Stdlib_Option.flatMap(event.identity, cognitoLookupName);
|
|
98
112
|
let lookupName = name !== undefined ? name : Stdlib_JsError.throwWithMessage("unauthenticated");
|
|
113
|
+
let id = Stdlib_Option.flatMap(event.identity, appClientId);
|
|
114
|
+
let clientId = id !== undefined ? id : Stdlib_JsError.throwWithMessage("Cannot determine which application this session belongs to, so the active role cannot be stored where the token minter will look for it");
|
|
99
115
|
let table = tableName();
|
|
100
116
|
let membership = await membershipOf(lookupName, userPoolId());
|
|
101
117
|
let match = Stdlib_Option.flatMap(event.arguments, a => a.activeRole);
|
|
@@ -112,11 +128,15 @@ async function handler(event) {
|
|
|
112
128
|
}
|
|
113
129
|
let item = Object.fromEntries([
|
|
114
130
|
[
|
|
115
|
-
|
|
131
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
|
|
116
132
|
sub
|
|
117
133
|
],
|
|
118
134
|
[
|
|
119
|
-
|
|
135
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey,
|
|
136
|
+
clientId
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
Auth_ActiveRoleStore_Schema$ReventlessAws.roleAttribute,
|
|
120
140
|
requested
|
|
121
141
|
],
|
|
122
142
|
[
|
|
@@ -130,7 +150,7 @@ async function handler(event) {
|
|
|
130
150
|
}));
|
|
131
151
|
return result(requested, membership);
|
|
132
152
|
}
|
|
133
|
-
await DynamoDb_DocumentClient$AwsSdk.
|
|
153
|
+
await DynamoDb_DocumentClient$AwsSdk.deleteByIdSort(table, sub, Auth_ActiveRoleStore_Schema$ReventlessAws.sortKey, clientId);
|
|
134
154
|
return result(undefined, membership);
|
|
135
155
|
}
|
|
136
156
|
|
|
@@ -141,6 +161,7 @@ export {
|
|
|
141
161
|
membershipOf,
|
|
142
162
|
mayActAs,
|
|
143
163
|
cognitoLookupName,
|
|
164
|
+
appClientId,
|
|
144
165
|
result,
|
|
145
166
|
handler,
|
|
146
167
|
}
|