@fjall/components-infrastructure 37.0.0 → 39.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 (72) hide show
  1. package/dist/lib/app.d.ts +52 -12
  2. package/dist/lib/app.js +60 -24
  3. package/dist/lib/config/aws/configRecorder.js +3 -0
  4. package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
  5. package/dist/lib/config/aws/disasterRecovery.js +167 -45
  6. package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
  7. package/dist/lib/patterns/aws/cdn.d.ts +19 -11
  8. package/dist/lib/patterns/aws/cdn.js +53 -25
  9. package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
  10. package/dist/lib/patterns/aws/computeEcs.js +17 -1
  11. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
  12. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  13. package/dist/lib/patterns/aws/domain.js +3 -3
  14. package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
  15. package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
  16. package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
  17. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
  18. package/dist/lib/patterns/aws/messaging.d.ts +10 -0
  19. package/dist/lib/patterns/aws/messaging.js +10 -6
  20. package/dist/lib/patterns/aws/payload.d.ts +5 -0
  21. package/dist/lib/patterns/aws/payload.js +23 -11
  22. package/dist/lib/patterns/aws/storage.d.ts +41 -0
  23. package/dist/lib/patterns/aws/storage.js +23 -8
  24. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
  25. package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
  26. package/dist/lib/resources/aws/backup/backupVault.d.ts +27 -2
  27. package/dist/lib/resources/aws/backup/backupVault.js +49 -6
  28. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
  29. package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
  30. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
  31. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
  32. package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
  33. package/dist/lib/resources/aws/cdn/index.js +1 -0
  34. package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
  35. package/dist/lib/resources/aws/compute/ecs.js +27 -21
  36. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
  37. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
  38. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
  39. package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
  40. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
  41. package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
  42. package/dist/lib/resources/aws/database/dynamodb.js +2 -1
  43. package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
  44. package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
  45. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
  46. package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
  47. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  48. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +16 -0
  49. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +91 -0
  50. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
  51. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
  52. package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
  53. package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
  54. package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
  55. package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
  56. package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
  57. package/dist/lib/resources/aws/messaging/sqs.js +40 -2
  58. package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
  59. package/dist/lib/resources/aws/networking/vpc.js +12 -6
  60. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
  61. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
  62. package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
  63. package/dist/lib/resources/aws/storage/s3.js +89 -3
  64. package/dist/lib/utils/backupTierMapping.d.ts +23 -2
  65. package/dist/lib/utils/backupTierMapping.js +25 -0
  66. package/dist/lib/utils/cdkContext.d.ts +14 -0
  67. package/dist/lib/utils/cdkContext.js +38 -1
  68. package/dist/lib/utils/exportNaming.d.ts +30 -27
  69. package/dist/lib/utils/exportNaming.js +32 -28
  70. package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
  71. package/dist/lib/utils/standardTagsAspect.js +8 -34
  72. package/package.json +4 -4
@@ -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–37.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: [
@@ -42,6 +42,9 @@ export class Trail extends Construct {
42
42
  encryptionKey: this.encryptionKey.key,
43
43
  versioned: true,
44
44
  removalPolicy: storagePolicy,
45
+ // CloudTrail delivers here continuously under the 365-day rule below;
46
+ // the audit record is the trail, not a restorable application state.
47
+ backupExemption: "log-delivery",
45
48
  // Custom lifecycleRules bypass S3Bucket's automatic
46
49
  // noncurrentVersionExpiration pairing — carried explicitly, or
47
50
  // noncurrent versions accumulate forever.
@@ -0,0 +1,16 @@
1
+ import { type IRuleRef } from "aws-cdk-lib/aws-events";
2
+ import type { IQueue } from "aws-cdk-lib/aws-sqs";
3
+ /** EventBridge's service principal — the sender every grant in this module admits. */
4
+ export declare const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
5
+ /**
6
+ * Let `rule` deliver to `queue`. Not for a queue encrypted with a customer
7
+ * managed key: naming the rule there closes a dependency cycle through the
8
+ * key policy, which is why CDK's target grants those by account instead.
9
+ */
10
+ export declare function grantRuleTargetDelivery(queue: IQueue, rule: IRuleRef): void;
11
+ /**
12
+ * Let `rule` dead-letter into `queue`, on the terms of CDK's
13
+ * `addToDeadLetterQueueResourcePolicy`: a queue in another region is refused,
14
+ * and a queue in another account is left to its owner with the same warning.
15
+ */
16
+ export declare function grantRuleDeadLetterDelivery(queue: IQueue, rule: IRuleRef): void;
@@ -0,0 +1,91 @@
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
+ /** EventBridge's service principal — the sender every grant in this module admits. */
6
+ export const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
7
+ /** The actions `IQueue.grantSendMessages` grants — what CDK's SQS rule target allows. */
8
+ const RULE_TARGET_ACTIONS = [
9
+ "sqs:SendMessage",
10
+ "sqs:GetQueueAttributes",
11
+ "sqs:GetQueueUrl"
12
+ ];
13
+ const RULE_DEAD_LETTER_ACTIONS = ["sqs:SendMessage"];
14
+ /**
15
+ * One queue-policy statement letting EventBridge deliver from every rule
16
+ * registered against the queue, each named in the statement's
17
+ * `aws:SourceArn` condition.
18
+ *
19
+ * A rule cannot reach SQS through an IAM role: the queue's resource policy is
20
+ * the only grant. CDK's `SqsQueue` target and `addToDeadLetterQueueResourcePolicy`
21
+ * add one statement per rule, and SQS refuses a queue policy past 8,192
22
+ * bytes, so a queue shared by about eighteen rules stops deploying. Listing
23
+ * the rules in one statement costs one ARN per rule instead of a statement.
24
+ *
25
+ * The statement's `Sid` is the grant's construct id. `@aws-cdk/aws-iam:minimizePolicies`
26
+ * merges statements without a `Sid` whose conditions serialise alike, and every
27
+ * lazy value serialises alike, so a queue's delivery and dead-letter grants
28
+ * would merge and one grant's rules would lose access.
29
+ */
30
+ class RuleSourceArnGrant extends Construct {
31
+ #ruleArns = [];
32
+ static of(queue, id, actions) {
33
+ const existing = queue.node.tryFindChild(id);
34
+ if (existing instanceof RuleSourceArnGrant)
35
+ return existing;
36
+ return new RuleSourceArnGrant(queue, id, actions);
37
+ }
38
+ constructor(queue, id, actions) {
39
+ super(queue, id);
40
+ queue.addToResourcePolicy(new PolicyStatement({
41
+ sid: id,
42
+ principals: [new ServicePrincipal(EVENTBRIDGE_SERVICE_PRINCIPAL)],
43
+ actions: [...actions],
44
+ resources: [queue.queueArn],
45
+ conditions: {
46
+ ArnEquals: {
47
+ "aws:SourceArn": Lazy.any({
48
+ produce: () => this.#ruleArns.length === 1
49
+ ? this.#ruleArns[0]
50
+ : [...this.#ruleArns]
51
+ })
52
+ }
53
+ }
54
+ }));
55
+ }
56
+ addRule(rule) {
57
+ const ruleArn = CfnRule.arnForRule(rule);
58
+ if (!this.#ruleArns.includes(ruleArn))
59
+ this.#ruleArns.push(ruleArn);
60
+ }
61
+ }
62
+ /**
63
+ * Let `rule` deliver to `queue`. Not for a queue encrypted with a customer
64
+ * managed key: naming the rule there closes a dependency cycle through the
65
+ * key policy, which is why CDK's target grants those by account instead.
66
+ */
67
+ export function grantRuleTargetDelivery(queue, rule) {
68
+ RuleSourceArnGrant.of(queue, "EventBridgeRuleTargetGrant", RULE_TARGET_ACTIONS).addRule(rule);
69
+ }
70
+ /**
71
+ * Let `rule` dead-letter into `queue`, on the terms of CDK's
72
+ * `addToDeadLetterQueueResourcePolicy`: a queue in another region is refused,
73
+ * and a queue in another account is left to its owner with the same warning.
74
+ */
75
+ export function grantRuleDeadLetterDelivery(queue, rule) {
76
+ if (!sameEnvDimension(rule.env.region, queue.env.region)) {
77
+ 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.`);
78
+ }
79
+ if (!sameEnvDimension(rule.env.account, queue.env.account)) {
80
+ 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}.`);
81
+ return;
82
+ }
83
+ RuleSourceArnGrant.of(queue, "EventBridgeRuleDeadLetterGrant", RULE_DEAD_LETTER_ACTIONS).addRule(rule);
84
+ }
85
+ function sameEnvDimension(left, right) {
86
+ return [
87
+ TokenComparison.SAME,
88
+ TokenComparison.ONE_UNRESOLVED,
89
+ TokenComparison.BOTH_UNRESOLVED
90
+ ].includes(Token.compareStrings(left, right));
91
+ }
@@ -1,9 +1,10 @@
1
1
  import { Construct } from "constructs";
2
- import { Duration } from "aws-cdk-lib";
2
+ import { Annotations, Duration } from "aws-cdk-lib";
3
3
  import { Rule } from "aws-cdk-lib/aws-events";
4
- import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { SQSQueue, SQS_LIMITS } from "./sqs.js";
6
6
  import { resolveTarget } from "./eventTargets.js";
7
+ import { EVENTBRIDGE_SERVICE_PRINCIPAL } from "./eventBridgeQueueGrants.js";
7
8
  import { createScheduleAlarms } from "../monitoring/scheduleAlarms.js";
8
9
  import { createSqsDlqAlarms } from "../monitoring/sqsAlarms.js";
9
10
  /**
@@ -36,7 +37,7 @@ export class EventBridgeRule extends Construct {
36
37
  const dlq = dlqResolution?.queue;
37
38
  if (dlq !== undefined)
38
39
  this.deadLetterQueue = dlq;
39
- grantKmsDecryptToEventBridge(dlq);
40
+ grantKmsDecryptToEventBridge(this, dlq);
40
41
  const retryPolicy = resolveRetryPolicy(props.kind, props.retryPolicy);
41
42
  const targetAdapter = resolveTarget(props.target, {
42
43
  retryPolicy,
@@ -84,13 +85,43 @@ export class EventBridgeRule extends Construct {
84
85
  return this.#rule.ruleArn;
85
86
  }
86
87
  }
87
- function grantKmsDecryptToEventBridge(dlq) {
88
+ /**
89
+ * Actions EventBridge needs on a dead-letter queue's key to encrypt the
90
+ * event it is dropping there.
91
+ */
92
+ const EVENTBRIDGE_DLQ_KEY_ACTIONS = ["kms:Decrypt", "kms:GenerateDataKey"];
93
+ /**
94
+ * Admit EventBridge to a CMK-encrypted dead-letter queue's key.
95
+ *
96
+ * Written as an `addToResourcePolicy` rather than `IKey.grant` because only
97
+ * the former reports whether the statement landed. A key imported by ARN —
98
+ * what `kmsKeyArn` on the patterns layer produces — carries no policy
99
+ * document CDK can write, and `grant` returns a Grant whose `success` is
100
+ * `true` regardless, so the admission would be lost in silence. It is the
101
+ * worst place to lose one: SQS DROPS a send its key refuses instead of
102
+ * returning an error, so the failure surfaces only as a dead letter that
103
+ * never arrives, long after the deploy that caused it.
104
+ *
105
+ * The warning therefore names the statement the key's owner must add. Fjall
106
+ * cannot add it: the key is theirs, and it may not even be in this account.
107
+ */
108
+ function grantKmsDecryptToEventBridge(scope, dlq) {
88
109
  if (dlq === undefined)
89
110
  return;
90
111
  const key = dlq.getQueue().encryptionMasterKey;
91
112
  if (key === undefined)
92
113
  return;
93
- key.grant(new ServicePrincipal("events.amazonaws.com"), "kms:Decrypt", "kms:GenerateDataKey");
114
+ const admitted = key.addToResourcePolicy(new PolicyStatement({
115
+ principals: [new ServicePrincipal(EVENTBRIDGE_SERVICE_PRINCIPAL)],
116
+ actions: EVENTBRIDGE_DLQ_KEY_ACTIONS,
117
+ resources: ["*"]
118
+ })).statementAdded;
119
+ if (admitted)
120
+ return;
121
+ Annotations.of(scope).addWarningV2("@fjall/components-infrastructure:eventBridgeDeadLetterKeyNotAdmitted", `The dead-letter queue's KMS key is imported, so this stack cannot add ` +
122
+ `to its key policy. Add ${EVENTBRIDGE_DLQ_KEY_ACTIONS.join(" + ")} for ` +
123
+ `${EVENTBRIDGE_SERVICE_PRINCIPAL} to the key yourself, or SQS will ` +
124
+ `silently drop every event EventBridge dead-letters to this queue.`);
94
125
  }
95
126
  function resolveRetryPolicy(kind, override) {
96
127
  if (override !== undefined)
@@ -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[];
@@ -0,0 +1,190 @@
1
+ import { CfnResource, Stack } from "aws-cdk-lib";
2
+ import { CfnEventBus, CfnRule } from "aws-cdk-lib/aws-events";
3
+ import { CfnQueue, QueuePolicy } from "aws-cdk-lib/aws-sqs";
4
+ /**
5
+ * SQS's queue-policy quotas.
6
+ * @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-policies.html
7
+ */
8
+ export const QUEUE_POLICY_LIMITS = {
9
+ MAX_BYTES: 8192,
10
+ MAX_STATEMENTS: 20
11
+ };
12
+ /** CDK's own ARN estimate, and the context key that tunes it, for a reference this module cannot size. */
13
+ const ARN_SIZE_ESTIMATE_CONTEXT_KEY = "@aws-cdk/aws-iam.arnSizeEstimate";
14
+ const DEFAULT_ARN_SIZE_ESTIMATE = 150;
15
+ const PSEUDO_PARAMETER_MAX_LENGTH = {
16
+ "AWS::AccountId": 12,
17
+ "AWS::Partition": 10,
18
+ "AWS::Region": 16,
19
+ "AWS::StackName": 128,
20
+ "AWS::URLSuffix": 16
21
+ };
22
+ const RULE_NAME_MAX_LENGTH = 64;
23
+ const QUEUE_NAME_MAX_LENGTH = 80;
24
+ const EVENT_BUS_NAME_MAX_LENGTH = 256;
25
+ /** `arn:<partition>:<service>:<region>:<account>:` at its longest, without the service. */
26
+ const ARN_PREFIX_MAX_LENGTH = "arn:::::".length +
27
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::Partition"] +
28
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::Region"] +
29
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::AccountId"];
30
+ /**
31
+ * Refuse, at synthesis, a queue policy SQS would refuse at deploy.
32
+ *
33
+ * Every grant to a queue — an EventBridge rule, an SNS subscription, an S3
34
+ * notification — lands in the queue's one policy, and SQS rejects the whole
35
+ * policy past its byte or statement quota. CloudFormation only finds out
36
+ * mid-deploy and rolls the stack back, so the check runs where the growth is
37
+ * authored.
38
+ *
39
+ * The byte count is an upper bound: an ARN CloudFormation has not minted yet
40
+ * is sized at the longest its partition, region, account and name can be, so
41
+ * the refusal can arrive a few grants before SQS's would. A reference whose
42
+ * shape is not known here takes CDK's ARN estimate, tunable through the
43
+ * `@aws-cdk/aws-iam.arnSizeEstimate` context key.
44
+ */
45
+ export function validateQueuePolicyQuota(queue) {
46
+ const policy = queue.node.children.find((child) => child instanceof QueuePolicy);
47
+ if (policy === undefined)
48
+ return [];
49
+ const stack = Stack.of(queue);
50
+ const document = stack.resolve(policy.document);
51
+ const statements = isRecord(document) ? document.Statement : undefined;
52
+ const statementCount = Array.isArray(statements) ? statements.length : 0;
53
+ const estimator = new PolicySizeEstimator(stack);
54
+ const bytes = estimator.jsonLength(document);
55
+ const errors = [];
56
+ if (statementCount > QUEUE_POLICY_LIMITS.MAX_STATEMENTS) {
57
+ errors.push(`The resource policy on queue ${queue.node.path} has ${statementCount} statements; SQS allows at most ${QUEUE_POLICY_LIMITS.MAX_STATEMENTS}. Split the grants across queues.`);
58
+ }
59
+ if (bytes > QUEUE_POLICY_LIMITS.MAX_BYTES) {
60
+ const unsized = estimator.unsizedReferences === 0
61
+ ? ""
62
+ : ` ${estimator.unsizedReferences} reference(s) this check cannot size are counted at ${estimator.unsizedReferenceBytes} bytes each; if those ARNs are shorter, lower the "${ARN_SIZE_ESTIMATE_CONTEXT_KEY}" context.`;
63
+ errors.push(`The resource policy on queue ${queue.node.path} may reach ${bytes} bytes; SQS refuses a policy over ${QUEUE_POLICY_LIMITS.MAX_BYTES}. Every EventBridge rule, SNS topic or other source granted this queue adds to the one policy — split them across queues.${unsized}`);
64
+ }
65
+ return errors;
66
+ }
67
+ class PolicySizeEstimator {
68
+ #stack;
69
+ #resources;
70
+ #unsizedReferences = 0;
71
+ constructor(stack) {
72
+ this.#stack = stack;
73
+ }
74
+ /** How many references this estimator has counted at the ARN estimate. */
75
+ get unsizedReferences() {
76
+ return this.#unsizedReferences;
77
+ }
78
+ get unsizedReferenceBytes() {
79
+ const configured = this.#stack.node.tryGetContext(ARN_SIZE_ESTIMATE_CONTEXT_KEY);
80
+ return typeof configured === "number" && configured > 0
81
+ ? configured
82
+ : DEFAULT_ARN_SIZE_ESTIMATE;
83
+ }
84
+ /** Bytes of `value` serialised as compact JSON, with each intrinsic sized as the string it deploys as. */
85
+ jsonLength(value) {
86
+ if (Array.isArray(value)) {
87
+ return (2 +
88
+ Math.max(value.length - 1, 0) +
89
+ value.reduce((sum, item) => sum + this.jsonLength(item), 0));
90
+ }
91
+ if (isRecord(value)) {
92
+ const intrinsic = this.#intrinsicLength(value);
93
+ if (intrinsic !== undefined)
94
+ return intrinsic + 2;
95
+ const entries = Object.entries(value);
96
+ return (2 +
97
+ Math.max(entries.length - 1, 0) +
98
+ entries.reduce((sum, [key, item]) => sum + utf8Length(JSON.stringify(key)) + 1 + this.jsonLength(item), 0));
99
+ }
100
+ return utf8Length(JSON.stringify(value) ?? "null");
101
+ }
102
+ #intrinsicLength(value) {
103
+ const keys = Object.keys(value);
104
+ if (keys.length !== 1)
105
+ return undefined;
106
+ const [name] = keys;
107
+ const argument = value[name];
108
+ if (name === "Ref" && typeof argument === "string") {
109
+ return Object.hasOwn(PSEUDO_PARAMETER_MAX_LENGTH, argument)
110
+ ? PSEUDO_PARAMETER_MAX_LENGTH[argument]
111
+ : this.#refLength(argument);
112
+ }
113
+ if (name === "Fn::Join" && Array.isArray(argument)) {
114
+ const [separator, parts] = argument;
115
+ if (typeof separator === "string" && Array.isArray(parts)) {
116
+ return (separator.length * Math.max(parts.length - 1, 0) +
117
+ parts.reduce((sum, part) => sum + this.#stringLength(part), 0));
118
+ }
119
+ }
120
+ if (name === "Fn::GetAtt" && Array.isArray(argument)) {
121
+ const [logicalId, attribute] = argument;
122
+ if (typeof logicalId === "string" && attribute === "Arn") {
123
+ return this.#arnLength(logicalId);
124
+ }
125
+ }
126
+ return name.startsWith("Fn::") ? this.#unknownLength() : undefined;
127
+ }
128
+ #stringLength(value) {
129
+ if (typeof value === "string")
130
+ return utf8Length(value);
131
+ const intrinsic = isRecord(value)
132
+ ? this.#intrinsicLength(value)
133
+ : undefined;
134
+ return intrinsic ?? this.#unknownLength();
135
+ }
136
+ #arnLength(logicalId) {
137
+ const resource = this.#resourceByLogicalId(logicalId);
138
+ if (resource instanceof CfnRule) {
139
+ const busName = resource.eventBusName;
140
+ const bus = busName === undefined
141
+ ? 0
142
+ : this.#stringLength(this.#stack.resolve(busName)) + 1;
143
+ return (ARN_PREFIX_MAX_LENGTH +
144
+ "events".length +
145
+ "rule/".length +
146
+ bus +
147
+ this.#nameLength(resource.name, RULE_NAME_MAX_LENGTH));
148
+ }
149
+ if (resource instanceof CfnQueue) {
150
+ return (ARN_PREFIX_MAX_LENGTH +
151
+ "sqs".length +
152
+ this.#nameLength(resource.queueName, QUEUE_NAME_MAX_LENGTH));
153
+ }
154
+ return this.#unknownLength();
155
+ }
156
+ /** `Ref` on an event bus returns its name; any other resource's `Ref` is not sized here. */
157
+ #refLength(logicalId) {
158
+ const resource = this.#resourceByLogicalId(logicalId);
159
+ return resource instanceof CfnEventBus
160
+ ? this.#nameLength(resource.name, EVENT_BUS_NAME_MAX_LENGTH)
161
+ : this.#unknownLength();
162
+ }
163
+ /** A name as deployed, or `bound` when CloudFormation will generate it or it resolves to an intrinsic. */
164
+ #nameLength(value, bound) {
165
+ if (value === undefined)
166
+ return bound;
167
+ const resolved = this.#stack.resolve(value);
168
+ return typeof resolved === "string" ? utf8Length(resolved) : bound;
169
+ }
170
+ #resourceByLogicalId(logicalId) {
171
+ if (this.#resources === undefined) {
172
+ const stack = this.#stack;
173
+ this.#resources = new Map(stack.node
174
+ .findAll()
175
+ .filter((construct) => construct instanceof CfnResource && Stack.of(construct) === stack)
176
+ .map((resource) => [stack.resolve(resource.logicalId), resource]));
177
+ }
178
+ return this.#resources.get(logicalId);
179
+ }
180
+ #unknownLength() {
181
+ this.#unsizedReferences++;
182
+ return this.unsizedReferenceBytes;
183
+ }
184
+ }
185
+ function utf8Length(value) {
186
+ return Buffer.byteLength(value, "utf8");
187
+ }
188
+ function isRecord(value) {
189
+ return typeof value === "object" && value !== null && !Array.isArray(value);
190
+ }
@@ -31,14 +31,14 @@ export interface ScheduleProps {
31
31
  /**
32
32
  * JSON payload sent to the target on every tick (D5 — typed as
33
33
  * `Record<string, unknown>`). Translates to `message` (SQS) or `event`
34
- * (Lambda / EventBus) on the underlying adapter. Values may be
35
- * `EventField.*` tokens for payload-template substitution.
36
- * `scheduledAt: EventField.time` is auto-injected unless the caller
37
- * already provides a `scheduledAt` key.
34
+ * (Lambda) on the underlying adapter. Values may be `EventField.*` tokens
35
+ * for payload-template substitution. `scheduledAt: EventField.time` is
36
+ * auto-injected unless the caller already provides a `scheduledAt` key.
38
37
  *
39
38
  * Not supported on ECS targets — CDK's `EcsTask` adapter ignores arbitrary
40
39
  * input; use container command/environment overrides on the task definition
41
- * instead. Passing `payload` against an ECS target throws.
40
+ * instead. Nor on event-bus targets, which forward the scheduled event
41
+ * unchanged. Passing `payload` against either throws.
42
42
  *
43
43
  * @example
44
44
  * app.addSchedule("MetricsCollection", {