@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.
Files changed (32) hide show
  1. package/dist/lib/app.js +8 -1
  2. package/dist/lib/config/aws/alarmTopic.js +20 -1
  3. package/dist/lib/config/aws/configRecorder.js +16 -5
  4. package/dist/lib/patterns/aws/computeLambda.d.ts +4 -1
  5. package/dist/lib/patterns/aws/computeLambda.js +4 -1
  6. package/dist/lib/patterns/aws/database.d.ts +11 -6
  7. package/dist/lib/patterns/aws/database.js +4 -2
  8. package/dist/lib/patterns/aws/storage.d.ts +6 -0
  9. package/dist/lib/patterns/aws/storage.js +3 -0
  10. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +3 -0
  11. package/dist/lib/resources/aws/compute/ecs.js +11 -2
  12. package/dist/lib/resources/aws/compute/lambda.d.ts +46 -0
  13. package/dist/lib/resources/aws/compute/lambda.js +100 -2
  14. package/dist/lib/resources/aws/compute/listenerRouting.js +6 -2
  15. package/dist/lib/resources/aws/database/dynamodb.d.ts +1 -0
  16. package/dist/lib/resources/aws/database/dynamodb.js +14 -1
  17. package/dist/lib/resources/aws/database/rdsAurora.js +5 -0
  18. package/dist/lib/resources/aws/database/rdsInstance.d.ts +7 -7
  19. package/dist/lib/resources/aws/database/rdsInstance.js +5 -4
  20. package/dist/lib/resources/aws/logging/cloudTrail.js +12 -3
  21. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +5 -2
  22. package/dist/lib/resources/aws/messaging/sns.js +7 -0
  23. package/dist/lib/resources/aws/messaging/sqs.js +2 -0
  24. package/dist/lib/resources/aws/networking/vpc.js +13 -2
  25. package/dist/lib/resources/aws/secrets/parameter.js +9 -7
  26. package/dist/lib/resources/aws/secrets/secret.js +5 -1
  27. package/dist/lib/resources/aws/storage/s3.d.ts +13 -0
  28. package/dist/lib/resources/aws/storage/s3.js +34 -7
  29. package/dist/lib/resources/aws/utilities/codeBuild.js +12 -1
  30. package/dist/lib/utils/costAllocationTags.d.ts +2 -2
  31. package/dist/lib/utils/costAllocationTags.js +2 -2
  32. 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 { Bucket, BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
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
- // Delivery bucket -- S3-managed encryption, 90-day lifecycle, no public access
18
- const deliveryBucket = new Bucket(this, "DeliveryBucket", {
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
- enforceSSL: true,
26
+ versioned: true,
22
27
  removalPolicy: RemovalPolicy.RETAIN,
23
- lifecycleRules: [{ expiration: Duration.days(90), enabled: true }]
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
- /** Lambda runtime. Default: NODEJS_24_X */
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
- * Enable RDS IAM database authentication (opt-in; default off). Instance
166
- * databases only. When true, IAM principals granted via
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. See ADR
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. Requires the instance
327
- * to be created with `iamAuthentication: true`. The L2 `grantConnect` scopes
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. Requires the instance
651
- * to be created with `iamAuthentication: true`. The L2 `grantConnect` scopes
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: props.functionUrlAuthType ?? FunctionUrlAuthType.AWS_IAM,
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
- * Enable RDS IAM database authentication on the instance. Opt-in; defaults to
37
- * off (undefined CDK omits the property, leaving existing consumers' synth
38
- * unchanged). When true, IAM principals granted via {@link grantIamConnect}
39
- * connect with short-lived `rds-db:connect` tokens instead of a stored
40
- * password password auth keeps working in parallel. See ADR
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. Requires the instance to be created with
100
- * `iamAuthentication: true`. Delegates to the L2 `grantConnect`, which scopes
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. Requires the instance to be created with
383
- * `iamAuthentication: true`. Delegates to the L2 `grantConnect`, which scopes
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: false,
43
+ versioned: true,
44
44
  removalPolicy: storagePolicy,
45
- lifecycleRules: [{ expiration: Duration.days(365), enabled: true }]
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
- ? [{ expiration: Duration.days(config.retentionDays), enabled: true }]
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: props.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: props.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
- ...(isPublic && {
35
- blockPublicAccess: new BlockPublicAccess({
36
- blockPublicAcls: false,
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: false,
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
- ? [{ noncurrentVersionExpiration: Duration.days(30), enabled: true }]
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": "27.1.0",
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": "^27.1.0",
82
- "@fjall/util": "^27.1.0",
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
  },