@reventlessdev/reventless-aws 3.0.0-alpha.223 → 3.0.0-alpha.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.224 (2026-07-22)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** register the QueuePolicy -> Queue dependency so destroys order correctly ([81cf2b3](https://github.com/ReventlessDev/reventless-core/commit/81cf2b3198fea7ad280817d2414102089ba0f13d))
11
+ * **aws:** scope the CommandTopic queue policy's EventBridge send grant ([2f5f148](https://github.com/ReventlessDev/reventless-core/commit/2f5f148f4d73b2997633207f071c8d055960cc75))
12
+
13
+
6
14
  # 3.0.0-alpha.223 (2026-07-22)
7
15
 
8
16
  * refactor!: retire the stale "Admin" vocabulary for platform-owned resources ([2770bd9](https://github.com/ReventlessDev/reventless-core/commit/2770bd96f309142bdfc65b9f36bd1a3ef60641bd))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.223",
3
+ "version": "3.0.0-alpha.224",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,14 +11,14 @@
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.24",
12
12
  "@reventlessdev/rescript-effect": "0.1.0-alpha.30",
13
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
14
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
15
14
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.57",
15
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
17
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.104",
17
18
  "@reventlessdev/reventless-core": "3.0.0-alpha.177",
18
- "@reventlessdev/reventless-spec": "3.0.0-alpha.80",
19
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.41",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.104"
20
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.80",
21
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.41"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "12.3.0",
@@ -1,3 +1,9 @@
1
+ // SQS queue ARN is `arn:aws:sqs:<region>:<accountId>:<name>` — segment index 4
2
+ // (0-based). Used to scope queue-policy service principals to this account via
3
+ // the aws:SourceAccount condition key.
4
+ let accountIdOfQueueArn = (queueArn: string) =>
5
+ queueArn->String.split(":")->Array.get(4)->Option.getOr("")
6
+
1
7
  let dynamoDbStreamResources = eventTopicResources =>
2
8
  eventTopicResources->ReventlessCore.Util.Adapter.filterSupportedResolvedResources([
3
9
  AWS.DynamoDbStream.service,
@@ -1,8 +1,13 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
  import * as AWS$ReventlessAws from "./AWS.res.mjs";
4
5
  import * as Util_Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Adapter.res.mjs";
5
6
 
7
+ function accountIdOfQueueArn(queueArn) {
8
+ return Stdlib_Option.getOr(queueArn.split(":")[4], "");
9
+ }
10
+
6
11
  function dynamoDbStreamResources(eventTopicResources) {
7
12
  return Util_Adapter$ReventlessCore.filterSupportedResolvedResources(eventTopicResources, [AWS$ReventlessAws.DynamoDbStream.service]);
8
13
  }
@@ -29,6 +34,7 @@ function dynamoDbResources(resources) {
29
34
  }
30
35
 
31
36
  export {
37
+ accountIdOfQueueArn,
32
38
  dynamoDbStreamResources,
33
39
  snsResources,
34
40
  sqsResources,
@@ -1,55 +1,73 @@
1
1
  open PulumiAws.PolicyDocument
2
2
  open Adapter_Helpers
3
3
 
4
+ let createQueuePolicyDocument = (~name, ~queueArn: string, ~lambdaArn: string) => {
5
+ let accountId = queueArn->accountIdOfQueueArn
6
+ PulumiAws.PolicyDocument.make(
7
+ ~id=name ++ "QueuePolicy",
8
+ ~statements=[
9
+ {
10
+ sid: "AllowLambdaToAccessQueue",
11
+ effect: Allow,
12
+ principal: Principals({
13
+ service: PrincipalId(AWS.Lambda.principal),
14
+ }),
15
+ actions: Actions(["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]),
16
+ resources: Resource(queueArn),
17
+ conditions: {
18
+ arnEquals: [("AWS:SourceArn", ConditionValue(lambdaArn))]->Dict.fromArray,
19
+ },
20
+ },
21
+ {
22
+ // ScheduledPublisher creates EventBridge rules at runtime
23
+ // (PutRule/PutTargets) targeting this queue directly, and EventBridge
24
+ // authorises SQS targets against the queue's resource policy — so the
25
+ // grant cannot name a single rule ARN. Scope it to rules in this
26
+ // account instead: SourceAccount closes the cross-account hole,
27
+ // ArnLike narrows the in-account surface to Events rules.
28
+ sid: "AllowCloudWatchEventsToSendToQueue",
29
+ effect: Allow,
30
+ principal: Principals({
31
+ service: PrincipalId(AWS.CloudwatchEventRule.principal),
32
+ }),
33
+ actions: Actions(["sqs:SendMessage"]),
34
+ resources: Resource(queueArn),
35
+ conditions: {
36
+ stringEquals: [("aws:SourceAccount", ConditionValue(accountId))]->Dict.fromArray,
37
+ arnLike: [
38
+ ("aws:SourceArn", ConditionValue(`arn:aws:events:*:${accountId}:rule/*`)),
39
+ ]->Dict.fromArray,
40
+ },
41
+ },
42
+ ],
43
+ )->toJsonString
44
+ }
45
+
4
46
  let createQueuePolicy = (
5
47
  queue: PulumiAws.SQS.Queue.t,
6
48
  name,
7
49
  lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t>,
8
50
  opts,
9
51
  ) => {
10
- let _ =
11
- (queue.arn, queue.id, lambda->Pulumi.Output.flatMap(lambda => lambda.arn))
12
- ->Pulumi.Output.all3
13
- ->Pulumi.Output.apply(((queueArn, queueId, lambdaArn)) => {
14
- let queuePolicyDocument =
15
- PulumiAws.PolicyDocument.make(
16
- ~id=name ++ "QueuePolicy",
17
- ~statements=[
18
- {
19
- sid: "AllowLambdaToAccessQueue",
20
- effect: Allow,
21
- principal: Principals({
22
- service: PrincipalId("lambda.amazonaws.com"),
23
- }),
24
- actions: Actions([
25
- "sqs:ReceiveMessage",
26
- "sqs:DeleteMessage",
27
- "sqs:GetQueueAttributes",
28
- ]),
29
- resources: Resource(queueArn),
30
- conditions: {
31
- arnEquals: [("AWS:SourceArn", ConditionValue(lambdaArn))]->Dict.fromArray,
32
- },
33
- },
34
- {
35
- sid: "AllowCloudWatchEventsToSendToQueue",
36
- effect: Allow,
37
- principal: Principals({
38
- service: PrincipalId("events.amazonaws.com"),
39
- }),
40
- actions: Actions(["sqs:SendMessage"]),
41
- resources: Resource(queueArn),
42
- },
43
- ],
44
- )
45
- ->toJsonString
46
- ->Pulumi.Input.make
47
- let _queuePolicy = PulumiAws.SQS.QueuePolicy.make(
48
- ~name,
49
- ~args={queueUrl: queueId->Pulumi.Input.make, policy: queuePolicyDocument},
50
- ~opts=Some(opts),
51
- )
52
- })
52
+ // The document is a *value* derived from resolved ARNs, so it is built in an
53
+ // apply. The QueuePolicy itself stays outside: passing `queue.id` as an Output
54
+ // is what registers the policy -> queue dependency, without which Pulumi has
55
+ // no ordering constraint and can delete the queue first on a replacement,
56
+ // leaving the policy's delete polling a queue that no longer exists.
57
+ let queuePolicyDocument =
58
+ (queue.arn, lambda->Pulumi.Output.flatMap(lambda => lambda.arn))
59
+ ->Pulumi.Output.all2
60
+ ->Pulumi.Output.apply(((queueArn, lambdaArn)) =>
61
+ createQueuePolicyDocument(~name, ~queueArn, ~lambdaArn)
62
+ )
63
+ let _queuePolicy = PulumiAws.SQS.QueuePolicy.make(
64
+ ~name,
65
+ ~args={
66
+ queueUrl: queue.id->Pulumi.Output.asInput,
67
+ policy: queuePolicyDocument->Pulumi.Output.asInput,
68
+ },
69
+ ~opts=Some(opts),
70
+ )
53
71
  }
54
72
 
55
73
  let createLambdaPolicy = (
@@ -100,10 +118,10 @@ let createLambdaPolicy = (
100
118
  "dynamodb:UpdateItem",
101
119
  "dynamodb:DeleteItem",
102
120
  "dynamodb:BatchWriteItem",
103
- // Phase 7 cross-partition fences emit read-only
104
- // `ConditionCheck` entries inside `TransactWriteItems`
105
- // when a write doesn't touch the cross-partition tag's
106
- // partition itself; this needs its own IAM action.
121
+ // Cross-partition fences emit read-only `ConditionCheck`
122
+ // entries inside `TransactWriteItems` when a write
123
+ // doesn't touch the cross-partition tag's partition
124
+ // itself; this needs its own IAM action.
107
125
  "dynamodb:ConditionCheckItem",
108
126
  ]),
109
127
  resources: Resources(
@@ -5,53 +5,65 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
8
9
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
9
10
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
10
11
  import * as Adapter_Helpers$ReventlessAws from "../Adapter_Helpers.res.mjs";
11
12
  import * as Util_EventSourceMapping$ReventlessAws from "../../util/Util_EventSourceMapping.res.mjs";
12
13
 
14
+ function createQueuePolicyDocument(name, queueArn, lambdaArn) {
15
+ let accountId = Adapter_Helpers$ReventlessAws.accountIdOfQueueArn(queueArn);
16
+ return PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "QueuePolicy", [
17
+ {
18
+ Sid: "AllowLambdaToAccessQueue",
19
+ Principal: {
20
+ Service: AWS$ReventlessAws.Lambda.principal
21
+ },
22
+ Effect: "Allow",
23
+ Action: [
24
+ "sqs:ReceiveMessage",
25
+ "sqs:DeleteMessage",
26
+ "sqs:GetQueueAttributes"
27
+ ],
28
+ Resource: queueArn,
29
+ Condition: {
30
+ ArnEquals: Object.fromEntries([[
31
+ "AWS:SourceArn",
32
+ lambdaArn
33
+ ]])
34
+ }
35
+ },
36
+ {
37
+ Sid: "AllowCloudWatchEventsToSendToQueue",
38
+ Principal: {
39
+ Service: AWS$ReventlessAws.CloudwatchEventRule.principal
40
+ },
41
+ Effect: "Allow",
42
+ Action: ["sqs:SendMessage"],
43
+ Resource: queueArn,
44
+ Condition: {
45
+ StringEquals: Object.fromEntries([[
46
+ "aws:SourceAccount",
47
+ accountId
48
+ ]]),
49
+ ArnLike: Object.fromEntries([[
50
+ "aws:SourceArn",
51
+ `arn:aws:events:*:` + accountId + `:rule/*`
52
+ ]])
53
+ }
54
+ }
55
+ ]));
56
+ }
57
+
13
58
  function createQueuePolicy(queue, name, lambda, opts) {
14
- Pulumi.all([
59
+ let queuePolicyDocument = Pulumi.all([
15
60
  queue.arn,
16
- queue.id,
17
61
  Output$Pulumi.flatMap(lambda, lambda => lambda.arn)
18
- ]).apply(param => {
19
- let queueArn = param[0];
20
- let queuePolicyDocument = PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "QueuePolicy", [
21
- {
22
- Sid: "AllowLambdaToAccessQueue",
23
- Principal: {
24
- Service: "lambda.amazonaws.com"
25
- },
26
- Effect: "Allow",
27
- Action: [
28
- "sqs:ReceiveMessage",
29
- "sqs:DeleteMessage",
30
- "sqs:GetQueueAttributes"
31
- ],
32
- Resource: queueArn,
33
- Condition: {
34
- ArnEquals: Object.fromEntries([[
35
- "AWS:SourceArn",
36
- param[2]
37
- ]])
38
- }
39
- },
40
- {
41
- Sid: "AllowCloudWatchEventsToSendToQueue",
42
- Principal: {
43
- Service: "events.amazonaws.com"
44
- },
45
- Effect: "Allow",
46
- Action: ["sqs:SendMessage"],
47
- Resource: queueArn
48
- }
49
- ]));
50
- new (Aws.sqs.QueuePolicy)(name, {
51
- policy: queuePolicyDocument,
52
- queueUrl: param[1]
53
- }, opts);
54
- });
62
+ ]).apply(param => createQueuePolicyDocument(name, param[0], param[1]));
63
+ new (Aws.sqs.QueuePolicy)(name, {
64
+ policy: queuePolicyDocument,
65
+ queueUrl: queue.id
66
+ }, opts);
55
67
  }
56
68
 
57
69
  function createLambdaPolicy(lambdaRole, name, queue, resources, opts) {
@@ -138,6 +150,7 @@ function subscribeLambda2SqsTopic(batchSize, lambda, name, queue, opts) {
138
150
  }
139
151
 
140
152
  export {
153
+ createQueuePolicyDocument,
141
154
  createQueuePolicy,
142
155
  createLambdaPolicy,
143
156
  subscribeLambda2SqsTopic,
@@ -10,64 +10,62 @@ let toResources = (eventTopics: ReventlessCore.EventTopic.allOutputs) =>
10
10
  ->Array.flatMap(outputs => outputs.resources)
11
11
  ->ReventlessCore.Adapter.resourcesToResolvedOutput
12
12
 
13
- let createQueuePolicy = (queue: PulumiAws.SQS.Queue.t, name, _resources, opts) => {
14
- let _ =
15
- (queue.arn, queue.id)
16
- ->Pulumi.Output.all2
17
- ->Pulumi.Output.apply(((queueArn, queueId)) => {
18
- // SQS queue ARN is `arn:aws:sqs:<region>:<accountId>:<name>` — segment
19
- // index 4 (0-based). Used to scope cross-plugin SNS sources to this
20
- // account (aws:SourceAccount) and to topic names following the
21
- // Reventless EventTopic naming convention (aws:SourceArn arnLike).
22
- let accountId = queueArn->String.split(":")->Array.get(4)->Option.getOr("")
23
- let queuePolicyDocument =
24
- PulumiAws.PolicyDocument.make(
25
- ~id=name ++ "QueuePolicy",
26
- ~statements=[
27
- // Single statement allows SendMessage from any SNS topic owned by
28
- // this AWS account whose name matches the Reventless EventTopic
29
- // naming convention. Replaces the previous per-topic arnEquals
30
- // list so cross-plugin SNS subscriptions created at runtime by the
31
- // admin's manageSubscriptions hook (Phase 3 Step 1) are accepted
32
- // without requiring a redeploy of the receiving plugin. Security
33
- // boundary: SourceAccount condition keeps third-party-account
34
- // topics out; ArnLike + SNS service principal further narrows
35
- // accepted senders to in-account EventTopic resources.
36
- {
37
- sid: "AllowReceiveSnsEvents",
38
- principal: Principals({
39
- service: PrincipalIds([AWS.SNS.principal]),
40
- }),
41
- effect: Allow,
42
- actions: Actions(["sqs:SendMessage"]),
43
- resources: Resource(queueArn),
44
- conditions: {
45
- stringEquals: [
46
- ("aws:SourceAccount", ConditionValue(accountId)),
47
- ]->Dict.fromArray,
48
- arnLike: [
49
- ("aws:SourceArn", ConditionValue(`arn:aws:sns:*:${accountId}:*EventTopic-*`)),
50
- ]->Dict.fromArray,
51
- },
52
- },
53
- ],
54
- )
55
- ->toJsonString
56
- ->Pulumi.Input.make
57
- let _queuePolicy = {
58
- PulumiAws.SQS.QueuePolicy.make(
59
- ~name,
60
- ~args={
61
- queueUrl: queueId->Pulumi.Input.make,
62
- policy: queuePolicyDocument,
13
+ let createQueuePolicy = (queue: PulumiAws.SQS.Queue.t, name, opts) => {
14
+ // The document is a *value* derived from the resolved ARN, so it is built in
15
+ // an apply. The QueuePolicy itself stays outside: passing `queue.id` as an
16
+ // Output is what registers the policy -> queue dependency, without which
17
+ // Pulumi has no ordering constraint and can delete the queue first on a
18
+ // replacement, leaving the policy's delete polling a queue that is gone.
19
+ let queuePolicyDocument = queue.arn->Pulumi.Output.apply(queueArn => {
20
+ // Scopes cross-plugin SNS sources to this account (aws:SourceAccount) and
21
+ // to topic names following the Reventless EventTopic naming convention
22
+ // (aws:SourceArn arnLike).
23
+ let accountId = queueArn->accountIdOfQueueArn
24
+ PulumiAws.PolicyDocument.make(
25
+ ~id=name ++ "QueuePolicy",
26
+ ~statements=[
27
+ // Single statement allows SendMessage from any SNS topic owned by
28
+ // this AWS account whose name matches the Reventless EventTopic
29
+ // naming convention. A per-topic arnEquals list would require a
30
+ // redeploy of the receiving plugin whenever the platform's
31
+ // manageSubscriptions hook creates a cross-plugin SNS subscription at
32
+ // runtime. Security boundary: SourceAccount keeps third-party-account
33
+ // topics out; ArnLike + SNS service principal further narrows accepted
34
+ // senders to in-account EventTopic resources.
35
+ {
36
+ sid: "AllowReceiveSnsEvents",
37
+ principal: Principals({
38
+ service: PrincipalIds([AWS.SNS.principal]),
39
+ }),
40
+ effect: Allow,
41
+ actions: Actions(["sqs:SendMessage"]),
42
+ resources: Resource(queueArn),
43
+ conditions: {
44
+ stringEquals: [("aws:SourceAccount", ConditionValue(accountId))]->Dict.fromArray,
45
+ arnLike: [
46
+ ("aws:SourceArn", ConditionValue(`arn:aws:sns:*:${accountId}:*EventTopic-*`)),
47
+ ]->Dict.fromArray,
63
48
  },
64
- ~opts=Some(opts),
65
- )
66
- }
67
- })
49
+ },
50
+ ],
51
+ )->toJsonString
52
+ })
53
+ let _queuePolicy = PulumiAws.SQS.QueuePolicy.make(
54
+ ~name,
55
+ ~args={
56
+ queueUrl: queue.id->Pulumi.Output.asInput,
57
+ policy: queuePolicyDocument->Pulumi.Output.asInput,
58
+ },
59
+ ~opts=Some(opts),
60
+ )
68
61
  }
69
62
 
70
- let subscribeQueue2SnsTopic = (queue, name, resources: array<ReventlessInfra.Adapter.resolvedResource>, opts) => {
63
+ let subscribeQueue2SnsTopic = (
64
+ queue,
65
+ name,
66
+ resources: array<ReventlessInfra.Adapter.resolvedResource>,
67
+ opts,
68
+ ) => {
71
69
  let _snsTopicSubscriptions = resources->Array.map(resource => {
72
70
  log.debug(~comp="EventCollector", `subscribeToSnsTopic: ${name} -> ${resource.name}`)
73
71
  let subscription = Util_SQS.subscribeToSnsTopic(
@@ -82,17 +80,21 @@ let subscribeQueue2SnsTopic = (queue, name, resources: array<ReventlessInfra.Ada
82
80
  }
83
81
 
84
82
  let connectSqsQueue2SnsTopics = (queue: PulumiAws.SQS.Queue.t, name, eventTopics, opts) => {
83
+ // The policy grants SendMessage to any in-account EventTopic by naming
84
+ // convention, so it does not depend on the resolved topic resources and is
85
+ // created outside the apply. Only the SNS subscriptions need them.
86
+ queue->createQueuePolicy(name, opts)
85
87
  let _ =
86
88
  (eventTopics->toResources, queue.id)
87
89
  ->Pulumi.Output.all2
88
90
  ->Pulumi.Output.apply(((eventTopicResources, queueId)) => {
89
91
  log.debug(
90
92
  ~comp="EventCollector",
91
- `connectSqsQueue2SnsTopics ${queueId}: ${eventTopicResources->Array.length->Int.toString} topic resource(s)`,
93
+ `connectSqsQueue2SnsTopics ${queueId}: ${eventTopicResources
94
+ ->Array.length
95
+ ->Int.toString} topic resource(s)`,
92
96
  )
93
- let snsResources = eventTopicResources->snsResources
94
- queue->createQueuePolicy(name, snsResources, opts)
95
- queue->subscribeQueue2SnsTopic(name, snsResources, opts)
97
+ queue->subscribeQueue2SnsTopic(name, eventTopicResources->snsResources, opts)
96
98
  })
97
99
  }
98
100
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
- import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
5
  import * as Pulumi from "@pulumi/pulumi";
7
6
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
7
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
@@ -20,14 +19,10 @@ function toResources(eventTopics) {
20
19
  return Adapter$ReventlessCore.resourcesToResolvedOutput(Object.values(eventTopics).flatMap(outputs => outputs.resources));
21
20
  }
22
21
 
23
- function createQueuePolicy(queue, name, _resources, opts) {
24
- Pulumi.all([
25
- queue.arn,
26
- queue.id
27
- ]).apply(param => {
28
- let queueArn = param[0];
29
- let accountId = Stdlib_Option.getOr(queueArn.split(":")[4], "");
30
- let queuePolicyDocument = PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "QueuePolicy", [{
22
+ function createQueuePolicy(queue, name, opts) {
23
+ let queuePolicyDocument = queue.arn.apply(queueArn => {
24
+ let accountId = Adapter_Helpers$ReventlessAws.accountIdOfQueueArn(queueArn);
25
+ return PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "QueuePolicy", [{
31
26
  Sid: "AllowReceiveSnsEvents",
32
27
  Principal: {
33
28
  Service: [AWS$ReventlessAws.SNS.principal]
@@ -46,11 +41,11 @@ function createQueuePolicy(queue, name, _resources, opts) {
46
41
  ]])
47
42
  }
48
43
  }]));
49
- new (Aws.sqs.QueuePolicy)(name, {
50
- policy: queuePolicyDocument,
51
- queueUrl: param[1]
52
- }, opts);
53
44
  });
45
+ new (Aws.sqs.QueuePolicy)(name, {
46
+ policy: queuePolicyDocument,
47
+ queueUrl: queue.id
48
+ }, opts);
54
49
  }
55
50
 
56
51
  function subscribeQueue2SnsTopic(queue, name, resources, opts) {
@@ -62,15 +57,14 @@ function subscribeQueue2SnsTopic(queue, name, resources, opts) {
62
57
  }
63
58
 
64
59
  function connectSqsQueue2SnsTopics(queue, name, eventTopics, opts) {
60
+ createQueuePolicy(queue, name, opts);
65
61
  Pulumi.all([
66
62
  toResources(eventTopics),
67
63
  queue.id
68
64
  ]).apply(param => {
69
65
  let eventTopicResources = param[0];
70
66
  log.debug("EventCollector", undefined, `connectSqsQueue2SnsTopics ` + param[1] + `: ` + eventTopicResources.length.toString() + ` topic resource(s)`);
71
- let snsResources = Adapter_Helpers$ReventlessAws.snsResources(eventTopicResources);
72
- createQueuePolicy(queue, name, snsResources, opts);
73
- subscribeQueue2SnsTopic(queue, name, snsResources, opts);
67
+ subscribeQueue2SnsTopic(queue, name, Adapter_Helpers$ReventlessAws.snsResources(eventTopicResources), opts);
74
68
  });
75
69
  }
76
70
 
@@ -114,38 +114,40 @@ let make = (
114
114
 
115
115
  // SQS queue policy — allow SNS to send messages
116
116
  let snsResource = eventTopicOutputs.resources->Array.getUnsafe(0)
117
- let _ =
118
- (queue.arn, queue.id, snsResource.urn)
119
- ->Pulumi.Output.all3
120
- ->Pulumi.Output.apply(((queueArn, queueId, snsUrn)) => {
117
+ // The document is a *value* derived from resolved ARNs, so it is built in an
118
+ // apply. The QueuePolicy itself stays outside: passing `queue.id` as an Output
119
+ // is what registers the policy -> queue dependency, without which Pulumi has
120
+ // no ordering constraint and can delete the queue first on a replacement,
121
+ // leaving the policy's delete polling a queue that no longer exists.
122
+ let queuePolicyDocument =
123
+ (queue.arn, snsResource.urn)
124
+ ->Pulumi.Output.all2
125
+ ->Pulumi.Output.apply(((queueArn, snsUrn)) => {
121
126
  open PolicyDocument
122
- let _queuePolicy = SQS.QueuePolicy.make(
123
- ~name=name ++ "EventLogSubQueuePolicy",
124
- ~args={
125
- queueUrl: queueId->Pulumi.Input.make,
126
- policy: PolicyDocument.make(
127
- ~id=name ++ "EventLogSubQueuePolicy",
128
- ~statements=[
129
- {
130
- sid: "AllowSNSSend",
131
- principal: Principals({service: PrincipalIds([AWS.SNS.principal])}),
132
- effect: Allow,
133
- actions: Actions(["sqs:SendMessage"]),
134
- resources: Resource(queueArn),
135
- conditions: {
136
- arnEquals: [
137
- ("aws:SourceArn", ConditionValues([snsUrn])),
138
- ]->Dict.fromArray,
139
- },
140
- },
141
- ],
142
- )
143
- ->PolicyDocument.toJsonString
144
- ->Pulumi.Input.make,
145
- },
146
- ~opts=Some(opts),
147
- )
127
+ PolicyDocument.make(
128
+ ~id=name ++ "EventLogSubQueuePolicy",
129
+ ~statements=[
130
+ {
131
+ sid: "AllowSNSSend",
132
+ principal: Principals({service: PrincipalIds([AWS.SNS.principal])}),
133
+ effect: Allow,
134
+ actions: Actions(["sqs:SendMessage"]),
135
+ resources: Resource(queueArn),
136
+ conditions: {
137
+ arnEquals: [("aws:SourceArn", ConditionValues([snsUrn]))]->Dict.fromArray,
138
+ },
139
+ },
140
+ ],
141
+ )->PolicyDocument.toJsonString
148
142
  })
143
+ let _queuePolicy = SQS.QueuePolicy.make(
144
+ ~name=name ++ "EventLogSubQueuePolicy",
145
+ ~args={
146
+ queueUrl: queue.id->Pulumi.Output.asInput,
147
+ policy: queuePolicyDocument->Pulumi.Output.asInput,
148
+ },
149
+ ~opts=Some(opts),
150
+ )
149
151
 
150
152
  // SNS → SQS subscription (raw delivery so body IS the event JSON)
151
153
  let _subscription = Util_SQS.subscribeToSnsTopic(
@@ -85,30 +85,28 @@ function make(name, topicName, eventTopicOutputs, eventsApi, opts) {
85
85
  sqsManagedSseEnabled: false
86
86
  }, opts);
87
87
  let snsResource = eventTopicOutputs.resources[0];
88
- Pulumi.all([
88
+ let queuePolicyDocument = Pulumi.all([
89
89
  queue.arn,
90
- queue.id,
91
90
  snsResource.urn
92
- ]).apply(param => {
93
- new (Aws.sqs.QueuePolicy)(name + "EventLogSubQueuePolicy", {
94
- policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "EventLogSubQueuePolicy", [{
95
- Sid: "AllowSNSSend",
96
- Principal: {
97
- Service: [AWS$ReventlessAws.SNS.principal]
98
- },
99
- Effect: "Allow",
100
- Action: ["sqs:SendMessage"],
101
- Resource: param[0],
102
- Condition: {
103
- ArnEquals: Object.fromEntries([[
104
- "aws:SourceArn",
105
- [param[2]]
106
- ]])
107
- }
108
- }])),
109
- queueUrl: param[1]
110
- }, opts);
111
- });
91
+ ]).apply(param => PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "EventLogSubQueuePolicy", [{
92
+ Sid: "AllowSNSSend",
93
+ Principal: {
94
+ Service: [AWS$ReventlessAws.SNS.principal]
95
+ },
96
+ Effect: "Allow",
97
+ Action: ["sqs:SendMessage"],
98
+ Resource: param[0],
99
+ Condition: {
100
+ ArnEquals: Object.fromEntries([[
101
+ "aws:SourceArn",
102
+ [param[1]]
103
+ ]])
104
+ }
105
+ }])));
106
+ new (Aws.sqs.QueuePolicy)(name + "EventLogSubQueuePolicy", {
107
+ policy: queuePolicyDocument,
108
+ queueUrl: queue.id
109
+ }, opts);
112
110
  Util_SQS$ReventlessAws.subscribeToSnsTopic(queue, name + "EventLogSub", name, snsResource, opts);
113
111
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "EventLogSubRole", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "EventLogSubRole", EventTopic$ReventlessCore.componentType, "Identity", undefined, name, undefined, undefined, undefined), opts);
114
112
  Pulumi.all([
@@ -122,7 +122,7 @@ let createQueuePolicyDocument = (name, queueArn: string, handlerArn: string) =>
122
122
  let _ =
123
123
  (queue.id, queue.arn, fifoQueue.id, fifoQueue.arn, handler.arn)
124
124
  ->Pulumi.Output.all5
125
- ->Pulumi.Output.apply(((queueId, queueArn, fifoQueueId, fifoQueueArn, handlerArn)) => {
125
+ ->Pulumi.Output.apply(((_queueId, queueArn, _fifoQueueId, fifoQueueArn, handlerArn)) => {
126
126
  let lambdaPolicyDocument = PulumiAws.PolicyDocument.make(
127
127
  ~id=name ++ "SQSPolicy",
128
128
  ~statements=[
@@ -152,10 +152,20 @@ let _ =
152
152
  ~opts,
153
153
  )
154
154
 
155
+ // `queueUrl` takes the queue's Output, not the resolved `queueId` string:
156
+ // that is what registers the policy -> queue dependency, without which
157
+ // Pulumi has no ordering constraint and can delete the queue first on a
158
+ // replacement, leaving the policy's delete polling a queue that is gone.
159
+ //
160
+ // Unlike the channel helpers, these two stay inside the apply. This module
161
+ // creates its resources at import time and this apply resolves late enough
162
+ // to race Jest's teardown; reshaping it (dropping the now-unused ids to
163
+ // narrow the tuple) shifts that timing and crashes the AWS unit suites, so
164
+ // `_queueId` / `_fifoQueueId` are deliberately kept.
155
165
  let _attachQueuePolicy = PulumiAws.SQS.QueuePolicy.make(
156
166
  ~name,
157
167
  ~args={
158
- queueUrl: queueId->Pulumi.Input.make,
168
+ queueUrl: queue.id->Pulumi.Output.asInput,
159
169
  policy: createQueuePolicyDocument(name, queueArn, handlerArn),
160
170
  },
161
171
  ~opts=Some(opts),
@@ -163,7 +173,7 @@ let _ =
163
173
  let _attachFifoQueuePolicy = PulumiAws.SQS.QueuePolicy.make(
164
174
  ~name=nameFifo,
165
175
  ~args={
166
- queueUrl: fifoQueueId->Pulumi.Input.make,
176
+ queueUrl: fifoQueue.id->Pulumi.Output.asInput,
167
177
  policy: createQueuePolicyDocument(nameFifo, fifoQueueArn, handlerArn),
168
178
  },
169
179
  ~opts=Some(opts),
@@ -132,11 +132,11 @@ Pulumi.all([
132
132
  }, opts);
133
133
  new (Aws.sqs.QueuePolicy)(name, {
134
134
  policy: createQueuePolicyDocument(name, queueArn, handlerArn),
135
- queueUrl: param[0]
135
+ queueUrl: queue.id
136
136
  }, opts);
137
137
  new (Aws.sqs.QueuePolicy)(nameFifo, {
138
138
  policy: createQueuePolicyDocument(nameFifo, fifoQueueArn, handlerArn),
139
- queueUrl: param[2]
139
+ queueUrl: fifoQueue.id
140
140
  }, opts);
141
141
  });
142
142
 
@@ -0,0 +1,65 @@
1
+ open JestGlobals
2
+
3
+ // Guards the CommandTopic queue policy's service-principal scoping. The
4
+ // EventBridge grant cannot name a single rule ARN — ScheduledPublisher creates
5
+ // rules at runtime and EventBridge authorises SQS targets against the queue's
6
+ // resource policy — so the condition keys are the only bound on who may send
7
+ // into a command topic. A dropped condition is invisible at deploy time.
8
+
9
+ let queueArn = "arn:aws:sqs:eu-west-1:123456789012:CatalogAggrCmdTopic-5150050"
10
+ let lambdaArn = "arn:aws:lambda:eu-west-1:123456789012:function:AllAggregates-1a2b3c4"
11
+
12
+ let policy =
13
+ CommandTopicChannel_Helpers.createQueuePolicyDocument(
14
+ ~name="CatalogAggrCmdTopic",
15
+ ~queueArn,
16
+ ~lambdaArn,
17
+ )->JSON.parseOrThrow
18
+
19
+ let field = (json, key) =>
20
+ json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
21
+
22
+ let statement = sid =>
23
+ policy
24
+ ->field("Statement")
25
+ ->Option.flatMap(JSON.Decode.array)
26
+ ->Option.getOr([])
27
+ ->Array.find(s => s->field("Sid")->Option.flatMap(JSON.Decode.string) == Some(sid))
28
+
29
+ let condition = (sid, operator, key) =>
30
+ statement(sid)
31
+ ->Option.flatMap(s => s->field("Condition"))
32
+ ->Option.flatMap(c => c->field(operator))
33
+ ->Option.flatMap(o => o->field(key))
34
+ ->Option.flatMap(JSON.Decode.string)
35
+
36
+ describe("CommandTopic queue policy — EventBridge grant", () => {
37
+ testSync("is scoped to this account", () => {
38
+ expect(
39
+ condition("AllowCloudWatchEventsToSendToQueue", "StringEquals", "aws:SourceAccount"),
40
+ )->toEqual(Some("123456789012"))
41
+ })
42
+
43
+ testSync("is scoped to EventBridge rule ARNs in this account", () => {
44
+ expect(condition("AllowCloudWatchEventsToSendToQueue", "ArnLike", "aws:SourceArn"))->toEqual(
45
+ Some("arn:aws:events:*:123456789012:rule/*"),
46
+ )
47
+ })
48
+
49
+ testSync("names the EventBridge service principal", () => {
50
+ let principal =
51
+ statement("AllowCloudWatchEventsToSendToQueue")
52
+ ->Option.flatMap(s => s->field("Principal"))
53
+ ->Option.flatMap(p => p->field("Service"))
54
+ ->Option.flatMap(JSON.Decode.string)
55
+ expect(principal)->toEqual(Some("events.amazonaws.com"))
56
+ })
57
+ })
58
+
59
+ describe("CommandTopic queue policy — Lambda grant", () => {
60
+ testSync("stays pinned to the consuming function ARN", () => {
61
+ expect(condition("AllowLambdaToAccessQueue", "ArnEquals", "AWS:SourceArn"))->toEqual(
62
+ Some(lambdaArn),
63
+ )
64
+ })
65
+ })
@@ -0,0 +1,53 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+ import * as CommandTopicChannel_Helpers$ReventlessAws from "../src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs";
7
+
8
+ let queueArn = "arn:aws:sqs:eu-west-1:123456789012:CatalogAggrCmdTopic-5150050";
9
+
10
+ let lambdaArn = "arn:aws:lambda:eu-west-1:123456789012:function:AllAggregates-1a2b3c4";
11
+
12
+ let policy = JSON.parse(CommandTopicChannel_Helpers$ReventlessAws.createQueuePolicyDocument("CatalogAggrCmdTopic", queueArn, lambdaArn));
13
+
14
+ function field(json, key) {
15
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[key]);
16
+ }
17
+
18
+ function statement(sid) {
19
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(field(policy, "Statement"), Stdlib_JSON.Decode.array), []).find(s => Primitive_object.equal(Stdlib_Option.flatMap(field(s, "Sid"), Stdlib_JSON.Decode.string), sid));
20
+ }
21
+
22
+ function condition(sid, operator, key) {
23
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(statement(sid), s => field(s, "Condition")), c => field(c, operator)), o => field(o, key)), Stdlib_JSON.Decode.string);
24
+ }
25
+
26
+ globalThis.describe("CommandTopic queue policy — EventBridge grant", () => {
27
+ globalThis.test("is scoped to this account", () => {
28
+ globalThis.expect(condition("AllowCloudWatchEventsToSendToQueue", "StringEquals", "aws:SourceAccount")).toEqual("123456789012");
29
+ });
30
+ globalThis.test("is scoped to EventBridge rule ARNs in this account", () => {
31
+ globalThis.expect(condition("AllowCloudWatchEventsToSendToQueue", "ArnLike", "aws:SourceArn")).toEqual("arn:aws:events:*:123456789012:rule/*");
32
+ });
33
+ globalThis.test("names the EventBridge service principal", () => {
34
+ let principal = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(statement("AllowCloudWatchEventsToSendToQueue"), s => field(s, "Principal")), p => field(p, "Service")), Stdlib_JSON.Decode.string);
35
+ globalThis.expect(principal).toEqual("events.amazonaws.com");
36
+ });
37
+ });
38
+
39
+ globalThis.describe("CommandTopic queue policy — Lambda grant", () => {
40
+ globalThis.test("stays pinned to the consuming function ARN", () => {
41
+ globalThis.expect(condition("AllowLambdaToAccessQueue", "ArnEquals", "AWS:SourceArn")).toEqual(lambdaArn);
42
+ });
43
+ });
44
+
45
+ export {
46
+ queueArn,
47
+ lambdaArn,
48
+ policy,
49
+ field,
50
+ statement,
51
+ condition,
52
+ }
53
+ /* policy Not a pure module */