@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +9 -5
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/util/Util_AwsError.res +56 -0
  22. package/src/util/Util_AwsError.res.mjs +59 -0
  23. package/src/util/Util_ShellConfig.res +28 -0
  24. package/src/util/Util_ShellConfig.res.mjs +22 -0
  25. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  26. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  27. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  28. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  29. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  30. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  31. package/tests/AwsErrorFixtures.mjs +16 -0
  32. package/tests/ProvisionIdentityTest.res +109 -0
  33. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  34. package/tests/Util_AwsErrorTest.res +127 -0
  35. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  36. package/tests/Util_ShellConfigTest.res +51 -0
  37. 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 applyTrigger(userPoolId, preTokenGenerationArn) {
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 Stdlib_JsError.throwWithMessage(`DescribeUserPool returned no pool for "` + userPoolId + `"; refusing to send an UpdateUserPool that would reset it`);
317
+ return throwOnEmptyDescribe(userPoolId);
239
318
  }
240
- let input = mergedUpdateInput(described, userPoolId, preTokenGenerationArn);
241
- return await client.send(newOf1(sdk.UpdateUserPoolCommand, input));
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 verifyTrigger(inputs.userPoolId, inputs.preTokenGenerationArn);
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 verifyTrigger(news.userPoolId, news.preTokenGenerationArn);
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
- return await applyTrigger(props.userPoolId, undefined);
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
- applyTrigger,
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 ? { sub: id.sub, username: id.username ?? null } : 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: `id` is the caller's Cognito `sub`, `activeRole` the
70
- group they chose, `updatedAt` an operational breadcrumb. No sort key — a caller
71
- acts as exactly one role at a time, so the row is the whole state. */
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=[{name: "id", type_: "S"}],
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 ? { sub: id.sub, username: id.username ?? null } : 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
- name: "id",
60
+ let table = Util_DynamoDb$ReventlessAws.makeTable([
61
+ {
62
+ name: Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
40
63
  type: "S"
41
- }], undefined, undefined, undefined, AWS_Tags$ReventlessAws.make(name + "Table", "Platform", "Auth", "Platform", undefined, undefined, undefined, undefined), opts$1, name);
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 = {sub?: string, username?: Nullable.t<string>}
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
- let _ = await DynamoDb_DocumentClient.deleteById(~tableName=table, ~id=sub)
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
- ("id", JSON.Encode.string(sub)),
174
- ("activeRole", JSON.Encode.string(role)),
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
- "id",
131
+ Auth_ActiveRoleStore_Schema$ReventlessAws.partitionKey,
116
132
  sub
117
133
  ],
118
134
  [
119
- "activeRole",
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.deleteById(table, sub);
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
  }