@fjall/components-infrastructure 36.0.0 → 38.0.0

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 (44) hide show
  1. package/dist/lib/app.d.ts +28 -0
  2. package/dist/lib/app.js +34 -2
  3. package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
  4. package/dist/lib/config/aws/disasterRecovery.js +52 -32
  5. package/dist/lib/patterns/aws/cdn.d.ts +19 -11
  6. package/dist/lib/patterns/aws/cdn.js +53 -25
  7. package/dist/lib/patterns/aws/domain.js +3 -3
  8. package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
  9. package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
  10. package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
  11. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
  12. package/dist/lib/patterns/aws/payload.d.ts +5 -0
  13. package/dist/lib/patterns/aws/payload.js +23 -11
  14. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
  15. package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
  16. package/dist/lib/resources/aws/backup/backupVault.d.ts +17 -2
  17. package/dist/lib/resources/aws/backup/backupVault.js +37 -6
  18. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
  19. package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
  20. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
  21. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
  22. package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
  23. package/dist/lib/resources/aws/cdn/index.js +1 -0
  24. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
  25. package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
  26. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +14 -0
  27. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +90 -0
  28. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
  29. package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
  30. package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
  31. package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
  32. package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
  33. package/dist/lib/resources/aws/messaging/sqs.js +4 -0
  34. package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
  35. package/dist/lib/resources/aws/secrets/kms.d.ts +8 -0
  36. package/dist/lib/resources/aws/secrets/kms.js +12 -2
  37. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +6 -4
  38. package/dist/lib/utils/backupTierMapping.d.ts +11 -16
  39. package/dist/lib/utils/backupTierMapping.js +2 -15
  40. package/dist/lib/utils/cdkContext.d.ts +14 -0
  41. package/dist/lib/utils/cdkContext.js +38 -1
  42. package/dist/lib/utils/resourceNaming.d.ts +22 -0
  43. package/dist/lib/utils/resourceNaming.js +32 -0
  44. package/package.json +3 -3
@@ -0,0 +1,213 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Annotations, Stack, Token } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { CfnDelivery, CfnDeliveryDestination, CfnDeliverySource } from "aws-cdk-lib/aws-logs";
5
+ import { Construct } from "constructs";
6
+ /**
7
+ * Output formats CloudWatch Logs writes a CloudFront access-log delivery in.
8
+ * `json` is the default; `parquet` adds CloudWatch's conversion charge on top
9
+ * of delivery. Fixed at creation — a change replaces the destination.
10
+ */
11
+ export const CLOUDFRONT_LOG_FORMATS = [
12
+ "json",
13
+ "parquet",
14
+ "plain",
15
+ "w3c",
16
+ "raw"
17
+ ];
18
+ export const DEFAULT_CLOUDFRONT_LOG_FORMAT = "json";
19
+ /**
20
+ * CloudWatch Logs mints every CloudFront delivery source, destination and
21
+ * delivery in us-east-1 whatever region the bucket is in — the delivery ARNs
22
+ * read `arn:aws:logs:us-east-1:…` and the API refuses other regions.
23
+ */
24
+ export const CLOUDFRONT_LOG_DELIVERY_REGION = "us-east-1";
25
+ /** The service principal that writes vended logs into the bucket. */
26
+ export const CLOUDFRONT_LOG_DELIVERY_PRINCIPAL = "delivery.logs.amazonaws.com";
27
+ /** Objects land at `<prefix>/<yyyy>/<MM>/<dd>/<HH>/…`. Changeable in place. */
28
+ export const CLOUDFRONT_LOG_S3_SUFFIX_PATH = "{yyyy}/{MM}/{dd}/{HH}";
29
+ /**
30
+ * Every delivery source and destination is named under this prefix —
31
+ * `fjall-cf-<distributionId>` for the source, `fjall-cf-<distributionId>-<digest>`
32
+ * for the destination. The deploy-test residue scan attributes delivery
33
+ * resources by the prefix and the digest width and restates both; a change
34
+ * here is a change to what the scan can see.
35
+ */
36
+ export const CLOUDFRONT_LOG_DELIVERY_NAME_PREFIX = "fjall-cf-";
37
+ /** Hex characters of the sha256 the destination name carries. */
38
+ export const CLOUDFRONT_LOG_DELIVERY_DIGEST_LENGTH = 8;
39
+ /**
40
+ * The prefix is part of the destination ARN (`arn:aws:s3:::<bucket>/<prefix>`),
41
+ * a create-only property, and it keys the destination's name, so it must be
42
+ * a literal at synth. Leading and trailing slashes are dropped (CloudWatch
43
+ * joins the suffix path with its own separator); an empty prefix is refused
44
+ * rather than silently selecting the bucket-root `AWSLogs/…` layout.
45
+ */
46
+ export function normaliseCloudFrontLogPrefix(context, prefix) {
47
+ if (Token.isUnresolved(prefix)) {
48
+ throw new Error(`${context}: the log prefix must be a literal string at synth — it ` +
49
+ "keys the delivery destination's name — but a token was given.");
50
+ }
51
+ const normalised = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
52
+ if (normalised === "") {
53
+ throw new Error(`${context}: the log prefix is empty after dropping slashes. Give a ` +
54
+ "key prefix such as 'cloudfront/<id>', or omit it for the default.");
55
+ }
56
+ return normalised;
57
+ }
58
+ function assertDeliveryRegion(context, stack) {
59
+ const region = stack.region;
60
+ if (Token.isUnresolved(region)) {
61
+ throw new Error(`${context}: the delivery stack's region is unresolved at synth (a ` +
62
+ `region token). CloudWatch Logs accepts these resources in ` +
63
+ `${CLOUDFRONT_LOG_DELIVERY_REGION} only, so the stack must be ` +
64
+ "env-pinned — deploy through the Fjall CLI, or export " +
65
+ "CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION before synthesising.");
66
+ }
67
+ if (region !== CLOUDFRONT_LOG_DELIVERY_REGION) {
68
+ throw new Error(`${context}: CloudWatch Logs accepts CloudFront delivery resources in ` +
69
+ `${CLOUDFRONT_LOG_DELIVERY_REGION} only, but the scope's stack ` +
70
+ `'${stack.stackName}' is in ${region}. Create the delivery in the ` +
71
+ "app's us-east-1 logging stack (App.getUsEast1CdnLoggingStack()) — " +
72
+ "the Cdn pattern places it there itself.");
73
+ }
74
+ }
75
+ /**
76
+ * Everything create-only on the destination — the bucket, the prefix and
77
+ * the format — folds into its name, so a change replaces the destination
78
+ * under a NEW name: CloudFormation creates the replacement before deleting
79
+ * the original, and an identical name would collide. An imported bucket
80
+ * contributes its literal name; a bucket built here contributes its
81
+ * construct path (its physical name is a token), which is stable under an
82
+ * unchanged logical id — the one residual is a same-id bucket replacement,
83
+ * which keeps the name. Nothing tokenised is hashed: token spellings vary
84
+ * between synths.
85
+ */
86
+ function destinationDigest(bucket, prefix, format) {
87
+ const bucketKey = Token.isUnresolved(bucket.bucketName)
88
+ ? bucket.node.path
89
+ : bucket.bucketName;
90
+ return createHash("sha256")
91
+ .update(`${bucketKey}|${prefix}|${format}`)
92
+ .digest("hex")
93
+ .slice(0, CLOUDFRONT_LOG_DELIVERY_DIGEST_LENGTH);
94
+ }
95
+ /**
96
+ * CloudFront standard logging (v2): the CloudWatch Logs delivery source
97
+ * (the distribution), delivery destination (the bucket and prefix) and the
98
+ * delivery joining them, plus the bucket-policy statement the delivery
99
+ * principal writes through. The names — `fjall-cf-<distributionId>` and
100
+ * `fjall-cf-<distributionId>-<digest>` — carry no construct path, but
101
+ * CloudFormation replaces on the LOGICAL id and creates the replacement
102
+ * before deleting the original, and CloudWatch refuses a second source for
103
+ * the same distribution. So the three logical ids are load-bearing once a
104
+ * delivery is live: a re-layout of this construct or its owner strands the
105
+ * update in rollback, and is a breaking change with a migration (delete
106
+ * the live delivery first) rather than a refactor. The delivery test pins
107
+ * the ids in both layouts.
108
+ *
109
+ * The scope MUST be a us-east-1 stack (asserted at synth). When the
110
+ * distribution and bucket live in another region's stack, their ARNs reach
111
+ * this stack through CDK cross-region references; both are stable under an
112
+ * unchanged logical id, which the export writer requires.
113
+ */
114
+ export class CloudFrontLogDelivery extends Construct {
115
+ sourceName;
116
+ destinationName;
117
+ source;
118
+ destination;
119
+ delivery;
120
+ constructor(scope, id, props) {
121
+ super(scope, id);
122
+ const context = `CloudFront log delivery '${id}'`;
123
+ const stack = Stack.of(this);
124
+ assertDeliveryRegion(context, stack);
125
+ const { distribution, bucket, format } = props;
126
+ const prefix = normaliseCloudFrontLogPrefix(context, props.prefix);
127
+ this.sourceName = `${CLOUDFRONT_LOG_DELIVERY_NAME_PREFIX}${distribution.distributionId}`;
128
+ this.destinationName = `${this.sourceName}-${destinationDigest(bucket, prefix, format)}`;
129
+ this.source = new CfnDeliverySource(this, "Source", {
130
+ name: this.sourceName,
131
+ resourceArn: distribution.distributionArn,
132
+ logType: "ACCESS_LOGS"
133
+ });
134
+ this.destination = new CfnDeliveryDestination(this, "Destination", {
135
+ name: this.destinationName,
136
+ deliveryDestinationType: "S3",
137
+ destinationResourceArn: `${bucket.bucketArn}/${prefix}`,
138
+ outputFormat: format
139
+ });
140
+ this.delivery = new CfnDelivery(this, "Delivery", {
141
+ deliverySourceName: this.sourceName,
142
+ deliveryDestinationArn: this.destination.attrArn,
143
+ s3SuffixPath: CLOUDFRONT_LOG_S3_SUFFIX_PATH,
144
+ s3EnableHiveCompatiblePath: false
145
+ });
146
+ // `deliverySourceName` is a plain string: CloudFormation sees no edge to
147
+ // the source and would happily create the delivery first.
148
+ this.delivery.addDependency(this.source);
149
+ this.grantDeliveryWrite(context, bucket, prefix, stack);
150
+ }
151
+ /**
152
+ * The bucket-policy statement the delivery principal writes through, and
153
+ * the key-policy statement when the bucket is KMS-encrypted. Both carry the
154
+ * confused-deputy conditions; the bucket statement additionally requires
155
+ * `bucket-owner-full-control`, the one ACL a BucketOwnerEnforced bucket
156
+ * accepts. Imported buckets and keys are not managed here — a synth
157
+ * warning names the statement the owner must add, because a missing grant
158
+ * fails delivery silently rather than at deploy.
159
+ */
160
+ grantDeliveryWrite(context, bucket, prefix, deliveryStack) {
161
+ const bucketStack = Stack.of(bucket);
162
+ const principal = new ServicePrincipal(CLOUDFRONT_LOG_DELIVERY_PRINCIPAL);
163
+ const sourceArnPattern = `arn:${bucketStack.partition}:logs:${CLOUDFRONT_LOG_DELIVERY_REGION}:` +
164
+ `${bucketStack.account}:delivery-source:*`;
165
+ // The partition is a token in every stack; the account may be too.
166
+ const sourceArnForHumans = `arn:<partition>:logs:${CLOUDFRONT_LOG_DELIVERY_REGION}:` +
167
+ "<account-id>:delivery-source:*";
168
+ const deputyConditions = {
169
+ StringEquals: { "aws:SourceAccount": bucketStack.account },
170
+ ArnLike: { "aws:SourceArn": sourceArnPattern }
171
+ };
172
+ const bucketGrant = bucket.addToResourcePolicy(new PolicyStatement({
173
+ effect: Effect.ALLOW,
174
+ principals: [principal],
175
+ actions: ["s3:PutObject"],
176
+ resources: [bucket.arnForObjects(`${prefix}/*`)],
177
+ conditions: {
178
+ StringEquals: {
179
+ "s3:x-amz-acl": "bucket-owner-full-control",
180
+ ...deputyConditions.StringEquals
181
+ },
182
+ ArnLike: deputyConditions.ArnLike
183
+ }
184
+ }));
185
+ if (!bucketGrant.statementAdded) {
186
+ Annotations.of(this).addWarningV2("fjall:cloudfront:log-delivery-bucket-policy", `${context}: bucket '${bucket.node.path}' is imported, so its policy ` +
187
+ "is not managed here. Allow s3:PutObject on " +
188
+ `'${prefix}/*' to ${CLOUDFRONT_LOG_DELIVERY_PRINCIPAL} with ` +
189
+ "s3:x-amz-acl=bucket-owner-full-control, aws:SourceAccount and " +
190
+ `aws:SourceArn=${sourceArnForHumans} conditions yourself — ` +
191
+ "without it delivery fails silently.");
192
+ }
193
+ else if (bucket.policy !== undefined && bucketStack === deliveryStack) {
194
+ this.delivery.node.addDependency(bucket.policy);
195
+ }
196
+ if (bucket.encryptionKey === undefined)
197
+ return;
198
+ const keyGrant = bucket.encryptionKey.addToResourcePolicy(new PolicyStatement({
199
+ effect: Effect.ALLOW,
200
+ principals: [principal],
201
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
202
+ resources: ["*"],
203
+ conditions: deputyConditions
204
+ }), true);
205
+ if (!keyGrant.statementAdded) {
206
+ Annotations.of(this).addWarningV2("fjall:cloudfront:log-delivery-key-policy", `${context}: the bucket's KMS key '${bucket.encryptionKey.node.path}' ` +
207
+ "is imported, so its key policy is not managed here. Allow " +
208
+ `kms:GenerateDataKey* and kms:Decrypt to ${CLOUDFRONT_LOG_DELIVERY_PRINCIPAL} ` +
209
+ `with aws:SourceAccount and aws:SourceArn=${sourceArnForHumans} ` +
210
+ "conditions yourself — without it delivery fails silently.");
211
+ }
212
+ }
213
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./cloudFrontLogDelivery.js";
2
3
  export * from "./responseHeadersPolicy.js";
@@ -1,2 +1,3 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./cloudFrontLogDelivery.js";
2
3
  export * from "./responseHeadersPolicy.js";
@@ -9,16 +9,23 @@ export declare const ACCESS_LOG_RETENTION_DAYS = 90;
9
9
  /**
10
10
  * The one bucket an enterprise-tier stack delivers its access logs to —
11
11
  * application load balancer access logs (`alb/<name>/`) and CloudFront
12
- * standard logs (`cloudfront/<id>/`) share it under distinct prefixes.
12
+ * standard logs v2 (`cloudfront/<id>/`) share it under distinct prefixes.
13
13
  *
14
14
  * Its shape is dictated by the two delivery services and deliberately NOT
15
15
  * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
16
- * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
17
- * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
18
- * versioning would only multiply expired log objects — the `access-logs`
19
- * exemption marker tells posture so, landing `S3_VERSIONING_DISABLED`
20
- * `mitigated` rather than `open`. The bucket takes the env-aware removal
21
- * policy like every other bucket: production RETAINs its audit evidence,
22
- * non-production DESTROYs.
16
+ * KMS-encrypted bucket); both deliveries are bucket-policy grants, so
17
+ * ownership is `BUCKET_OWNER_ENFORCED` (ACLs off — the S3 default, and the
18
+ * posture every other Fjall bucket carries); and versioning would only
19
+ * multiply expired log objects — the `access-logs` exemption marker tells
20
+ * posture so, landing `S3_VERSIONING_DISABLED` `mitigated` rather than
21
+ * `open`. The bucket takes the env-aware removal policy like every other
22
+ * bucket: production RETAINs its audit evidence, non-production DESTROYs.
23
+ *
24
+ * Migration from 33.0.0–36.x: CloudFront standard (legacy) logging granted
25
+ * its `awslogsdelivery` canonical user FULL_CONTROL on the bucket ACL and
26
+ * never removes it, so on a bucket legacy logging wrote to the ownership
27
+ * flip fails with `InvalidBucketAclWithObjectOwnership`. Cure once, then
28
+ * redeploy: `aws s3api put-bucket-acl --bucket <name> --acl private`. The
29
+ * objects already delivered stay readable under the old prefix.
23
30
  */
24
31
  export declare function stackAccessLogBucket(scope: Construct): S3Bucket;
@@ -11,17 +11,24 @@ export const ACCESS_LOG_RETENTION_DAYS = 90;
11
11
  /**
12
12
  * The one bucket an enterprise-tier stack delivers its access logs to —
13
13
  * application load balancer access logs (`alb/<name>/`) and CloudFront
14
- * standard logs (`cloudfront/<id>/`) share it under distinct prefixes.
14
+ * standard logs v2 (`cloudfront/<id>/`) share it under distinct prefixes.
15
15
  *
16
16
  * Its shape is dictated by the two delivery services and deliberately NOT
17
17
  * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
18
- * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
19
- * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
20
- * versioning would only multiply expired log objects — the `access-logs`
21
- * exemption marker tells posture so, landing `S3_VERSIONING_DISABLED`
22
- * `mitigated` rather than `open`. The bucket takes the env-aware removal
23
- * policy like every other bucket: production RETAINs its audit evidence,
24
- * non-production DESTROYs.
18
+ * KMS-encrypted bucket); both deliveries are bucket-policy grants, so
19
+ * ownership is `BUCKET_OWNER_ENFORCED` (ACLs off — the S3 default, and the
20
+ * posture every other Fjall bucket carries); and versioning would only
21
+ * multiply expired log objects — the `access-logs` exemption marker tells
22
+ * posture so, landing `S3_VERSIONING_DISABLED` `mitigated` rather than
23
+ * `open`. The bucket takes the env-aware removal policy like every other
24
+ * bucket: production RETAINs its audit evidence, non-production DESTROYs.
25
+ *
26
+ * Migration from 33.0.0–36.x: CloudFront standard (legacy) logging granted
27
+ * its `awslogsdelivery` canonical user FULL_CONTROL on the bucket ACL and
28
+ * never removes it, so on a bucket legacy logging wrote to the ownership
29
+ * flip fails with `InvalidBucketAclWithObjectOwnership`. Cure once, then
30
+ * redeploy: `aws s3api put-bucket-acl --bucket <name> --acl private`. The
31
+ * objects already delivered stay readable under the old prefix.
25
32
  */
26
33
  export function stackAccessLogBucket(scope) {
27
34
  const stack = Stack.of(scope);
@@ -30,7 +37,7 @@ export function stackAccessLogBucket(scope) {
30
37
  return existing;
31
38
  return new S3Bucket(stack, STACK_ACCESS_LOG_BUCKET_ID, {
32
39
  encryption: BucketEncryption.S3_MANAGED,
33
- objectOwnership: ObjectOwnership.OBJECT_WRITER,
40
+ objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
34
41
  versioned: false,
35
42
  versioningExemption: "access-logs",
36
43
  lifecycleRules: [
@@ -0,0 +1,14 @@
1
+ import { type IRuleRef } from "aws-cdk-lib/aws-events";
2
+ import type { IQueue } from "aws-cdk-lib/aws-sqs";
3
+ /**
4
+ * Let `rule` deliver to `queue`. Not for a queue encrypted with a customer
5
+ * managed key: naming the rule there closes a dependency cycle through the
6
+ * key policy, which is why CDK's target grants those by account instead.
7
+ */
8
+ export declare function grantRuleTargetDelivery(queue: IQueue, rule: IRuleRef): void;
9
+ /**
10
+ * Let `rule` dead-letter into `queue`, on the terms of CDK's
11
+ * `addToDeadLetterQueueResourcePolicy`: a queue in another region is refused,
12
+ * and a queue in another account is left to its owner with the same warning.
13
+ */
14
+ export declare function grantRuleDeadLetterDelivery(queue: IQueue, rule: IRuleRef): void;
@@ -0,0 +1,90 @@
1
+ import { Construct } from "constructs";
2
+ import { Annotations, Lazy, Names, Token, TokenComparison } from "aws-cdk-lib";
3
+ import { CfnRule } from "aws-cdk-lib/aws-events";
4
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
+ const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
6
+ /** The actions `IQueue.grantSendMessages` grants — what CDK's SQS rule target allows. */
7
+ const RULE_TARGET_ACTIONS = [
8
+ "sqs:SendMessage",
9
+ "sqs:GetQueueAttributes",
10
+ "sqs:GetQueueUrl"
11
+ ];
12
+ const RULE_DEAD_LETTER_ACTIONS = ["sqs:SendMessage"];
13
+ /**
14
+ * One queue-policy statement letting EventBridge deliver from every rule
15
+ * registered against the queue, each named in the statement's
16
+ * `aws:SourceArn` condition.
17
+ *
18
+ * A rule cannot reach SQS through an IAM role: the queue's resource policy is
19
+ * the only grant. CDK's `SqsQueue` target and `addToDeadLetterQueueResourcePolicy`
20
+ * add one statement per rule, and SQS refuses a queue policy past 8,192
21
+ * bytes, so a queue shared by about eighteen rules stops deploying. Listing
22
+ * the rules in one statement costs one ARN per rule instead of a statement.
23
+ *
24
+ * The statement's `Sid` is the grant's construct id. `@aws-cdk/aws-iam:minimizePolicies`
25
+ * merges statements without a `Sid` whose conditions serialise alike, and every
26
+ * lazy value serialises alike, so a queue's delivery and dead-letter grants
27
+ * would merge and one grant's rules would lose access.
28
+ */
29
+ class RuleSourceArnGrant extends Construct {
30
+ #ruleArns = [];
31
+ static of(queue, id, actions) {
32
+ const existing = queue.node.tryFindChild(id);
33
+ if (existing instanceof RuleSourceArnGrant)
34
+ return existing;
35
+ return new RuleSourceArnGrant(queue, id, actions);
36
+ }
37
+ constructor(queue, id, actions) {
38
+ super(queue, id);
39
+ queue.addToResourcePolicy(new PolicyStatement({
40
+ sid: id,
41
+ principals: [new ServicePrincipal(EVENTBRIDGE_SERVICE_PRINCIPAL)],
42
+ actions: [...actions],
43
+ resources: [queue.queueArn],
44
+ conditions: {
45
+ ArnEquals: {
46
+ "aws:SourceArn": Lazy.any({
47
+ produce: () => this.#ruleArns.length === 1
48
+ ? this.#ruleArns[0]
49
+ : [...this.#ruleArns]
50
+ })
51
+ }
52
+ }
53
+ }));
54
+ }
55
+ addRule(rule) {
56
+ const ruleArn = CfnRule.arnForRule(rule);
57
+ if (!this.#ruleArns.includes(ruleArn))
58
+ this.#ruleArns.push(ruleArn);
59
+ }
60
+ }
61
+ /**
62
+ * Let `rule` deliver to `queue`. Not for a queue encrypted with a customer
63
+ * managed key: naming the rule there closes a dependency cycle through the
64
+ * key policy, which is why CDK's target grants those by account instead.
65
+ */
66
+ export function grantRuleTargetDelivery(queue, rule) {
67
+ RuleSourceArnGrant.of(queue, "EventBridgeRuleTargetGrant", RULE_TARGET_ACTIONS).addRule(rule);
68
+ }
69
+ /**
70
+ * Let `rule` dead-letter into `queue`, on the terms of CDK's
71
+ * `addToDeadLetterQueueResourcePolicy`: a queue in another region is refused,
72
+ * and a queue in another account is left to its owner with the same warning.
73
+ */
74
+ export function grantRuleDeadLetterDelivery(queue, rule) {
75
+ if (!sameEnvDimension(rule.env.region, queue.env.region)) {
76
+ throw new Error(`Cannot assign Dead Letter Queue in region ${queue.env.region} to the rule ${Names.nodeUniqueId(rule.node)} in region ${rule.env.region}. Both the queue and the rule must be in the same region.`);
77
+ }
78
+ if (!sameEnvDimension(rule.env.account, queue.env.account)) {
79
+ Annotations.of(rule).addWarningV2("@aws-cdk/aws-events-targets:manuallyAddDLQResourcePolicy", `Cannot add a resource policy to your dead letter queue associated with rule ${CfnRule.arnForRule(rule)} because the queue is in a different account. You must add the resource policy manually to the dead letter queue in account ${queue.env.account}.`);
80
+ return;
81
+ }
82
+ RuleSourceArnGrant.of(queue, "EventBridgeRuleDeadLetterGrant", RULE_DEAD_LETTER_ACTIONS).addRule(rule);
83
+ }
84
+ function sameEnvDimension(left, right) {
85
+ return [
86
+ TokenComparison.SAME,
87
+ TokenComparison.ONE_UNRESOLVED,
88
+ TokenComparison.BOTH_UNRESOLVED
89
+ ].includes(Token.compareStrings(left, right));
90
+ }
@@ -61,9 +61,10 @@ export interface ResolveTargetOptions {
61
61
  /**
62
62
  * Optional JSON payload for the target adapter (D5 — typed as
63
63
  * `Record<string, unknown>` at the EventBridge boundary). Translates to
64
- * `message` (SQS), `event` (Lambda / EventBus / CodeBuild), or `input`
65
- * (ECS task) on the underlying adapter via `RuleTargetInput.fromObject(...)`.
66
- * Values may be `EventField.*` tokens for payload-template substitution.
64
+ * `message` (SQS) or `event` (Lambda / CodeBuild) on the underlying adapter
65
+ * via `RuleTargetInput.fromObject(...)`. ECS, event-bus and log-group targets
66
+ * refuse it. Values may be `EventField.*` tokens for payload-template
67
+ * substitution.
67
68
  * For schedule-kind rules, `scheduledAt: EventField.time` is auto-injected
68
69
  * unless the caller already provided a `scheduledAt` key.
69
70
  */
@@ -1,8 +1,9 @@
1
1
  import { EventField, RuleTargetInput } from "aws-cdk-lib/aws-events";
2
2
  import { ArnFormat, Stack } from "aws-cdk-lib";
3
3
  import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
- import { SqsQueue as SqsTarget, LambdaFunction as LambdaTarget, EventBus as EventBusTarget, CodeBuildProject as CodeBuildTarget, EcsTask as EcsTaskTarget, addToDeadLetterQueueResourcePolicy, bindBaseTargetConfig } from "aws-cdk-lib/aws-events-targets";
4
+ import { SqsQueue as SqsTarget, LambdaFunction as LambdaTarget, EventBus as EventBusTarget, CodeBuildProject as CodeBuildTarget, EcsTask as EcsTaskTarget, bindBaseTargetConfig } from "aws-cdk-lib/aws-events-targets";
5
5
  import { SQSQueue } from "./sqs.js";
6
+ import { grantRuleDeadLetterDelivery, grantRuleTargetDelivery } from "./eventBridgeQueueGrants.js";
6
7
  import { EventBridgeBus } from "./eventbridge.js";
7
8
  import { LogGroup } from "../logging/logGroup.js";
8
9
  import { CodeBuildProject } from "../utilities/codeBuild.js";
@@ -49,10 +50,30 @@ function buildTargetInput(payload, kind) {
49
50
  * entry per D3.
50
51
  */
51
52
  export function resolveTarget(target, options) {
53
+ const adapter = resolveAdapter(target, options);
54
+ return options.deadLetterQueue === undefined
55
+ ? adapter
56
+ : withDeadLetterQueue(adapter, options.deadLetterQueue);
57
+ }
58
+ /**
59
+ * The DLQ is bound here, never on the adapter: CDK's adapters each add one
60
+ * `AllowEventRule<id>` statement per rule to the DLQ's policy, so a DLQ shared
61
+ * by many rules outgrows the SQS policy quota exactly as a shared target
62
+ * queue did.
63
+ */
64
+ function withDeadLetterQueue(adapter, deadLetterQueue) {
65
+ return {
66
+ bind: (rule, id) => {
67
+ const config = adapter.bind(rule, id);
68
+ grantRuleDeadLetterDelivery(deadLetterQueue, rule);
69
+ return { ...config, deadLetterConfig: { arn: deadLetterQueue.queueArn } };
70
+ }
71
+ };
72
+ }
73
+ function resolveAdapter(target, options) {
52
74
  const baseProps = {
53
75
  retryAttempts: options.retryPolicy?.retryAttempts,
54
- maxEventAge: options.retryPolicy?.maxEventAge,
55
- deadLetterQueue: options.deadLetterQueue
76
+ maxEventAge: options.retryPolicy?.maxEventAge
56
77
  };
57
78
  const targetInput = options.payload !== undefined
58
79
  ? buildTargetInput(options.payload, options.kind)
@@ -61,7 +82,7 @@ export function resolveTarget(target, options) {
61
82
  return resolveQueueTarget(target, options.messageGroupId, baseProps, targetInput);
62
83
  }
63
84
  if (target instanceof EventBridgeBus) {
64
- return resolveEventBusTarget(target, options.messageGroupId, baseProps, targetInput);
85
+ return resolveEventBusTarget(target, options.messageGroupId, targetInput);
65
86
  }
66
87
  if (target instanceof CodeBuildProject) {
67
88
  return resolveCodeBuildTarget(target, options.messageGroupId, baseProps, targetInput);
@@ -86,11 +107,28 @@ function resolveQueueTarget(target, messageGroupId, baseProps, targetInput) {
86
107
  if (!isFifo && messageGroupId !== undefined) {
87
108
  throw new Error("messageGroupId only applies to FIFO queue targets");
88
109
  }
89
- return new SqsTarget(queue, {
90
- ...baseProps,
91
- messageGroupId,
92
- ...(targetInput !== undefined && { message: targetInput })
93
- });
110
+ // CDK's adapter grants a customer-managed-key queue by account, one
111
+ // statement however many rules share it; naming each rule there would close
112
+ // a dependency cycle through the key policy.
113
+ if (queue.encryptionMasterKey !== undefined) {
114
+ return new SqsTarget(queue, {
115
+ ...baseProps,
116
+ messageGroupId,
117
+ ...(targetInput !== undefined && { message: targetInput })
118
+ });
119
+ }
120
+ return {
121
+ bind: (rule) => {
122
+ grantRuleTargetDelivery(queue, rule);
123
+ return {
124
+ ...bindBaseTargetConfig(baseProps),
125
+ arn: queue.queueArn,
126
+ input: targetInput,
127
+ targetResource: queue,
128
+ sqsParameters: messageGroupId !== undefined ? { messageGroupId } : undefined
129
+ };
130
+ }
131
+ };
94
132
  }
95
133
  function resolveLambdaTarget(target, messageGroupId, baseProps, targetInput) {
96
134
  rejectMessageGroupIdOnNonQueue("lambda", messageGroupId);
@@ -127,12 +165,19 @@ function resolveEcsTarget(target, messageGroupId, baseProps, targetInput) {
127
165
  taskCount: target.taskCount
128
166
  });
129
167
  }
130
- function resolveEventBusTarget(target, messageGroupId, baseProps, targetInput) {
168
+ /**
169
+ * `payload` is rejected: CDK's `EventBus` adapter carries no input, so the rule
170
+ * forwards the matched event to the bus unchanged and a payload would be
171
+ * dropped without a word.
172
+ */
173
+ function resolveEventBusTarget(target, messageGroupId, targetInput) {
131
174
  rejectMessageGroupIdOnNonQueue("eventBus", messageGroupId);
132
- return new EventBusTarget(target.getEventBus(), {
133
- ...baseProps,
134
- ...(targetInput !== undefined && { event: targetInput })
135
- });
175
+ if (targetInput !== undefined) {
176
+ throw new Error("payload is not supported on event-bus targets — the rule forwards the " +
177
+ "matched event to the bus unchanged, so a payload would be dropped. " +
178
+ "Target the consumer directly to send it a payload.");
179
+ }
180
+ return new EventBusTarget(target.getEventBus());
136
181
  }
137
182
  function resolveCodeBuildTarget(target, messageGroupId, baseProps, targetInput) {
138
183
  rejectMessageGroupIdOnNonQueue("codeBuild", messageGroupId);
@@ -169,23 +214,18 @@ function resolveLogGroupTarget(target, messageGroupId, baseProps, targetInput) {
169
214
  principals: [new ServicePrincipal("events.amazonaws.com")]
170
215
  }));
171
216
  return {
172
- bind: (rule) => {
173
- if (baseProps.deadLetterQueue !== undefined) {
174
- addToDeadLetterQueueResourcePolicy(rule, baseProps.deadLetterQueue);
175
- }
176
- return {
177
- ...bindBaseTargetConfig(baseProps),
178
- // EventBridge expects the log-group ARN WITHOUT the `:*` suffix that
179
- // `logGroupArn` carries.
180
- arn: Stack.of(target).formatArn({
181
- service: "logs",
182
- resource: "log-group",
183
- arnFormat: ArnFormat.COLON_RESOURCE_NAME,
184
- resourceName: target.logGroupName
185
- }),
186
- targetResource: target
187
- };
188
- }
217
+ bind: () => ({
218
+ ...bindBaseTargetConfig(baseProps),
219
+ // EventBridge expects the log-group ARN WITHOUT the `:*` suffix that
220
+ // `logGroupArn` carries.
221
+ arn: Stack.of(target).formatArn({
222
+ service: "logs",
223
+ resource: "log-group",
224
+ arnFormat: ArnFormat.COLON_RESOURCE_NAME,
225
+ resourceName: target.logGroupName
226
+ }),
227
+ targetResource: target
228
+ })
189
229
  };
190
230
  }
191
231
  function rejectMessageGroupIdOnNonQueue(kind, messageGroupId) {
@@ -0,0 +1,25 @@
1
+ import { type IQueue } from "aws-cdk-lib/aws-sqs";
2
+ /**
3
+ * SQS's queue-policy quotas.
4
+ * @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-policies.html
5
+ */
6
+ export declare const QUEUE_POLICY_LIMITS: {
7
+ readonly MAX_BYTES: 8192;
8
+ readonly MAX_STATEMENTS: 20;
9
+ };
10
+ /**
11
+ * Refuse, at synthesis, a queue policy SQS would refuse at deploy.
12
+ *
13
+ * Every grant to a queue — an EventBridge rule, an SNS subscription, an S3
14
+ * notification — lands in the queue's one policy, and SQS rejects the whole
15
+ * policy past its byte or statement quota. CloudFormation only finds out
16
+ * mid-deploy and rolls the stack back, so the check runs where the growth is
17
+ * authored.
18
+ *
19
+ * The byte count is an upper bound: an ARN CloudFormation has not minted yet
20
+ * is sized at the longest its partition, region, account and name can be, so
21
+ * the refusal can arrive a few grants before SQS's would. A reference whose
22
+ * shape is not known here takes CDK's ARN estimate, tunable through the
23
+ * `@aws-cdk/aws-iam.arnSizeEstimate` context key.
24
+ */
25
+ export declare function validateQueuePolicyQuota(queue: IQueue): string[];