@fjall/components-infrastructure 33.1.0 → 34.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/config/aws/scpPreset.js +11 -1
- package/dist/lib/patterns/aws/bastionFactory.js +2 -1
- package/dist/lib/patterns/aws/cdn.d.ts +4 -1
- package/dist/lib/patterns/aws/clickhouseDatabase.js +2 -4
- package/dist/lib/patterns/aws/compute.d.ts +2 -2
- package/dist/lib/patterns/aws/computeEc2.d.ts +5 -18
- package/dist/lib/patterns/aws/computeEc2.js +9 -3
- package/dist/lib/patterns/aws/computeEcs.d.ts +4 -4
- package/dist/lib/patterns/aws/computeEcs.js +5 -9
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -1
- package/dist/lib/patterns/aws/fivetranProxy.d.ts +8 -3
- package/dist/lib/patterns/aws/fivetranProxy.js +9 -6
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +8 -0
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +16 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +52 -2
- package/dist/lib/resources/aws/compute/ec2.d.ts +33 -6
- package/dist/lib/resources/aws/compute/ec2.js +63 -14
- package/dist/lib/resources/aws/compute/ec2SshAccess.d.ts +80 -0
- package/dist/lib/resources/aws/compute/ec2SshAccess.js +166 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +11 -2
- package/dist/lib/resources/aws/compute/ecs.js +29 -6
- package/dist/lib/resources/aws/compute/lambda.js +1 -3
- package/dist/lib/resources/aws/database/dynamodb.js +5 -6
- package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +5 -3
- package/dist/lib/resources/aws/logging/accessLogBucket.js +6 -3
- package/dist/lib/resources/aws/messaging/sns.d.ts +17 -7
- package/dist/lib/resources/aws/messaging/sns.js +4 -4
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +17 -4
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +20 -4
- package/dist/lib/resources/aws/storage/s3.d.ts +2 -1
- package/package.json +5 -4
|
@@ -346,9 +346,19 @@ function buildCostControls() {
|
|
|
346
346
|
"rds:ModifyDBCluster"
|
|
347
347
|
],
|
|
348
348
|
Resource: "*",
|
|
349
|
+
// A negated operator over an ABSENT key evaluates true, so without
|
|
350
|
+
// the Null guard this statement denies every RDS request that names
|
|
351
|
+
// no instance class: a class-less ModifyDBInstance (the destroy
|
|
352
|
+
// pre-flight's DeletionProtection flip, a retention or parameter-group
|
|
353
|
+
// change), and Aurora's CreateDBCluster/ModifyDBCluster, where the
|
|
354
|
+
// class lives on the instances. The guard limits the deny to requests
|
|
355
|
+
// that carry a class; the instances still gate Aurora, whose Fjall
|
|
356
|
+
// construct is Serverless v2 only (`db.serverless`).
|
|
357
|
+
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html
|
|
349
358
|
Condition: {
|
|
359
|
+
Null: { "rds:DatabaseClass": "false" },
|
|
350
360
|
StringNotLike: {
|
|
351
|
-
"rds:DatabaseClass": ["db.t3.*", "db.t4g.*"]
|
|
361
|
+
"rds:DatabaseClass": ["db.t3.*", "db.t4g.*", "db.serverless"]
|
|
352
362
|
}
|
|
353
363
|
}
|
|
354
364
|
}
|
|
@@ -11,7 +11,8 @@ export function createBastion(networkStack, appName, stackPrefix, vpc, config) {
|
|
|
11
11
|
serviceName: `${stackPrefix}Bastion`,
|
|
12
12
|
instanceType,
|
|
13
13
|
vpc,
|
|
14
|
-
|
|
14
|
+
// Session Manager only: no sshAccess, so no key pair, no port-22 ingress
|
|
15
|
+
// and private subnets.
|
|
15
16
|
minCapacity: 1,
|
|
16
17
|
maxCapacity: 1
|
|
17
18
|
});
|
|
@@ -87,7 +87,10 @@ interface BaseCdnProps {
|
|
|
87
87
|
* `cloudFrontLogging` column — enterprise logs to the stack's shared,
|
|
88
88
|
* lifecycle-expired access-log bucket under `cloudfront/<id>/`. Set either
|
|
89
89
|
* knob to take over: `enableLogging: false` opts out at any tier,
|
|
90
|
-
* `logBucket` delivers to a bucket of your own.
|
|
90
|
+
* `logBucket` delivers to a bucket of your own. Standard logging cannot
|
|
91
|
+
* deliver into an opt-in region, so a tier- or explicitly-enabled CDN whose
|
|
92
|
+
* delivery region is opt-in is refused at synth with those two cures — see
|
|
93
|
+
* {@link CloudFrontDistributionProps.enableLogging}.
|
|
91
94
|
*/
|
|
92
95
|
enableLogging?: boolean;
|
|
93
96
|
logBucket?: IBucket;
|
|
@@ -16,7 +16,6 @@ import { S3Bucket } from "../../resources/aws/storage/s3.js";
|
|
|
16
16
|
import { Secret } from "../../resources/aws/secrets/secret.js";
|
|
17
17
|
import { LogGroup } from "../../resources/aws/logging/logGroup.js";
|
|
18
18
|
import { StackEncryptionKey } from "../../resources/aws/secrets/stackEncryptionKey.js";
|
|
19
|
-
import { tierDefault } from "../../utils/backupTierMapping.js";
|
|
20
19
|
import { createClickHouseSecurityGroup } from "../../resources/aws/database/clickhouseSecurityGroup.js";
|
|
21
20
|
import { buildMaintenancePreamble, maintenanceOkMarker, maintenanceRun } from "../../resources/aws/database/clickhouseMaintenanceScript.js";
|
|
22
21
|
import { buildClickHouseEntrypointWrapper, buildClickHouseUserData, generateUsersConfigXml } from "../../resources/aws/database/clickhouseUserData.js";
|
|
@@ -393,9 +392,8 @@ export class ClickHouseDatabase extends Construct {
|
|
|
393
392
|
// The maintenance-task log groups sit beside the compute's own container
|
|
394
393
|
// and exec-audit groups and follow the same tier: the stack's shared key
|
|
395
394
|
// at enterprise, nothing otherwise.
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
: {};
|
|
395
|
+
const stackKey = StackEncryptionKey.forTier(this, props.appBackupTier);
|
|
396
|
+
const taskLogGroupEncryption = stackKey === undefined ? {} : { encryptionKey: stackKey.forLogGroups() };
|
|
399
397
|
const backupTaskLogGroup = backupEnabled
|
|
400
398
|
? new LogGroup(this, "ClickHouseBackupTaskLogGroup", {
|
|
401
399
|
retention: RetentionDays.TWO_WEEKS,
|
|
@@ -5,8 +5,8 @@ import { type ComputeType, type IEcsCompute, type ILambdaCompute, type IEc2Compu
|
|
|
5
5
|
import type App from "../../app.js";
|
|
6
6
|
import { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, type ContainerDependency, type EcsMigrationsConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig } from "./computeEcs.js";
|
|
7
7
|
import { LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions } from "./computeLambda.js";
|
|
8
|
-
import { Ec2Compute, type Ec2ComputeProps
|
|
9
|
-
export { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, type ContainerDependency, type EcsMigrationsConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig, LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions, Ec2Compute, type Ec2ComputeProps
|
|
8
|
+
import { Ec2Compute, type Ec2ComputeProps } from "./computeEc2.js";
|
|
9
|
+
export { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type ServiceLogAlarm, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, type ContainerDependency, type EcsMigrationsConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig, LambdaCompute, type LambdaComputeProps, type ContainerLambdaProps, type CodeLambdaProps, type FunctionUrlConfig, type ResolvedLambdaDeployment, resolveLambdaDeployment, lambdaImageKey, Architecture, HttpMethod, InvokeMode, type FunctionUrlCorsOptions, Ec2Compute, type Ec2ComputeProps };
|
|
10
10
|
export type { ComputeType } from "./interfaces/compute.js";
|
|
11
11
|
/**
|
|
12
12
|
* Configuration defaults for each compute type.
|
|
@@ -3,31 +3,18 @@ import { type IAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { type IEc2Compute } from "./interfaces/compute.js";
|
|
5
5
|
import { Ec2Instance } from "../../resources/aws/compute/ec2.js";
|
|
6
|
-
|
|
7
|
-
* SSH access configuration for EC2 instances.
|
|
8
|
-
* - Omit: disabled (default)
|
|
9
|
-
* - `{}`: enabled with auto-generated key
|
|
10
|
-
* - `{ keyName: "my-key" }`: enabled with existing key
|
|
11
|
-
* - `false`: explicitly disabled
|
|
12
|
-
*/
|
|
13
|
-
export interface SshConfig {
|
|
14
|
-
/** SSH key pair name */
|
|
15
|
-
keyName?: string;
|
|
16
|
-
/** Allowed CIDR blocks for SSH access */
|
|
17
|
-
allowedCidrs?: string[];
|
|
18
|
-
}
|
|
6
|
+
import { type Ec2SshAccessProps } from "../../resources/aws/compute/ec2SshAccess.js";
|
|
19
7
|
export interface Ec2ComputeProps {
|
|
20
8
|
type: "ec2";
|
|
21
9
|
vpc?: IVpc;
|
|
22
10
|
/** EC2 instance type. Default: "t4g.micro" */
|
|
23
11
|
instanceType?: string;
|
|
24
12
|
/**
|
|
25
|
-
* SSH access
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
* - `false`: explicitly disabled
|
|
13
|
+
* SSH access: who may reach port 22 (`allowedIpCidrs`), which key pair
|
|
14
|
+
* (`keyName`, or omit to mint one) and whether the instances are public
|
|
15
|
+
* (`public`). Omit for Session Manager-only access, the default.
|
|
29
16
|
*/
|
|
30
|
-
|
|
17
|
+
sshAccess?: Ec2SshAccessProps;
|
|
31
18
|
/** User data script */
|
|
32
19
|
userData?: UserData;
|
|
33
20
|
/** Machine image (AMI) */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
+
import { ec2ServiceName } from "@fjall/util";
|
|
2
3
|
import { Ec2Instance } from "../../resources/aws/compute/ec2.js";
|
|
4
|
+
import { checkRetiredSshProp } from "../../resources/aws/compute/ec2SshAccess.js";
|
|
3
5
|
import { COMPUTE_DEFAULTS } from "./compute.js";
|
|
4
6
|
/**
|
|
5
7
|
* EC2 compute wrapper implementing IEc2Compute.
|
|
@@ -11,11 +13,15 @@ export class Ec2Compute extends Construct {
|
|
|
11
13
|
ec2Instance;
|
|
12
14
|
constructor(scope, id, props) {
|
|
13
15
|
super(scope, id);
|
|
14
|
-
|
|
16
|
+
// The retired `ssh` prop read only its own presence (`keyName` and
|
|
17
|
+
// `allowedCidrs` were declared and never forwarded); a caller still
|
|
18
|
+
// passing it gets the translation, or a warning when it asked for the
|
|
19
|
+
// default.
|
|
20
|
+
checkRetiredSshProp(this, "Ec2Compute", props);
|
|
15
21
|
const ec2Props = {
|
|
16
|
-
serviceName:
|
|
22
|
+
serviceName: ec2ServiceName(id),
|
|
17
23
|
instanceType: props.instanceType || COMPUTE_DEFAULTS.EC2.INSTANCE_TYPE,
|
|
18
|
-
|
|
24
|
+
...(props.sshAccess !== undefined && { sshAccess: props.sshAccess }),
|
|
19
25
|
userData: props.userData,
|
|
20
26
|
machineImage: props.machineImage,
|
|
21
27
|
minCapacity: props.minCapacity || COMPUTE_DEFAULTS.EC2.MIN_CAPACITY,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ICluster, type IBaseService, type TaskDefinition, type FargateTaskDefinition } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import { type Connections, type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
|
|
3
|
-
import { type IGrantable, type IRole
|
|
3
|
+
import { type Grant, type IGrantable, type IRole } from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import { type IApplicationLoadBalancer, type ApplicationListener } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
5
5
|
import { Construct } from "constructs";
|
|
6
6
|
import { type IEcsCompute } from "./interfaces/compute.js";
|
|
@@ -264,9 +264,9 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
|
|
|
264
264
|
*/
|
|
265
265
|
getEcsCluster(): EcsCluster;
|
|
266
266
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
267
|
+
* Grant what an ECS Exec caller needs: `ecs:ExecuteCommand` on this
|
|
268
|
+
* cluster's tasks and, at a tier that encrypts the session, the
|
|
269
|
+
* `kms:GenerateDataKey` on the stack key the caller must hold.
|
|
270
270
|
*/
|
|
271
271
|
grantExecuteCommand(grantee: IGrantable): Grant;
|
|
272
272
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
|
-
import { Effect,
|
|
3
|
+
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import { Annotations, Aws, Stack, Token } from "aws-cdk-lib";
|
|
5
5
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
6
6
|
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
@@ -1688,16 +1688,12 @@ export class EcsCompute extends Construct {
|
|
|
1688
1688
|
return this.ecsCluster;
|
|
1689
1689
|
}
|
|
1690
1690
|
/**
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1691
|
+
* Grant what an ECS Exec caller needs: `ecs:ExecuteCommand` on this
|
|
1692
|
+
* cluster's tasks and, at a tier that encrypts the session, the
|
|
1693
|
+
* `kms:GenerateDataKey` on the stack key the caller must hold.
|
|
1694
1694
|
*/
|
|
1695
1695
|
grantExecuteCommand(grantee) {
|
|
1696
|
-
return
|
|
1697
|
-
grantee,
|
|
1698
|
-
actions: ["ecs:ExecuteCommand"],
|
|
1699
|
-
resourceArns: ["*"]
|
|
1700
|
-
});
|
|
1696
|
+
return this.ecsCluster.grantExecuteCommand(grantee);
|
|
1701
1697
|
}
|
|
1702
1698
|
/**
|
|
1703
1699
|
* Get the EC2 instance role for the cluster's underlying ASG. EC2-mode
|
|
@@ -3,15 +3,20 @@ import { type Construct } from "constructs";
|
|
|
3
3
|
import { type KeyValue } from "../../types.js";
|
|
4
4
|
import { type Connections } from "aws-cdk-lib/aws-ec2";
|
|
5
5
|
import { type IVpc } from "aws-cdk-lib/aws-ec2";
|
|
6
|
-
interface
|
|
6
|
+
export interface FivetranProxyProps extends StackProps {
|
|
7
7
|
accountId?: string;
|
|
8
8
|
vpc?: IVpc;
|
|
9
9
|
publicKey: string;
|
|
10
10
|
instanceType: string;
|
|
11
|
+
/**
|
|
12
|
+
* Source ranges Fivetran connects from, one port-22 ingress rule each.
|
|
13
|
+
* Fivetran publishes its addresses per region; the proxy ships no
|
|
14
|
+
* world-open default.
|
|
15
|
+
*/
|
|
16
|
+
allowedIpCidrs: string[];
|
|
11
17
|
databaseConnection?: Connections;
|
|
12
18
|
tags?: KeyValue;
|
|
13
19
|
}
|
|
14
20
|
export declare class FivetranProxy extends Stack {
|
|
15
|
-
constructor(scope: Construct, id: string, props:
|
|
21
|
+
constructor(scope: Construct, id: string, props: FivetranProxyProps);
|
|
16
22
|
}
|
|
17
|
-
export {};
|
|
@@ -4,6 +4,10 @@ import { UserData } from "aws-cdk-lib/aws-ec2";
|
|
|
4
4
|
export class FivetranProxy extends Stack {
|
|
5
5
|
constructor(scope, id, props) {
|
|
6
6
|
super(scope, id, props);
|
|
7
|
+
if (!Array.isArray(props.allowedIpCidrs) ||
|
|
8
|
+
props.allowedIpCidrs.length === 0) {
|
|
9
|
+
throw new Error("FivetranProxy.allowedIpCidrs must list at least one IPv4 CIDR: the ranges Fivetran connects from for your region. The proxy ships no world-open default.");
|
|
10
|
+
}
|
|
7
11
|
const userData = UserData.forLinux();
|
|
8
12
|
userData.addCommands("groupadd fivetran", "useradd -m -g fivetran fivetran", "mkdir -p /home/fivetran/.ssh", "chown fivetran:fivetran /home/fivetran/.ssh", "chmod 700 /home/fivetran/.ssh", `echo "${props.publicKey}" > /home/fivetran/.ssh/authorized_keys`, "chown fivetran:fivetran /home/fivetran/.ssh/authorized_keys", "chmod 600 /home/fivetran/.ssh/authorized_keys");
|
|
9
13
|
new Ec2Instance(this, "FivetranProxy", {
|
|
@@ -11,12 +15,11 @@ export class FivetranProxy extends Stack {
|
|
|
11
15
|
serviceName: "fivetranProxy",
|
|
12
16
|
vpc: props.vpc,
|
|
13
17
|
instanceType: props.instanceType || "t2.micro",
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
enableSSH: true,
|
|
18
|
+
// This proxy exists so Fivetran can reach a private database over SSH
|
|
19
|
+
// (it provisions the `fivetran` user and its authorized_keys above), so
|
|
20
|
+
// the instance must be publicly reachable on port 22. `public` is
|
|
21
|
+
// therefore not the caller's choice; the source range is.
|
|
22
|
+
sshAccess: { allowedIpCidrs: props.allowedIpCidrs, public: true },
|
|
20
23
|
env: {
|
|
21
24
|
account: props.env?.account,
|
|
22
25
|
region: props.env?.region
|
|
@@ -73,6 +73,14 @@ export interface IEcsCompute extends ICompute, IConnectable {
|
|
|
73
73
|
* that front this compute. Undefined when there is no load balancer.
|
|
74
74
|
*/
|
|
75
75
|
getIngressProfile(): EcsIngressProfile | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Grant an operator principal what an `aws ecs execute-command` session
|
|
78
|
+
* needs against this cluster: `ecs:ExecuteCommand` scoped to the cluster
|
|
79
|
+
* and its tasks, plus `kms:GenerateDataKey` on the stack key when the
|
|
80
|
+
* session channel is key-encrypted (backup tier `enterprise`).
|
|
81
|
+
* @param grantee The operator principal
|
|
82
|
+
*/
|
|
83
|
+
grantExecuteCommand(grantee: IGrantable): Grant;
|
|
76
84
|
}
|
|
77
85
|
/**
|
|
78
86
|
* Lambda compute interface.
|
|
@@ -58,7 +58,23 @@ export interface CloudFrontDistributionProps {
|
|
|
58
58
|
domainNames?: string[];
|
|
59
59
|
certificate?: ICertificate;
|
|
60
60
|
comment?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Standard (legacy) S3 access logging. It cannot deliver to a bucket in an
|
|
63
|
+
* opt-in region (`af-south-1`, `ap-east-1`, …): CloudFormation rejects the
|
|
64
|
+
* distribution at deploy, so a concrete opt-in delivery region is refused
|
|
65
|
+
* at synth instead — cure with `enableLogging: false`, or a `logBucket`
|
|
66
|
+
* whose region CDK knows to be default-enabled (see `logBucket`).
|
|
67
|
+
*/
|
|
61
68
|
enableLogging?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Delivery bucket for standard logs; without it CDK creates one in the
|
|
71
|
+
* distribution's own stack (and region). Its `env.region` is the delivery
|
|
72
|
+
* region the opt-in refusal on `enableLogging` checks, so an imported
|
|
73
|
+
* bucket must declare it: `Bucket.fromBucketAttributes(…, { region })`, or
|
|
74
|
+
* a `Bucket` in a stack pinned to its region. `fromBucketName` and
|
|
75
|
+
* `fromBucketArn` inherit this stack's region, and a token bucket region
|
|
76
|
+
* falls back to it.
|
|
77
|
+
*/
|
|
62
78
|
logBucket?: IBucket;
|
|
63
79
|
/** Key prefix for standard logs delivered to `logBucket`. */
|
|
64
80
|
logFilePrefix?: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { CfnOutput, Names, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { CfnOutput, Names, Stack, Token } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
4
|
import { registerCdnAliasTarget } from "../../../utils/cdnAliasTargetRegistry.js";
|
|
5
|
-
import { cdnDomainExportName } from "@fjall/util";
|
|
5
|
+
import { cdnDomainExportName, getRegionInfo, OPT_IN_REGION_CODES } from "@fjall/util";
|
|
6
6
|
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
|
|
7
7
|
import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
|
|
8
8
|
/**
|
|
@@ -42,6 +42,55 @@ const URI_REWRITE_FRAGMENTS = {
|
|
|
42
42
|
" else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
|
|
43
43
|
" if (seg.indexOf('.') === -1) { request.uri = uri + '/index.html'; } } }"
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* CloudFront standard (legacy) logging cannot deliver to an S3 bucket in an
|
|
47
|
+
* opt-in region: CDK's `renderLogging` performs no region check and
|
|
48
|
+
* CloudFormation rejects the distribution at deploy, after the rest of the
|
|
49
|
+
* stack has been created. The delivery region is known at synth whenever the
|
|
50
|
+
* bucket's stack is env-pinned, so the refusal lives here — the seam every
|
|
51
|
+
* distribution crosses, whether built directly or through the `Cdn`
|
|
52
|
+
* pattern — and names both cures.
|
|
53
|
+
*
|
|
54
|
+
* Logging is on exactly when `renderLogging` emits a Logging block:
|
|
55
|
+
* `enableLogging: true`, or a `logBucket` without an explicit
|
|
56
|
+
* `enableLogging: false` (that pair is CDK's own contradiction error and is
|
|
57
|
+
* left to it). The delivery region is the bucket's `env.region` when it is
|
|
58
|
+
* concrete, else the distribution stack's — the bucket CDK would create.
|
|
59
|
+
* A token delivery region (an unpinned stack, and no region on the bucket)
|
|
60
|
+
* cannot be checked and passes. A concrete region `AWS_REGIONS_METADATA`
|
|
61
|
+
* does not list is refused too — every region launched since 2019 is opt-in,
|
|
62
|
+
* so an unlisted one cannot be assumed default-enabled — naming the metadata
|
|
63
|
+
* as the cure. The guard fires whenever the delivery region is concrete and
|
|
64
|
+
* known to the metadata.
|
|
65
|
+
*/
|
|
66
|
+
function validateStandardLoggingRegion(scope, id, props) {
|
|
67
|
+
const loggingOn = props.enableLogging === true ||
|
|
68
|
+
(props.enableLogging === undefined && props.logBucket !== undefined);
|
|
69
|
+
if (!loggingOn)
|
|
70
|
+
return;
|
|
71
|
+
const bucketRegion = props.logBucket?.env.region;
|
|
72
|
+
const region = bucketRegion !== undefined && !Token.isUnresolved(bucketRegion)
|
|
73
|
+
? bucketRegion
|
|
74
|
+
: Stack.of(scope).region;
|
|
75
|
+
if (Token.isUnresolved(region))
|
|
76
|
+
return;
|
|
77
|
+
if (getRegionInfo(region) === undefined) {
|
|
78
|
+
throw new Error(`CloudFront distribution '${id}': region ${region} is not in ` +
|
|
79
|
+
"@fjall/util's AWS_REGIONS_METADATA, so its opt-in status cannot be " +
|
|
80
|
+
"checked — update the metadata, or set enableLogging: false on this " +
|
|
81
|
+
"CDN.");
|
|
82
|
+
}
|
|
83
|
+
if (!OPT_IN_REGION_CODES.has(region))
|
|
84
|
+
return;
|
|
85
|
+
throw new Error(`CloudFront distribution '${id}': standard (legacy) logging cannot ` +
|
|
86
|
+
`deliver to an S3 bucket in opt-in region ${region} — logging was ` +
|
|
87
|
+
"enabled explicitly or by the app backup tier's cloudFrontLogging " +
|
|
88
|
+
"default. Set enableLogging: false on this CDN, or pass a logBucket " +
|
|
89
|
+
"whose region CDK knows to be default-enabled: " +
|
|
90
|
+
"Bucket.fromBucketAttributes(…, { region }), or a bucket in a stack " +
|
|
91
|
+
"pinned to that region (fromBucketName and fromBucketArn inherit this " +
|
|
92
|
+
"stack's region).");
|
|
93
|
+
}
|
|
45
94
|
export class CloudFrontDistribution extends Construct {
|
|
46
95
|
id;
|
|
47
96
|
distribution;
|
|
@@ -53,6 +102,7 @@ export class CloudFrontDistribution extends Construct {
|
|
|
53
102
|
constructor(scope, id, props) {
|
|
54
103
|
super(scope, id);
|
|
55
104
|
this.id = id;
|
|
105
|
+
validateStandardLoggingRegion(this, id, props);
|
|
56
106
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
57
107
|
const outputName = toPascalCase(id);
|
|
58
108
|
const defaultOrigin = this.createOrigin(props.defaultOrigin);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BlockDevice, type IMachineImage, type IVpc, type UserData, type ISecurityGroup, type IConnectable, Connections, type SubnetConfiguration, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
|
|
2
2
|
import { Construct } from "constructs";
|
|
3
|
+
import { type Ec2SshAccessProps } from "./ec2SshAccess.js";
|
|
3
4
|
import { Duration, Stack, type StackProps } from "aws-cdk-lib";
|
|
4
5
|
import { type Role } from "aws-cdk-lib/aws-iam";
|
|
5
6
|
import { AutoScalingGroup, Monitoring, type WarmPoolOptions } from "aws-cdk-lib/aws-autoscaling";
|
|
@@ -42,6 +43,7 @@ export type Ec2InstanceUpdatePolicyConfig = {
|
|
|
42
43
|
} | {
|
|
43
44
|
type: "none";
|
|
44
45
|
};
|
|
46
|
+
export { type Ec2SshAccessProps } from "./ec2SshAccess.js";
|
|
45
47
|
export interface Ec2InstanceProps extends StackProps {
|
|
46
48
|
spotCapacityPercentage?: number;
|
|
47
49
|
blockDevices?: BlockDevice[];
|
|
@@ -57,7 +59,13 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
57
59
|
machineImage?: IMachineImage;
|
|
58
60
|
userData?: UserData;
|
|
59
61
|
role?: Role;
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* SSH access: who may reach port 22, which key pair, and whether the
|
|
64
|
+
* instances are public. Omit for Session Manager-only access. Rejected
|
|
65
|
+
* alongside a caller-supplied `securityGroup` — the construct adds ingress
|
|
66
|
+
* only to a security group it owns.
|
|
67
|
+
*/
|
|
68
|
+
sshAccess?: Ec2SshAccessProps;
|
|
61
69
|
defaultPort?: number;
|
|
62
70
|
/**
|
|
63
71
|
* Caller-supplied EC2 instance monitoring resolution. Routes through the
|
|
@@ -79,8 +87,10 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
79
87
|
securityGroup?: ISecurityGroup;
|
|
80
88
|
/**
|
|
81
89
|
* CDK `AutoScalingGroupProps.vpcSubnets`. Overrides the default
|
|
82
|
-
* `
|
|
83
|
-
* the caller needs a precise subnet selection (e.g. AZ pinning).
|
|
90
|
+
* `sshAccess.public ? PUBLIC : resolvePrivateSubnetType(vpc)` inference.
|
|
91
|
+
* Use when the caller needs a precise subnet selection (e.g. AZ pinning).
|
|
92
|
+
* With `sshAccess.public: true` the selection may pin availability zones
|
|
93
|
+
* but not choose a non-public subnet type, group or list.
|
|
84
94
|
*/
|
|
85
95
|
vpcSubnets?: SubnetSelection;
|
|
86
96
|
/** CDK `AutoScalingGroupProps.capacityRebalance`. Absent → CDK default. */
|
|
@@ -92,9 +102,9 @@ export interface Ec2InstanceProps extends StackProps {
|
|
|
92
102
|
*/
|
|
93
103
|
warmPool?: WarmPoolOptions;
|
|
94
104
|
/**
|
|
95
|
-
* CDK `LaunchTemplateProps.associatePublicIpAddress`.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
105
|
+
* CDK `LaunchTemplateProps.associatePublicIpAddress`. Absent → `true` only
|
|
106
|
+
* when `sshAccess.public` is `true`. An explicit `false` alongside
|
|
107
|
+
* `sshAccess.public: true` is rejected rather than silently overridden.
|
|
98
108
|
*/
|
|
99
109
|
associatePublicIpAddress?: boolean;
|
|
100
110
|
/**
|
|
@@ -162,11 +172,28 @@ export declare class Ec2Instance extends Construct implements IConnectable {
|
|
|
162
172
|
private autoScalingGroup;
|
|
163
173
|
private keyPair;
|
|
164
174
|
private persistentDataVolume?;
|
|
175
|
+
private readonly vpcSubnets;
|
|
176
|
+
private readonly associatePublicIpAddress;
|
|
165
177
|
readonly connections: Connections;
|
|
166
178
|
constructor(scope: Construct, id: string, props: Ec2InstanceProps);
|
|
167
179
|
private addVpc;
|
|
168
180
|
private addKeyPair;
|
|
181
|
+
/**
|
|
182
|
+
* One ingress rule per source range. A world-open range still lands (the
|
|
183
|
+
* caller asked for it) but warns; the wording reads the placement the group
|
|
184
|
+
* actually resolved to, not `sshAccess.public` alone, because a caller who
|
|
185
|
+
* owns the placement through public `vpcSubnets` and
|
|
186
|
+
* `associatePublicIpAddress` exposes it just the same.
|
|
187
|
+
*/
|
|
188
|
+
private addSshIngress;
|
|
169
189
|
private addLaunchTemplate;
|
|
190
|
+
/**
|
|
191
|
+
* `sshAccess.public` decides the subnet type; an explicit `vpcSubnets` keeps
|
|
192
|
+
* its other selectors (AZ pinning) — `validateEc2InstanceProps` has already
|
|
193
|
+
* rejected a selection that contradicts `public: true`, so filling in
|
|
194
|
+
* `PUBLIC` here only completes an AZ-only pin.
|
|
195
|
+
*/
|
|
196
|
+
private resolveVpcSubnets;
|
|
170
197
|
private addAutoScalingGroup;
|
|
171
198
|
/**
|
|
172
199
|
* Apply `props.tags` to the underlying ASG with
|
|
@@ -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
|
-
|
|
115
|
+
const access = props.sshAccess;
|
|
116
|
+
if (access === undefined) {
|
|
105
117
|
this.keyPair = undefined;
|
|
118
|
+
return;
|
|
106
119
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
124
|
-
this.
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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),
|
|
@@ -178,9 +178,7 @@ export class LambdaFunction extends Function {
|
|
|
178
178
|
const tracingEnabled = tracing ?? tierDefault(appBackupTier, "lambdaTracing");
|
|
179
179
|
// Grant-based: Lambda encrypts the environment through a KMS grant the
|
|
180
180
|
// deploying role creates, so the execution role needs no key policy.
|
|
181
|
-
const encryptionKey =
|
|
182
|
-
? StackEncryptionKey.of(scope)
|
|
183
|
-
: undefined;
|
|
181
|
+
const encryptionKey = StackEncryptionKey.forTier(scope, appBackupTier);
|
|
184
182
|
super(scope, id, {
|
|
185
183
|
...cdkProps,
|
|
186
184
|
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
|
-
(
|
|
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
|
-
...(
|
|
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
|
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
33
|
-
*
|
|
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
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
2
|
import { CfnOutput } from "aws-cdk-lib";
|
|
3
3
|
import { Topic } from "aws-cdk-lib/aws-sns";
|
|
4
|
-
import { tierDefault } from "../../../utils/backupTierMapping.js";
|
|
5
4
|
import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
|
|
6
5
|
import { toRemovalPolicy } from "./utils.js";
|
|
7
6
|
export class SNSTopic extends Construct {
|
|
@@ -11,10 +10,11 @@ export class SNSTopic extends Construct {
|
|
|
11
10
|
super(scope, id);
|
|
12
11
|
this.id = id;
|
|
13
12
|
const isFifo = props.fifo === true;
|
|
13
|
+
const encryptionOverride = props.encryption === undefined
|
|
14
|
+
? undefined
|
|
15
|
+
: props.encryption === "SSE_KMS";
|
|
14
16
|
const masterKey = props.masterKey ??
|
|
15
|
-
(
|
|
16
|
-
? StackEncryptionKey.of(this).forSnsServicePublishers()
|
|
17
|
-
: undefined);
|
|
17
|
+
StackEncryptionKey.forTier(this, props.appBackupTier, encryptionOverride)?.forSnsServicePublishers();
|
|
18
18
|
const topicName = props.topicName
|
|
19
19
|
? isFifo
|
|
20
20
|
? `${props.topicName}.fifo`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IKey } from "aws-cdk-lib/aws-kms";
|
|
2
2
|
import type { Construct } from "constructs";
|
|
3
|
+
import { type BackupTier } from "../../../utils/backupTierMapping.js";
|
|
3
4
|
import { CustomerManagedKey } from "./kms.js";
|
|
4
5
|
/**
|
|
5
6
|
* The one customer-managed KMS key an enterprise-tier stack encrypts its
|
|
@@ -16,10 +17,12 @@ import { CustomerManagedKey } from "./kms.js";
|
|
|
16
17
|
* costs $1–3/month; a destroyed key would render the surviving table
|
|
17
18
|
* permanently unreadable after its 30-day pending window.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Constructs reach it through [[forTier]] — the one place the tier's
|
|
21
|
+
* `customerManagedKeys` column is read. Grant-based consumers (DynamoDB,
|
|
22
|
+
* Lambda `environmentEncryption`, ECS Exec) then read `key` directly — the
|
|
23
|
+
* SDK/CDK grants they need ride the resource's own IAM path. Service-side
|
|
24
|
+
* encryptors need a resource-policy admission first: CloudWatch Logs through
|
|
25
|
+
* [[forLogGroups]], SNS service publishers through
|
|
23
26
|
* [[forSnsServicePublishers]]. Each admission is added once per stack.
|
|
24
27
|
*/
|
|
25
28
|
export declare class StackEncryptionKey extends CustomerManagedKey {
|
|
@@ -28,6 +31,16 @@ export declare class StackEncryptionKey extends CustomerManagedKey {
|
|
|
28
31
|
private constructor();
|
|
29
32
|
/** The stack's shared key, created on first use. */
|
|
30
33
|
static of(scope: Construct): StackEncryptionKey;
|
|
34
|
+
/**
|
|
35
|
+
* The stack's shared key when the tier's `customerManagedKeys` column says
|
|
36
|
+
* so, else `undefined` — the one place the column is read. A resource with
|
|
37
|
+
* its own encryption vocabulary (DynamoDB `encryption`, SNS `encryption`)
|
|
38
|
+
* passes the explicit choice as `override`; `undefined` lets the tier
|
|
39
|
+
* decide. The key is only created when the answer is yes, so an opt-out at
|
|
40
|
+
* enterprise mints nothing and an opt-in below enterprise mints the shared
|
|
41
|
+
* key rather than a per-resource one.
|
|
42
|
+
*/
|
|
43
|
+
static forTier(scope: Construct, tier: BackupTier | undefined, override?: boolean): StackEncryptionKey | undefined;
|
|
31
44
|
/**
|
|
32
45
|
* The key, with CloudWatch Logs admitted to it. The CDK `LogGroup` sets
|
|
33
46
|
* `KmsKeyId` but adds no key-policy statement, and without one the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import { tierDefault } from "../../../utils/backupTierMapping.js";
|
|
3
4
|
import { CustomerManagedKey } from "./kms.js";
|
|
4
5
|
const STACK_ENCRYPTION_KEY_ID = "FjallStackEncryptionKey";
|
|
5
6
|
/**
|
|
@@ -33,10 +34,12 @@ const SNS_SERVICE_PUBLISHERS = [
|
|
|
33
34
|
* costs $1–3/month; a destroyed key would render the surviving table
|
|
34
35
|
* permanently unreadable after its 30-day pending window.
|
|
35
36
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Constructs reach it through [[forTier]] — the one place the tier's
|
|
38
|
+
* `customerManagedKeys` column is read. Grant-based consumers (DynamoDB,
|
|
39
|
+
* Lambda `environmentEncryption`, ECS Exec) then read `key` directly — the
|
|
40
|
+
* SDK/CDK grants they need ride the resource's own IAM path. Service-side
|
|
41
|
+
* encryptors need a resource-policy admission first: CloudWatch Logs through
|
|
42
|
+
* [[forLogGroups]], SNS service publishers through
|
|
40
43
|
* [[forSnsServicePublishers]]. Each admission is added once per stack.
|
|
41
44
|
*/
|
|
42
45
|
export class StackEncryptionKey extends CustomerManagedKey {
|
|
@@ -59,6 +62,19 @@ export class StackEncryptionKey extends CustomerManagedKey {
|
|
|
59
62
|
return existing;
|
|
60
63
|
return new StackEncryptionKey(stack);
|
|
61
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The stack's shared key when the tier's `customerManagedKeys` column says
|
|
67
|
+
* so, else `undefined` — the one place the column is read. A resource with
|
|
68
|
+
* its own encryption vocabulary (DynamoDB `encryption`, SNS `encryption`)
|
|
69
|
+
* passes the explicit choice as `override`; `undefined` lets the tier
|
|
70
|
+
* decide. The key is only created when the answer is yes, so an opt-out at
|
|
71
|
+
* enterprise mints nothing and an opt-in below enterprise mints the shared
|
|
72
|
+
* key rather than a per-resource one.
|
|
73
|
+
*/
|
|
74
|
+
static forTier(scope, tier, override) {
|
|
75
|
+
const onStackKey = override ?? tierDefault(tier, "customerManagedKeys");
|
|
76
|
+
return onStackKey ? StackEncryptionKey.of(scope) : undefined;
|
|
77
|
+
}
|
|
62
78
|
/**
|
|
63
79
|
* The key, with CloudWatch Logs admitted to it. The CDK `LogGroup` sets
|
|
64
80
|
* `KmsKeyId` but adds no key-policy statement, and without one the
|
|
@@ -68,7 +68,8 @@ export interface S3BucketProps extends BucketProps {
|
|
|
68
68
|
* `S3_VERSIONING_DISABLED` finding `mitigated` rather than `open` on
|
|
69
69
|
* buckets whose contents are rebuilt, not kept. Constructs pass it for the
|
|
70
70
|
* buckets they own (Payload build output and ISR cache, ClickHouse cold
|
|
71
|
-
* tier); an author may pass it for a
|
|
71
|
+
* tier, the stack access-log bucket); an author may pass it for a
|
|
72
|
+
* disposable bucket of their own.
|
|
72
73
|
*/
|
|
73
74
|
versioningExemption?: S3VersioningExemption;
|
|
74
75
|
publicReadAccess?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "34.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"clean": "rm -rf ./dist",
|
|
40
40
|
"clean:node": "rm -rf ./node_modules",
|
|
41
41
|
"build:cert-gen-lambda": "node lib/lambda-assets/cert-generator/src/build.mjs",
|
|
42
|
-
"build": "
|
|
42
|
+
"build": "node ../../scripts/run-build.mjs --purpose=build -- npm run build:steps",
|
|
43
|
+
"build:steps": "npm run clean && npm run build:cert-gen-lambda && tsc && cp -r lib/layers dist/lib/layers && mkdir -p dist/lib/lambda-assets/cert-generator/asset && cp lib/lambda-assets/cert-generator/asset/index.js dist/lib/lambda-assets/cert-generator/asset/index.js && cp lib/lambda-assets/cert-generator/asset/package.json dist/lib/lambda-assets/cert-generator/asset/package.json && mkdir -p dist/lib/lambda-assets/identity-store-user/asset && cp lib/lambda-assets/identity-store-user/asset/index.js dist/lib/lambda-assets/identity-store-user/asset/index.js && cp lib/lambda-assets/identity-store-user/asset/package.json dist/lib/lambda-assets/identity-store-user/asset/package.json && mkdir -p dist/lib/lambda-assets/static-site-forms/asset && cp lib/lambda-assets/static-site-forms/asset/index.js dist/lib/lambda-assets/static-site-forms/asset/index.js && cp lib/lambda-assets/static-site-forms/asset/package.json dist/lib/lambda-assets/static-site-forms/asset/package.json && cp lib/resources/aws/compute/lifecycleHookLambda.source.cjs dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs && cp lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs && cp lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs && cp lib/patterns/aws/devSubstrate.waker.source.cjs dist/lib/patterns/aws/devSubstrate.waker.source.cjs && node ../../scripts/stamp-build.mjs",
|
|
43
44
|
"prepack": "node ../../scripts/check-dist-freshness.mjs",
|
|
44
45
|
"prepublishOnly": "node ../../scripts/audit-package-tarball.mjs",
|
|
45
46
|
"watch": "tsc -w",
|
|
@@ -79,8 +80,8 @@
|
|
|
79
80
|
},
|
|
80
81
|
"dependencies": {
|
|
81
82
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
82
|
-
"@fjall/generator": "^
|
|
83
|
-
"@fjall/util": "^
|
|
83
|
+
"@fjall/generator": "^34.0.0",
|
|
84
|
+
"@fjall/util": "^34.0.0",
|
|
84
85
|
"constructs": "^10.7.2",
|
|
85
86
|
"zod": "^4.4.3"
|
|
86
87
|
},
|