@fjall/components-infrastructure 33.1.0 → 35.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/lib/app.js +2 -0
  2. package/dist/lib/config/aws/deployBoundarySingleton.d.ts +6 -0
  3. package/dist/lib/config/aws/deployBoundarySingleton.js +10 -2
  4. package/dist/lib/config/aws/identityCenter.js +2 -0
  5. package/dist/lib/config/aws/scpPreset.js +11 -1
  6. package/dist/lib/patterns/aws/bastionFactory.js +2 -1
  7. package/dist/lib/patterns/aws/cdn.d.ts +4 -1
  8. package/dist/lib/patterns/aws/clickhouseDatabase.js +2 -4
  9. package/dist/lib/patterns/aws/compute.d.ts +2 -2
  10. package/dist/lib/patterns/aws/computeEc2.d.ts +5 -18
  11. package/dist/lib/patterns/aws/computeEc2.js +9 -3
  12. package/dist/lib/patterns/aws/computeEcs.d.ts +4 -4
  13. package/dist/lib/patterns/aws/computeEcs.js +5 -9
  14. package/dist/lib/patterns/aws/computePropApplicability.js +1 -1
  15. package/dist/lib/patterns/aws/fivetranProxy.d.ts +8 -3
  16. package/dist/lib/patterns/aws/fivetranProxy.js +9 -6
  17. package/dist/lib/patterns/aws/interfaces/compute.d.ts +8 -0
  18. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +16 -0
  19. package/dist/lib/resources/aws/cdn/cloudFront.js +52 -2
  20. package/dist/lib/resources/aws/compute/ec2.d.ts +33 -6
  21. package/dist/lib/resources/aws/compute/ec2.js +63 -14
  22. package/dist/lib/resources/aws/compute/ec2SshAccess.d.ts +80 -0
  23. package/dist/lib/resources/aws/compute/ec2SshAccess.js +166 -0
  24. package/dist/lib/resources/aws/compute/ecs.d.ts +11 -2
  25. package/dist/lib/resources/aws/compute/ecs.js +29 -6
  26. package/dist/lib/resources/aws/compute/lambda.d.ts +5 -0
  27. package/dist/lib/resources/aws/compute/lambda.js +9 -3
  28. package/dist/lib/resources/aws/database/dynamodb.js +5 -6
  29. package/dist/lib/resources/aws/iam/identityCenter/user.js +2 -0
  30. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +5 -3
  31. package/dist/lib/resources/aws/logging/accessLogBucket.js +6 -3
  32. package/dist/lib/resources/aws/messaging/sns.d.ts +17 -7
  33. package/dist/lib/resources/aws/messaging/sns.js +4 -4
  34. package/dist/lib/resources/aws/networking/vpcPeeringAccepterRole.js +3 -2
  35. package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +2 -1
  36. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +17 -4
  37. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +20 -4
  38. package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +5 -0
  39. package/dist/lib/resources/aws/storage/bucketDeployment.js +7 -0
  40. package/dist/lib/resources/aws/storage/s3.d.ts +2 -1
  41. package/dist/lib/resources/aws/utilities/awsCustomResource.d.ts +9 -0
  42. package/dist/lib/resources/aws/utilities/awsCustomResource.js +16 -0
  43. package/dist/lib/resources/aws/utilities/customResource.js +2 -0
  44. package/dist/lib/resources/aws/utilities/customResourceProvider.d.ts +6 -0
  45. package/dist/lib/resources/aws/utilities/customResourceProvider.js +8 -0
  46. package/dist/lib/utils/deployTimeWriterMarkerAspect.d.ts +23 -0
  47. package/dist/lib/utils/deployTimeWriterMarkerAspect.js +35 -0
  48. package/dist/lib/utils/resourceTypeTags.d.ts +41 -0
  49. package/dist/lib/utils/resourceTypeTags.js +54 -0
  50. package/package.json +5 -4
@@ -1,8 +1,11 @@
1
1
  import { InstanceType, LaunchTemplate, SubnetType, MachineImage, LaunchTemplateHttpTokens, KeyPair, SecurityGroup, Peer, Port, Connections } from "aws-cdk-lib/aws-ec2";
2
2
  import { Construct } from "constructs";
3
+ import { WORLD_OPEN_IPV4_CIDR } from "@fjall/generator";
4
+ import { mintedEc2KeyPairName } from "@fjall/util";
5
+ import { EC2_WORLD_OPEN_SSH_WARNING_ID, checkRetiredSshProp, sshFacesInternet, validateSshAccess, worldOpenSshWarning } from "./ec2SshAccess.js";
3
6
  import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
4
7
  import { safeEbs } from "./blockDeviceVolume.js";
5
- import { Duration, Stack, Tags } from "aws-cdk-lib";
8
+ import { Annotations, Duration, Stack, Tags } from "aws-cdk-lib";
6
9
  import { AutoScalingGroup, GroupMetrics, InstanceRefreshStrategy, Monitoring, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
7
10
  import { DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS, INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY, INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY, INSTANCE_REFRESH_PDV_MAX_HEALTHY, INSTANCE_REFRESH_PDV_MIN_HEALTHY } from "./ecsConstants.js";
8
11
  import { AwsCustomResource } from "../utilities/awsCustomResource.js";
@@ -72,11 +75,19 @@ export class Ec2Instance extends Construct {
72
75
  autoScalingGroup;
73
76
  keyPair;
74
77
  persistentDataVolume;
78
+ vpcSubnets;
79
+ associatePublicIpAddress;
75
80
  connections;
76
81
  constructor(scope, id, props) {
77
82
  super(scope, id);
78
83
  validateEc2InstanceProps(props);
84
+ checkRetiredSshProp(this, "Ec2Instance", props);
85
+ // `sshAccess.public` assigns a public IP unless the caller's own prop
86
+ // decides; validateEc2InstanceProps has already rejected a contradiction.
87
+ this.associatePublicIpAddress =
88
+ props.associatePublicIpAddress ?? props.sshAccess?.public === true;
79
89
  this.addVpc(props);
90
+ this.vpcSubnets = this.resolveVpcSubnets(props);
80
91
  this.addKeyPair(props);
81
92
  this.addLaunchTemplate(props);
82
93
  this.addAutoScalingGroup(props);
@@ -101,13 +112,35 @@ export class Ec2Instance extends Construct {
101
112
  }
102
113
  }
103
114
  addKeyPair(props) {
104
- if (!props.enableSSH) {
115
+ const access = props.sshAccess;
116
+ if (access === undefined) {
105
117
  this.keyPair = undefined;
118
+ return;
106
119
  }
107
- else {
108
- this.keyPair = new KeyPair(this, "KeyPair", {
109
- keyPairName: `${props.serviceName}KeyPair`
110
- });
120
+ this.keyPair =
121
+ access.keyName !== undefined
122
+ ? KeyPair.fromKeyPairName(this, "KeyPair", access.keyName)
123
+ : new KeyPair(this, "KeyPair", {
124
+ keyPairName: mintedEc2KeyPairName(props.serviceName)
125
+ });
126
+ }
127
+ /**
128
+ * One ingress rule per source range. A world-open range still lands (the
129
+ * caller asked for it) but warns; the wording reads the placement the group
130
+ * actually resolved to, not `sshAccess.public` alone, because a caller who
131
+ * owns the placement through public `vpcSubnets` and
132
+ * `associatePublicIpAddress` exposes it just the same.
133
+ */
134
+ addSshIngress(props, access) {
135
+ const facesInternet = sshFacesInternet({
136
+ associatePublicIpAddress: this.associatePublicIpAddress,
137
+ hasPublicSubnets: this.vpc.selectSubnets(this.vpcSubnets).hasPublic
138
+ });
139
+ for (const cidr of access.allowedIpCidrs) {
140
+ if (cidr === WORLD_OPEN_IPV4_CIDR) {
141
+ Annotations.of(this).addWarningV2(EC2_WORLD_OPEN_SSH_WARNING_ID, worldOpenSshWarning(props.serviceName, facesInternet));
142
+ }
143
+ this.asgSecurityGroup.addIngressRule(Peer.ipv4(cidr), Port.tcp(22), `SSH from ${cidr}`);
111
144
  }
112
145
  }
113
146
  addLaunchTemplate(props) {
@@ -120,8 +153,8 @@ export class Ec2Instance extends Construct {
120
153
  description: `Security group for the ${props.serviceName} auto scaling group`
121
154
  });
122
155
  }
123
- if (props.enableSSH) {
124
- this.asgSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(22), "Allow SSH");
156
+ if (props.sshAccess !== undefined) {
157
+ this.addSshIngress(props, props.sshAccess);
125
158
  }
126
159
  this.launchTemplate = new LaunchTemplate(this, "LaunchTemplate", {
127
160
  launchTemplateName: `${props.serviceName}LaunchTemplate`,
@@ -144,15 +177,30 @@ export class Ec2Instance extends Construct {
144
177
  httpTokens: LaunchTemplateHttpTokens.REQUIRED,
145
178
  instanceMetadataTags: true,
146
179
  keyPair: this.keyPair,
147
- associatePublicIpAddress: props.associatePublicIpAddress ?? !!this.keyPair
180
+ associatePublicIpAddress: this.associatePublicIpAddress
148
181
  });
149
182
  }
183
+ /**
184
+ * `sshAccess.public` decides the subnet type; an explicit `vpcSubnets` keeps
185
+ * its other selectors (AZ pinning) — `validateEc2InstanceProps` has already
186
+ * rejected a selection that contradicts `public: true`, so filling in
187
+ * `PUBLIC` here only completes an AZ-only pin.
188
+ */
189
+ resolveVpcSubnets(props) {
190
+ const isPublic = props.sshAccess?.public === true;
191
+ if (props.vpcSubnets === undefined) {
192
+ return {
193
+ subnetType: isPublic
194
+ ? SubnetType.PUBLIC
195
+ : resolvePrivateSubnetType(this.vpc)
196
+ };
197
+ }
198
+ return isPublic
199
+ ? { ...props.vpcSubnets, subnetType: SubnetType.PUBLIC }
200
+ : props.vpcSubnets;
201
+ }
150
202
  addAutoScalingGroup(props) {
151
- const vpcSubnets = props.vpcSubnets ?? {
152
- subnetType: props.enableSSH
153
- ? SubnetType.PUBLIC
154
- : resolvePrivateSubnetType(this.vpc)
155
- };
203
+ const vpcSubnets = this.vpcSubnets;
156
204
  const resolvedUpdatePolicy = resolveUpdatePolicy(props.updatePolicy, props.persistentDataVolume !== undefined);
157
205
  const baseAsgProps = {
158
206
  vpc: this.vpc,
@@ -282,6 +330,7 @@ export class Ec2Instance extends Construct {
282
330
  }
283
331
  }
284
332
  function validateEc2InstanceProps(props) {
333
+ validateSshAccess(props);
285
334
  if (props.spotCapacityPercentage !== undefined) {
286
335
  if (!Number.isInteger(props.spotCapacityPercentage) ||
287
336
  props.spotCapacityPercentage < 0 ||
@@ -0,0 +1,80 @@
1
+ import { type ISecurityGroup, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
2
+ import { type IConstruct } from "constructs";
3
+ /**
4
+ * SSH access to the instances. Each field is one decision the construct used
5
+ * to fold into a single `enableSSH` boolean. Absent → no key pair, no port-22
6
+ * ingress, private subnets and no public IP; Session Manager remains the
7
+ * access path.
8
+ */
9
+ export interface Ec2SshAccessProps {
10
+ /**
11
+ * IPv4 source ranges allowed on TCP 22, one ingress rule each. At least one
12
+ * entry, no duplicates. A literal is validated as dotted-quad/prefix-length
13
+ * (octets 0–255, prefix 0–32); a CDK token per element such as
14
+ * `vpc.vpcCidrBlock` passes through to CloudFormation, but the list itself
15
+ * must be literal at synth (no `valueAsList` / `Fn.split` over a token),
16
+ * because each range becomes one rule. `"0.0.0.0/0"` synthesises with a
17
+ * warning that names the instance.
18
+ */
19
+ allowedIpCidrs: string[];
20
+ /**
21
+ * Existing EC2 key pair name, resolved with `KeyPair.fromKeyPairName`. Omit
22
+ * to mint one named `${serviceName}KeyPair`; naming that key pair here is
23
+ * rejected, because the switch to an imported reference would delete it.
24
+ */
25
+ keyName?: string;
26
+ /**
27
+ * Public subnets and a public IP. Default `false`: the instances stay in
28
+ * private subnets, so the ranges in `allowedIpCidrs` reach port 22 only from
29
+ * inside the VPC, a peered VPC or a VPN. `vpcSubnets` may still pin
30
+ * availability zones; a `vpcSubnets.subnetType` or an
31
+ * `associatePublicIpAddress` that contradicts `public: true` is rejected.
32
+ */
33
+ public?: boolean;
34
+ }
35
+ export declare const EC2_WORLD_OPEN_SSH_WARNING_ID = "@fjall/components-infrastructure:ec2WorldOpenSsh";
36
+ export declare const EC2_RETIRED_SSH_PROP_WARNING_ID = "@fjall/components-infrastructure:ec2RetiredSshProp";
37
+ type RetiredSshOwner = "Ec2Instance" | "Ec2Compute";
38
+ /**
39
+ * The retired spelling is read at synth, not only at typecheck: a JavaScript
40
+ * caller, or a spread of an older props object, would otherwise deploy with
41
+ * no SSH and no signal. A value the pre-34 owner read as SSH on asked for an
42
+ * exposure the new shape must spell out, so it throws with the translation.
43
+ * A value it read as off asked for the default and gets it, with a warning
44
+ * to delete the prop: refusing a no-op would strand every file the pre-34
45
+ * wizard wrote.
46
+ */
47
+ export declare function checkRetiredSshProp(scope: IConstruct, owner: RetiredSshOwner, props: object): void;
48
+ /** The fields of `Ec2InstanceProps` the SSH contract reads. */
49
+ export interface SshAccessValidationInput {
50
+ serviceName: string;
51
+ sshAccess?: Ec2SshAccessProps;
52
+ securityGroup?: ISecurityGroup;
53
+ associatePublicIpAddress?: boolean;
54
+ vpcSubnets?: SubnetSelection;
55
+ }
56
+ /** Synth-time contract for `sshAccess`; every refusal names the cure. */
57
+ export declare function validateSshAccess(props: SshAccessValidationInput): void;
58
+ /** The placement `Ec2Instance` resolved for the group, as the warning reads it. */
59
+ export interface ResolvedSshPlacement {
60
+ /** The launch template's setting once `sshAccess.public` and the caller's own prop are reconciled. */
61
+ associatePublicIpAddress: boolean;
62
+ /** Whether the subnets the group lands in include public ones (`vpc.selectSubnets(...).hasPublic`). */
63
+ hasPublicSubnets: boolean;
64
+ }
65
+ /**
66
+ * Whether port 22 faces the internet. An instance is reachable from outside
67
+ * only with a public IP AND a subnet routed to an internet gateway, so both
68
+ * halves of the resolved placement must say public. `public: true` sets
69
+ * both; a caller who owns the placement through `vpcSubnets` and
70
+ * `associatePublicIpAddress` reaches the same exposure without the flag,
71
+ * which is why the warning reads the placement rather than `sshAccess.public`.
72
+ */
73
+ export declare function sshFacesInternet(placement: ResolvedSshPlacement): boolean;
74
+ /**
75
+ * The world-open warning: a `0.0.0.0/0` range still lands (the caller asked
76
+ * for it) but synthesises with an acknowledgeable warning, as
77
+ * `rdsWorldOpenIngress` does for a database.
78
+ */
79
+ export declare function worldOpenSshWarning(serviceName: string, facesInternet: boolean): string;
80
+ export {};
@@ -0,0 +1,166 @@
1
+ import { Annotations, Token } from "aws-cdk-lib";
2
+ import { SubnetType } from "aws-cdk-lib/aws-ec2";
3
+ import { CIDR_REGEX, WORLD_OPEN_IPV4_CIDR } from "@fjall/generator";
4
+ import { mintedEc2KeyPairName } from "@fjall/util";
5
+ /**
6
+ * The construct prop and the generator's `Ec2SshAccessSchema` are one contract
7
+ * spelled twice: the plan's `sshAccess` projects literally into this prop.
8
+ * This map fails typecheck until both sides agree: a key missing from or
9
+ * added to either side, or a field whose type drifts between them (`true` is
10
+ * not assignable to the `never` a drifted field resolves to). Its keys are
11
+ * also the runtime accept-set for a caller-supplied object.
12
+ */
13
+ const SSH_ACCESS_FIELD_PARITY = {
14
+ allowedIpCidrs: true,
15
+ keyName: true,
16
+ public: true
17
+ };
18
+ const SSH_ACCESS_KEYS = Object.keys(SSH_ACCESS_FIELD_PARITY);
19
+ export const EC2_WORLD_OPEN_SSH_WARNING_ID = "@fjall/components-infrastructure:ec2WorldOpenSsh";
20
+ export const EC2_RETIRED_SSH_PROP_WARNING_ID = "@fjall/components-infrastructure:ec2RetiredSshProp";
21
+ /**
22
+ * One retired spelling per owner, read the way its pre-34 owner read it:
23
+ * `Ec2Instance` tested `enableSSH` for truthiness; `Ec2Compute` treated any
24
+ * `ssh` other than `undefined` or `false` as SSH on, and forwarded neither
25
+ * its `keyName` nor its `allowedCidrs`.
26
+ */
27
+ const RETIRED_SSH_PROPS = {
28
+ Ec2Instance: {
29
+ prop: "enableSSH",
30
+ askedForDefault: (value) => !value,
31
+ translation: `enableSSH: true meant sshAccess: { allowedIpCidrs: ["${WORLD_OPEN_IPV4_CIDR}"], public: true }.`
32
+ },
33
+ Ec2Compute: {
34
+ prop: "ssh",
35
+ askedForDefault: (value) => value === undefined || value === false,
36
+ translation: `ssh: {} meant sshAccess: { allowedIpCidrs: ["${WORLD_OPEN_IPV4_CIDR}"], public: true }; its keyName and allowedCidrs were never forwarded. ` +
37
+ "Carry them into sshAccess only to have them honoured now: allowedIpCidrs narrows the rule, and naming a key pair switches the group " +
38
+ "from the key pair this construct mints to the named one, so CloudFormation deletes the minted key pair and its stored private key."
39
+ }
40
+ };
41
+ /**
42
+ * The retired spelling is read at synth, not only at typecheck: a JavaScript
43
+ * caller, or a spread of an older props object, would otherwise deploy with
44
+ * no SSH and no signal. A value the pre-34 owner read as SSH on asked for an
45
+ * exposure the new shape must spell out, so it throws with the translation.
46
+ * A value it read as off asked for the default and gets it, with a warning
47
+ * to delete the prop: refusing a no-op would strand every file the pre-34
48
+ * wizard wrote.
49
+ */
50
+ export function checkRetiredSshProp(scope, owner, props) {
51
+ const retired = RETIRED_SSH_PROPS[owner];
52
+ if (!(retired.prop in props))
53
+ return;
54
+ const value = props[retired.prop];
55
+ if (retired.askedForDefault(value)) {
56
+ Annotations.of(scope).addWarningV2(EC2_RETIRED_SSH_PROP_WARNING_ID, `${owner}.${retired.prop} was removed in @fjall/components-infrastructure 34; ${retired.prop}: ${String(value)} asked for the default (no SSH), which still applies. Delete the prop.`);
57
+ return;
58
+ }
59
+ throw new Error(`${owner}.${retired.prop} was removed in @fjall/components-infrastructure 34. ` +
60
+ "Declare sshAccess: { allowedIpCidrs: [...], keyName?, public? }, or omit it for Session Manager-only access. " +
61
+ retired.translation);
62
+ }
63
+ function listTokenError(where) {
64
+ return new Error(`Ec2Instance.sshAccess.${where} cannot be a list token (CfnParameter.valueAsList, Fn.split over a parameter): ` +
65
+ "each range becomes one ingress rule, so the list itself must be literal at synth. " +
66
+ "A token per element, such as vpc.vpcCidrBlock, is fine.");
67
+ }
68
+ /** Synth-time contract for `sshAccess`; every refusal names the cure. */
69
+ export function validateSshAccess(props) {
70
+ const access = props.sshAccess;
71
+ if (access === undefined)
72
+ return;
73
+ const raw = access;
74
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
75
+ const got = raw === null
76
+ ? "null"
77
+ : Array.isArray(raw)
78
+ ? "an array"
79
+ : `a ${typeof raw}`;
80
+ throw new Error(`Ec2Instance.sshAccess must be an object { allowedIpCidrs, keyName?, public? }; got ${got}. Omit it for Session Manager-only access.`);
81
+ }
82
+ if (props.securityGroup !== undefined) {
83
+ throw new Error("Ec2Instance.sshAccess cannot be combined with a caller-supplied securityGroup. " +
84
+ "sshAccess adds port-22 ingress rules to the ASG security group; routing them onto an " +
85
+ "externally-owned security group couples this construct's permissions onto a SG whose lifecycle it " +
86
+ "does not own. Either omit securityGroup (a Fjall-owned SG will be created) or omit sshAccess and " +
87
+ "add the port-22 rule to your own security group.");
88
+ }
89
+ const unknownKey = Object.keys(access).find((key) => !SSH_ACCESS_KEYS.includes(key));
90
+ if (unknownKey !== undefined) {
91
+ throw new Error(`Ec2Instance.sshAccess has no field '${unknownKey}'; its fields are ${SSH_ACCESS_KEYS.join(", ")}. ` +
92
+ "The pre-34 ssh prop's allowedCidrs is allowedIpCidrs here.");
93
+ }
94
+ if (Token.isUnresolved(access.allowedIpCidrs)) {
95
+ throw listTokenError("allowedIpCidrs");
96
+ }
97
+ if (!Array.isArray(access.allowedIpCidrs) ||
98
+ access.allowedIpCidrs.length === 0) {
99
+ throw new Error("Ec2Instance.sshAccess.allowedIpCidrs must list at least one IPv4 CIDR. " +
100
+ "SSH that no address can reach is not a configuration — omit sshAccess for no SSH.");
101
+ }
102
+ const seen = new Map();
103
+ access.allowedIpCidrs.forEach((cidr, index) => {
104
+ if (typeof cidr !== "string" || cidr === "") {
105
+ throw new Error(`Ec2Instance.sshAccess.allowedIpCidrs[${index}] must be a non-empty IPv4 CIDR string`);
106
+ }
107
+ // A list token spread into a literal list ([...ranges.valueAsList, "10.0.0.0/8"])
108
+ // carries the list marker in one element; the whole-list check above cannot see it.
109
+ if (Token.isUnresolved([cidr])) {
110
+ throw listTokenError(`allowedIpCidrs[${index}]`);
111
+ }
112
+ if (!Token.isUnresolved(cidr) && !CIDR_REGEX.test(cidr)) {
113
+ throw new Error(`Ec2Instance.sshAccess.allowedIpCidrs[${index}] '${cidr}' is not an IPv4 CIDR (dotted-quad/prefix-length, e.g. 203.0.113.4/32)`);
114
+ }
115
+ const first = seen.get(cidr);
116
+ if (first !== undefined) {
117
+ throw new Error(`Ec2Instance.sshAccess.allowedIpCidrs[${index}] duplicates allowedIpCidrs[${first}] ('${cidr}'); each range is one ingress rule and AWS rejects a duplicate rule`);
118
+ }
119
+ seen.set(cidr, index);
120
+ });
121
+ const minted = mintedEc2KeyPairName(props.serviceName);
122
+ if (access.keyName === "") {
123
+ throw new Error(`Ec2Instance.sshAccess.keyName must not be empty; omit it to mint a key pair named ${minted}`);
124
+ }
125
+ if (access.keyName === minted) {
126
+ throw new Error(`Ec2Instance.sshAccess.keyName '${minted}' is the name of the key pair this construct mints for serviceName '${props.serviceName}'. ` +
127
+ "Omit keyName to keep the managed key pair: naming it switches the construct to an imported reference, and CloudFormation then deletes the managed key pair and its stored private key.");
128
+ }
129
+ if (access.public === true) {
130
+ if (props.associatePublicIpAddress === false) {
131
+ throw new Error("Ec2Instance.sshAccess.public: true assigns a public IP, but associatePublicIpAddress is false. Drop one of them.");
132
+ }
133
+ const selection = props.vpcSubnets;
134
+ if (selection !== undefined) {
135
+ if (selection.subnetType !== undefined &&
136
+ selection.subnetType !== SubnetType.PUBLIC) {
137
+ throw new Error(`Ec2Instance.sshAccess.public: true places the instances in public subnets, but vpcSubnets.subnetType is ${selection.subnetType}. Drop one of them.`);
138
+ }
139
+ if (selection.subnetGroupName !== undefined ||
140
+ selection.subnets !== undefined) {
141
+ throw new Error("Ec2Instance.sshAccess.public: true places the instances in the VPC's public subnets; vpcSubnets may pin availabilityZones but cannot select subnets by group name or instance. Set public: false and own the placement, or drop the selector.");
142
+ }
143
+ }
144
+ }
145
+ }
146
+ /**
147
+ * Whether port 22 faces the internet. An instance is reachable from outside
148
+ * only with a public IP AND a subnet routed to an internet gateway, so both
149
+ * halves of the resolved placement must say public. `public: true` sets
150
+ * both; a caller who owns the placement through `vpcSubnets` and
151
+ * `associatePublicIpAddress` reaches the same exposure without the flag,
152
+ * which is why the warning reads the placement rather than `sshAccess.public`.
153
+ */
154
+ export function sshFacesInternet(placement) {
155
+ return placement.associatePublicIpAddress && placement.hasPublicSubnets;
156
+ }
157
+ /**
158
+ * The world-open warning: a `0.0.0.0/0` range still lands (the caller asked
159
+ * for it) but synthesises with an acknowledgeable warning, as
160
+ * `rdsWorldOpenIngress` does for a database.
161
+ */
162
+ export function worldOpenSshWarning(serviceName, facesInternet) {
163
+ return facesInternet
164
+ ? `Ec2Instance '${serviceName}': sshAccess.allowedIpCidrs contains '${WORLD_OPEN_IPV4_CIDR}' and the instances are publicly reachable, so port 22 is open to the entire internet. Scope allowedIpCidrs to the addresses that need access (e.g. "203.0.113.4/32").`
165
+ : `Ec2Instance '${serviceName}': sshAccess.allowedIpCidrs contains '${WORLD_OPEN_IPV4_CIDR}'. The instances are not reachable from the internet as placed, so the rule admits every address that can route to them (the VPC, peered networks, VPNs); scope allowedIpCidrs to the ranges that need access (e.g. "10.0.0.0/8").`;
166
+ }
@@ -2,6 +2,7 @@ import { Cluster as CdkCluster, type FargateService, type Ec2Service, type TaskD
2
2
  import { Connections, type IConnectable } from "aws-cdk-lib/aws-ec2";
3
3
  import { Construct } from "constructs";
4
4
  import type { StackBuilder } from "../base/awsStack.js";
5
+ import { Grant, type IGrantable } from "aws-cdk-lib/aws-iam";
5
6
  import type { ApplicationListener, ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
6
7
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
7
8
  import { type EcsClusterProps } from "./ecsTypes.js";
@@ -70,8 +71,8 @@ export default class EcsCluster extends Construct implements IConnectable {
70
71
  /**
71
72
  * The stack's shared key when the app backup tier encrypts (resolved once,
72
73
  * here); undefined otherwise. Read by the patterns layer for the log
73
- * groups it creates beside the cluster's own, so the tier is consulted in
74
- * one place.
74
+ * groups it creates beside the cluster's own and by `grantExecuteCommand`,
75
+ * so the tier is consulted in one place.
75
76
  */
76
77
  readonly encryptionKey: StackEncryptionKey | undefined;
77
78
  private readonly loadBalancerDeletionProtection;
@@ -111,6 +112,14 @@ export default class EcsCluster extends Construct implements IConnectable {
111
112
  registerScheduledTaskDefinition(name: string, taskDefinition: TaskDefinition): void;
112
113
  /** Get the ECS cluster construct. */
113
114
  getCluster(): CdkCluster;
115
+ /**
116
+ * Grant what an ECS Exec caller needs on this cluster: `ecs:ExecuteCommand`
117
+ * on the cluster and its tasks and, when the tier encrypts the session,
118
+ * `kms:GenerateDataKey` on the stack key — the caller-side half of ECS Exec
119
+ * encryption (the task role's Decrypt rides CDK's BaseService wiring).
120
+ * https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html
121
+ */
122
+ grantExecuteCommand(grantee: IGrantable): Grant;
114
123
  /**
115
124
  * Connectable scoped to the cluster's EC2 capacity security groups alone.
116
125
  * Scheduled tasks run on the cluster's instances (bridge/host networking),
@@ -1,7 +1,8 @@
1
1
  import { Cluster as CdkCluster, ContainerInsights, ExecuteCommandLogging } from "aws-cdk-lib/aws-ecs";
2
2
  import { Connections, Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { Construct } from "constructs";
4
- import { CfnOutput, Aspects } from "aws-cdk-lib";
4
+ import { CfnOutput, Aspects, Stack } from "aws-cdk-lib";
5
+ import { Grant } from "aws-cdk-lib/aws-iam";
5
6
  import { processConnections } from "../../../utils/connections.js";
6
7
  import { tierDefault } from "../../../utils/backupTierMapping.js";
7
8
  import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
@@ -91,8 +92,8 @@ export default class EcsCluster extends Construct {
91
92
  /**
92
93
  * The stack's shared key when the app backup tier encrypts (resolved once,
93
94
  * here); undefined otherwise. Read by the patterns layer for the log
94
- * groups it creates beside the cluster's own, so the tier is consulted in
95
- * one place.
95
+ * groups it creates beside the cluster's own and by `grantExecuteCommand`,
96
+ * so the tier is consulted in one place.
96
97
  */
97
98
  encryptionKey;
98
99
  loadBalancerDeletionProtection;
@@ -116,9 +117,7 @@ export default class EcsCluster extends Construct {
116
117
  const tier = props.appBackupTier;
117
118
  this.containerInsightsEnabled =
118
119
  props.containerInsights ?? tierDefault(tier, "containerInsights");
119
- this.encryptionKey = tierDefault(tier, "customerManagedKeys")
120
- ? StackEncryptionKey.of(this)
121
- : undefined;
120
+ this.encryptionKey = StackEncryptionKey.forTier(this, tier);
122
121
  this.loadBalancerDeletionProtection =
123
122
  props.cluster?.loadBalancerDeletionProtection ??
124
123
  (tierDefault(tier, "deletionProtection") &&
@@ -254,6 +253,30 @@ export default class EcsCluster extends Construct {
254
253
  getCluster() {
255
254
  return this.cluster;
256
255
  }
256
+ /**
257
+ * Grant what an ECS Exec caller needs on this cluster: `ecs:ExecuteCommand`
258
+ * on the cluster and its tasks and, when the tier encrypts the session,
259
+ * `kms:GenerateDataKey` on the stack key — the caller-side half of ECS Exec
260
+ * encryption (the task role's Decrypt rides CDK's BaseService wiring).
261
+ * https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html
262
+ */
263
+ grantExecuteCommand(grantee) {
264
+ const exec = Grant.addToPrincipal({
265
+ grantee,
266
+ actions: ["ecs:ExecuteCommand"],
267
+ resourceArns: [
268
+ this.cluster.clusterArn,
269
+ Stack.of(this).formatArn({
270
+ service: "ecs",
271
+ resource: "task",
272
+ resourceName: `${this.props.clusterName}/*`
273
+ })
274
+ ]
275
+ });
276
+ return this.encryptionKey === undefined
277
+ ? exec
278
+ : exec.combine(this.encryptionKey.key.grant(grantee, "kms:GenerateDataKey"));
279
+ }
257
280
  /**
258
281
  * Connectable scoped to the cluster's EC2 capacity security groups alone.
259
282
  * Scheduled tasks run on the cluster's instances (bridge/host networking),
@@ -109,6 +109,11 @@ export interface LambdaFunctionProps {
109
109
  /**
110
110
  * Framework custom-resource handlers are tier-agnostic: the tier-owned
111
111
  * knobs are excluded at the type rather than silently ignored.
112
+ *
113
+ * Every singleton in this library backs a custom-resource provider, which is
114
+ * why the constructor marks the hoisted subtree as a deploy-time writer. A
115
+ * singleton fronting a runtime lambda would need that call moved to the two
116
+ * writer call sites (`utilities/customResource.ts`, `iam/identityCenter/user.ts`).
112
117
  */
113
118
  export interface SingletonFunctionProps extends Omit<LambdaFunctionProps, "tracing" | "appBackupTier"> {
114
119
  uuid?: string;
@@ -18,6 +18,7 @@ import { resolveImportedSecret } from "../secrets/index.js";
18
18
  import { validateSsmPathComponent } from "./ecsValidation.js";
19
19
  import { toPascalCase } from "../../../utils/capitaliseString.js";
20
20
  import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
21
+ import { markDeployTimeWriter } from "../../../utils/resourceTypeTags.js";
21
22
  import { createLambdaAlarms } from "../monitoring/index.js";
22
23
  function addPoliciesToRole(target, statements) {
23
24
  for (const statement of statements) {
@@ -155,6 +156,13 @@ export class SingletonFunction extends singletonFunction {
155
156
  addPoliciesToRole(this, props.inlinePolicy);
156
157
  applyRoleDescription(this, props.roleDescription);
157
158
  applyRolePathAndName(this, props.rolePath, props.roleName);
159
+ // aws-cdk-lib hoists the singleton's Function — and with it the execution
160
+ // role — to the stack scope, so a tag applied to this construct or to the
161
+ // enclosing writer never reaches either. Found the way CDK's own
162
+ // `ensureLambda` finds it, by the public `constructName`; `findChild`
163
+ // throws if the hoist ever moves, so a CDK upgrade fails at synth rather
164
+ // than shipping an unmarked role.
165
+ markDeployTimeWriter(Stack.of(this).node.findChild(this.constructName));
158
166
  }
159
167
  /**
160
168
  * The Lambda's execution role (auto-generated by CDK)
@@ -178,9 +186,7 @@ export class LambdaFunction extends Function {
178
186
  const tracingEnabled = tracing ?? tierDefault(appBackupTier, "lambdaTracing");
179
187
  // Grant-based: Lambda encrypts the environment through a KMS grant the
180
188
  // deploying role creates, so the execution role needs no key policy.
181
- const encryptionKey = tierDefault(appBackupTier, "customerManagedKeys")
182
- ? StackEncryptionKey.of(scope)
183
- : undefined;
189
+ const encryptionKey = StackEncryptionKey.forTier(scope, appBackupTier);
184
190
  super(scope, id, {
185
191
  ...cdkProps,
186
192
  vpcSubnets,
@@ -66,10 +66,11 @@ export class DynamoDBTable extends Construct {
66
66
  constructor(scope, id, props) {
67
67
  super(scope, id);
68
68
  this.id = id;
69
+ const stackKey = StackEncryptionKey.forTier(this, props.appBackupTier, props.encryption === undefined
70
+ ? undefined
71
+ : props.encryption === "CUSTOMER_MANAGED");
69
72
  const encryption = props.encryption ??
70
- (tierDefault(props.appBackupTier, "customerManagedKeys")
71
- ? "CUSTOMER_MANAGED"
72
- : "AWS_MANAGED");
73
+ (stackKey === undefined ? "AWS_MANAGED" : "CUSTOMER_MANAGED");
73
74
  const deletionProtection = props.deletionProtection ??
74
75
  tierDefault(props.appBackupTier, "deletionProtection");
75
76
  this.table = new Table(this, `${id}Table`, {
@@ -92,9 +93,7 @@ export class DynamoDBTable extends Construct {
92
93
  pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true
93
94
  },
94
95
  encryption: toTableEncryption(encryption),
95
- ...(encryption === "CUSTOMER_MANAGED" && {
96
- encryptionKey: StackEncryptionKey.of(this).key
97
- }),
96
+ ...(stackKey !== undefined && { encryptionKey: stackKey.key }),
98
97
  deletionProtection,
99
98
  removalPolicy: toRemovalPolicy(props.removalPolicy),
100
99
  timeToLiveAttribute: props.timeToLiveAttribute
@@ -7,6 +7,7 @@ import { Provider } from "aws-cdk-lib/custom-resources";
7
7
  import { Construct } from "constructs";
8
8
  import { SingletonFunction } from "../../compute/lambda.js";
9
9
  import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../../logging/logGroup.js";
10
+ import { markDeployTimeWriter } from "../../../../utils/resourceTypeTags.js";
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  /**
12
13
  * Resolves relative to the compiled location too — the build step copies the
@@ -58,6 +59,7 @@ export class IdentityStoreUserProvider extends Construct {
58
59
  })
59
60
  });
60
61
  this.serviceToken = provider.serviceToken;
62
+ markDeployTimeWriter(this);
61
63
  }
62
64
  }
63
65
  /**
@@ -15,8 +15,10 @@ export declare const ACCESS_LOG_RETENTION_DAYS = 90;
15
15
  * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
16
16
  * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
17
17
  * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
18
- * versioning would only multiply expired log objects. The bucket takes the
19
- * env-aware removal policy like every other bucket: production RETAINs its
20
- * audit evidence, non-production DESTROYs.
18
+ * versioning would only multiply expired log objects — the `access-logs`
19
+ * exemption marker tells posture so, landing `S3_VERSIONING_DISABLED`
20
+ * `mitigated` rather than `open`. The bucket takes the env-aware removal
21
+ * policy like every other bucket: production RETAINs its audit evidence,
22
+ * non-production DESTROYs.
21
23
  */
22
24
  export declare function stackAccessLogBucket(scope: Construct): S3Bucket;
@@ -17,9 +17,11 @@ export const ACCESS_LOG_RETENTION_DAYS = 90;
17
17
  * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
18
18
  * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
19
19
  * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
20
- * versioning would only multiply expired log objects. The bucket takes the
21
- * env-aware removal policy like every other bucket: production RETAINs its
22
- * audit evidence, non-production DESTROYs.
20
+ * versioning would only multiply expired log objects — the `access-logs`
21
+ * exemption marker tells posture so, landing `S3_VERSIONING_DISABLED`
22
+ * `mitigated` rather than `open`. The bucket takes the env-aware removal
23
+ * policy like every other bucket: production RETAINs its audit evidence,
24
+ * non-production DESTROYs.
23
25
  */
24
26
  export function stackAccessLogBucket(scope) {
25
27
  const stack = Stack.of(scope);
@@ -30,6 +32,7 @@ export function stackAccessLogBucket(scope) {
30
32
  encryption: BucketEncryption.S3_MANAGED,
31
33
  objectOwnership: ObjectOwnership.OBJECT_WRITER,
32
34
  versioned: false,
35
+ versioningExemption: "access-logs",
33
36
  lifecycleRules: [
34
37
  {
35
38
  id: "ExpireAccessLogs",
@@ -24,13 +24,23 @@ export interface SNSTopicProps {
24
24
  */
25
25
  signatureVersion?: "1" | "2";
26
26
  /**
27
- * KMS key for server-side encryption at rest. Absent ⇒ the app backup
28
- * tier decides: enterprise encrypts with the stack's shared
29
- * [[StackEncryptionKey]] (its SNS service publishers admitted); every other
30
- * tier stays unencrypted — the accessible default (a customer-managed key
31
- * costs $1–3/month per account-region once annual rotation accrues), so
32
- * the SNS_TOPIC_NOT_ENCRYPTED posture finding stands as the signpost to
33
- * the tier rather than being silently pre-paid.
27
+ * Server-side encryption at rest. Absent ⇒ the app backup tier decides
28
+ * through its `customerManagedKeys` column: `"SSE_KMS"` at enterprise,
29
+ * `"NONE"` at every other tier. `"SSE_KMS"` encrypts with the stack's
30
+ * shared [[StackEncryptionKey]] (its SNS service publishers admitted)
31
+ * unless `masterKey` brings a key of your own; `"NONE"` at enterprise is
32
+ * the auditable opt-out — it sits in the declaration, so the
33
+ * SNS_TOPIC_NOT_ENCRYPTED finding it leaves is a choice, not an omission.
34
+ * Below enterprise the topic stays unencrypted by default — the accessible
35
+ * default (a customer-managed key costs $1–3/month per account-region once
36
+ * annual rotation accrues), so the finding stands as the signpost to the
37
+ * tier rather than being silently pre-paid.
38
+ */
39
+ encryption?: "SSE_KMS" | "NONE";
40
+ /**
41
+ * Bring your own KMS key for encryption at rest. Beats `encryption` and
42
+ * the tier: the topic encrypts with this key and the stack's shared key is
43
+ * not minted for it.
34
44
  *
35
45
  * Every AWS service principal that publishes to the topic needs
36
46
  * `kms:Decrypt` + `kms:GenerateDataKey*` on this key's RESOURCE policy or