@fjall/components-infrastructure 36.0.0 → 38.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/resources/aws/secrets/kms.d.ts +8 -0
- package/dist/lib/resources/aws/secrets/kms.js +12 -2
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +6 -4
- package/dist/lib/utils/backupTierMapping.d.ts +11 -16
- package/dist/lib/utils/backupTierMapping.js +2 -15
- package/dist/lib/utils/cdkContext.d.ts +14 -0
- package/dist/lib/utils/cdkContext.js +38 -1
- package/dist/lib/utils/resourceNaming.d.ts +22 -0
- package/dist/lib/utils/resourceNaming.js +32 -0
- package/package.json +3 -3
|
@@ -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", {
|
|
@@ -4,6 +4,7 @@ import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
|
4
4
|
import { Queue, QueueEncryption, FifoThroughputLimit, DeduplicationScope } from "aws-cdk-lib/aws-sqs";
|
|
5
5
|
import { toRemovalPolicy } from "./utils.js";
|
|
6
6
|
import { createSqsDlqAlarms } from "../monitoring/index.js";
|
|
7
|
+
import { validateQueuePolicyQuota } from "./queuePolicyQuota.js";
|
|
7
8
|
/**
|
|
8
9
|
* AWS SQS service limits and defaults.
|
|
9
10
|
* @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html
|
|
@@ -169,6 +170,9 @@ export class SQSQueue extends Construct {
|
|
|
169
170
|
: undefined,
|
|
170
171
|
removalPolicy: resolvedRemovalPolicy
|
|
171
172
|
});
|
|
173
|
+
this.node.addValidation({
|
|
174
|
+
validate: () => [this.queue, this.dlq].flatMap((queue) => queue === undefined ? [] : validateQueuePolicyQuota(queue))
|
|
175
|
+
});
|
|
172
176
|
new CfnOutput(this, `${outputName}QueueUrl`, {
|
|
173
177
|
key: `${outputName}QueueUrl`,
|
|
174
178
|
value: this.queue.queueUrl,
|
|
@@ -30,15 +30,16 @@ export interface SubscriptionProps {
|
|
|
30
30
|
/**
|
|
31
31
|
* Optional JSON payload that overrides the matched event detail before
|
|
32
32
|
* delivery to the target (D5 — typed as `Record<string, unknown>`).
|
|
33
|
-
* Translates to `message` (SQS) or `event` (Lambda /
|
|
34
|
-
*
|
|
33
|
+
* Translates to `message` (SQS) or `event` (Lambda / CodeBuild) on the
|
|
34
|
+
* underlying adapter. Values may be `EventField.*` tokens for
|
|
35
35
|
* payload-template substitution (e.g.
|
|
36
36
|
* `EventField.fromPath("$.detail.repository.name")`). When omitted, the
|
|
37
37
|
* full matched event is forwarded verbatim — the common pub/sub case.
|
|
38
38
|
*
|
|
39
39
|
* Not supported on ECS targets — CDK's `EcsTask` adapter ignores arbitrary
|
|
40
40
|
* input; use container command/environment overrides on the task definition
|
|
41
|
-
* instead.
|
|
41
|
+
* instead. Nor on log-group targets, whose metric filters read the matched
|
|
42
|
+
* event untransformed. Passing `payload` against either throws.
|
|
42
43
|
*/
|
|
43
44
|
payload?: Record<string, unknown>;
|
|
44
45
|
/**
|
|
@@ -10,6 +10,14 @@ export type KeyProtectionScope = (typeof KEY_PROTECTION_SCOPES)[number];
|
|
|
10
10
|
* forgotten (tier-aware alert policy design §8 K4, `KMS_KEY_ORPHAN`).
|
|
11
11
|
*/
|
|
12
12
|
export { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
|
|
13
|
+
/**
|
|
14
|
+
* Stamped beside the retention marker, valued with the alias name as `kms
|
|
15
|
+
* list-aliases` reports it. The alias resource goes with the stack and
|
|
16
|
+
* CloudFormation tags nothing on a KMS key, so after a stack delete this tag
|
|
17
|
+
* is the only thing that still says which stack's key a retained key was —
|
|
18
|
+
* the deploy-test teardown releases retained stack keys by it.
|
|
19
|
+
*/
|
|
20
|
+
export { KMS_ALIAS_TAG_KEY } from "@fjall/util/aws";
|
|
13
21
|
interface CustomerManagedKeyProps {
|
|
14
22
|
description?: string;
|
|
15
23
|
aliasName?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
|
|
1
|
+
import { KMS_ALIAS_TAG_KEY, KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
|
|
2
2
|
import { CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
|
|
3
3
|
import { Alias, Key } from "aws-cdk-lib/aws-kms";
|
|
4
4
|
import { Construct } from "constructs";
|
|
@@ -17,6 +17,14 @@ export const KEY_PROTECTION_SCOPES = [
|
|
|
17
17
|
* forgotten (tier-aware alert policy design §8 K4, `KMS_KEY_ORPHAN`).
|
|
18
18
|
*/
|
|
19
19
|
export { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
|
|
20
|
+
/**
|
|
21
|
+
* Stamped beside the retention marker, valued with the alias name as `kms
|
|
22
|
+
* list-aliases` reports it. The alias resource goes with the stack and
|
|
23
|
+
* CloudFormation tags nothing on a KMS key, so after a stack delete this tag
|
|
24
|
+
* is the only thing that still says which stack's key a retained key was —
|
|
25
|
+
* the deploy-test teardown releases retained stack keys by it.
|
|
26
|
+
*/
|
|
27
|
+
export { KMS_ALIAS_TAG_KEY } from "@fjall/util/aws";
|
|
20
28
|
/**
|
|
21
29
|
* How long a scheduled-for-deletion key stays recoverable.
|
|
22
30
|
*
|
|
@@ -52,8 +60,10 @@ export class CustomerManagedKey extends Construct {
|
|
|
52
60
|
? PENDING_DELETION_WINDOW
|
|
53
61
|
: undefined
|
|
54
62
|
});
|
|
63
|
+
const aliasName = props.aliasName || ResourceNaming.defaultKeyAlias(id);
|
|
55
64
|
if (removalPolicy === RemovalPolicy.RETAIN) {
|
|
56
65
|
Tags.of(this.key).add(KMS_RETENTION_TAG_KEY, props.protects);
|
|
66
|
+
Tags.of(this.key).add(KMS_ALIAS_TAG_KEY, ResourceNaming.qualifiedKeyAlias(aliasName));
|
|
57
67
|
}
|
|
58
68
|
new CfnOutput(this, `${outputName}KeyArn`, {
|
|
59
69
|
key: `${outputName}Arn`,
|
|
@@ -61,7 +71,7 @@ export class CustomerManagedKey extends Construct {
|
|
|
61
71
|
exportName: stackScopedExportName(this, `${outputName}KeyArn`)
|
|
62
72
|
});
|
|
63
73
|
this.alias = new Alias(this, `${id}KeyAlias`, {
|
|
64
|
-
aliasName
|
|
74
|
+
aliasName,
|
|
65
75
|
targetKey: this.key
|
|
66
76
|
});
|
|
67
77
|
new CfnOutput(this, `${outputName}KeyAliasArn`, {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { tierDefault } from "../../../utils/backupTierMapping.js";
|
|
4
|
+
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
4
5
|
import { CustomerManagedKey } from "./kms.js";
|
|
5
|
-
const STACK_ENCRYPTION_KEY_ID =
|
|
6
|
+
const STACK_ENCRYPTION_KEY_ID = ResourceNaming.stackEncryptionKeyConstructId();
|
|
6
7
|
/**
|
|
7
8
|
* AWS services that publish to an application's SNS topics on the app's
|
|
8
9
|
* behalf and therefore encrypt messages through the topic's key: EventBridge
|
|
@@ -48,9 +49,10 @@ export class StackEncryptionKey extends CustomerManagedKey {
|
|
|
48
49
|
constructor(stack) {
|
|
49
50
|
super(stack, STACK_ENCRYPTION_KEY_ID, {
|
|
50
51
|
description: `Encrypts the enterprise-tier resources of ${stack.stackName} (DynamoDB tables, SNS topics, Lambda environment, ECS Exec sessions, log groups)`,
|
|
51
|
-
//
|
|
52
|
-
// the
|
|
53
|
-
|
|
52
|
+
// Derived through ResourceNaming so the deploy-test teardown, which
|
|
53
|
+
// finds the retained key by this alias after the stack is gone, cannot
|
|
54
|
+
// spell it differently.
|
|
55
|
+
aliasName: ResourceNaming.stackEncryptionKeyAlias(stack.stackName),
|
|
54
56
|
protects: "outlives-stack"
|
|
55
57
|
});
|
|
56
58
|
}
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
|
|
2
|
-
/** Tag key used by AWS Backup plan selection rules */
|
|
3
|
-
export declare const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
|
|
1
|
+
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
|
|
4
2
|
/**
|
|
5
|
-
* The application backup tier vocabulary
|
|
6
|
-
* `@fjall/util` (`BACKUP_TIERS`,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
3
|
+
* The application backup tier vocabulary, its defaults table, the DR tag key
|
|
4
|
+
* and the tier → tag-value map are owned by `@fjall/util` (`BACKUP_TIERS`,
|
|
5
|
+
* `BACKUP_TIER_DEFAULTS`, `tierDefault`, `BACKUP_TIER_TAG_KEY`,
|
|
6
|
+
* `BACKUP_TIER_TAG_MAP`) and shared with the generator (`BACKUP_VAULT_TIERS`),
|
|
7
|
+
* the CLI's synth expectations and the webapp's posture rules (which read the
|
|
8
|
+
* tag back from discovered resources), so every system derives from one
|
|
9
|
+
* declaration. Re-exported here so construct code keeps one import path for
|
|
10
|
+
* tier concerns: `App.applyBackupTier()` (app-level tag), `StandardTagsAspect`
|
|
11
|
+
* (per-resource tag) and `BackupPlan` (selection key) all read the same pair.
|
|
10
12
|
*/
|
|
11
13
|
export type { BackupTier, BackupTierDefaults };
|
|
12
|
-
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
|
|
13
|
-
/**
|
|
14
|
-
* Shared mapping from application backup tier names to AWS Backup plan tag values.
|
|
15
|
-
*
|
|
16
|
-
* Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
|
|
17
|
-
* (per-resource tag) to ensure consistent tier → tag translation.
|
|
18
|
-
*/
|
|
19
|
-
export declare const BACKUP_TIER_TAG_MAP: Readonly<Record<BackupTier, string>>;
|
|
14
|
+
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
|
|
20
15
|
/**
|
|
21
16
|
* The tier a resource actually runs under: its own override, else the
|
|
22
17
|
* app-level tier `App.getApp({ backup: { tier } })` declared. The single
|
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault } from "@fjall/util";
|
|
2
|
-
|
|
3
|
-
export const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
|
|
4
|
-
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
|
|
5
|
-
/**
|
|
6
|
-
* Shared mapping from application backup tier names to AWS Backup plan tag values.
|
|
7
|
-
*
|
|
8
|
-
* Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
|
|
9
|
-
* (per-resource tag) to ensure consistent tier → tag translation.
|
|
10
|
-
*/
|
|
11
|
-
export const BACKUP_TIER_TAG_MAP = Object.freeze({
|
|
12
|
-
standard: "default",
|
|
13
|
-
resilient: "resilient",
|
|
14
|
-
enterprise: "enterprise"
|
|
15
|
-
});
|
|
1
|
+
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault } from "@fjall/util";
|
|
2
|
+
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
|
|
16
3
|
/**
|
|
17
4
|
* The tier a resource actually runs under: its own override, else the
|
|
18
5
|
* app-level tier `App.getApp({ backup: { tier } })` declared. The single
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Node } from "constructs";
|
|
2
2
|
import { ACCOUNT_TRAIL_STATES, type AccountTrailState } from "@fjall/util/config";
|
|
3
|
+
import { type BackupVaultVerdict } from "@fjall/util";
|
|
3
4
|
export declare const CDK_CONTEXT_KEYS: {
|
|
4
5
|
readonly ORG_ID: "orgId";
|
|
5
6
|
readonly ROOT_ID: "rootId";
|
|
@@ -7,6 +8,7 @@ export declare const CDK_CONTEXT_KEYS: {
|
|
|
7
8
|
readonly ORG_CONFIG: "orgConfig";
|
|
8
9
|
readonly ACCOUNT_TRAIL_STATE: "fjallAccountTrailState";
|
|
9
10
|
readonly DEV_OU_ID: "fjallDevOuId";
|
|
11
|
+
readonly BACKUP_VAULT: "fjallBackupVault";
|
|
10
12
|
};
|
|
11
13
|
export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
|
|
12
14
|
/**
|
|
@@ -16,6 +18,18 @@ export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
|
|
|
16
18
|
* mid-decommission must fail the synth, not silently re-create a trail.
|
|
17
19
|
*/
|
|
18
20
|
export declare function resolveAccountTrailState(node: Node): AccountTrailState;
|
|
21
|
+
/**
|
|
22
|
+
* Reads the backup-vault ownership verdict the deploy engine injects before
|
|
23
|
+
* synth (`-c fjallBackupVault=<json>`; the wire contract is
|
|
24
|
+
* `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
|
|
25
|
+
* empty-string boundary, is `absent` — the create template, which is what a
|
|
26
|
+
* synth with no engine in front of it (a local `cdk synth`, a construct test)
|
|
27
|
+
* has always produced. Malformed context is a LOUD synth error, never a
|
|
28
|
+
* default: a mis-read verdict would either re-CREATE a colliding fixed-name
|
|
29
|
+
* vault or declare a re-owned vault against the wrong key, and
|
|
30
|
+
* `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readBackupVaultVerdict(node: Node): BackupVaultVerdict;
|
|
19
33
|
export declare const DEFAULT_ORG_ID: "default";
|
|
20
34
|
export declare function resolveOrgId(node: Node, fallback: string): string;
|
|
21
35
|
export declare function resolveOrgId(node: Node): string | undefined;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ACCOUNT_TRAIL_STATES } from "@fjall/util/config";
|
|
2
|
+
import { BACKUP_VAULT_CONTEXT_KEY, BackupVaultVerdictSchema } from "@fjall/util";
|
|
2
3
|
export const CDK_CONTEXT_KEYS = {
|
|
3
4
|
ORG_ID: "orgId",
|
|
4
5
|
ROOT_ID: "rootId",
|
|
5
6
|
MANAGEMENT_ACCOUNT_ID: "managementAccountId",
|
|
6
7
|
ORG_CONFIG: "orgConfig",
|
|
7
8
|
ACCOUNT_TRAIL_STATE: "fjallAccountTrailState",
|
|
8
|
-
DEV_OU_ID: "fjallDevOuId"
|
|
9
|
+
DEV_OU_ID: "fjallDevOuId",
|
|
10
|
+
BACKUP_VAULT: BACKUP_VAULT_CONTEXT_KEY
|
|
9
11
|
};
|
|
10
12
|
export { ACCOUNT_TRAIL_STATES };
|
|
11
13
|
/**
|
|
@@ -25,6 +27,41 @@ export function resolveAccountTrailState(node) {
|
|
|
25
27
|
}
|
|
26
28
|
throw new Error(`${CDK_CONTEXT_KEYS.ACCOUNT_TRAIL_STATE} must be one of ${ACCOUNT_TRAIL_STATES.join(", ")}; received "${String(raw)}"`);
|
|
27
29
|
}
|
|
30
|
+
const ABSENT_BACKUP_VAULT = { state: "absent" };
|
|
31
|
+
/**
|
|
32
|
+
* Reads the backup-vault ownership verdict the deploy engine injects before
|
|
33
|
+
* synth (`-c fjallBackupVault=<json>`; the wire contract is
|
|
34
|
+
* `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
|
|
35
|
+
* empty-string boundary, is `absent` — the create template, which is what a
|
|
36
|
+
* synth with no engine in front of it (a local `cdk synth`, a construct test)
|
|
37
|
+
* has always produced. Malformed context is a LOUD synth error, never a
|
|
38
|
+
* default: a mis-read verdict would either re-CREATE a colliding fixed-name
|
|
39
|
+
* vault or declare a re-owned vault against the wrong key, and
|
|
40
|
+
* `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
|
|
41
|
+
*/
|
|
42
|
+
export function readBackupVaultVerdict(node) {
|
|
43
|
+
const raw = node.tryGetContext(CDK_CONTEXT_KEYS.BACKUP_VAULT);
|
|
44
|
+
if (raw === undefined || raw === "") {
|
|
45
|
+
return ABSENT_BACKUP_VAULT;
|
|
46
|
+
}
|
|
47
|
+
let value = raw;
|
|
48
|
+
if (typeof raw === "string") {
|
|
49
|
+
// `-c key=value` always arrives as a string; tests may set the object form.
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is not valid JSON: ` +
|
|
55
|
+
`${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parsed = BackupVaultVerdictSchema.safeParse(value);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is malformed — refusing to ` +
|
|
61
|
+
`synth a backup vault whose ownership is unknown: ${parsed.error.message}`);
|
|
62
|
+
}
|
|
63
|
+
return parsed.data;
|
|
64
|
+
}
|
|
28
65
|
export const DEFAULT_ORG_ID = "default";
|
|
29
66
|
export function resolveOrgId(node, fallback) {
|
|
30
67
|
const raw = node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
|
|
@@ -77,6 +77,28 @@ export declare class ResourceNaming {
|
|
|
77
77
|
* reports.
|
|
78
78
|
*/
|
|
79
79
|
static defaultKeyAlias(constructId: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* Construct id of the one `StackEncryptionKey` an enterprise-tier stack
|
|
82
|
+
* mints (lib/resources/aws/secrets/stackEncryptionKey.ts). It is also the
|
|
83
|
+
* alias suffix, so the two are spelled once.
|
|
84
|
+
*/
|
|
85
|
+
static stackEncryptionKeyConstructId(): string;
|
|
86
|
+
/**
|
|
87
|
+
* Alias the stack encryption key is created under. KMS alias names are
|
|
88
|
+
* unique per account-region, so the stack name is the namespace. Unprefixed
|
|
89
|
+
* like the other alias helpers; the deploy tests that find the retained key
|
|
90
|
+
* after its stack is gone derive the reported form through
|
|
91
|
+
* [[qualifiedKeyAlias]], the same way the construct's alias tag does.
|
|
92
|
+
*/
|
|
93
|
+
static stackEncryptionKeyAlias(stackName: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* An alias name as KMS reports it. The CDK `Alias` prepends `alias/` to a
|
|
96
|
+
* resolved name that lacks it, and to a token only when the token's
|
|
97
|
+
* leading literal fragment lacks it — every Fjall alias starts with the
|
|
98
|
+
* literal `cmk/`, so the two rules agree. An already-qualified name is
|
|
99
|
+
* returned as it is.
|
|
100
|
+
*/
|
|
101
|
+
static qualifiedKeyAlias(aliasName: string): string;
|
|
80
102
|
/**
|
|
81
103
|
* S3 bucket holding a VPC's flow logs. `id` is the Network construct id —
|
|
82
104
|
* the network name for an additional VPC, `${stackPrefix}Vpc` for the
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { toKebab } from "./capitaliseString.js";
|
|
2
2
|
/** ELBv2's hard limit on an Application Load Balancer's name. */
|
|
3
3
|
const LOAD_BALANCER_NAME_MAX_LENGTH = 32;
|
|
4
|
+
/** What `kms list-aliases` prepends to every alias name. */
|
|
5
|
+
const KMS_ALIAS_PREFIX = "alias/";
|
|
4
6
|
/**
|
|
5
7
|
* Centralised resource naming for infrastructure.
|
|
6
8
|
* Ensures consistent, predictable names across constructs and tests.
|
|
@@ -127,6 +129,36 @@ export class ResourceNaming {
|
|
|
127
129
|
static defaultKeyAlias(constructId) {
|
|
128
130
|
return `cmk/${constructId}`;
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Construct id of the one `StackEncryptionKey` an enterprise-tier stack
|
|
134
|
+
* mints (lib/resources/aws/secrets/stackEncryptionKey.ts). It is also the
|
|
135
|
+
* alias suffix, so the two are spelled once.
|
|
136
|
+
*/
|
|
137
|
+
static stackEncryptionKeyConstructId() {
|
|
138
|
+
return "FjallStackEncryptionKey";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Alias the stack encryption key is created under. KMS alias names are
|
|
142
|
+
* unique per account-region, so the stack name is the namespace. Unprefixed
|
|
143
|
+
* like the other alias helpers; the deploy tests that find the retained key
|
|
144
|
+
* after its stack is gone derive the reported form through
|
|
145
|
+
* [[qualifiedKeyAlias]], the same way the construct's alias tag does.
|
|
146
|
+
*/
|
|
147
|
+
static stackEncryptionKeyAlias(stackName) {
|
|
148
|
+
return `cmk/${stackName}/${ResourceNaming.stackEncryptionKeyConstructId()}`;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* An alias name as KMS reports it. The CDK `Alias` prepends `alias/` to a
|
|
152
|
+
* resolved name that lacks it, and to a token only when the token's
|
|
153
|
+
* leading literal fragment lacks it — every Fjall alias starts with the
|
|
154
|
+
* literal `cmk/`, so the two rules agree. An already-qualified name is
|
|
155
|
+
* returned as it is.
|
|
156
|
+
*/
|
|
157
|
+
static qualifiedKeyAlias(aliasName) {
|
|
158
|
+
return aliasName.startsWith(KMS_ALIAS_PREFIX)
|
|
159
|
+
? aliasName
|
|
160
|
+
: `${KMS_ALIAS_PREFIX}${aliasName}`;
|
|
161
|
+
}
|
|
130
162
|
/**
|
|
131
163
|
* S3 bucket holding a VPC's flow logs. `id` is the Network construct id —
|
|
132
164
|
* the network name for an additional VPC, `${stackPrefix}Vpc` for the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "38.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
83
|
-
"@fjall/generator": "^
|
|
84
|
-
"@fjall/util": "^
|
|
83
|
+
"@fjall/generator": "^38.0.0",
|
|
84
|
+
"@fjall/util": "^38.0.0",
|
|
85
85
|
"constructs": "^10.7.2",
|
|
86
86
|
"zod": "^4.4.3"
|
|
87
87
|
},
|