@fjall/components-infrastructure 37.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.
- package/dist/lib/app.d.ts +28 -0
- package/dist/lib/app.js +34 -2
- package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
- package/dist/lib/config/aws/disasterRecovery.js +52 -32
- package/dist/lib/patterns/aws/cdn.d.ts +19 -11
- package/dist/lib/patterns/aws/cdn.js +53 -25
- 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/payload.d.ts +5 -0
- package/dist/lib/patterns/aws/payload.js +23 -11
- 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 +17 -2
- package/dist/lib/resources/aws/backup/backupVault.js +37 -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/logging/accessLogBucket.d.ts +15 -8
- package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +14 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +90 -0
- 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.js +4 -0
- package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
- package/dist/lib/utils/cdkContext.d.ts +14 -0
- package/dist/lib/utils/cdkContext.js +38 -1
- 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
|
+
}
|
|
@@ -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)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `mitigated` rather than
|
|
21
|
-
*
|
|
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)
|
|
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–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.
|
|
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)
|
|
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[];
|