@reventlessdev/reventless-aws 3.0.0-alpha.395 → 3.0.0-alpha.396

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 (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +7 -7
  3. package/scripts/DeployApp.res +7 -1
  4. package/scripts/DeployApp.res.mjs +1 -1
  5. package/scripts/ProvisionAccounts.res +89 -4
  6. package/scripts/ProvisionAccounts.res.mjs +101 -5
  7. package/scripts/ProvisionIdentity.res +42 -18
  8. package/scripts/ProvisionIdentity.res.mjs +40 -26
  9. package/src/Platform.res +47 -10
  10. package/src/Platform.res.mjs +44 -10
  11. package/src/Platform_Stack.res +17 -0
  12. package/src/Platform_Stack.res.mjs +7 -1
  13. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +16 -7
  14. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +16 -3
  15. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +59 -9
  16. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +53 -6
  17. package/src/adapter/Auth/Auth_Cognito.res +13 -2
  18. package/src/adapter/Auth/Auth_Cognito.res.mjs +9 -2
  19. package/src/adapter/Auth/Auth_IdentityStore.res +52 -0
  20. package/src/adapter/Auth/Auth_IdentityStore.res.mjs +46 -0
  21. package/src/adapter/Auth/Auth_IdentityStore_Schema.res +39 -0
  22. package/src/adapter/Auth/Auth_IdentityStore_Schema.res.mjs +35 -0
  23. package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res +251 -0
  24. package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res.mjs +300 -0
  25. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +7 -4
  26. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +2 -2
  27. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +80 -0
  28. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +53 -2
  29. package/src/components/AutomationSlice_Builder.res.mjs +1 -1
  30. package/src/components/OutboundTranslationSlice_Builder.res +2 -0
  31. package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
  32. package/src/plugin/runtime/PluginRuntime_Builder.res +23 -0
  33. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +29 -0
  34. package/tests/Auth_ActiveRoleTrigger_OpsTest.res +40 -0
  35. package/tests/Auth_ActiveRoleTrigger_OpsTest.res.mjs +59 -8
  36. package/tests/Auth_CognitoTest.res +62 -0
  37. package/tests/Auth_CognitoTest.res.mjs +98 -0
  38. package/tests/Auth_IdentityStore_SchemaTest.res +35 -0
  39. package/tests/Auth_IdentityStore_SchemaTest.res.mjs +43 -0
  40. package/tests/IdentityProvider_Cognito_BackendTest.res +314 -0
  41. package/tests/IdentityProvider_Cognito_BackendTest.res.mjs +442 -0
  42. package/tests/ProvisionAccountsTest.res.mjs +1 -1
@@ -1412,6 +1412,20 @@ function MakeWithConfig(Config) {
1412
1412
  PluginRuntime_Builder$ReventlessAws.registerMessagingSender(messagingEmailSender);
1413
1413
  let messagingEmailProvider = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("messagingEmailProvider").apply(o => Stdlib_Option.getOr(o, "")) : messagingEmailProviderRef.contents;
1414
1414
  PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("MESSAGING_EMAIL_PROVIDER", messagingEmailProvider);
1415
+ let platformOutput = (name, local) => {
1416
+ if (platformStackRef !== undefined) {
1417
+ return Primitive_option.valFromOption(platformStackRef).getOutput(name).apply(o => Stdlib_Option.getOr(o, ""));
1418
+ } else {
1419
+ return local();
1420
+ }
1421
+ };
1422
+ let identityProviderId = platformOutput("identityProviderId", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().poolId);
1423
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("IDENTITY_PROVIDER_ID", identityProviderId);
1424
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("IDENTITY_STORE_TABLE", platformOutput("identityStore", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().identityTable.name));
1425
+ PluginRuntime_Builder$ReventlessAws.registerIdentityProvider({
1426
+ poolArn: platformOutput("identityProviderArn", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().poolArn),
1427
+ storeArn: platformOutput("identityStoreArn", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().identityTable.arn)
1428
+ });
1415
1429
  let pluginComponent = plugin.make();
1416
1430
  Plugin_Helpers$ReventlessCore.clearOffload();
1417
1431
  currentDeployTarget.contents = "Domain";
@@ -1469,15 +1483,18 @@ function MakeWithConfig(Config) {
1469
1483
  pluginOutputs.pluginStructure,
1470
1484
  stackRef.getOutput("objectStores"),
1471
1485
  geocoderPlaceIndex,
1472
- messagingEmailSender
1486
+ messagingEmailSender,
1487
+ identityProviderId
1473
1488
  ]).apply(param => {
1474
1489
  let structure = param[0];
1475
1490
  let missing = CapabilityNeed$Reventless.unmet(Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredCapabilities), []).map(d => [
1476
1491
  d.capability,
1477
1492
  d.component
1478
- ]), (
1479
- param[2] === "" ? [] : ["Geocoding"]
1480
- ).concat(param[3] === "" ? [] : ["Messaging"]));
1493
+ ]), [
1494
+ param[2] === "" ? [] : ["Geocoding"],
1495
+ param[3] === "" ? [] : ["Messaging"],
1496
+ param[4] === "" ? [] : ["IdentityProvider"]
1497
+ ].flat());
1481
1498
  if (missing.length !== 0) {
1482
1499
  Stdlib_JsError.throwWithMessage(CapabilityNeed$Reventless.unmetMessage(missing) + Belt_SetString.toArray(Belt_SetString.fromArray(missing.map(u => {
1483
1500
  let match = u.need;
@@ -1487,7 +1504,7 @@ function MakeWithConfig(Config) {
1487
1504
  case "Messaging" :
1488
1505
  return `\n Messaging is \`Capability_Messaging.make\`, passed to the platform as \`~messagingSender\`. The sender itself is configuration and has no default: set \`platform:messagingEmailSender\` in Pulumi.<stack>.yaml (or REVENTLESS_MESSAGING_EMAIL_SENDER), then verify the address with SES — or \`platform:messagingEmailProvider: log\` to log every message and send none.`;
1489
1506
  case "IdentityProvider" :
1490
- return `\n IdentityProvider has no backend on any platform yet — the seam and the refusal exist, the Cognito and local implementations do not. A plugin declaring this need cannot be deployed until one lands; nothing about this deployment's configuration will change that.`;
1507
+ return `\n IdentityProvider is the platform's Cognito pool, exported as \`identityProviderId\`. This platform stack exports none — deploy it first.`;
1491
1508
  }
1492
1509
  }))).join(""));
1493
1510
  }
@@ -2804,6 +2821,20 @@ function Make($star) {
2804
2821
  PluginRuntime_Builder$ReventlessAws.registerMessagingSender(messagingEmailSender);
2805
2822
  let messagingEmailProvider = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("messagingEmailProvider").apply(o => Stdlib_Option.getOr(o, "")) : messagingEmailProviderRef.contents;
2806
2823
  PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("MESSAGING_EMAIL_PROVIDER", messagingEmailProvider);
2824
+ let platformOutput = (name, local) => {
2825
+ if (platformStackRef !== undefined) {
2826
+ return Primitive_option.valFromOption(platformStackRef).getOutput(name).apply(o => Stdlib_Option.getOr(o, ""));
2827
+ } else {
2828
+ return local();
2829
+ }
2830
+ };
2831
+ let identityProviderId = platformOutput("identityProviderId", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().poolId);
2832
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("IDENTITY_PROVIDER_ID", identityProviderId);
2833
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("IDENTITY_STORE_TABLE", platformOutput("identityStore", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().identityTable.name));
2834
+ PluginRuntime_Builder$ReventlessAws.registerIdentityProvider({
2835
+ poolArn: platformOutput("identityProviderArn", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().poolArn),
2836
+ storeArn: platformOutput("identityStoreArn", () => Platform_Stack$ReventlessAws.resolveCognitoUserPool().identityTable.arn)
2837
+ });
2807
2838
  let pluginComponent = plugin.make();
2808
2839
  Plugin_Helpers$ReventlessCore.clearOffload();
2809
2840
  currentDeployTarget.contents = "Domain";
@@ -2861,15 +2892,18 @@ function Make($star) {
2861
2892
  pluginOutputs.pluginStructure,
2862
2893
  stackRef.getOutput("objectStores"),
2863
2894
  geocoderPlaceIndex,
2864
- messagingEmailSender
2895
+ messagingEmailSender,
2896
+ identityProviderId
2865
2897
  ]).apply(param => {
2866
2898
  let structure = param[0];
2867
2899
  let missing = CapabilityNeed$Reventless.unmet(Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredCapabilities), []).map(d => [
2868
2900
  d.capability,
2869
2901
  d.component
2870
- ]), (
2871
- param[2] === "" ? [] : ["Geocoding"]
2872
- ).concat(param[3] === "" ? [] : ["Messaging"]));
2902
+ ]), [
2903
+ param[2] === "" ? [] : ["Geocoding"],
2904
+ param[3] === "" ? [] : ["Messaging"],
2905
+ param[4] === "" ? [] : ["IdentityProvider"]
2906
+ ].flat());
2873
2907
  if (missing.length !== 0) {
2874
2908
  Stdlib_JsError.throwWithMessage(CapabilityNeed$Reventless.unmetMessage(missing) + Belt_SetString.toArray(Belt_SetString.fromArray(missing.map(u => {
2875
2909
  let match = u.need;
@@ -2879,7 +2913,7 @@ function Make($star) {
2879
2913
  case "Messaging" :
2880
2914
  return `\n Messaging is \`Capability_Messaging.make\`, passed to the platform as \`~messagingSender\`. The sender itself is configuration and has no default: set \`platform:messagingEmailSender\` in Pulumi.<stack>.yaml (or REVENTLESS_MESSAGING_EMAIL_SENDER), then verify the address with SES — or \`platform:messagingEmailProvider: log\` to log every message and send none.`;
2881
2915
  case "IdentityProvider" :
2882
- return `\n IdentityProvider has no backend on any platform yet — the seam and the refusal exist, the Cognito and local implementations do not. A plugin declaring this need cannot be deployed until one lands; nothing about this deployment's configuration will change that.`;
2916
+ return `\n IdentityProvider is the platform's Cognito pool, exported as \`identityProviderId\`. This platform stack exports none — deploy it first.`;
2883
2917
  }
2884
2918
  }))).join(""));
2885
2919
  }
@@ -12,6 +12,9 @@ type cognitoUserPool = {
12
12
  is declared with the trigger's ARN), while the mutation that writes it is
13
13
  provisioned after the API — see [Auth_ActiveRoleStore.res]. */
14
14
  activeRoleTable: Auth_ActiveRoleStore.storeTable,
15
+ /** Rows mapping each subject to the domain's own user id, read by the same
16
+ trigger and written when an account is created — see [Auth_IdentityStore.res]. */
17
+ identityTable: Auth_IdentityStore.storeTable,
15
18
  /** The sign-in attribute this stack created the pool with. `None` in BYO mode:
16
19
  the pool's own choice was made elsewhere and no lookup here reads it back,
17
20
  so reporting one would be a guess. */
@@ -179,9 +182,16 @@ let _resolveUncached = (): cognitoUserPool => {
179
182
  ~choice=Auth_ActiveRoleStore.chooseStore(~identityProviderId=existingPoolId),
180
183
  ~opts={},
181
184
  )
185
+ // Same scope rule, same reason: the trigger that reads it is the pool's.
186
+ let identityTable = Auth_IdentityStore.resolveTable(
187
+ ~choice=Auth_IdentityStore.chooseStore(~identityProviderId=existingPoolId),
188
+ ~opts={},
189
+ )
182
190
  let activeRoleTrigger = Auth_ActiveRoleTrigger.make(
183
191
  ~activeRoleTableName=activeRoleTable.name->Pulumi.Output.asInput,
184
192
  ~activeRoleTableArn=activeRoleTable.arn->Pulumi.Output.asInput,
193
+ ~identityTableName=identityTable.name->Pulumi.Output.asInput,
194
+ ~identityTableArn=identityTable.arn->Pulumi.Output.asInput,
185
195
  ~opts={},
186
196
  )
187
197
 
@@ -239,6 +249,7 @@ let _resolveUncached = (): cognitoUserPool => {
239
249
  poolArn: lookup->Pulumi.Output.apply(r => r.arn),
240
250
  managed: false,
241
251
  activeRoleTable,
252
+ identityTable,
242
253
  loginIdentifier: None,
243
254
  }
244
255
 
@@ -334,6 +345,7 @@ let _resolveUncached = (): cognitoUserPool => {
334
345
  poolArn: pool.arn,
335
346
  managed: true,
336
347
  activeRoleTable,
348
+ identityTable,
337
349
  loginIdentifier: Some(loginIdentifier),
338
350
  }
339
351
  }
@@ -409,6 +421,11 @@ let _resolveUncached = (): cognitoUserPool => {
409
421
  // the name is derived, not configured, so this is a convenience for checking
410
422
  // rather than a value anything is meant to copy into a config.
411
423
  Pulumi.Pulumi.export("activeRoleStore", result.activeRoleTable.name)
424
+ // Read by `provision-accounts` on a stack-owned pool, whose table carries a
425
+ // generated suffix no derivation can produce.
426
+ Pulumi.Pulumi.export("identityStore", result.identityTable.name)
427
+ // What a plugin stack grants its principal-making slices write access to.
428
+ Pulumi.Pulumi.export("identityStoreArn", result.identityTable.arn)
412
429
 
413
430
  result
414
431
  }
@@ -10,6 +10,7 @@ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/
10
10
  import * as AWS_Tags$ReventlessAws from "./adapter/AWS_Tags.res.mjs";
11
11
  import * as Auth_SignUpMode$ReventlessAws from "./adapter/Auth/Auth_SignUpMode.res.mjs";
12
12
  import * as Util_LocalConfig$ReventlessAws from "./util/Util_LocalConfig.res.mjs";
13
+ import * as Auth_IdentityStore$ReventlessAws from "./adapter/Auth/Auth_IdentityStore.res.mjs";
13
14
  import * as Auth_ActiveRoleStore$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleStore.res.mjs";
14
15
  import * as Auth_LoginIdentifier$ReventlessAws from "./adapter/Auth/Auth_LoginIdentifier.res.mjs";
15
16
  import * as Util_CognitoGroupUser$ReventlessAws from "./util/Util_CognitoGroupUser.res.mjs";
@@ -66,7 +67,8 @@ function _resolveUncached() {
66
67
  let cfg = new Pulumi.Config("platform");
67
68
  let existingPoolId = _identityProviderId(cfg);
68
69
  let activeRoleTable = Auth_ActiveRoleStore$ReventlessAws.resolveTable(Auth_ActiveRoleStore$ReventlessAws.chooseStore(existingPoolId), {});
69
- let activeRoleTrigger = Auth_ActiveRoleTrigger$ReventlessAws.make(activeRoleTable.name, activeRoleTable.arn, undefined, {});
70
+ let identityTable = Auth_IdentityStore$ReventlessAws.resolveTable(Auth_IdentityStore$ReventlessAws.chooseStore(existingPoolId), {});
71
+ let activeRoleTrigger = Auth_ActiveRoleTrigger$ReventlessAws.make(activeRoleTable.name, activeRoleTable.arn, identityTable.name, identityTable.arn, undefined, {});
70
72
  let result;
71
73
  if (existingPoolId !== undefined) {
72
74
  let tokenUnits_accessToken = "minutes";
@@ -105,6 +107,7 @@ function _resolveUncached() {
105
107
  poolArn: lookup.apply(r => r.arn),
106
108
  managed: false,
107
109
  activeRoleTable: activeRoleTable,
110
+ identityTable: identityTable,
108
111
  loginIdentifier: undefined
109
112
  };
110
113
  } else {
@@ -161,6 +164,7 @@ function _resolveUncached() {
161
164
  poolArn: pool.arn,
162
165
  managed: true,
163
166
  activeRoleTable: activeRoleTable,
167
+ identityTable: identityTable,
164
168
  loginIdentifier: loginIdentifier
165
169
  };
166
170
  }
@@ -183,6 +187,8 @@ function _resolveUncached() {
183
187
  Pulumi$Pulumi.$$export("cognitoUserPoolManaged", managedStr);
184
188
  Pulumi$Pulumi.$$export("identityProviderAdminGroup", Pulumi.output(Role$Reventless.adminGroup()));
185
189
  Pulumi$Pulumi.$$export("activeRoleStore", result.activeRoleTable.name);
190
+ Pulumi$Pulumi.$$export("identityStore", result.identityTable.name);
191
+ Pulumi$Pulumi.$$export("identityStoreArn", result.identityTable.arn);
186
192
  return result;
187
193
  }
188
194
 
@@ -30,6 +30,8 @@ type triggerOutputs = {
30
30
  let make = (
31
31
  ~activeRoleTableName: Pulumi.Input.t<string>,
32
32
  ~activeRoleTableArn: Pulumi.Input.t<string>,
33
+ ~identityTableName: Pulumi.Input.t<string>,
34
+ ~identityTableArn: Pulumi.Input.t<string>,
33
35
  ~name: string="ActiveRoleTrigger",
34
36
  ~opts: Pulumi.ComponentResource.options,
35
37
  ): triggerOutputs => {
@@ -47,14 +49,14 @@ let make = (
47
49
  ~opts,
48
50
  )
49
51
 
50
- // Logs and a single-item read. Nothing else: the trigger needs no Cognito
51
- // permission at all, because the membership it checks against arrives in the
52
- // event as `request.groupConfiguration.groupsToOverride`. `GetItem` alone —
53
- // this function must never be able to write the preference it reads.
52
+ // Logs and single-item reads of the two stores. Nothing else: the trigger
53
+ // needs no Cognito permission at all, because the membership it checks
54
+ // against arrives in the event as `request.groupConfiguration.groupsToOverride`.
55
+ // `GetItem` alone — this function must never be able to write what it reads.
54
56
  let _policy =
55
- activeRoleTableArn
56
- ->Pulumi.Output.fromInput
57
- ->Pulumi.Output.apply(tableArn => {
57
+ (activeRoleTableArn->Pulumi.Output.fromInput, identityTableArn->Pulumi.Output.fromInput)
58
+ ->Pulumi.Output.all2
59
+ ->Pulumi.Output.apply(((tableArn, identityArn)) => {
58
60
  let _ = IAM.RolePolicy.make(
59
61
  ~name=name ++ "Policy",
60
62
  ~args={
@@ -77,6 +79,12 @@ let make = (
77
79
  actions: Action("dynamodb:GetItem"),
78
80
  resources: Resource(tableArn),
79
81
  },
82
+ {
83
+ sid: "AllowReadingDomainId",
84
+ effect: Allow,
85
+ actions: Action("dynamodb:GetItem"),
86
+ resources: Resource(identityArn),
87
+ },
80
88
  ],
81
89
  )
82
90
  ->PolicyDocument.toJsonString
@@ -140,6 +148,7 @@ let make = (
140
148
  Lambda.Function.variables: Dict.fromArray([
141
149
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
142
150
  ("ACTIVE_ROLE_TABLE", activeRoleTableName),
151
+ ("IDENTITY_STORE_TABLE", identityTableName),
143
152
  ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
144
153
  ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
145
154
  Util_LambdaLogging.logLevelEntry(),
@@ -11,11 +11,14 @@ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
11
11
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
12
12
  import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
13
13
 
14
- function make(activeRoleTableName, activeRoleTableArn, nameOpt, opts) {
14
+ function make(activeRoleTableName, activeRoleTableArn, identityTableName, identityTableArn, nameOpt, opts) {
15
15
  let name = nameOpt !== undefined ? nameOpt : "ActiveRoleTrigger";
16
16
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
17
17
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
18
- activeRoleTableArn.apply(tableArn => {
18
+ Pulumi.all([
19
+ activeRoleTableArn,
20
+ identityTableArn
21
+ ]).apply(param => {
19
22
  new (Aws.iam.RolePolicy)(name + "Policy", {
20
23
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "Policy", [
21
24
  {
@@ -32,7 +35,13 @@ function make(activeRoleTableName, activeRoleTableArn, nameOpt, opts) {
32
35
  Sid: "AllowReadingStoredRole",
33
36
  Effect: "Allow",
34
37
  Action: "dynamodb:GetItem",
35
- Resource: tableArn
38
+ Resource: param[0]
39
+ },
40
+ {
41
+ Sid: "AllowReadingDomainId",
42
+ Effect: "Allow",
43
+ Action: "dynamodb:GetItem",
44
+ Resource: param[1]
36
45
  }
37
46
  ])),
38
47
  role: lambdaRole.id
@@ -65,6 +74,10 @@ function make(activeRoleTableName, activeRoleTableArn, nameOpt, opts) {
65
74
  "ACTIVE_ROLE_TABLE",
66
75
  activeRoleTableName
67
76
  ],
77
+ [
78
+ "IDENTITY_STORE_TABLE",
79
+ identityTableName
80
+ ],
68
81
  [
69
82
  "NODE_OPTIONS",
70
83
  Util_Bundle$ReventlessAws.esmLoaderNodeOptions
@@ -41,6 +41,11 @@ let tableName = (): string =>
41
41
  | None => JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured")
42
42
  }
43
43
 
44
+ /** The identity store, read for the domain id claim. Optional where the role
45
+ store is not: a trigger deployed before the store existed must keep minting
46
+ tokens, and an absent store means exactly what an absent row means. */
47
+ let identityTableName = (): option<string> => getEnv("IDENTITY_STORE_TABLE")
48
+
44
49
  // ── Cognito pre-token-generation event (V1_0) ───────────────────────────────
45
50
 
46
51
  type userAttributes = {sub?: string}
@@ -124,16 +129,24 @@ exactly one thing.
124
129
  `claimsOverrideDetails` is not the same as none, and the regression line for this
125
130
  whole feature is that a caller with no stored role gets byte-identical output.
126
131
  */
127
- let respond = (~event: event, ~decision: decision): event => {
132
+ let respond = (~event: event, ~decision: decision, ~userId: option<string>=?): event => {
128
133
  let incoming = event.request->Option.flatMap(r => r.groupConfiguration)
129
134
  let iamRolesToOverride = incoming->Option.flatMap(g => g.iamRolesToOverride)->Option.getOr([])
130
135
  let preferredRole = incoming->Option.flatMap(g => g.preferredRole)
131
136
 
137
+ // The domain id rides on whatever else the mint adds. On its own it adds only
138
+ // the claim and leaves the group configuration alone, so a caller with a
139
+ // domain id and no stored role keeps exactly the groups the pool granted.
140
+ let withUserId = (claims: dict<string>) => {
141
+ userId->Option.forEach(id => claims->Dict.set(ReventlessCore.Auth_UserId.claim, id))
142
+ claims
143
+ }
144
+
132
145
  let overrideWith = (~groups: array<string>, ~claims: dict<string>) => {
133
146
  ...event,
134
147
  response: {
135
148
  claimsOverrideDetails: {
136
- claimsToAddOrOverride: claims,
149
+ claimsToAddOrOverride: withUserId(claims),
137
150
  groupOverrideDetails: {
138
151
  groupsToOverride: groups,
139
152
  iamRolesToOverride,
@@ -143,9 +156,13 @@ let respond = (~event: event, ~decision: decision): event => {
143
156
  },
144
157
  }
145
158
 
146
- switch decision {
147
- | Unchanged => event
148
- | Narrow({role, membership}) =>
159
+ switch (decision, userId) {
160
+ | (Unchanged, None) => event
161
+ | (Unchanged, Some(_)) => {
162
+ ...event,
163
+ response: {claimsOverrideDetails: {claimsToAddOrOverride: withUserId(Dict.make())}},
164
+ }
165
+ | (Narrow({role, membership}), _) =>
149
166
  overrideWith(
150
167
  ~groups=[role],
151
168
  ~claims=Dict.fromArray([
@@ -153,7 +170,7 @@ let respond = (~event: event, ~decision: decision): event => {
153
170
  (ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
154
171
  ]),
155
172
  )
156
- | Stale({role, membership}) =>
173
+ | (Stale({role, membership}), _) =>
157
174
  // The full set, as the pool granted it — plus the marker that explains why the
158
175
  // stored choice did not apply.
159
176
  overrideWith(
@@ -194,6 +211,33 @@ let storedRoleFor = async (~sub: string, ~clientId: string, ~table: string): opt
194
211
  | _ => None
195
212
  }
196
213
 
214
+ /**
215
+ The domain id minted for this subject, or `None` for an account created before
216
+ the store existed — whose id is its `sub`, and must stay so: every row it has
217
+ written is keyed by it.
218
+
219
+ Fails to `None` for the reason [storedRoleFor] does: a claim this trigger could
220
+ not read is a login that must still succeed, and a caller without the claim
221
+ lands on the fallback every older account already uses.
222
+ */
223
+ let userIdFor = async (~sub: string, ~table: option<string>): option<string> =>
224
+ switch table {
225
+ | None => None
226
+ | Some(table) =>
227
+ try {
228
+ let out = await DynamoDb_DocumentClient.GetCommand.make({
229
+ tableName: table,
230
+ key: Dict.fromArray([(Auth_IdentityStore_Schema.partitionKey, JSON.Encode.string(sub))]),
231
+ })->DynamoDb_DocumentClient.GetCommand.send
232
+ out.item
233
+ ->Option.flatMap(JSON.Decode.object)
234
+ ->Option.flatMap(o => o->Dict.get(Auth_IdentityStore_Schema.userIdAttribute))
235
+ ->Option.flatMap(JSON.Decode.string)
236
+ } catch {
237
+ | _ => None
238
+ }
239
+ }
240
+
197
241
  // ── Handler ─────────────────────────────────────────────────────────────────
198
242
 
199
243
  let handler = async (event: event): event => {
@@ -216,10 +260,16 @@ let handler = async (event: event): event => {
216
260
  // granted — the safe direction, and the same one a read failure takes. An
217
261
  // absent client id is not worth failing a login over: the caller lands on their
218
262
  // full membership, which is their existing privileges and not more.
219
- if sub == "" || clientId == "" {
263
+ if sub == "" {
220
264
  event
265
+ } else if clientId == "" {
266
+ respond(~event, ~decision=Unchanged, ~userId=?await userIdFor(~sub, ~table=identityTableName()))
221
267
  } else {
222
- let storedRole = await storedRoleFor(~sub, ~clientId, ~table=tableName())
223
- respond(~event, ~decision=decide(~membership, ~storedRole))
268
+ // Both reads at once: this function is a login's latency.
269
+ let (storedRole, userId) = await Promise.all2((
270
+ storedRoleFor(~sub, ~clientId, ~table=tableName()),
271
+ userIdFor(~sub, ~table=identityTableName()),
272
+ ))
273
+ respond(~event, ~decision=decide(~membership, ~storedRole), ~userId?)
224
274
  }
225
275
  }
@@ -4,8 +4,10 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
6
  import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
7
+ import * as Auth_UserId$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_UserId.res.mjs";
7
8
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
9
  import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
10
+ import * as Auth_IdentityStore_Schema$ReventlessAws from "./Auth_IdentityStore_Schema.res.mjs";
9
11
  import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
10
12
 
11
13
  function getEnv(k) {
@@ -24,6 +26,10 @@ function tableName() {
24
26
  }
25
27
  }
26
28
 
29
+ function identityTableName() {
30
+ return getEnv("IDENTITY_STORE_TABLE");
31
+ }
32
+
27
33
  function decide(membership, storedRole) {
28
34
  if (storedRole !== undefined && storedRole !== "") {
29
35
  if (membership.includes(storedRole)) {
@@ -44,15 +50,21 @@ function decide(membership, storedRole) {
44
50
  }
45
51
  }
46
52
 
47
- function respond(event, decision) {
53
+ function respond(event, decision, userId) {
48
54
  let incoming = Stdlib_Option.flatMap(event.request, r => r.groupConfiguration);
49
55
  let iamRolesToOverride = Stdlib_Option.getOr(Stdlib_Option.flatMap(incoming, g => g.iamRolesToOverride), []);
50
56
  let preferredRole = Stdlib_Option.flatMap(incoming, g => g.preferredRole);
57
+ let withUserId = claims => {
58
+ Stdlib_Option.forEach(userId, id => {
59
+ claims[Auth_UserId$ReventlessCore.claim] = id;
60
+ });
61
+ return claims;
62
+ };
51
63
  let overrideWith = (groups, claims) => {
52
64
  let newrecord = {...event};
53
65
  newrecord.response = {
54
66
  claimsOverrideDetails: {
55
- claimsToAddOrOverride: claims,
67
+ claimsToAddOrOverride: withUserId(claims),
56
68
  groupOverrideDetails: {
57
69
  groupsToOverride: groups,
58
70
  iamRolesToOverride: iamRolesToOverride,
@@ -63,7 +75,16 @@ function respond(event, decision) {
63
75
  return newrecord;
64
76
  };
65
77
  if (typeof decision !== "object") {
66
- return event;
78
+ if (userId === undefined) {
79
+ return event;
80
+ }
81
+ let newrecord = {...event};
82
+ newrecord.response = {
83
+ claimsOverrideDetails: {
84
+ claimsToAddOrOverride: withUserId({})
85
+ }
86
+ };
87
+ return newrecord;
67
88
  }
68
89
  if (decision.TAG === "Narrow") {
69
90
  let role = decision.role;
@@ -112,23 +133,49 @@ async function storedRoleFor(sub, clientId, table) {
112
133
  }
113
134
  }
114
135
 
136
+ async function userIdFor(sub, table) {
137
+ if (table === undefined) {
138
+ return;
139
+ }
140
+ try {
141
+ let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
142
+ TableName: table,
143
+ Key: Object.fromEntries([[
144
+ Auth_IdentityStore_Schema$ReventlessAws.partitionKey,
145
+ sub
146
+ ]])
147
+ }));
148
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o[Auth_IdentityStore_Schema$ReventlessAws.userIdAttribute]), Stdlib_JSON.Decode.string);
149
+ } catch (exn) {
150
+ return;
151
+ }
152
+ }
153
+
115
154
  async function handler(event) {
116
155
  let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
117
156
  let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
118
157
  let clientId = Stdlib_Option.getOr(Stdlib_Option.flatMap(event.callerContext, c => c.clientId), "");
119
- if (sub === "" || clientId === "") {
158
+ if (sub === "") {
120
159
  return event;
121
160
  }
122
- let storedRole = await storedRoleFor(sub, clientId, tableName());
123
- return respond(event, decide(membership, storedRole));
161
+ if (clientId === "") {
162
+ return respond(event, "Unchanged", await userIdFor(sub, getEnv("IDENTITY_STORE_TABLE")));
163
+ }
164
+ let match = await Promise.all([
165
+ storedRoleFor(sub, clientId, tableName()),
166
+ userIdFor(sub, getEnv("IDENTITY_STORE_TABLE"))
167
+ ]);
168
+ return respond(event, decide(membership, match[0]), match[1]);
124
169
  }
125
170
 
126
171
  export {
127
172
  getEnv,
128
173
  tableName,
174
+ identityTableName,
129
175
  decide,
130
176
  respond,
131
177
  storedRoleFor,
178
+ userIdFor,
132
179
  handler,
133
180
  }
134
181
  /* @aws-sdk/lib-dynamodb Not a pure module */
@@ -58,13 +58,24 @@ let _getHeader = (headers: dict<string>, name: string): option<string> =>
58
58
  | None => headers->Dict.get(name)
59
59
  }
60
60
 
61
+ // The domain id when the token carries one, else the `sub`. The fallback is
62
+ // what every account created before the identity store existed relies on: its
63
+ // rows are keyed by the `sub`, and a different answer here would orphan them.
64
+ let _userIdOf = (~claims: dict<JSON.t>, ~sub: string): string =>
65
+ claims
66
+ ->Dict.get(ReventlessCore.Auth_UserId.claim)
67
+ ->Option.flatMap(JSON.Decode.string)
68
+ ->Option.filter(id => id != "")
69
+ ->Option.getOr(sub)
70
+
61
71
  let _identityFromClaims = (claims: 'a): Identity.t => {
62
72
  let claimsDict: dict<JSON.t> = claims->Obj.magic
63
- let userId =
73
+ let sub =
64
74
  claimsDict
65
75
  ->Dict.get("sub")
66
76
  ->Option.flatMap(JSON.Decode.string)
67
77
  ->Option.getOr("anonymous")
78
+ let userId = _userIdOf(~claims=claimsDict, ~sub)
68
79
  let username =
69
80
  claimsDict
70
81
  ->Dict.get("cognito:username")
@@ -154,7 +165,7 @@ let fromAppSyncIdentity = (id: option<appSyncIdentity>): Identity.authResult =>
154
165
  ->Array.map(((k, v)) => (k, v->JSON.Decode.string->Option.getOr(v->JSON.stringify)))
155
166
  ->Dict.fromArray
156
167
  Authenticated({
157
- userId: sub,
168
+ userId: _userIdOf(~claims=rawClaims, ~sub),
158
169
  username,
159
170
  groups,
160
171
  claims,
@@ -4,6 +4,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Pulumi from "@pulumi/pulumi";
6
6
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
+ import * as Auth_UserId$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_UserId.res.mjs";
7
8
  import * as Platform_Stack$ReventlessAws from "../../Platform_Stack.res.mjs";
8
9
 
9
10
  let _decodeJwtClaims = (function(token) {
@@ -31,8 +32,13 @@ function _getHeader(headers, name) {
31
32
  }
32
33
  }
33
34
 
35
+ function _userIdOf(claims, sub) {
36
+ return Stdlib_Option.getOr(Stdlib_Option.filter(Stdlib_Option.flatMap(claims[Auth_UserId$ReventlessCore.claim], Stdlib_JSON.Decode.string), id => id !== ""), sub);
37
+ }
38
+
34
39
  function _identityFromClaims(claims) {
35
- let userId = Stdlib_Option.getOr(Stdlib_Option.flatMap(claims["sub"], Stdlib_JSON.Decode.string), "anonymous");
40
+ let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(claims["sub"], Stdlib_JSON.Decode.string), "anonymous");
41
+ let userId = _userIdOf(claims, sub);
36
42
  let username = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.orElse(claims["cognito:username"], claims["username"]), Stdlib_JSON.Decode.string), userId);
37
43
  let groups = Stdlib_Option.getOr(Stdlib_Option.map(claims["cognito:groups"], g => g), []);
38
44
  return {
@@ -81,7 +87,7 @@ function fromAppSyncIdentity(id) {
81
87
  return {
82
88
  TAG: "Authenticated",
83
89
  _0: {
84
- userId: sub,
90
+ userId: _userIdOf(rawClaims, sub),
85
91
  username: username,
86
92
  groups: groups,
87
93
  claims: claims,
@@ -127,6 +133,7 @@ export {
127
133
  _decodeJwtClaims,
128
134
  _bearerToken,
129
135
  _getHeader,
136
+ _userIdOf,
130
137
  _identityFromClaims,
131
138
  authenticate,
132
139
  fromAppSyncIdentity,
@@ -0,0 +1,52 @@
1
+ // The rows mapping a Cognito `sub` to the domain's own user id, and where they
2
+ // live. Read by the pre-token-generation trigger on every mint, written only
3
+ // when an account is created (`provision-accounts`, and the Cognito identity
4
+ // provider when it exists).
5
+ //
6
+ // 🚨 **The rows follow the identity provider, not the stack**, exactly as the
7
+ // active-role store's do and for the same reason: a pool holds one pre-token
8
+ // trigger, so two stacks sharing a pool with a table each would have the winning
9
+ // trigger reading rows the other stack wrote nowhere near. A stack that owns its
10
+ // pool owns this table; a stack handed an `identityProviderId` looks it up at
11
+ // [Auth_IdentityStore_Schema.derivedStoreName] and never creates it.
12
+
13
+ type storeTable = Auth_ActiveRoleStore.storeTable
14
+
15
+ /** Which store a deployment's trigger reads. Total and pure, like the active-role
16
+ store's: whether the provider is ours decides it, and nothing else. */
17
+ let chooseStore = (~identityProviderId: option<string>): Auth_ActiveRoleStore.storeChoice =>
18
+ switch identityProviderId {
19
+ | None => StackScoped
20
+ | Some(id) => ProviderScoped(Auth_IdentityStore_Schema.derivedStoreName(~identityProviderId=id))
21
+ }
22
+
23
+ /** One row per subject — the key schema is [Auth_IdentityStore_Schema]'s — plus
24
+ `userId`, the domain id minted for it. */
25
+ let makeTable = (
26
+ ~name: string="IdentityStore",
27
+ ~opts: Pulumi.ComponentResource.options,
28
+ ): storeTable => {
29
+ let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
30
+ let table = Util_DynamoDb.makeTable(
31
+ ~attributes=[{name: Auth_IdentityStore_Schema.partitionKey, type_: "S"}],
32
+ ~tags=AWS.Tags.make(
33
+ ~name=name ++ "Table",
34
+ ~kind=ReventlessCore.ComponentType.Platform,
35
+ ~role=Auth,
36
+ ~scope=Platform,
37
+ ),
38
+ ~opts,
39
+ name,
40
+ )
41
+ {name: table.name, arn: table.arn}
42
+ }
43
+
44
+ /** The store a resolved provider calls for — created or looked up. */
45
+ let resolveTable = (
46
+ ~choice: Auth_ActiveRoleStore.storeChoice,
47
+ ~opts: Pulumi.ComponentResource.options,
48
+ ): storeTable =>
49
+ switch choice {
50
+ | StackScoped => makeTable(~opts)
51
+ | ProviderScoped(tableName) => Auth_ActiveRoleStore.lookupTable(~tableName)
52
+ }