@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
@@ -13,7 +13,6 @@ import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/compon
13
13
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
14
14
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
15
15
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
16
- import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
17
16
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
18
17
  import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.mjs";
19
18
  import * as Messaging_Ses_Backend$ReventlessAws from "../Messaging/Messaging_Ses_Backend.res.mjs";
@@ -26,6 +25,7 @@ import * as StreamRoutedEntryPoint_Ops$ReventlessAws from "./StreamRoutedEntryPo
26
25
  import * as Geocoder_AwsLocation_Backend$ReventlessAws from "../Geocoder/Geocoder_AwsLocation_Backend.res.mjs";
27
26
  import * as CommandTopicChannel_SQS_Runtime$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
28
27
  import * as QueryDbStorage_DynamoDb_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
28
+ import * as IdentityProvider_Cognito_Backend$ReventlessAws from "../Identity/IdentityProvider_Cognito_Backend.res.mjs";
29
29
  import * as OutboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
30
30
  import * as ScheduledPublisher_EventBridgeScheduler_Runtime$ReventlessAws from "../ScheduledPublisher/ScheduledPublisher_EventBridgeScheduler_Runtime.res.mjs";
31
31
 
@@ -275,7 +275,7 @@ function capabilities() {
275
275
  return {
276
276
  geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined),
277
277
  messaging: messagingEmailProvider(),
278
- identityProvider: Capabilities$Reventless.none.identityProvider,
278
+ identityProvider: IdentityProvider_Cognito_Backend$ReventlessAws.provider(Stdlib_Option.getOr(process.env["IDENTITY_PROVIDER_ID"], ""), Stdlib_Option.getOr(process.env["IDENTITY_STORE_TABLE"], "")),
279
279
  secrets: Secrets_Node_Backend$ReventlessCore.provider
280
280
  };
281
281
  }
@@ -54,6 +54,9 @@ type sliceInfo = {
54
54
  // The schedules store's table, empty for a slice that maps no `Clock`. Granted to
55
55
  // the Lambda with the to-do tables, and named in the slice's handler config.
56
56
  schedulesResources: array<ReventlessInfra.Adapter.resource>,
57
+ // The capabilities the slice's spec declares. Read by grants that must not be
58
+ // handed to every slice on the shared Lambda for one slice's sake.
59
+ capabilityNeeds: array<Reventless.CapabilityNeed.t>,
57
60
  }
58
61
 
59
62
  let bundledInfos: dict<sliceInfo> = Dict.make()
@@ -103,6 +106,7 @@ let registerAutomationSlice = (
103
106
  ~commandTargetName=?,
104
107
  ~schedule=?,
105
108
  ~schedulesResources=[],
109
+ ~capabilityNeeds=[],
106
110
  ) =>
107
111
  bundledInfos->Dict.set(
108
112
  name,
@@ -118,6 +122,7 @@ let registerAutomationSlice = (
118
122
  commandTargetName,
119
123
  schedule,
120
124
  schedulesResources,
125
+ capabilityNeeds,
121
126
  },
122
127
  )
123
128
 
@@ -334,6 +339,80 @@ let makeMessagingGrant = (~runtime: ReventlessCore.Runtime.environment<runtimePa
334
339
  )
335
340
  }
336
341
 
342
+ /** Whether any slice on the shared Lambda declares the identity provider. The
343
+ grant below is the one that can create and delete accounts, so it follows a
344
+ declaration rather than the pool merely existing — which on AWS it always
345
+ does. */
346
+ let declaresIdentityProvider = (infos: array<sliceInfo>): bool =>
347
+ infos->Array.some(info => info.capabilityNeeds->Array.includes(IdentityProvider))
348
+
349
+ /** The admin calls the Cognito provider makes, on the one pool. */
350
+ let identityProviderPoolActions = [
351
+ "cognito-idp:AdminCreateUser",
352
+ "cognito-idp:AdminGetUser",
353
+ "cognito-idp:AdminSetUserPassword",
354
+ "cognito-idp:AdminAddUserToGroup",
355
+ "cognito-idp:AdminRemoveUserFromGroup",
356
+ "cognito-idp:AdminDeleteUser",
357
+ ]
358
+
359
+ // Built like the two grants above: a plain bool decides, the ARNs are Outputs,
360
+ // and the RolePolicy is created at top level rather than inside an `apply`.
361
+ let makeIdentityProviderGrant = (
362
+ ~runtime: ReventlessCore.Runtime.environment<runtimeParts>,
363
+ ~opts,
364
+ ) =>
365
+ if (
366
+ PluginRuntime_Builder.identityProviderProvisioned() &&
367
+ declaresIdentityProvider(bundledInfos->Dict.valuesToArray)
368
+ ) {
369
+ let customOpts =
370
+ opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
371
+ let {
372
+ poolArn,
373
+ storeArn,
374
+ }: PluginRuntime_Builder.identityProviderResources = PluginRuntime_Builder.identityProvider()
375
+ let policyJson = Pulumi.Output.all2((poolArn, storeArn))->Pulumi.Output.apply(((
376
+ pool,
377
+ store,
378
+ )) => {
379
+ open PulumiAws.PolicyDocument
380
+ PulumiAws.PolicyDocument.make(
381
+ ~id="AllAutomationSlicesIdentityProvider",
382
+ ~statements=Array.concat(
383
+ [
384
+ {
385
+ sid: "AllowManagingPrincipals",
386
+ effect: Allow,
387
+ actions: Actions(identityProviderPoolActions),
388
+ resources: Resource(pool),
389
+ },
390
+ ],
391
+ // A pool provisioned before the store has none, and its principals
392
+ // keep their `sub`; there is nothing to grant.
393
+ store == ""
394
+ ? []
395
+ : [
396
+ {
397
+ sid: "AllowRecordingDomainIds",
398
+ effect: Allow,
399
+ actions: Actions(["dynamodb:PutItem", "dynamodb:DeleteItem"]),
400
+ resources: Resource(store),
401
+ },
402
+ ],
403
+ ),
404
+ )->PulumiAws.PolicyDocument.toJsonString
405
+ })
406
+ let _ = PulumiAws.IAM.RolePolicy.make(
407
+ ~name="AllAutomationSlicesIdentityProvider",
408
+ ~args={
409
+ policy: policyJson->Pulumi.Output.asInput,
410
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
411
+ },
412
+ ~opts=customOpts,
413
+ )
414
+ }
415
+
337
416
  // The automations' schedules. The Lambda may manage the schedules in the stack's
338
417
  // scheduler group and pass the scheduler role to them; the scheduler role may invoke
339
418
  // the Lambda; and each deploy invokes the Lambda to reconcile the declared schedules
@@ -833,6 +912,7 @@ let finishWithDcbEventLog = (
833
912
  makeAutomationSchedules(~runtime, ~schedulerRoleArn, ~sourceCodeHash, ~opts)
834
913
  makeGeocoderGrant(~runtime, ~opts)
835
914
  makeMessagingGrant(~runtime, ~opts)
915
+ makeIdentityProviderGrant(~runtime, ~opts)
836
916
  | None =>
837
917
  log.warn(
838
918
  ~comp="AutomationSliceRuntime_Builder_Single",
@@ -56,12 +56,13 @@ function getDcbQueueUrl() {
56
56
  return dcbQueueUrlRef.contents;
57
57
  }
58
58
 
59
- function registerAutomationSlice(name, specModulePath, bodyModulePath, callbackTypeOpt, queryDbTableName, queryDbResourcesOpt, context, sourceTopicsOpt, consumesDcbLogOpt, commandTargetName, schedule, schedulesResourcesOpt) {
59
+ function registerAutomationSlice(name, specModulePath, bodyModulePath, callbackTypeOpt, queryDbTableName, queryDbResourcesOpt, context, sourceTopicsOpt, consumesDcbLogOpt, commandTargetName, schedule, schedulesResourcesOpt, capabilityNeedsOpt) {
60
60
  let callbackType = callbackTypeOpt !== undefined ? callbackTypeOpt : "automation";
61
61
  let queryDbResources = queryDbResourcesOpt !== undefined ? queryDbResourcesOpt : [];
62
62
  let sourceTopics = sourceTopicsOpt !== undefined ? sourceTopicsOpt : ({});
63
63
  let consumesDcbLog = consumesDcbLogOpt !== undefined ? consumesDcbLogOpt : true;
64
64
  let schedulesResources = schedulesResourcesOpt !== undefined ? schedulesResourcesOpt : [];
65
+ let capabilityNeeds = capabilityNeedsOpt !== undefined ? capabilityNeedsOpt : [];
65
66
  bundledInfos[name] = {
66
67
  specModulePath: specModulePath,
67
68
  bodyModulePath: bodyModulePath,
@@ -73,7 +74,8 @@ function registerAutomationSlice(name, specModulePath, bodyModulePath, callbackT
73
74
  consumesDcbLog: consumesDcbLog,
74
75
  commandTargetName: commandTargetName,
75
76
  schedule: schedule,
76
- schedulesResources: schedulesResources
77
+ schedulesResources: schedulesResources,
78
+ capabilityNeeds: capabilityNeeds
77
79
  };
78
80
  }
79
81
 
@@ -175,6 +177,51 @@ function makeMessagingGrant(runtime, opts) {
175
177
  }, customOpts);
176
178
  }
177
179
 
180
+ function declaresIdentityProvider(infos) {
181
+ return infos.some(info => info.capabilityNeeds.includes("IdentityProvider"));
182
+ }
183
+
184
+ let identityProviderPoolActions = [
185
+ "cognito-idp:AdminCreateUser",
186
+ "cognito-idp:AdminGetUser",
187
+ "cognito-idp:AdminSetUserPassword",
188
+ "cognito-idp:AdminAddUserToGroup",
189
+ "cognito-idp:AdminRemoveUserFromGroup",
190
+ "cognito-idp:AdminDeleteUser"
191
+ ];
192
+
193
+ function makeIdentityProviderGrant(runtime, opts) {
194
+ if (!(PluginRuntime_Builder$ReventlessAws.identityProviderProvisioned() && declaresIdentityProvider(Object.values(bundledInfos)))) {
195
+ return;
196
+ }
197
+ let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
198
+ let match = PluginRuntime_Builder$ReventlessAws.identityProvider();
199
+ let policyJson = Pulumi.all([
200
+ match.poolArn,
201
+ match.storeArn
202
+ ]).apply(param => {
203
+ let store = param[1];
204
+ return PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllAutomationSlicesIdentityProvider", [{
205
+ Sid: "AllowManagingPrincipals",
206
+ Effect: "Allow",
207
+ Action: identityProviderPoolActions,
208
+ Resource: param[0]
209
+ }].concat(store === "" ? [] : [{
210
+ Sid: "AllowRecordingDomainIds",
211
+ Effect: "Allow",
212
+ Action: [
213
+ "dynamodb:PutItem",
214
+ "dynamodb:DeleteItem"
215
+ ],
216
+ Resource: store
217
+ }])));
218
+ });
219
+ new (Aws.iam.RolePolicy)("AllAutomationSlicesIdentityProvider", {
220
+ policy: policyJson,
221
+ role: runtime.parts.lambdaRole.id
222
+ }, customOpts);
223
+ }
224
+
178
225
  function makeAutomationSchedules(runtime, schedulerRoleArn, sourceCodeHash, opts) {
179
226
  let name = "AllAutomationSlices";
180
227
  let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
@@ -452,6 +499,7 @@ function finishWithDcbEventLog(schedulerRoleArn, dcbEventLog) {
452
499
  makeAutomationSchedules(runtime, schedulerRoleArn, sourceCodeHash, opts);
453
500
  makeGeocoderGrant(runtime, opts);
454
501
  makeMessagingGrant(runtime, opts);
502
+ makeIdentityProviderGrant(runtime, opts);
455
503
  } else {
456
504
  log.warn("AutomationSliceRuntime_Builder_Single", undefined, "finishWithDcbEventLog: DCB EventLog has no parent");
457
505
  }
@@ -483,6 +531,9 @@ export {
483
531
  makeSweepSchedule,
484
532
  makeGeocoderGrant,
485
533
  makeMessagingGrant,
534
+ declaresIdentityProvider,
535
+ identityProviderPoolActions,
536
+ makeIdentityProviderGrant,
486
537
  makeAutomationSchedules,
487
538
  finished,
488
539
  finish,
@@ -62,7 +62,7 @@ function Make(Api) {
62
62
  let queryDbTableName = tableResource.name;
63
63
  let sourceTopics = EventTopic$ReventlessCore.filter(allEventTopics, Belt_SetString.fromArray(sourceNames));
64
64
  let consumesDcbLog = sourceNames.length === 0 || sourceNames.some(name => !(name in allEventTopics));
65
- AutomationSliceRuntime_Builder_Single$ReventlessAws.registerAutomationSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Automation.moduleUrl), "automation", queryDbTableName, queryDbOutputs.resources, context, sourceTopics, consumesDcbLog, undefined, Spec.schedule, Stdlib_Option.mapOr(Component$ReventlessCore.outputs(as_).schedulesDb, [], db => db.resources));
65
+ AutomationSliceRuntime_Builder_Single$ReventlessAws.registerAutomationSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Automation.moduleUrl), "automation", queryDbTableName, queryDbOutputs.resources, context, sourceTopics, consumesDcbLog, undefined, Spec.schedule, Stdlib_Option.mapOr(Component$ReventlessCore.outputs(as_).schedulesDb, [], db => db.resources), undefined);
66
66
  return as_;
67
67
  };
68
68
  return {
@@ -94,6 +94,8 @@ module Make = (
94
94
  ~consumesDcbLog,
95
95
  // Only the name: the target Aggregate's CommandTopic does not exist yet.
96
96
  ~commandTargetName=?Spec.targetName,
97
+ // What decides whether the shared Lambda may create and delete accounts.
98
+ ~capabilityNeeds=Spec.capabilityNeeds,
97
99
  )
98
100
 
99
101
  ots
@@ -63,7 +63,7 @@ function Make(Api) {
63
63
  let declaredSources = Spec.sourceNames;
64
64
  let sourceTopics = EventTopic$ReventlessCore.filter(allEventTopics, Belt_SetString.fromArray(declaredSources));
65
65
  let consumesDcbLog = declaredSources.length === 0 || declaredSources.some(name => !(name in allEventTopics));
66
- AutomationSliceRuntime_Builder_Single$ReventlessAws.registerAutomationSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Translation.moduleUrl), "outbound", queryDbTableName, queryDbOutputs.resources, undefined, sourceTopics, consumesDcbLog, Spec.targetName, undefined, undefined);
66
+ AutomationSliceRuntime_Builder_Single$ReventlessAws.registerAutomationSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Translation.moduleUrl), "outbound", queryDbTableName, queryDbOutputs.resources, undefined, sourceTopics, consumesDcbLog, Spec.targetName, undefined, undefined, Spec.capabilityNeeds);
67
67
  return ots;
68
68
  };
69
69
  return {
@@ -91,6 +91,29 @@ let registerMessagingSender = (sender: Pulumi.Output.t<string>) => {
91
91
  let messagingSender = () => messagingSenderRef.contents
92
92
  let messagingProvisioned = () => messagingProvisionedRef.contents
93
93
 
94
+ /** The pool and the identity store, carried for the grant: a slice making
95
+ principals needs Cognito's admin calls on the one pool and writes on the one
96
+ store. Granted only to a plugin with a slice that declares the need — see
97
+ `AutomationSliceRuntime_Builder_Single.makeIdentityProviderGrant`. */
98
+ type identityProviderResources = {
99
+ poolArn: Pulumi.Output.t<string>,
100
+ storeArn: Pulumi.Output.t<string>,
101
+ }
102
+
103
+ let identityProviderRef: ref<identityProviderResources> = ref({
104
+ poolArn: Pulumi.Output.make(""),
105
+ storeArn: Pulumi.Output.make(""),
106
+ })
107
+ let identityProviderProvisionedRef = ref(false)
108
+
109
+ let registerIdentityProvider = (resources: identityProviderResources) => {
110
+ identityProviderRef := resources
111
+ identityProviderProvisionedRef := true
112
+ }
113
+
114
+ let identityProvider = () => identityProviderRef.contents
115
+ let identityProviderProvisioned = () => identityProviderProvisionedRef.contents
116
+
94
117
  type sliceModulePaths = {
95
118
  specPath: string,
96
119
  decisionPath: string,
@@ -91,6 +91,30 @@ function messagingProvisioned() {
91
91
  return messagingProvisionedRef.contents;
92
92
  }
93
93
 
94
+ let identityProviderRef = {
95
+ contents: {
96
+ poolArn: Pulumi.output(""),
97
+ storeArn: Pulumi.output("")
98
+ }
99
+ };
100
+
101
+ let identityProviderProvisionedRef = {
102
+ contents: false
103
+ };
104
+
105
+ function registerIdentityProvider(resources) {
106
+ identityProviderRef.contents = resources;
107
+ identityProviderProvisionedRef.contents = true;
108
+ }
109
+
110
+ function identityProvider() {
111
+ return identityProviderRef.contents;
112
+ }
113
+
114
+ function identityProviderProvisioned() {
115
+ return identityProviderProvisionedRef.contents;
116
+ }
117
+
94
118
  let dcbConfigRef = {
95
119
  contents: {
96
120
  pluginName: "",
@@ -591,6 +615,11 @@ export {
591
615
  registerMessagingSender,
592
616
  messagingSender,
593
617
  messagingProvisioned,
618
+ identityProviderRef,
619
+ identityProviderProvisionedRef,
620
+ registerIdentityProvider,
621
+ identityProvider,
622
+ identityProviderProvisioned,
594
623
  dcbConfigRef,
595
624
  registeredSliceModulePaths,
596
625
  registerStateChangeSliceSpec,
@@ -171,3 +171,43 @@ describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
171
171
  )->toEqual(Some(["arn:aws:iam::1:role/Shopper"]))
172
172
  })
173
173
  })
174
+
175
+ // ── The domain id claim ─────────────────────────────────────────────────────
176
+ //
177
+ // Rides on whatever else the mint adds. The regression line above still holds
178
+ // for every account created before the identity store existed, because such an
179
+ // account has no row and so no `userId` arrives here.
180
+
181
+ describe("Auth_ActiveRoleTrigger_Ops.respond with a domain id", () => {
182
+ testSync("no stored role and a domain id adds the claim and nothing else", () => {
183
+ let input = eventFor(~membership=["Admin", "Shopper"])
184
+ let event = Trigger.respond(~event=input, ~decision=Unchanged, ~userId="u_abc")
185
+ expect(claimOf(event, ReventlessCore.Auth_UserId.claim))->toEqual(Some("u_abc"))
186
+ expect(groupsOf(event))->toEqual(None)
187
+ })
188
+
189
+ testSync("narrowing and a domain id carry both", () => {
190
+ let event = Trigger.respond(
191
+ ~event=eventFor(~membership=["Admin", "Shopper"]),
192
+ ~decision=Narrow({role: "Shopper", membership: ["Admin", "Shopper"]}),
193
+ ~userId="u_abc",
194
+ )
195
+ expect(groupsOf(event))->toEqual(Some(["Shopper"]))
196
+ expect((
197
+ claimOf(event, Contract.activeRoleClaim),
198
+ claimOf(event, ReventlessCore.Auth_UserId.claim),
199
+ ))->toEqual((Some("Shopper"), Some("u_abc")))
200
+ })
201
+
202
+ testSync("no domain id leaves a narrowed token without the claim", () => {
203
+ let event = Trigger.respond(
204
+ ~event=eventFor(~membership=["Admin", "Shopper"]),
205
+ ~decision=Narrow({role: "Shopper", membership: ["Admin", "Shopper"]}),
206
+ )
207
+ expect(claimOf(event, ReventlessCore.Auth_UserId.claim))->toEqual(None)
208
+ })
209
+
210
+ testPromise("no identity store configured reads as no row", async () =>
211
+ expect(await Trigger.userIdFor(~sub="u-carol", ~table=None))->toEqual(None)
212
+ )
213
+ })
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Auth_UserId$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_UserId.res.mjs";
4
5
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
5
6
  import * as Auth_ActiveRoleTrigger_Ops$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs";
6
7
 
@@ -43,7 +44,7 @@ globalThis.describe("the conformance table, minted by the trigger", () => {
43
44
  let requested = param.requested;
44
45
  let membership = param.membership;
45
46
  globalThis.test(param.label, () => {
46
- let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(eventFor(membership, undefined, undefined), Auth_ActiveRoleTrigger_Ops$ReventlessAws.decide(membership, requested));
47
+ let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(eventFor(membership, undefined, undefined), Auth_ActiveRoleTrigger_Ops$ReventlessAws.decide(membership, requested), undefined);
47
48
  globalThis.expect(Stdlib_Option.getOr(groupsOf(event), membership)).toEqual(Stdlib_Option.getOr(expected, membership));
48
49
  });
49
50
  });
@@ -84,7 +85,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
84
85
  "Admin",
85
86
  "Shopper"
86
87
  ], undefined, undefined);
87
- globalThis.expect(Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(input, "Unchanged")).toEqual(input);
88
+ globalThis.expect(Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(input, "Unchanged", undefined)).toEqual(input);
88
89
  });
89
90
  globalThis.test("narrowing puts the chosen role alone in the group claim", () => {
90
91
  let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(eventFor([
@@ -97,7 +98,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
97
98
  "Admin",
98
99
  "Shopper"
99
100
  ]
100
- });
101
+ }, undefined);
101
102
  globalThis.expect(groupsOf(event)).toEqual(["Shopper"]);
102
103
  });
103
104
  globalThis.test("a narrowed token records the choice and what it gave up", () => {
@@ -111,7 +112,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
111
112
  "Admin",
112
113
  "Shopper"
113
114
  ]
114
- });
115
+ }, undefined);
115
116
  globalThis.expect([
116
117
  claimOf(event, Auth_ActiveRole$ReventlessCore.activeRoleClaim),
117
118
  claimOf(event, Auth_ActiveRole$ReventlessCore.availableRolesClaim)
@@ -125,7 +126,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
125
126
  TAG: "Stale",
126
127
  role: "Admin",
127
128
  membership: ["Shopper"]
128
- });
129
+ }, undefined);
129
130
  globalThis.expect([
130
131
  groupsOf(event),
131
132
  claimOf(event, Auth_ActiveRole$ReventlessCore.staleRoleClaim)
@@ -139,7 +140,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
139
140
  TAG: "Stale",
140
141
  role: "Admin",
141
142
  membership: ["Shopper"]
142
- });
143
+ }, undefined);
143
144
  globalThis.expect(claimOf(event, Auth_ActiveRole$ReventlessCore.activeRoleClaim)).toEqual(undefined);
144
145
  });
145
146
  globalThis.test("narrowing carries the caller's IAM roles through untouched", () => {
@@ -153,7 +154,7 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
153
154
  "Admin",
154
155
  "Shopper"
155
156
  ]
156
- });
157
+ }, undefined);
157
158
  let details = Stdlib_Option.flatMap(overrideOf(event), c => c.groupOverrideDetails);
158
159
  globalThis.expect([
159
160
  Stdlib_Option.map(details, d => d.iamRolesToOverride),
@@ -168,11 +169,61 @@ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond", () => {
168
169
  TAG: "Stale",
169
170
  role: "Admin",
170
171
  membership: ["Shopper"]
171
- });
172
+ }, undefined);
172
173
  globalThis.expect(Stdlib_Option.map(Stdlib_Option.flatMap(overrideOf(event), c => c.groupOverrideDetails), d => d.iamRolesToOverride)).toEqual(["arn:aws:iam::1:role/Shopper"]);
173
174
  });
174
175
  });
175
176
 
177
+ globalThis.describe("Auth_ActiveRoleTrigger_Ops.respond with a domain id", () => {
178
+ globalThis.test("no stored role and a domain id adds the claim and nothing else", () => {
179
+ let input = eventFor([
180
+ "Admin",
181
+ "Shopper"
182
+ ], undefined, undefined);
183
+ let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(input, "Unchanged", "u_abc");
184
+ globalThis.expect(claimOf(event, Auth_UserId$ReventlessCore.claim)).toEqual("u_abc");
185
+ globalThis.expect(groupsOf(event)).toEqual(undefined);
186
+ });
187
+ globalThis.test("narrowing and a domain id carry both", () => {
188
+ let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(eventFor([
189
+ "Admin",
190
+ "Shopper"
191
+ ], undefined, undefined), {
192
+ TAG: "Narrow",
193
+ role: "Shopper",
194
+ membership: [
195
+ "Admin",
196
+ "Shopper"
197
+ ]
198
+ }, "u_abc");
199
+ globalThis.expect(groupsOf(event)).toEqual(["Shopper"]);
200
+ globalThis.expect([
201
+ claimOf(event, Auth_ActiveRole$ReventlessCore.activeRoleClaim),
202
+ claimOf(event, Auth_UserId$ReventlessCore.claim)
203
+ ]).toEqual([
204
+ "Shopper",
205
+ "u_abc"
206
+ ]);
207
+ });
208
+ globalThis.test("no domain id leaves a narrowed token without the claim", () => {
209
+ let event = Auth_ActiveRoleTrigger_Ops$ReventlessAws.respond(eventFor([
210
+ "Admin",
211
+ "Shopper"
212
+ ], undefined, undefined), {
213
+ TAG: "Narrow",
214
+ role: "Shopper",
215
+ membership: [
216
+ "Admin",
217
+ "Shopper"
218
+ ]
219
+ }, undefined);
220
+ globalThis.expect(claimOf(event, Auth_UserId$ReventlessCore.claim)).toEqual(undefined);
221
+ });
222
+ globalThis.test("no identity store configured reads as no row", async () => {
223
+ globalThis.expect(await Auth_ActiveRoleTrigger_Ops$ReventlessAws.userIdFor("u-carol", undefined)).toEqual(undefined);
224
+ });
225
+ });
226
+
176
227
  let Trigger;
177
228
 
178
229
  let Contract;
@@ -186,3 +186,65 @@ describe("Auth_Cognito.fromAppSyncIdentity", () => {
186
186
  }
187
187
  })
188
188
  })
189
+
190
+ // ── The domain id claim ─────────────────────────────────────────────────────
191
+ //
192
+ // Both arms, on both entry points. The second arm is the one that will look
193
+ // redundant to a later reader and is not: every `@owner` field written before
194
+ // the identity store existed holds a `sub`, and it keeps matching only while a
195
+ // token without the claim produces exactly that `sub`.
196
+
197
+ describe("Auth_Cognito and the domain id claim", () => {
198
+ let claimName = ReventlessCore.Auth_UserId.claim
199
+
200
+ testAsync("authenticate: a token carrying the claim yields it, not the sub", async () => {
201
+ let token = buildJwt(
202
+ Dict.fromArray([
203
+ ("sub", JSON.Encode.string("c3741234-aaaa")),
204
+ (claimName, JSON.Encode.string("u_0123")),
205
+ ]),
206
+ )
207
+ switch await Auth_Cognito.authenticate(buildContext([("authorization", "Bearer " ++ token)])) {
208
+ | Authenticated(identity) => expect(identity.userId)->toBe("u_0123")
209
+ | _ => JsError.throwWithMessage("expected Authenticated")
210
+ }
211
+ })
212
+
213
+ testAsync("authenticate: a token without the claim yields exactly the sub", async () => {
214
+ let token = buildJwt(Dict.fromArray([("sub", JSON.Encode.string("c3741234-aaaa"))]))
215
+ switch await Auth_Cognito.authenticate(buildContext([("authorization", "Bearer " ++ token)])) {
216
+ | Authenticated(identity) => expect(identity.userId)->toBe("c3741234-aaaa")
217
+ | _ => JsError.throwWithMessage("expected Authenticated")
218
+ }
219
+ })
220
+
221
+ testSync("fromAppSyncIdentity: the claim wins over the sub", () => {
222
+ let id: Auth_Cognito.appSyncIdentity = {
223
+ sub: "c3741234-aaaa",
224
+ claims: Dict.fromArray([(claimName, JSON.Encode.string("u_0123"))]),
225
+ }
226
+ switch Auth_Cognito.fromAppSyncIdentity(Some(id)) {
227
+ | Authenticated(identity) => expect(identity.userId)->toBe("u_0123")
228
+ | _ => JsError.throwWithMessage("expected Authenticated")
229
+ }
230
+ })
231
+
232
+ testSync("fromAppSyncIdentity: no claim is exactly the sub", () => {
233
+ let id: Auth_Cognito.appSyncIdentity = {sub: "c3741234-aaaa", claims: Dict.make()}
234
+ switch Auth_Cognito.fromAppSyncIdentity(Some(id)) {
235
+ | Authenticated(identity) => expect(identity.userId)->toBe("c3741234-aaaa")
236
+ | _ => JsError.throwWithMessage("expected Authenticated")
237
+ }
238
+ })
239
+
240
+ testSync("an empty claim is treated as absent", () => {
241
+ let id: Auth_Cognito.appSyncIdentity = {
242
+ sub: "c3741234-aaaa",
243
+ claims: Dict.fromArray([(claimName, JSON.Encode.string(""))]),
244
+ }
245
+ switch Auth_Cognito.fromAppSyncIdentity(Some(id)) {
246
+ | Authenticated(identity) => expect(identity.userId)->toBe("c3741234-aaaa")
247
+ | _ => JsError.throwWithMessage("expected Authenticated")
248
+ }
249
+ })
250
+ })