@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.
- package/dist/lib/app.d.ts +52 -12
- package/dist/lib/app.js +60 -24
- package/dist/lib/config/aws/configRecorder.js +3 -0
- package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
- package/dist/lib/config/aws/disasterRecovery.js +167 -45
- package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
- package/dist/lib/patterns/aws/cdn.d.ts +19 -11
- package/dist/lib/patterns/aws/cdn.js +53 -25
- package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
- package/dist/lib/patterns/aws/computeEcs.js +17 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
- package/dist/lib/patterns/aws/domain.js +3 -3
- package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
- package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
- package/dist/lib/patterns/aws/messaging.d.ts +10 -0
- package/dist/lib/patterns/aws/messaging.js +10 -6
- package/dist/lib/patterns/aws/payload.d.ts +5 -0
- package/dist/lib/patterns/aws/payload.js +23 -11
- package/dist/lib/patterns/aws/storage.d.ts +41 -0
- package/dist/lib/patterns/aws/storage.js +23 -8
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
- package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
- package/dist/lib/resources/aws/backup/backupVault.d.ts +27 -2
- package/dist/lib/resources/aws/backup/backupVault.js +49 -6
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
- package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
- package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
- package/dist/lib/resources/aws/cdn/index.js +1 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
- package/dist/lib/resources/aws/compute/ecs.js +27 -21
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
- package/dist/lib/resources/aws/database/dynamodb.js +2 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
- package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
- package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
- package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
- package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +16 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +91 -0
- package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
- package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
- package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
- package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
- package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
- package/dist/lib/resources/aws/messaging/sqs.js +40 -2
- package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
- package/dist/lib/resources/aws/networking/vpc.js +12 -6
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
- package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
- package/dist/lib/resources/aws/storage/s3.js +89 -3
- package/dist/lib/utils/backupTierMapping.d.ts +23 -2
- package/dist/lib/utils/backupTierMapping.js +25 -0
- package/dist/lib/utils/cdkContext.d.ts +14 -0
- package/dist/lib/utils/cdkContext.js +38 -1
- package/dist/lib/utils/exportNaming.d.ts +30 -27
- package/dist/lib/utils/exportNaming.js +32 -28
- package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
- package/dist/lib/utils/standardTagsAspect.js +8 -34
- 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)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `mitigated` rather than
|
|
23
|
-
*
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)
|
|
65
|
-
*
|
|
66
|
-
* Values may be `EventField.*` tokens for payload-template
|
|
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,
|
|
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,
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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: (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
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", {
|