@fjall/components-infrastructure 27.1.0 → 28.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/app.js +8 -1
- package/dist/lib/config/aws/alarmTopic.js +20 -1
- package/dist/lib/config/aws/configRecorder.js +16 -5
- package/dist/lib/patterns/aws/computeLambda.d.ts +4 -1
- package/dist/lib/patterns/aws/computeLambda.js +4 -1
- package/dist/lib/patterns/aws/database.d.ts +11 -6
- package/dist/lib/patterns/aws/database.js +4 -2
- package/dist/lib/patterns/aws/storage.d.ts +6 -0
- package/dist/lib/patterns/aws/storage.js +3 -0
- package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +3 -0
- package/dist/lib/resources/aws/compute/ecs.js +11 -2
- package/dist/lib/resources/aws/compute/lambda.d.ts +46 -0
- package/dist/lib/resources/aws/compute/lambda.js +100 -2
- package/dist/lib/resources/aws/compute/listenerRouting.js +6 -2
- package/dist/lib/resources/aws/database/dynamodb.d.ts +1 -0
- package/dist/lib/resources/aws/database/dynamodb.js +14 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +5 -0
- package/dist/lib/resources/aws/database/rdsInstance.d.ts +7 -7
- package/dist/lib/resources/aws/database/rdsInstance.js +5 -4
- package/dist/lib/resources/aws/logging/cloudTrail.js +12 -3
- package/dist/lib/resources/aws/messaging/eventBridgeRule.js +5 -2
- package/dist/lib/resources/aws/messaging/sns.js +7 -0
- package/dist/lib/resources/aws/messaging/sqs.js +2 -0
- package/dist/lib/resources/aws/networking/vpc.js +13 -2
- package/dist/lib/resources/aws/secrets/parameter.js +9 -7
- package/dist/lib/resources/aws/secrets/secret.js +5 -1
- package/dist/lib/resources/aws/storage/s3.d.ts +13 -0
- package/dist/lib/resources/aws/storage/s3.js +34 -7
- package/dist/lib/resources/aws/utilities/codeBuild.js +12 -1
- package/dist/lib/utils/costAllocationTags.d.ts +2 -2
- package/dist/lib/utils/costAllocationTags.js +2 -2
- package/package.json +3 -3
package/dist/lib/app.js
CHANGED
|
@@ -24,7 +24,7 @@ import { FjallLogger } from "./utils/validationLogger.js";
|
|
|
24
24
|
import { getManifestCollector, writeManifest } from "./utils/manifestWriter.js";
|
|
25
25
|
import { resetDnsRecordRegistry } from "./utils/dnsRecordRegistry.js";
|
|
26
26
|
import { toPascalCase, toKebab } from "./utils/capitaliseString.js";
|
|
27
|
-
import { COST_ALLOCATION_TAGS } from "./utils/costAllocationTags.js";
|
|
27
|
+
import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_OWNER } from "./utils/costAllocationTags.js";
|
|
28
28
|
import { DEFAULT_ORG_ID, resolveOrgId } from "./utils/cdkContext.js";
|
|
29
29
|
/**
|
|
30
30
|
* The basic corner-stone of all Fjall-hosted applications.
|
|
@@ -792,6 +792,13 @@ export class App extends CdkApp {
|
|
|
792
792
|
this.globalTags[COST_ALLOCATION_TAGS.OWNER] = orgId;
|
|
793
793
|
}
|
|
794
794
|
}
|
|
795
|
+
// Last-resort owner: without it, apps built directly on the construct
|
|
796
|
+
// library (no generator addTags block, no orgId context) ship every
|
|
797
|
+
// resource owner-less → MISSING_REQUIRED_TAGS across the estate.
|
|
798
|
+
if (this.globalTags[COST_ALLOCATION_TAGS.OWNER] === undefined) {
|
|
799
|
+
this.globalTags[COST_ALLOCATION_TAGS.OWNER] =
|
|
800
|
+
DEFAULT_COST_ALLOCATION_OWNER;
|
|
801
|
+
}
|
|
795
802
|
// Apply standard tags using Tags.of(this).add()
|
|
796
803
|
for (const [key, value] of Object.entries(this.globalTags)) {
|
|
797
804
|
Tags.of(this).add(key, value);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CfnOutput } from "aws-cdk-lib";
|
|
1
|
+
import { CfnOutput, Stack } from "aws-cdk-lib";
|
|
2
2
|
import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { EmailSubscription, UrlSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
|
|
4
4
|
import { Construct } from "constructs";
|
|
@@ -38,6 +38,25 @@ export class SharedAlarmTopic extends Construct {
|
|
|
38
38
|
...(masterKey !== undefined && { masterKey })
|
|
39
39
|
});
|
|
40
40
|
this.topic = wrapped.getTopic();
|
|
41
|
+
// The wrapper's TLS-only TopicPolicy REPLACES the topic's implicit
|
|
42
|
+
// default policy, whose Allow was the SOLE authorisation for CloudWatch
|
|
43
|
+
// alarm-transition publishes — without this explicit Allow every alarm
|
|
44
|
+
// fires into a deny-only policy and pages nobody. Unlike the KMS path
|
|
45
|
+
// above, the topic-policy publish DOES carry the confused-deputy keys
|
|
46
|
+
// (the AWS-documented alarms-to-SNS shape), so scope to this account's
|
|
47
|
+
// alarms.
|
|
48
|
+
const stack = Stack.of(this);
|
|
49
|
+
this.topic.addToResourcePolicy(new PolicyStatement({
|
|
50
|
+
principals: [new ServicePrincipal("cloudwatch.amazonaws.com")],
|
|
51
|
+
actions: ["sns:Publish"],
|
|
52
|
+
resources: [wrapped.getTopicArn()],
|
|
53
|
+
conditions: {
|
|
54
|
+
StringEquals: { "aws:SourceAccount": stack.account },
|
|
55
|
+
ArnLike: {
|
|
56
|
+
"aws:SourceArn": `arn:${stack.partition}:cloudwatch:${stack.region}:${stack.account}:alarm:*`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
41
60
|
for (const email of props?.subscriptions?.emails ?? []) {
|
|
42
61
|
this.topic.addSubscription(new EmailSubscription(email));
|
|
43
62
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
2
|
import { CfnConfigurationRecorder, CfnDeliveryChannel } from "aws-cdk-lib/aws-config";
|
|
3
3
|
import { Role, ServicePrincipal, ManagedPolicy } from "aws-cdk-lib/aws-iam";
|
|
4
|
-
import {
|
|
4
|
+
import { BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
|
|
5
5
|
import { Construct } from "constructs";
|
|
6
|
+
import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../../resources/aws/storage/index.js";
|
|
6
7
|
/**
|
|
7
8
|
* AWS Config recorder with S3 delivery channel.
|
|
8
9
|
* Records configuration changes to all supported resources.
|
|
@@ -14,13 +15,23 @@ export class ConfigRecorder extends Construct {
|
|
|
14
15
|
super(scope, id);
|
|
15
16
|
const allResources = props?.allResources !== false;
|
|
16
17
|
const includeGlobalResources = props?.includeGlobalResources !== false;
|
|
17
|
-
//
|
|
18
|
-
|
|
18
|
+
// S3Bucket keeps the raw Bucket's construct id, so the deployed bucket's
|
|
19
|
+
// logical ID is unchanged (a rename would replace the bucket on redeploy);
|
|
20
|
+
// the wrapper supplies enforceSSL + multipart hygiene. Custom
|
|
21
|
+
// lifecycleRules bypass its automatic noncurrentVersionExpiration
|
|
22
|
+
// pairing — carried explicitly, or noncurrent versions accumulate forever.
|
|
23
|
+
const deliveryBucket = new S3Bucket(this, "DeliveryBucket", {
|
|
19
24
|
encryption: BucketEncryption.S3_MANAGED,
|
|
20
25
|
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
|
|
21
|
-
|
|
26
|
+
versioned: true,
|
|
22
27
|
removalPolicy: RemovalPolicy.RETAIN,
|
|
23
|
-
lifecycleRules: [
|
|
28
|
+
lifecycleRules: [
|
|
29
|
+
{
|
|
30
|
+
expiration: Duration.days(90),
|
|
31
|
+
noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
|
|
32
|
+
enabled: true
|
|
33
|
+
}
|
|
34
|
+
]
|
|
24
35
|
});
|
|
25
36
|
this.deliveryBucketName = deliveryBucket.bucketName;
|
|
26
37
|
// IAM role for Config service
|
|
@@ -201,7 +201,10 @@ export interface CodeLambdaProps extends BaseLambdaProps {
|
|
|
201
201
|
code: Code;
|
|
202
202
|
/** Handler function. Default: "index.handler" */
|
|
203
203
|
handler?: string;
|
|
204
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Lambda runtime. Default: NODEJS_24_X. A runtime AWS Lambda has
|
|
206
|
+
* deprecated fails synth.
|
|
207
|
+
*/
|
|
205
208
|
runtime?: Runtime;
|
|
206
209
|
}
|
|
207
210
|
/**
|
|
@@ -3,7 +3,7 @@ import { Connections } from "aws-cdk-lib/aws-ec2";
|
|
|
3
3
|
import { Repository } from "aws-cdk-lib/aws-ecr";
|
|
4
4
|
import { Construct } from "constructs";
|
|
5
5
|
import { processConnections } from "../../utils/connections.js";
|
|
6
|
-
import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
|
|
6
|
+
import { LambdaFunction, validateRuntimeNotDeprecated } from "../../resources/aws/compute/lambda.js";
|
|
7
7
|
import { getOrCreateImageTagParameter } from "../../resources/aws/compute/imageTagParameter.js";
|
|
8
8
|
import { COMPUTE_DEFAULTS } from "./compute.js";
|
|
9
9
|
import { LAMBDA_ARCHITECTURE_VALUES } from "@fjall/util/manifest/schemas";
|
|
@@ -114,6 +114,9 @@ export function resolveLambdaDeployment(scope, id, props) {
|
|
|
114
114
|
runtime: Runtime.FROM_IMAGE
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
+
if (props.runtime !== undefined) {
|
|
118
|
+
validateRuntimeNotDeprecated(props.runtime, id);
|
|
119
|
+
}
|
|
117
120
|
return {
|
|
118
121
|
code: props.code,
|
|
119
122
|
handler: props.handler || COMPUTE_DEFAULTS.LAMBDA.HANDLER,
|
|
@@ -117,7 +117,9 @@ export interface AuroraDatabaseProps extends BaseDatabaseProps {
|
|
|
117
117
|
/**
|
|
118
118
|
* IP address to allow access from when publiclyAccessible is true.
|
|
119
119
|
* If not specified, access is restricted to VPC only even when public.
|
|
120
|
-
* Format: CIDR notation (e.g., "203.0.113.0/32" for single IP)
|
|
120
|
+
* Format: IPv4 CIDR notation (e.g., "203.0.113.0/32" for single IP).
|
|
121
|
+
* The world-open CIDR "0.0.0.0/0" raises a synth-time warning; a non-IPv4
|
|
122
|
+
* CIDR (e.g. "::/0") fails synth outright — the ingress peer is IPv4-only.
|
|
121
123
|
*/
|
|
122
124
|
allowedIpCidr?: string;
|
|
123
125
|
}
|
|
@@ -162,10 +164,11 @@ export interface InstanceDatabaseProps extends BaseDatabaseProps {
|
|
|
162
164
|
encryption?: EncryptionConfig;
|
|
163
165
|
publiclyAccessible?: boolean;
|
|
164
166
|
/**
|
|
165
|
-
*
|
|
166
|
-
* databases only.
|
|
167
|
+
* RDS IAM database authentication (default ON; pass false to opt out).
|
|
168
|
+
* Instance databases only. IAM principals granted via
|
|
167
169
|
* {@link RelationalDatabase.grantIamConnect} connect with short-lived
|
|
168
|
-
* `rds-db:connect` tokens instead of a stored password
|
|
170
|
+
* `rds-db:connect` tokens instead of a stored password; password auth keeps
|
|
171
|
+
* working in parallel. See ADR
|
|
169
172
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
170
173
|
*/
|
|
171
174
|
iamAuthentication?: boolean;
|
|
@@ -189,6 +192,7 @@ export interface DynamoDBDatabaseProps {
|
|
|
189
192
|
stream?: "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY";
|
|
190
193
|
pointInTimeRecovery?: boolean;
|
|
191
194
|
encryption?: "AWS_OWNED" | "AWS_MANAGED" | "CUSTOMER_MANAGED";
|
|
195
|
+
deletionProtection?: boolean;
|
|
192
196
|
removalPolicy?: "DESTROY" | "RETAIN" | "SNAPSHOT";
|
|
193
197
|
}
|
|
194
198
|
/**
|
|
@@ -323,8 +327,9 @@ export declare class RelationalDatabase extends Construct implements IRelational
|
|
|
323
327
|
/**
|
|
324
328
|
* Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
|
|
325
329
|
* database authentication. Instance databases only — Aurora uses a different
|
|
326
|
-
* mechanism, so this throws for non-Instance databases.
|
|
327
|
-
*
|
|
330
|
+
* mechanism, so this throws for non-Instance databases. IAM auth is on by
|
|
331
|
+
* default; this throws if the instance opted out with
|
|
332
|
+
* `iamAuthentication: false`. The L2 `grantConnect` scopes
|
|
328
333
|
* `rds-db:connect` to the exact `dbuser:<resourceId>/<dbUsername>` ARN — never
|
|
329
334
|
* a bare wildcard. See ADR
|
|
330
335
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
@@ -222,6 +222,7 @@ export class DynamoDBDatabase extends Construct {
|
|
|
222
222
|
stream: props.stream,
|
|
223
223
|
pointInTimeRecovery: props.pointInTimeRecovery,
|
|
224
224
|
encryption: props.encryption,
|
|
225
|
+
deletionProtection: props.deletionProtection,
|
|
225
226
|
removalPolicy: props.removalPolicy
|
|
226
227
|
};
|
|
227
228
|
this.table = new DynamoDBTable(this, id, tableProps);
|
|
@@ -647,8 +648,9 @@ export class RelationalDatabase extends Construct {
|
|
|
647
648
|
/**
|
|
648
649
|
* Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
|
|
649
650
|
* database authentication. Instance databases only — Aurora uses a different
|
|
650
|
-
* mechanism, so this throws for non-Instance databases.
|
|
651
|
-
*
|
|
651
|
+
* mechanism, so this throws for non-Instance databases. IAM auth is on by
|
|
652
|
+
* default; this throws if the instance opted out with
|
|
653
|
+
* `iamAuthentication: false`. The L2 `grantConnect` scopes
|
|
652
654
|
* `rds-db:connect` to the exact `dbuser:<resourceId>/<dbUsername>` ARN — never
|
|
653
655
|
* a bare wildcard. See ADR
|
|
654
656
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
@@ -36,6 +36,12 @@ export interface S3Props {
|
|
|
36
36
|
readonly versioned?: boolean;
|
|
37
37
|
readonly encryption?: "AES256" | "KMS";
|
|
38
38
|
readonly kmsKeyArn?: string;
|
|
39
|
+
/**
|
|
40
|
+
* S3 Bucket Keys for KMS-encrypted buckets — defaults to true when
|
|
41
|
+
* encryption resolves to KMS (cuts per-object KMS request charges); pass
|
|
42
|
+
* false to opt out. Ignored for non-KMS encryption.
|
|
43
|
+
*/
|
|
44
|
+
readonly bucketKeyEnabled?: boolean;
|
|
39
45
|
readonly backupVaultTier?: BackupTier;
|
|
40
46
|
readonly cors?: CorsRule[];
|
|
41
47
|
readonly deployment?: S3DeploymentConfig;
|
|
@@ -80,6 +80,9 @@ export class Storage extends Construct {
|
|
|
80
80
|
versioned: props.versioned,
|
|
81
81
|
encryption: toBucketEncryption(props.encryption),
|
|
82
82
|
encryptionKey,
|
|
83
|
+
...(props.bucketKeyEnabled !== undefined && {
|
|
84
|
+
bucketKeyEnabled: props.bucketKeyEnabled
|
|
85
|
+
}),
|
|
83
86
|
backupVaultTier: props.backupVaultTier,
|
|
84
87
|
publicReadAccess: props.publicReadAccess,
|
|
85
88
|
websiteHosting: props.websiteHosting,
|
|
@@ -16,6 +16,9 @@ export function createApplicationLoadBalancer(scope, id, options) {
|
|
|
16
16
|
// ALB-fronted containers receive it as FJALL_ALB_IDLE_TIMEOUT_SECONDS
|
|
17
17
|
// (ecsTaskDefinition.ts) for keep-alive tuning — one constant, both sides.
|
|
18
18
|
idleTimeout: Duration.seconds(DEFAULT_ALB_IDLE_TIMEOUT_SECONDS),
|
|
19
|
+
// ELB.4 — invalid HTTP headers are a request-smuggling surface; drop them
|
|
20
|
+
// at the ALB. Attribute-only, so existing LBs update in place.
|
|
21
|
+
dropInvalidHeaderFields: true,
|
|
19
22
|
...(options.securityGroup && { securityGroup: options.securityGroup }),
|
|
20
23
|
...(options.loadBalancerName && {
|
|
21
24
|
loadBalancerName: options.loadBalancerName
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Cluster as CdkCluster, ContainerInsights } from "aws-cdk-lib/aws-ecs";
|
|
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
4
|
import { CfnOutput, Aspects } from "aws-cdk-lib";
|
|
5
5
|
import { processConnections } from "../../../utils/connections.js";
|
|
6
|
+
import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
|
|
6
7
|
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
7
8
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
8
9
|
import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
|
|
@@ -382,13 +383,21 @@ export default class EcsCluster extends Construct {
|
|
|
382
383
|
}
|
|
383
384
|
addCluster(props) {
|
|
384
385
|
const needsFargate = this.anyServiceUsesFargate();
|
|
386
|
+
// Every service enables execute command; without an OVERRIDE here the
|
|
387
|
+
// exec session output interleaves into the container app log groups
|
|
388
|
+
// (DEFAULT mode). No kmsKey — session encryption is tier-gated.
|
|
389
|
+
const execAuditLogGroup = new LogGroup(this, `${props.clusterName}ExecAuditLogGroup`, { retention: DEFAULT_FRAMEWORK_LOG_RETENTION });
|
|
385
390
|
const cluster = new CdkCluster(this, `${props.clusterName}Cluster`, {
|
|
386
391
|
vpc: props.vpc,
|
|
387
392
|
clusterName: props.clusterName,
|
|
388
393
|
containerInsightsV2: props.containerInsights === true
|
|
389
394
|
? ContainerInsights.ENABLED
|
|
390
395
|
: ContainerInsights.DISABLED,
|
|
391
|
-
enableFargateCapacityProviders: needsFargate
|
|
396
|
+
enableFargateCapacityProviders: needsFargate,
|
|
397
|
+
executeCommandConfiguration: {
|
|
398
|
+
logging: ExecuteCommandLogging.OVERRIDE,
|
|
399
|
+
logConfiguration: { cloudWatchLogGroup: execAuditLogGroup }
|
|
400
|
+
}
|
|
392
401
|
});
|
|
393
402
|
new CfnOutput(this, `${this.outputName}DeployableCluster`, {
|
|
394
403
|
key: `${this.outputName}DeployableCluster`,
|
|
@@ -10,6 +10,23 @@ import { type KeyValue } from "../../../types.js";
|
|
|
10
10
|
import { type SecretImport } from "../secrets/index.js";
|
|
11
11
|
import type { ITopic } from "aws-cdk-lib/aws-sns";
|
|
12
12
|
import { type LambdaAlarmThresholds } from "../monitoring/index.js";
|
|
13
|
+
/**
|
|
14
|
+
* Runtime names AWS Lambda has deprecated, mirrored from the `@deprecated`
|
|
15
|
+
* markers on `Runtime`'s static members in the vendored aws-cdk-lib
|
|
16
|
+
* (aws-lambda/lib/runtime.d.ts). The SDK exposes no runtime-readable
|
|
17
|
+
* deprecation flag on `Runtime` instances, so this set can drift when
|
|
18
|
+
* aws-cdk-lib is upgraded — lambda-deprecated-runtime.test.ts re-derives it
|
|
19
|
+
* from the vendored declaration file and fails on any mismatch.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEPRECATED_RUNTIME_NAMES: ReadonlySet<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Throw at synth time when a deprecated Lambda runtime is configured —
|
|
24
|
+
* shift-left of the posture scan's LAMBDA_DEPRECATED_RUNTIME finding, which
|
|
25
|
+
* previously let the deploy succeed and flagged it days later. A deprecated
|
|
26
|
+
* runtime receives no security patches and AWS eventually blocks function
|
|
27
|
+
* creation on it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateRuntimeNotDeprecated(runtime: Runtime, functionId: string): void;
|
|
13
30
|
export interface LambdaFunctionProps {
|
|
14
31
|
code: Code;
|
|
15
32
|
handler: string;
|
|
@@ -53,6 +70,16 @@ export interface LambdaFunctionProps {
|
|
|
53
70
|
functionUrlCors?: FunctionUrlCorsOptions;
|
|
54
71
|
/** Invoke mode for Function URL. Use RESPONSE_STREAM for Lambda streaming. */
|
|
55
72
|
functionUrlInvokeMode?: InvokeMode;
|
|
73
|
+
/**
|
|
74
|
+
* Failure destination for asynchronous invocations. S3 event notifications
|
|
75
|
+
* invoke the function asynchronously, and a failed event is silently
|
|
76
|
+
* dropped after the default two retries. Unless set to `false`, the first
|
|
77
|
+
* `addS3EventSource` call lazily creates one SQS queue for the function
|
|
78
|
+
* (SSL-enforced, 14-day retention) and wires it as the async dead-letter
|
|
79
|
+
* queue; pass an existing queue to reuse it instead. Functions with no
|
|
80
|
+
* async event source get no queue.
|
|
81
|
+
*/
|
|
82
|
+
asyncFailureQueue?: IQueue | false;
|
|
56
83
|
environment?: KeyValue;
|
|
57
84
|
secrets?: string[];
|
|
58
85
|
ssmSecretsPath?: string;
|
|
@@ -79,6 +106,8 @@ export declare class SingletonFunction extends singletonFunction {
|
|
|
79
106
|
}
|
|
80
107
|
export declare class LambdaFunction extends Function {
|
|
81
108
|
private functionUrlValue?;
|
|
109
|
+
private readonly asyncFailureQueueProp?;
|
|
110
|
+
private asyncFailureQueueWired?;
|
|
82
111
|
constructor(scope: Construct, id: string, props: LambdaFunctionProps);
|
|
83
112
|
/**
|
|
84
113
|
* The Lambda's execution role (auto-generated by CDK)
|
|
@@ -114,6 +143,9 @@ export declare class LambdaFunction extends Function {
|
|
|
114
143
|
* Add an S3 bucket as an event source for this Lambda function.
|
|
115
144
|
* This will trigger the Lambda when objects are created, modified, or deleted.
|
|
116
145
|
* Useful for ISR cache invalidation and file processing workflows.
|
|
146
|
+
*
|
|
147
|
+
* S3 invokes asynchronously, so the first call also wires the async
|
|
148
|
+
* failure queue (see `asyncFailureQueue`) unless it is set to `false`.
|
|
117
149
|
*/
|
|
118
150
|
addS3EventSource(bucket: Bucket, options?: {
|
|
119
151
|
events?: Array<"OBJECT_CREATED" | "OBJECT_REMOVED" | "OBJECT_CREATED_PUT" | "OBJECT_CREATED_POST" | "OBJECT_CREATED_COPY" | "OBJECT_REMOVED_DELETE">;
|
|
@@ -122,6 +154,20 @@ export declare class LambdaFunction extends Function {
|
|
|
122
154
|
suffix?: string;
|
|
123
155
|
}>;
|
|
124
156
|
}): void;
|
|
157
|
+
/**
|
|
158
|
+
* Get the SQS queue wired as the async-invocation failure destination, if
|
|
159
|
+
* one has been created or supplied.
|
|
160
|
+
*/
|
|
161
|
+
getAsyncFailureQueue(): IQueue | undefined;
|
|
162
|
+
/**
|
|
163
|
+
* Wire the async-invocation dead-letter queue on first use. Without one,
|
|
164
|
+
* an event that fails all retries is silently dropped. `Function`'s
|
|
165
|
+
* `deadLetterQueue` prop is constructor-only and the queue is only wanted
|
|
166
|
+
* once an async event source exists, so reach the L1 `CfnFunction` and
|
|
167
|
+
* mirror what the prop does: DeadLetterConfig plus an sqs:SendMessage
|
|
168
|
+
* grant to the execution role.
|
|
169
|
+
*/
|
|
170
|
+
private ensureAsyncFailureQueue;
|
|
125
171
|
/**
|
|
126
172
|
* Add secrets support using AWS Parameters and Secrets Lambda Extension.
|
|
127
173
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CfnOutput, Duration, Stack, Size } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, CfnOutput, Duration, Stack, Size } from "aws-cdk-lib";
|
|
2
2
|
import { SingletonFunction as singletonFunction, Function, Code, Architecture, FunctionUrlAuthType, StartingPosition, LayerVersion } from "aws-cdk-lib/aws-lambda";
|
|
3
3
|
import { FactName } from "aws-cdk-lib/region-info";
|
|
4
4
|
import path from "node:path";
|
|
@@ -8,6 +8,8 @@ import { SqsEventSource, DynamoEventSource, S3EventSource } from "aws-cdk-lib/aw
|
|
|
8
8
|
import { EventType } from "aws-cdk-lib/aws-s3";
|
|
9
9
|
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
10
10
|
import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
|
|
11
|
+
import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
|
|
12
|
+
import { SQS_LIMITS } from "../messaging/sqs.js";
|
|
11
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
14
|
import { buildParameterPath } from "@fjall/util";
|
|
13
15
|
import { resolveImportedSecret } from "../secrets/index.js";
|
|
@@ -66,6 +68,54 @@ const SECRETS_EXTENSION = {
|
|
|
66
68
|
* mis-tune alarms relative to runtime behaviour.
|
|
67
69
|
*/
|
|
68
70
|
const LAMBDA_DEFAULT_TIMEOUT_SECONDS = 300;
|
|
71
|
+
/**
|
|
72
|
+
* Runtime names AWS Lambda has deprecated, mirrored from the `@deprecated`
|
|
73
|
+
* markers on `Runtime`'s static members in the vendored aws-cdk-lib
|
|
74
|
+
* (aws-lambda/lib/runtime.d.ts). The SDK exposes no runtime-readable
|
|
75
|
+
* deprecation flag on `Runtime` instances, so this set can drift when
|
|
76
|
+
* aws-cdk-lib is upgraded — lambda-deprecated-runtime.test.ts re-derives it
|
|
77
|
+
* from the vendored declaration file and fails on any mismatch.
|
|
78
|
+
*/
|
|
79
|
+
export const DEPRECATED_RUNTIME_NAMES = new Set([
|
|
80
|
+
"nodejs",
|
|
81
|
+
"nodejs4.3",
|
|
82
|
+
"nodejs6.10",
|
|
83
|
+
"nodejs8.10",
|
|
84
|
+
"nodejs10.x",
|
|
85
|
+
"nodejs12.x",
|
|
86
|
+
"nodejs14.x",
|
|
87
|
+
"nodejs16.x",
|
|
88
|
+
"nodejs18.x",
|
|
89
|
+
"python2.7",
|
|
90
|
+
"python3.6",
|
|
91
|
+
"python3.7",
|
|
92
|
+
"python3.8",
|
|
93
|
+
"python3.9",
|
|
94
|
+
"java8",
|
|
95
|
+
"dotnet6",
|
|
96
|
+
"dotnetcore1.0",
|
|
97
|
+
"dotnetcore2.0",
|
|
98
|
+
"dotnetcore2.1",
|
|
99
|
+
"dotnetcore3.1",
|
|
100
|
+
"go1.x",
|
|
101
|
+
"ruby2.5",
|
|
102
|
+
"ruby2.7",
|
|
103
|
+
"provided"
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* Throw at synth time when a deprecated Lambda runtime is configured —
|
|
107
|
+
* shift-left of the posture scan's LAMBDA_DEPRECATED_RUNTIME finding, which
|
|
108
|
+
* previously let the deploy succeed and flagged it days later. A deprecated
|
|
109
|
+
* runtime receives no security patches and AWS eventually blocks function
|
|
110
|
+
* creation on it.
|
|
111
|
+
*/
|
|
112
|
+
export function validateRuntimeNotDeprecated(runtime, functionId) {
|
|
113
|
+
if (DEPRECATED_RUNTIME_NAMES.has(runtime.name)) {
|
|
114
|
+
throw new Error(`Lambda '${functionId}': runtime '${runtime.name}' is deprecated by ` +
|
|
115
|
+
`AWS Lambda. Migrate to a supported runtime — e.g. ` +
|
|
116
|
+
`Runtime.NODEJS_24_X for Node.js.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
69
119
|
/**
|
|
70
120
|
* Stable, deterministic uuid for a SingletonFunction so its logical ID
|
|
71
121
|
* (`SingletonLambda${uuid-without-dashes}`) does not drift across synths. A
|
|
@@ -86,6 +136,7 @@ function deriveStableSingletonUuid(scope, id) {
|
|
|
86
136
|
}
|
|
87
137
|
export class SingletonFunction extends singletonFunction {
|
|
88
138
|
constructor(scope, id, props) {
|
|
139
|
+
validateRuntimeNotDeprecated(props.runtime, id);
|
|
89
140
|
super(scope, id, {
|
|
90
141
|
...props,
|
|
91
142
|
uuid: props.uuid ?? deriveStableSingletonUuid(scope, id),
|
|
@@ -112,7 +163,10 @@ export class SingletonFunction extends singletonFunction {
|
|
|
112
163
|
}
|
|
113
164
|
export class LambdaFunction extends Function {
|
|
114
165
|
functionUrlValue;
|
|
166
|
+
asyncFailureQueueProp;
|
|
167
|
+
asyncFailureQueueWired;
|
|
115
168
|
constructor(scope, id, props) {
|
|
169
|
+
validateRuntimeNotDeprecated(props.runtime, id);
|
|
116
170
|
const vpcSubnets = props.vpc
|
|
117
171
|
? { subnetType: resolvePrivateSubnetType(props.vpc) }
|
|
118
172
|
: undefined;
|
|
@@ -130,6 +184,7 @@ export class LambdaFunction extends Function {
|
|
|
130
184
|
retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION
|
|
131
185
|
})
|
|
132
186
|
});
|
|
187
|
+
this.asyncFailureQueueProp = props.asyncFailureQueue;
|
|
133
188
|
addPoliciesToRole(this, props.inlinePolicy);
|
|
134
189
|
applyRoleDescription(this, props.roleDescription);
|
|
135
190
|
applyRolePathAndName(this, props.rolePath, props.roleName);
|
|
@@ -137,11 +192,19 @@ export class LambdaFunction extends Function {
|
|
|
137
192
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
138
193
|
const outputName = toPascalCase(id);
|
|
139
194
|
if (props.enableFunctionUrl) {
|
|
195
|
+
const authType = props.functionUrlAuthType ?? FunctionUrlAuthType.AWS_IAM;
|
|
140
196
|
const functionUrl = this.addFunctionUrl({
|
|
141
|
-
authType
|
|
197
|
+
authType,
|
|
142
198
|
cors: props.functionUrlCors,
|
|
143
199
|
invokeMode: props.functionUrlInvokeMode
|
|
144
200
|
});
|
|
201
|
+
if (authType === FunctionUrlAuthType.NONE &&
|
|
202
|
+
props.reservedConcurrentExecutions === undefined) {
|
|
203
|
+
Annotations.of(this).addWarning(`Lambda '${id}' exposes a public Function URL (authType NONE) ` +
|
|
204
|
+
`without reservedConcurrentExecutions. An unauthenticated caller ` +
|
|
205
|
+
`can drive unbounded invocations, taking spend and account-wide ` +
|
|
206
|
+
`concurrency with it — set reservedConcurrentExecutions to cap it.`);
|
|
207
|
+
}
|
|
145
208
|
this.functionUrlValue = functionUrl.url;
|
|
146
209
|
new CfnOutput(this, `${outputName}FunctionUrl`, {
|
|
147
210
|
key: `${outputName}FunctionUrl`,
|
|
@@ -218,6 +281,9 @@ export class LambdaFunction extends Function {
|
|
|
218
281
|
* Add an S3 bucket as an event source for this Lambda function.
|
|
219
282
|
* This will trigger the Lambda when objects are created, modified, or deleted.
|
|
220
283
|
* Useful for ISR cache invalidation and file processing workflows.
|
|
284
|
+
*
|
|
285
|
+
* S3 invokes asynchronously, so the first call also wires the async
|
|
286
|
+
* failure queue (see `asyncFailureQueue`) unless it is set to `false`.
|
|
221
287
|
*/
|
|
222
288
|
addS3EventSource(bucket, options) {
|
|
223
289
|
const eventTypes = (options?.events ?? ["OBJECT_CREATED"]).map((event) => {
|
|
@@ -246,6 +312,38 @@ export class LambdaFunction extends Function {
|
|
|
246
312
|
};
|
|
247
313
|
const eventSource = new S3EventSource(bucket, s3EventSourceProps);
|
|
248
314
|
this.addEventSource(eventSource);
|
|
315
|
+
this.ensureAsyncFailureQueue();
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Get the SQS queue wired as the async-invocation failure destination, if
|
|
319
|
+
* one has been created or supplied.
|
|
320
|
+
*/
|
|
321
|
+
getAsyncFailureQueue() {
|
|
322
|
+
return this.asyncFailureQueueWired;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Wire the async-invocation dead-letter queue on first use. Without one,
|
|
326
|
+
* an event that fails all retries is silently dropped. `Function`'s
|
|
327
|
+
* `deadLetterQueue` prop is constructor-only and the queue is only wanted
|
|
328
|
+
* once an async event source exists, so reach the L1 `CfnFunction` and
|
|
329
|
+
* mirror what the prop does: DeadLetterConfig plus an sqs:SendMessage
|
|
330
|
+
* grant to the execution role.
|
|
331
|
+
*/
|
|
332
|
+
ensureAsyncFailureQueue() {
|
|
333
|
+
if (this.asyncFailureQueueProp === false)
|
|
334
|
+
return;
|
|
335
|
+
if (this.asyncFailureQueueWired !== undefined)
|
|
336
|
+
return;
|
|
337
|
+
const queue = this.asyncFailureQueueProp ??
|
|
338
|
+
new Queue(this, "AsyncFailureQueue", {
|
|
339
|
+
enforceSSL: true,
|
|
340
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
341
|
+
retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS)
|
|
342
|
+
});
|
|
343
|
+
this.asyncFailureQueueWired = queue;
|
|
344
|
+
const cfnFunction = this.node.defaultChild;
|
|
345
|
+
cfnFunction.deadLetterConfig = { targetArn: queue.queueArn };
|
|
346
|
+
queue.grantSendMessages(this);
|
|
249
347
|
}
|
|
250
348
|
/**
|
|
251
349
|
* Add secrets support using AWS Parameters and Secrets Lambda Extension.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
1
|
+
import { ListenerAction, SslPolicy } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
2
2
|
/**
|
|
3
3
|
* Context-free ALB listener factory shared by the ECS cluster path and the dev
|
|
4
4
|
* substrate. When `default404` is set, the listener's default action is a plain
|
|
@@ -25,7 +25,11 @@ export function addRoutingListener(loadBalancer, id, options) {
|
|
|
25
25
|
certificates: [
|
|
26
26
|
options.certificate,
|
|
27
27
|
...(options.additionalCertificates ?? [])
|
|
28
|
-
]
|
|
28
|
+
],
|
|
29
|
+
// TLS 1.3/1.2 only (ELBSecurityPolicy-TLS13-1-2-2021-06) — drops
|
|
30
|
+
// TLS 1.0/1.1 clients. HTTPS-only: CloudFormation rejects SslPolicy on
|
|
31
|
+
// a plain HTTP listener.
|
|
32
|
+
sslPolicy: SslPolicy.RECOMMENDED_TLS
|
|
29
33
|
}),
|
|
30
34
|
...(defaultAction !== undefined && { defaultAction })
|
|
31
35
|
});
|
|
@@ -25,6 +25,7 @@ export interface DynamoDBTableProps {
|
|
|
25
25
|
stream?: "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY";
|
|
26
26
|
pointInTimeRecovery?: boolean;
|
|
27
27
|
encryption?: "AWS_OWNED" | "AWS_MANAGED" | "CUSTOMER_MANAGED";
|
|
28
|
+
deletionProtection?: boolean;
|
|
28
29
|
removalPolicy?: "DESTROY" | "RETAIN" | "SNAPSHOT";
|
|
29
30
|
}
|
|
30
31
|
export declare class DynamoDBTable extends Construct {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
2
|
import { CfnOutput } from "aws-cdk-lib";
|
|
3
3
|
import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
|
|
4
|
-
import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
|
|
5
5
|
function toAttributeType(type) {
|
|
6
6
|
switch (type) {
|
|
7
7
|
case "S":
|
|
@@ -21,6 +21,17 @@ function toBillingMode(mode) {
|
|
|
21
21
|
return BillingMode.PAY_PER_REQUEST;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
function toTableEncryption(encryption) {
|
|
25
|
+
switch (encryption) {
|
|
26
|
+
case "AWS_OWNED":
|
|
27
|
+
return TableEncryption.DEFAULT;
|
|
28
|
+
case "CUSTOMER_MANAGED":
|
|
29
|
+
return TableEncryption.CUSTOMER_MANAGED;
|
|
30
|
+
case "AWS_MANAGED":
|
|
31
|
+
default:
|
|
32
|
+
return TableEncryption.AWS_MANAGED;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
24
35
|
function toStreamViewType(stream) {
|
|
25
36
|
switch (stream) {
|
|
26
37
|
case "NEW_IMAGE":
|
|
@@ -72,6 +83,8 @@ export class DynamoDBTable extends Construct {
|
|
|
72
83
|
pointInTimeRecoverySpecification: {
|
|
73
84
|
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true
|
|
74
85
|
},
|
|
86
|
+
encryption: toTableEncryption(props.encryption),
|
|
87
|
+
deletionProtection: props.deletionProtection ?? false,
|
|
75
88
|
removalPolicy: toRemovalPolicy(props.removalPolicy),
|
|
76
89
|
timeToLiveAttribute: props.timeToLiveAttribute
|
|
77
90
|
});
|
|
@@ -124,6 +124,11 @@ export class RdsAurora extends Construct {
|
|
|
124
124
|
}
|
|
125
125
|
// Allow external access when publicly accessible with allowed IP
|
|
126
126
|
if (props.publiclyAccessible && props.allowedIpCidr) {
|
|
127
|
+
// "::/0" is deliberately not matched here: Peer.ipv4 below throws on any
|
|
128
|
+
// non-IPv4 CIDR before a warning could ever render.
|
|
129
|
+
if (props.allowedIpCidr === "0.0.0.0/0") {
|
|
130
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:rdsWorldOpenIngress", `Database '${this.databaseNameValue}' is publicly accessible and allowedIpCidr '${props.allowedIpCidr}' opens its database port to the entire internet. Scope allowedIpCidr to the addresses that need access (e.g. "203.0.113.4/32").`);
|
|
131
|
+
}
|
|
127
132
|
clusterSecurityGroup.addIngressRule(Peer.ipv4(props.allowedIpCidr), Port.tcp(this.port), "Allow external access from allowed IP");
|
|
128
133
|
}
|
|
129
134
|
this.connections = new Connections({
|
|
@@ -33,11 +33,11 @@ interface RdsProps {
|
|
|
33
33
|
publiclyAccessible?: boolean;
|
|
34
34
|
deletionProtection?: boolean;
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* RDS IAM database authentication on the instance (and its read replica).
|
|
37
|
+
* Defaults to ON — free, and it grants nothing until {@link grantIamConnect};
|
|
38
|
+
* password auth keeps working in parallel. Pass false to opt out. For stacks
|
|
39
|
+
* deployed under the old opt-in default the false→true flip is a
|
|
40
|
+
* No-interruption CFN modify, so redeploying is safe. See ADR
|
|
41
41
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
42
42
|
*/
|
|
43
43
|
iamAuthentication?: boolean;
|
|
@@ -96,8 +96,8 @@ export declare class RdsInstance extends Construct implements IConnectable {
|
|
|
96
96
|
getConnectionString(): string;
|
|
97
97
|
/**
|
|
98
98
|
* Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
|
|
99
|
-
* database authentication.
|
|
100
|
-
* `iamAuthentication:
|
|
99
|
+
* database authentication. Enabled by default; throws if the instance opted
|
|
100
|
+
* out with `iamAuthentication: false`. Delegates to the L2 `grantConnect`, which scopes
|
|
101
101
|
* `rds-db:connect` to the exact `dbuser:<dbiResourceId>/<dbUsername>` ARN —
|
|
102
102
|
* never a bare wildcard. See ADR
|
|
103
103
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
@@ -161,7 +161,7 @@ export class RdsInstance extends Construct {
|
|
|
161
161
|
preferredMaintenanceWindow: props.preferredMaintenanceWindow ??
|
|
162
162
|
RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW,
|
|
163
163
|
publiclyAccessible: props.publiclyAccessible ?? false,
|
|
164
|
-
iamAuthentication: props.iamAuthentication
|
|
164
|
+
iamAuthentication: props.iamAuthentication ?? true
|
|
165
165
|
};
|
|
166
166
|
if (props.snapshotIdentifier) {
|
|
167
167
|
// Create from snapshot
|
|
@@ -338,7 +338,8 @@ exports.handler = async (event) => {
|
|
|
338
338
|
port: this.port,
|
|
339
339
|
deletionProtection: props.deletionProtection ?? true,
|
|
340
340
|
preferredMaintenanceWindow: props.preferredMaintenanceWindow ??
|
|
341
|
-
RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW
|
|
341
|
+
RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW,
|
|
342
|
+
iamAuthentication: props.iamAuthentication ?? true
|
|
342
343
|
});
|
|
343
344
|
readReplica.node.addDependency(deletionWaiter.resource);
|
|
344
345
|
}
|
|
@@ -379,8 +380,8 @@ exports.handler = async (event) => {
|
|
|
379
380
|
}
|
|
380
381
|
/**
|
|
381
382
|
* Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
|
|
382
|
-
* database authentication.
|
|
383
|
-
* `iamAuthentication:
|
|
383
|
+
* database authentication. Enabled by default; throws if the instance opted
|
|
384
|
+
* out with `iamAuthentication: false`. Delegates to the L2 `grantConnect`, which scopes
|
|
384
385
|
* `rds-db:connect` to the exact `dbuser:<dbiResourceId>/<dbUsername>` ARN —
|
|
385
386
|
* never a bare wildcard. See ADR
|
|
386
387
|
* decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
|
|
@@ -2,7 +2,7 @@ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
|
2
2
|
import * as CloudTrail from "aws-cdk-lib/aws-cloudtrail";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { CustomerManagedKey } from "../secrets/index.js";
|
|
5
|
-
import { S3Bucket } from "../storage/index.js";
|
|
5
|
+
import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../storage/index.js";
|
|
6
6
|
import { BucketEncryption } from "aws-cdk-lib/aws-s3";
|
|
7
7
|
import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
8
8
|
export function validateTrailProps(props) {
|
|
@@ -40,9 +40,18 @@ export class Trail extends Construct {
|
|
|
40
40
|
bucketKeyEnabled: true,
|
|
41
41
|
encryption: BucketEncryption.KMS,
|
|
42
42
|
encryptionKey: this.encryptionKey.key,
|
|
43
|
-
versioned:
|
|
43
|
+
versioned: true,
|
|
44
44
|
removalPolicy: storagePolicy,
|
|
45
|
-
|
|
45
|
+
// Custom lifecycleRules bypass S3Bucket's automatic
|
|
46
|
+
// noncurrentVersionExpiration pairing — carried explicitly, or
|
|
47
|
+
// noncurrent versions accumulate forever.
|
|
48
|
+
lifecycleRules: [
|
|
49
|
+
{
|
|
50
|
+
expiration: Duration.days(365),
|
|
51
|
+
noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
|
|
52
|
+
enabled: true
|
|
53
|
+
}
|
|
54
|
+
]
|
|
46
55
|
});
|
|
47
56
|
const effectiveTrailName = props.trailName || `${id}Trail`;
|
|
48
57
|
if (props.isOrganizationTrail === true) {
|
|
@@ -2,7 +2,7 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { Duration } from "aws-cdk-lib";
|
|
3
3
|
import { Rule } from "aws-cdk-lib/aws-events";
|
|
4
4
|
import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
5
|
-
import { SQSQueue } from "./sqs.js";
|
|
5
|
+
import { SQSQueue, SQS_LIMITS } from "./sqs.js";
|
|
6
6
|
import { resolveTarget } from "./eventTargets.js";
|
|
7
7
|
import { createScheduleAlarms } from "../monitoring/scheduleAlarms.js";
|
|
8
8
|
import { createSqsDlqAlarms } from "../monitoring/sqsAlarms.js";
|
|
@@ -69,7 +69,10 @@ export class EventBridgeRule extends Construct {
|
|
|
69
69
|
}
|
|
70
70
|
return {
|
|
71
71
|
queue: new SQSQueue(this, `${id}Dlq`, {
|
|
72
|
-
queueType: "standard"
|
|
72
|
+
queueType: "standard",
|
|
73
|
+
// Same 14-day window as the SQS wrapper's auto-DLQ — without it the
|
|
74
|
+
// provisioned DLQ falls back to the 4-day CloudFormation default.
|
|
75
|
+
messageRetentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS).toSeconds()
|
|
73
76
|
}),
|
|
74
77
|
provisioned: true
|
|
75
78
|
};
|
|
@@ -22,6 +22,13 @@ export class SNSTopic extends Construct {
|
|
|
22
22
|
? (props.contentBasedDeduplication ?? true)
|
|
23
23
|
: undefined,
|
|
24
24
|
signatureVersion: props.signatureVersion ?? "2",
|
|
25
|
+
// The TLS-only deny synthesises an AWS::SNS::TopicPolicy, and that
|
|
26
|
+
// resource REPLACES the topic's implicit default policy — so any AWS
|
|
27
|
+
// service principal that published under the default Allow needs an
|
|
28
|
+
// explicit Allow merged in via getTopic().addToResourcePolicy(...)
|
|
29
|
+
// (SharedAlarmTopic carries the cloudwatch.amazonaws.com one).
|
|
30
|
+
// Same-account IAM publishers are unaffected: identity policies suffice.
|
|
31
|
+
enforceSSL: true,
|
|
25
32
|
masterKey: props.masterKey
|
|
26
33
|
});
|
|
27
34
|
// An SNS topic is a transient fan-out medium: it holds no durable state
|
|
@@ -118,6 +118,7 @@ export class SQSQueue extends Construct {
|
|
|
118
118
|
queueName: dlqName,
|
|
119
119
|
fifo: isFifo,
|
|
120
120
|
encryption: toEncryption(props.encryption),
|
|
121
|
+
enforceSSL: true,
|
|
121
122
|
retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS),
|
|
122
123
|
removalPolicy: resolvedRemovalPolicy
|
|
123
124
|
});
|
|
@@ -156,6 +157,7 @@ export class SQSQueue extends Construct {
|
|
|
156
157
|
: undefined,
|
|
157
158
|
deadLetterQueue,
|
|
158
159
|
encryption: toEncryption(props.encryption),
|
|
160
|
+
enforceSSL: true,
|
|
159
161
|
contentBasedDeduplication: isFifo
|
|
160
162
|
? (props.contentBasedDeduplication ?? true)
|
|
161
163
|
: undefined,
|
|
@@ -2,7 +2,7 @@ import { CfnOutput, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
|
2
2
|
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
|
3
3
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
4
4
|
import { LogGroup } from "../logging/logGroup.js";
|
|
5
|
-
import { S3Bucket } from "../storage/index.js";
|
|
5
|
+
import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../storage/index.js";
|
|
6
6
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
7
7
|
export const FLOW_LOG_TRAFFIC_TYPES = ["ALL", "ACCEPT", "REJECT"];
|
|
8
8
|
// Read at two sites (resources-layer constructor default + patterns-layer natGateways guard) that must agree.
|
|
@@ -183,8 +183,19 @@ export class Vpc extends ec2.Vpc {
|
|
|
183
183
|
const bucket = new S3Bucket(scope, `${id}FlowLogBucket`, {
|
|
184
184
|
bucketName: ResourceNaming.vpcFlowLogBucketName(id, Stack.of(scope).account),
|
|
185
185
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
186
|
+
versioned: true,
|
|
187
|
+
// Custom lifecycleRules bypass S3Bucket's automatic
|
|
188
|
+
// noncurrentVersionExpiration pairing — carried explicitly, or
|
|
189
|
+
// noncurrent versions accumulate forever. The no-retentionDays branch
|
|
190
|
+
// passes undefined, so the wrapper's own pairing applies there.
|
|
186
191
|
lifecycleRules: config.retentionDays
|
|
187
|
-
? [
|
|
192
|
+
? [
|
|
193
|
+
{
|
|
194
|
+
expiration: Duration.days(config.retentionDays),
|
|
195
|
+
noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
|
|
196
|
+
enabled: true
|
|
197
|
+
}
|
|
198
|
+
]
|
|
188
199
|
: undefined
|
|
189
200
|
});
|
|
190
201
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { aws_ssm as ssm, Stack } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, aws_ssm as ssm, Stack } from "aws-cdk-lib";
|
|
2
2
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
4
4
|
import { Construct } from "constructs";
|
|
@@ -20,6 +20,12 @@ export class SecureStringParameter extends Construct {
|
|
|
20
20
|
constructor(scope, id, props) {
|
|
21
21
|
super(scope, id);
|
|
22
22
|
this.name = props.name;
|
|
23
|
+
// Matches the || fall-through below: an empty-string value or env var
|
|
24
|
+
// also lands on the placeholder.
|
|
25
|
+
const resolvedValue = props.value || process.env[`CDK_SECURE_STRING_${id}`];
|
|
26
|
+
if (!resolvedValue) {
|
|
27
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:secrets:secureStringPlaceholderValue", `SecureStringParameter '${props.name}' has no value source — the well-known literal 'placeholderValue' will be written to the SecureString on create and update, and consumers will silently read it. Supply a real value via the 'value' prop or the CDK_SECURE_STRING_${id} environment variable at synth time.`);
|
|
28
|
+
}
|
|
23
29
|
if (props.cmk) {
|
|
24
30
|
this.cmk = props.cmk;
|
|
25
31
|
}
|
|
@@ -39,9 +45,7 @@ export class SecureStringParameter extends Construct {
|
|
|
39
45
|
parameters: {
|
|
40
46
|
Name: props.name,
|
|
41
47
|
Description: props.description || `${id} secure parameter`,
|
|
42
|
-
Value:
|
|
43
|
-
process.env[`CDK_SECURE_STRING_${id}`] ||
|
|
44
|
-
"placeholderValue",
|
|
48
|
+
Value: resolvedValue || "placeholderValue",
|
|
45
49
|
Type: "SecureString",
|
|
46
50
|
KeyId: this.cmk.alias.keyId
|
|
47
51
|
// TODO: Add tags to the parameter
|
|
@@ -54,9 +58,7 @@ export class SecureStringParameter extends Construct {
|
|
|
54
58
|
parameters: {
|
|
55
59
|
Name: props.name,
|
|
56
60
|
Description: props.description || `${id} secure parameter`,
|
|
57
|
-
Value:
|
|
58
|
-
process.env[`CDK_SECURE_STRING_${id}`] ||
|
|
59
|
-
"placeholderValue",
|
|
61
|
+
Value: resolvedValue || "placeholderValue",
|
|
60
62
|
Overwrite: props.overwrite,
|
|
61
63
|
Type: "SecureString",
|
|
62
64
|
KeyId: this.cmk.alias.keyId
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SecretValue } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, SecretValue } from "aws-cdk-lib";
|
|
2
2
|
import { Secret as CdkSecret } from "aws-cdk-lib/aws-secretsmanager";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { CustomerManagedKey } from "./kms.js";
|
|
@@ -81,6 +81,10 @@ export class Secret extends Construct {
|
|
|
81
81
|
/**
|
|
82
82
|
* If a secretStringValue is provided, use it to create the secret.
|
|
83
83
|
*/
|
|
84
|
+
if (props.secretStringValue) {
|
|
85
|
+
// Gotcha: never interpolate the secret value into this message.
|
|
86
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:secrets:plainTextSecretValue", `Secret '${id}' uses secretStringValue — the plain-text value is embedded verbatim in the synthesised CloudFormation template and every synth artefact (SecretValue.unsafePlainText). Prefer generateSecretString (server-generated, never leaves AWS) or secretObjectValue with SecretValue references (e.g. SecretValue.ssmSecure).`);
|
|
87
|
+
}
|
|
84
88
|
const secretStringValue = props.secretStringValue
|
|
85
89
|
? {
|
|
86
90
|
secretStringValue: SecretValue.unsafePlainText(props.secretStringValue)
|
|
@@ -2,6 +2,14 @@ import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
|
|
|
2
2
|
import { type Construct } from "constructs";
|
|
3
3
|
import { type BackupTier } from "../../../utils/backupTierMapping.js";
|
|
4
4
|
export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
5
|
+
/**
|
|
6
|
+
* Days a noncurrent object version is retained before lifecycle expiry.
|
|
7
|
+
* The wrapper pairs this automatically when it enables versioning; constructs
|
|
8
|
+
* passing custom `lifecycleRules` (cloudTrail, configRecorder, vpc flow logs)
|
|
9
|
+
* bypass that pairing and must carry the same value explicitly — coupled at
|
|
10
|
+
* every site so the retention window cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
export declare const NONCURRENT_VERSION_EXPIRY_DAYS = 30;
|
|
5
13
|
export interface WebsiteHostingConfig {
|
|
6
14
|
readonly indexDocument: string;
|
|
7
15
|
readonly errorDocument?: string;
|
|
@@ -31,6 +39,11 @@ export interface ResourcePolicyStatement {
|
|
|
31
39
|
* `autoDeleteObjects` is accepted for source compatibility but always
|
|
32
40
|
* overridden to `false` (ADR D4.3) — passing `true` raises a synth-time
|
|
33
41
|
* warning rather than a compile error so older scaffolds keep building.
|
|
42
|
+
*
|
|
43
|
+
* Non-public buckets assert `BlockPublicAccess.BLOCK_ALL` in the template
|
|
44
|
+
* (caller `blockPublicAccess` wins); public/website buckets keep the two
|
|
45
|
+
* policy halves open but block the ACL halves. KMS-encrypted buckets default
|
|
46
|
+
* `bucketKeyEnabled: true` (pass `false` to opt out).
|
|
34
47
|
*/
|
|
35
48
|
export interface S3BucketProps extends BucketProps {
|
|
36
49
|
backupVaultTier?: BackupTier;
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
2
2
|
import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
|
|
3
|
-
import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
|
|
3
|
+
import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
|
|
4
4
|
import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
|
|
5
5
|
import { RegionInfo } from "aws-cdk-lib/region-info";
|
|
6
6
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
7
7
|
import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
|
|
8
8
|
import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
|
|
9
9
|
export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
10
|
+
/**
|
|
11
|
+
* Days a noncurrent object version is retained before lifecycle expiry.
|
|
12
|
+
* The wrapper pairs this automatically when it enables versioning; constructs
|
|
13
|
+
* passing custom `lifecycleRules` (cloudTrail, configRecorder, vpc flow logs)
|
|
14
|
+
* bypass that pairing and must carry the same value explicitly — coupled at
|
|
15
|
+
* every site so the retention window cannot drift.
|
|
16
|
+
*/
|
|
17
|
+
export const NONCURRENT_VERSION_EXPIRY_DAYS = 30;
|
|
10
18
|
function shouldAutoVersion(tier) {
|
|
11
19
|
return tier === "resilient" || tier === "enterprise";
|
|
12
20
|
}
|
|
@@ -22,6 +30,12 @@ export class S3Bucket extends Bucket {
|
|
|
22
30
|
const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
|
|
23
31
|
const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
|
|
24
32
|
const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
|
|
33
|
+
// Mirrors the CDK Bucket's own resolution: an encryptionKey with no
|
|
34
|
+
// explicit encryption infers KMS. DSSE is excluded deliberately — S3
|
|
35
|
+
// Bucket Keys are not supported for dual-layer SSE-KMS.
|
|
36
|
+
const kmsEncryption = props.encryption === BucketEncryption.KMS ||
|
|
37
|
+
props.encryption === BucketEncryption.KMS_MANAGED ||
|
|
38
|
+
(props.encryption === undefined && props.encryptionKey !== undefined);
|
|
25
39
|
super(scope, id, {
|
|
26
40
|
...cdkProps,
|
|
27
41
|
enforceSSL: true,
|
|
@@ -31,14 +45,22 @@ export class S3Bucket extends Bucket {
|
|
|
31
45
|
autoDeleteObjects: false,
|
|
32
46
|
removalPolicy,
|
|
33
47
|
publicReadAccess: isPublic,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
// Public access here is bucket-policy-based, so only the policy halves
|
|
49
|
+
// stay open (grantPublicAccess validates against blockPublicPolicy
|
|
50
|
+
// alone); the ACL halves close — ACLs are disabled on modern buckets.
|
|
51
|
+
// Non-public buckets assert BLOCK_ALL in the template rather than
|
|
52
|
+
// relying on the creation-time account default, so a manually loosened
|
|
53
|
+
// bucket is healed on the next deploy.
|
|
54
|
+
blockPublicAccess: isPublic
|
|
55
|
+
? new BlockPublicAccess({
|
|
56
|
+
blockPublicAcls: true,
|
|
37
57
|
blockPublicPolicy: false,
|
|
38
|
-
ignorePublicAcls:
|
|
58
|
+
ignorePublicAcls: true,
|
|
39
59
|
restrictPublicBuckets: false
|
|
40
60
|
})
|
|
41
|
-
|
|
61
|
+
: (props.blockPublicAccess ?? BlockPublicAccess.BLOCK_ALL),
|
|
62
|
+
...(props.bucketKeyEnabled === undefined &&
|
|
63
|
+
kmsEncryption && { bucketKeyEnabled: true }),
|
|
42
64
|
...(websiteHosting && {
|
|
43
65
|
websiteIndexDocument: websiteHosting.indexDocument,
|
|
44
66
|
websiteErrorDocument: websiteHosting.errorDocument ?? "error.html"
|
|
@@ -54,7 +76,12 @@ export class S3Bucket extends Bucket {
|
|
|
54
76
|
enabled: true
|
|
55
77
|
},
|
|
56
78
|
...(versioned && !props.lifecycleRules
|
|
57
|
-
? [
|
|
79
|
+
? [
|
|
80
|
+
{
|
|
81
|
+
noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
|
|
82
|
+
enabled: true
|
|
83
|
+
}
|
|
84
|
+
]
|
|
58
85
|
: (props.lifecycleRules ?? []))
|
|
59
86
|
]
|
|
60
87
|
});
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import { Project } from "aws-cdk-lib/aws-codebuild";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
|
+
import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
|
|
4
5
|
export class CodeBuildProject extends Construct {
|
|
5
6
|
project;
|
|
6
7
|
constructor(scope, id, props) {
|
|
7
8
|
super(scope, id);
|
|
8
9
|
this.project = new Project(this, id, {
|
|
9
10
|
...props,
|
|
10
|
-
description: props.description || `Code build project for ${id}
|
|
11
|
+
description: props.description || `Code build project for ${id}`,
|
|
12
|
+
// Without a logging prop CodeBuild auto-creates a never-expiring
|
|
13
|
+
// runtime log group outside CloudFormation; default to a framework
|
|
14
|
+
// group so retention and removal policy are governed.
|
|
15
|
+
logging: props.logging ?? {
|
|
16
|
+
cloudWatch: {
|
|
17
|
+
logGroup: new LogGroup(this, `${id}LogGroup`, {
|
|
18
|
+
retention: DEFAULT_FRAMEWORK_LOG_RETENTION
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}
|
|
11
22
|
});
|
|
12
23
|
}
|
|
13
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
|
|
1
|
+
import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER } from "@fjall/util/aws";
|
|
2
2
|
import type { IConstruct } from "constructs";
|
|
3
|
-
export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
|
|
3
|
+
export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER };
|
|
4
4
|
export interface CostAllocationTagsArgs {
|
|
5
5
|
readonly service: string;
|
|
6
6
|
readonly domain: string;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
|
|
1
|
+
import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER } from "@fjall/util/aws";
|
|
2
2
|
import { Tags } from "aws-cdk-lib";
|
|
3
3
|
// Canonical home: @fjall/util (`util/src/aws/costAllocationTags.ts`) — shared
|
|
4
4
|
// with deploy-core's `cdk bootstrap --tags` stamping. Re-exported here so
|
|
5
5
|
// construct-package consumers keep their existing import path.
|
|
6
|
-
export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
|
|
6
|
+
export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER };
|
|
7
7
|
export function applyCostAllocationTags(scope, args) {
|
|
8
8
|
if (args.environment !== undefined) {
|
|
9
9
|
Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, args.environment);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "28.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
80
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
81
|
-
"@fjall/generator": "^
|
|
82
|
-
"@fjall/util": "^
|
|
81
|
+
"@fjall/generator": "^28.0.0",
|
|
82
|
+
"@fjall/util": "^28.0.0",
|
|
83
83
|
"constructs": "^10.7.2",
|
|
84
84
|
"zod": "^4.4.3"
|
|
85
85
|
},
|