@fjall/components-infrastructure 38.0.0 → 39.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 (46) hide show
  1. package/dist/lib/app.d.ts +24 -12
  2. package/dist/lib/app.js +30 -26
  3. package/dist/lib/config/aws/configRecorder.js +3 -0
  4. package/dist/lib/config/aws/disasterRecovery.js +115 -13
  5. package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
  6. package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
  7. package/dist/lib/patterns/aws/computeEcs.js +17 -1
  8. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
  9. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  10. package/dist/lib/patterns/aws/messaging.d.ts +10 -0
  11. package/dist/lib/patterns/aws/messaging.js +10 -6
  12. package/dist/lib/patterns/aws/storage.d.ts +41 -0
  13. package/dist/lib/patterns/aws/storage.js +23 -8
  14. package/dist/lib/resources/aws/backup/backupVault.d.ts +10 -0
  15. package/dist/lib/resources/aws/backup/backupVault.js +12 -0
  16. package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
  17. package/dist/lib/resources/aws/compute/ecs.js +27 -21
  18. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
  19. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
  20. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
  21. package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
  22. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
  23. package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
  24. package/dist/lib/resources/aws/database/dynamodb.js +2 -1
  25. package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
  26. package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
  27. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +1 -1
  28. package/dist/lib/resources/aws/logging/accessLogBucket.js +1 -1
  29. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  30. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +2 -0
  31. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +2 -1
  32. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
  33. package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
  34. package/dist/lib/resources/aws/messaging/sqs.js +36 -2
  35. package/dist/lib/resources/aws/networking/vpc.js +12 -6
  36. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
  37. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
  38. package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
  39. package/dist/lib/resources/aws/storage/s3.js +89 -3
  40. package/dist/lib/utils/backupTierMapping.d.ts +23 -2
  41. package/dist/lib/utils/backupTierMapping.js +25 -0
  42. package/dist/lib/utils/exportNaming.d.ts +30 -27
  43. package/dist/lib/utils/exportNaming.js +32 -28
  44. package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
  45. package/dist/lib/utils/standardTagsAspect.js +8 -34
  46. package/package.json +4 -4
@@ -1,3 +1,4 @@
1
+ import { Key } from "aws-cdk-lib/aws-kms";
1
2
  import { EventBus as CdkEventBus } from "aws-cdk-lib/aws-events";
2
3
  import { SQSQueue, SQS_LIMITS } from "../../resources/aws/messaging/sqs.js";
3
4
  import { SNSTopic } from "../../resources/aws/messaging/sns.js";
@@ -49,11 +50,10 @@ function validateSQSProps(props) {
49
50
  "This setting will be ignored for standard queues.");
50
51
  }
51
52
  }
52
- // Validate KMS encryption
53
- if (props.kmsKeyArn && props.encryption !== "SSE_KMS") {
54
- FjallLogger.warn("'kmsKeyArn' provided but 'encryption' is not SSE_KMS. " +
55
- "The KMS key will be ignored.");
56
- }
53
+ // `kmsKeyArn` beside a non-KMS encryption used to warn here and then be
54
+ // ignored. `SQSQueue` refuses it instead — a warning that the queue is
55
+ // about to discard the key it was handed is not a gate, and the refusal
56
+ // states both cures.
57
57
  }
58
58
  /**
59
59
  * Validates SNS Topic props.
@@ -85,7 +85,11 @@ export class QueueMessaging extends SQSQueue {
85
85
  deliveryDelay: props.deliveryDelay,
86
86
  deadLetterQueue: props.deadLetterQueue,
87
87
  encryption: props.encryption,
88
- kmsKeyArn: props.kmsKeyArn,
88
+ // Resolved in the parent scope for the same reason `alertsTopic` is:
89
+ // `this` does not exist before super().
90
+ masterKey: props.kmsKeyArn === undefined
91
+ ? undefined
92
+ : Key.fromKeyArn(scope, `${id}KmsKey`, props.kmsKeyArn),
89
93
  contentBasedDeduplication: props.contentBasedDeduplication,
90
94
  fifoThroughputLimit: props.fifoThroughputLimit,
91
95
  deduplicationScope: props.deduplicationScope,
@@ -47,7 +47,35 @@ export interface S3Props {
47
47
  * that versions after all is not stamped.
48
48
  */
49
49
  readonly versioningExemption?: S3VersioningExemption;
50
+ /**
51
+ * `"AES256"` is SSE-S3: AWS's own key, free, and real encryption at rest.
52
+ * `"KMS"` is SSE-KMS with a key of YOUR OWN, so it requires `kmsKeyArn` —
53
+ * it is refused without one rather than falling back.
54
+ *
55
+ * The fallback is what this refuses. Asking CloudFormation for KMS
56
+ * encryption with no key makes CDK mint an `AWS::KMS::Key` of its own,
57
+ * `DeletionPolicy: Retain`, with no alias and no tags — an orphan every
58
+ * Fjall teardown sweep is blind to, because a sweep finds keys by the
59
+ * alias tag `CustomerManagedKey` stamps and that key carries none.
60
+ *
61
+ * Nor does the bucket quietly join the stack's shared key, the way an
62
+ * `"SSE_KMS"` queue does. A queue holds messages for at most fourteen
63
+ * days; a bucket holds objects forever, and an object is readable only
64
+ * with the key it was written under — so moving a live bucket's key
65
+ * revokes read access to everything already in it, at the same deploy
66
+ * that rewrites the reader's `kms:Decrypt` grant onto the new key. The
67
+ * choice of key for a bucket is the operator's, always.
68
+ */
50
69
  readonly encryption?: "AES256" | "KMS";
70
+ /**
71
+ * The customer managed key this bucket's objects are encrypted with.
72
+ * Implies `encryption: "KMS"` when no encryption is stated, and is refused
73
+ * beside `"AES256"` rather than silently ignored.
74
+ *
75
+ * An ARN rather than an `IKey` because this is the plan vocabulary the
76
+ * scaffold and the codemod write; a key you hold in `infrastructure.ts`
77
+ * passes as `key.keyArn`.
78
+ */
51
79
  readonly kmsKeyArn?: string;
52
80
  /**
53
81
  * S3 Bucket Keys for KMS-encrypted buckets — defaults to true when
@@ -94,6 +122,19 @@ export interface StorageBuildProps extends S3Props {
94
122
  export type StorageFactoryFn = ((app: App, scope: Construct) => Storage) & {
95
123
  stackPlacement?: "storage" | "cdn" | "compute";
96
124
  };
125
+ /**
126
+ * Refuse the two `encryption`/`kmsKeyArn` pairings a bucket cannot honour.
127
+ *
128
+ * Both were warnings until 38.x, and both warnings said something untrue.
129
+ * `"KMS"` with no key did not fall back to "the default AWS managed key": it
130
+ * made CDK mint a retained, alias-less, tag-less key of its own beside a
131
+ * bucket that may be DESTROY — the orphan class this refusal exists for. And
132
+ * a key beside `"AES256"` was not "ignored": CDK refuses that pair itself,
133
+ * one frame later, in construct vocabulary the operator did not type.
134
+ *
135
+ * A key with NO stated encryption is neither, and is deliberately allowed —
136
+ * CDK infers KMS from the key, which is what the caller asked for.
137
+ */
97
138
  export declare function validateStorageProps(props: S3Props): void;
98
139
  export declare class Storage extends Construct implements IStorage, IStorageConnector {
99
140
  readonly connectorType: "storage";
@@ -4,7 +4,6 @@ import { Source, CacheControl } from "aws-cdk-lib/aws-s3-deployment";
4
4
  import { Key } from "aws-cdk-lib/aws-kms";
5
5
  import { CfnOutput, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
6
6
  import { BucketDeployment, S3Bucket } from "../../resources/aws/storage/index.js";
7
- import { FjallLogger } from "../../utils/validationLogger.js";
8
7
  export { isStorage } from "./interfaces/storage.js";
9
8
  function toBucketEncryption(encryption) {
10
9
  if (!encryption)
@@ -50,14 +49,30 @@ function resolveStorageRemovalPolicy(props) {
50
49
  }
51
50
  return props.retain ? RemovalPolicy.RETAIN : undefined;
52
51
  }
52
+ /**
53
+ * Refuse the two `encryption`/`kmsKeyArn` pairings a bucket cannot honour.
54
+ *
55
+ * Both were warnings until 38.x, and both warnings said something untrue.
56
+ * `"KMS"` with no key did not fall back to "the default AWS managed key": it
57
+ * made CDK mint a retained, alias-less, tag-less key of its own beside a
58
+ * bucket that may be DESTROY — the orphan class this refusal exists for. And
59
+ * a key beside `"AES256"` was not "ignored": CDK refuses that pair itself,
60
+ * one frame later, in construct vocabulary the operator did not type.
61
+ *
62
+ * A key with NO stated encryption is neither, and is deliberately allowed —
63
+ * CDK infers KMS from the key, which is what the caller asked for.
64
+ */
53
65
  export function validateStorageProps(props) {
54
- if (props.encryption === "KMS" && !props.kmsKeyArn) {
55
- FjallLogger.warn("'encryption' is set to 'KMS' but 'kmsKeyArn' is not provided. " +
56
- "The bucket will use the default AWS managed key.");
57
- }
58
- if (props.kmsKeyArn && props.encryption !== "KMS") {
59
- FjallLogger.warn("'kmsKeyArn' is provided but 'encryption' is not set to 'KMS'. " +
60
- "The KMS key will be ignored. Set encryption: 'KMS' to use the key.");
66
+ if (props.encryption === "KMS" && props.kmsKeyArn === undefined) {
67
+ throw new Error(`encryption: "KMS" needs the ARN of the key to encrypt with. Pass ` +
68
+ `'kmsKeyArn', or use encryption: "AES256" for S3-managed encryption ` +
69
+ `at rest, which costs nothing and needs no key.`);
70
+ }
71
+ if (props.kmsKeyArn !== undefined && props.encryption === "AES256") {
72
+ throw new Error(`'kmsKeyArn' encrypts with a key of your own and encryption: "AES256" ` +
73
+ `encrypts with S3's, so the pair has no meaning. Set ` +
74
+ `encryption: "KMS" to use the key, or drop 'kmsKeyArn' to keep ` +
75
+ `"AES256".`);
61
76
  }
62
77
  }
63
78
  export class Storage extends Construct {
@@ -29,6 +29,16 @@ export declare class BackupVault extends Construct {
29
29
  readonly vault: Vault;
30
30
  readonly vaultArn: CfnOutput;
31
31
  readonly vaultName: CfnOutput;
32
+ /** The key the vault encrypts recovery points with. */
33
+ readonly encryptionKey: IKey;
34
+ /**
35
+ * Whether this stack created the key. A re-owned vault names a live key the
36
+ * stack does not manage, and `addToResourcePolicy` on an imported key is a
37
+ * no-op — so a caller that needs a statement on the key policy (a
38
+ * cross-account copy destination does) must check this and say so when it
39
+ * cannot apply one.
40
+ */
41
+ readonly ownsEncryptionKey: boolean;
32
42
  constructor(scope: Construct, id: string, props: BackupVaultProps);
33
43
  static build(id: string, props: BackupVaultProps): (scope: Construct) => BackupVault;
34
44
  }
@@ -18,6 +18,16 @@ export class BackupVault extends Construct {
18
18
  vault;
19
19
  vaultArn;
20
20
  vaultName;
21
+ /** The key the vault encrypts recovery points with. */
22
+ encryptionKey;
23
+ /**
24
+ * Whether this stack created the key. A re-owned vault names a live key the
25
+ * stack does not manage, and `addToResourcePolicy` on an imported key is a
26
+ * no-op — so a caller that needs a statement on the key policy (a
27
+ * cross-account copy destination does) must check this and say so when it
28
+ * cannot apply one.
29
+ */
30
+ ownsEncryptionKey;
21
31
  constructor(scope, id, props) {
22
32
  super(scope, id);
23
33
  // RETAIN default: a DESTROY-policy vault is silently deleted with the stack.
@@ -41,6 +51,8 @@ export class BackupVault extends Construct {
41
51
  removalPolicy
42
52
  }).key;
43
53
  }
54
+ this.encryptionKey = encryptionKey;
55
+ this.ownsEncryptionKey = props.encryptionKey === undefined;
44
56
  this.vault = new Vault(this, `${props.vaultName}Vault`, {
45
57
  backupVaultName: props.vaultName,
46
58
  encryptionKey,
@@ -152,11 +152,14 @@ export default class EcsCluster extends Construct implements IConnectable {
152
152
  private addServiceToCluster;
153
153
  private setupConnections;
154
154
  /**
155
- * Create DeployableService outputs for deployment automation.
156
- * Each service gets a DeployableService output so the deployment service
157
- * can find and deploy all services in the cluster.
155
+ * The outputs the deploy engine's `EcsServiceResolver` reads to find this
156
+ * cluster's services: the cluster's `DeployableCluster` and `ClusterArn`,
157
+ * then one `DeployableService` per service. Export names come from
158
+ * `@fjall/util`'s `stackExportNames` builders — the same functions the
159
+ * resolver rebuilds them with — and the output keys keep the PascalCase
160
+ * form a logical id needs.
158
161
  */
159
- private addDeployableServiceOutputs;
162
+ private addDeployableOutputs;
160
163
  /** Checks if any service in the cluster uses a Fargate capacity provider. */
161
164
  private anyServiceUsesFargate;
162
165
  /** Checks if any service in the cluster uses an EC2 capacity provider. */
@@ -8,7 +8,7 @@ import { tierDefault } from "../../../utils/backupTierMapping.js";
8
8
  import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
9
9
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
10
10
  import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
11
- import { stackScopedExportName } from "../../../utils/exportNaming.js";
11
+ import { clusterArnExportName, deployableClusterExportName, deployableServiceExportName } from "@fjall/util";
12
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
13
13
  import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
14
14
  // Extracted modules
@@ -177,7 +177,9 @@ export default class EcsCluster extends Construct {
177
177
  applicationId: props.applicationId
178
178
  });
179
179
  }
180
- this.addDeployableServiceOutputs(props);
180
+ if (props.deployableServices !== false) {
181
+ this.addDeployableOutputs(props);
182
+ }
181
183
  this.connections = this.setupConnections(props);
182
184
  Aspects.of(this).add(new CapacityProviderDependencyAspect(this.cluster));
183
185
  }
@@ -412,16 +414,31 @@ export default class EcsCluster extends Construct {
412
414
  });
413
415
  }
414
416
  /**
415
- * Create DeployableService outputs for deployment automation.
416
- * Each service gets a DeployableService output so the deployment service
417
- * can find and deploy all services in the cluster.
417
+ * The outputs the deploy engine's `EcsServiceResolver` reads to find this
418
+ * cluster's services: the cluster's `DeployableCluster` and `ClusterArn`,
419
+ * then one `DeployableService` per service. Export names come from
420
+ * `@fjall/util`'s `stackExportNames` builders — the same functions the
421
+ * resolver rebuilds them with — and the output keys keep the PascalCase
422
+ * form a logical id needs.
418
423
  */
419
- addDeployableServiceOutputs(props) {
424
+ addDeployableOutputs(props) {
425
+ const stackName = Stack.of(this).stackName;
426
+ new CfnOutput(this, `${this.outputName}DeployableCluster`, {
427
+ key: `${this.outputName}DeployableCluster`,
428
+ exportName: deployableClusterExportName(stackName, props.clusterName),
429
+ value: this.cluster.clusterArn
430
+ });
431
+ new CfnOutput(this, `${this.outputName}ClusterArn`, {
432
+ key: `${this.outputName}ClusterArn`,
433
+ exportName: clusterArnExportName(stackName, props.clusterName),
434
+ value: this.cluster.clusterArn,
435
+ description: `ECS Cluster ARN for ${props.clusterName}`
436
+ });
420
437
  for (const [serviceName, serviceData] of this.services) {
421
- const safeServiceName = toPascalCase(serviceName);
422
- new CfnOutput(this, `${this.outputName}${safeServiceName}DeployableService`, {
423
- key: `${this.outputName}${safeServiceName}DeployableService`,
424
- exportName: stackScopedExportName(this, `${props.clusterName}${serviceName}DeployableService`),
438
+ const outputKey = `${this.outputName}${toPascalCase(serviceName)}DeployableService`;
439
+ new CfnOutput(this, outputKey, {
440
+ key: outputKey,
441
+ exportName: deployableServiceExportName(stackName, props.clusterName, serviceName),
425
442
  value: serviceData.service.serviceArn,
426
443
  description: `Deployable ECS Service ARN for ${serviceName} in ${props.clusterName}`
427
444
  });
@@ -467,17 +484,6 @@ export default class EcsCluster extends Construct {
467
484
  }
468
485
  }
469
486
  });
470
- new CfnOutput(this, `${this.outputName}DeployableCluster`, {
471
- key: `${this.outputName}DeployableCluster`,
472
- exportName: stackScopedExportName(this, `${props.clusterName}DeployableCluster`),
473
- value: cluster.clusterArn
474
- });
475
- new CfnOutput(this, `${this.outputName}ClusterArn`, {
476
- key: `${this.outputName}ClusterArn`,
477
- exportName: stackScopedExportName(this, `${props.clusterName}ClusterArn`),
478
- value: cluster.clusterArn,
479
- description: `ECS Cluster ARN for ${props.clusterName}`
480
- });
481
487
  return cluster;
482
488
  }
483
489
  static build(id, props) {
@@ -195,6 +195,9 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
195
195
  ...(ec2Config.persistentDataVolume.alarmTopic === undefined &&
196
196
  ctx.props.alertsTopic !== undefined && {
197
197
  alarmTopic: ctx.props.alertsTopic
198
+ }),
199
+ ...(ctx.props.appBackupTier !== undefined && {
200
+ appBackupTier: ctx.props.appBackupTier
198
201
  })
199
202
  },
200
203
  // Pinned slots reproduce the deployed fjall:OwnerLogicalId verbatim —
@@ -763,6 +763,17 @@ export interface EcsClusterProps {
763
763
  * auditable opt-out for cost-sensitive clusters.
764
764
  */
765
765
  taskStopWatchdog?: false;
766
+ /**
767
+ * Deployable-service exports (default on): one `DeployableService` output
768
+ * per service plus the cluster's `DeployableCluster` and `ClusterArn`
769
+ * outputs, named by `@fjall/util`'s `stackExportNames` builders. The
770
+ * deploy engine's `EcsServiceResolver` reads the app's Compute stack
771
+ * outputs to find the ECS services that `fjall services`, `fjall rollout`,
772
+ * `fjall restart`, a code-only deploy and a rollback act on. `false` opts
773
+ * out a cluster hosting an infrastructure service, so the resolver never
774
+ * binds it as an app service.
775
+ */
776
+ deployableServices?: false;
766
777
  /**
767
778
  * CloudWatch Container Insights. Default: the app backup tier's
768
779
  * `containerInsights` column (on at enterprise, off below). Enabling it
@@ -26,6 +26,14 @@ import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTy
26
26
  * a field on `EcsServiceProps`, so a direct `new EcsCluster(...)` consumer
27
27
  * cannot pass it and there is no resources-layer code path to validate.
28
28
  *
29
+ * Same applies to `cluster.scheduledTasks`: the whole array is a patterns-
30
+ * layer field on `EcsClusterConfig`, materialised into `Ec2TaskDefinition`s
31
+ * and `app.addSchedule(...)` calls by `materialiseScheduledTasks` in
32
+ * `computeEcs.ts`. Its two per-entry refusals (`networkMode: AWS_VPC`, and
33
+ * `logGroup` together with `logRetention`) therefore run in `validateEcsProps`
34
+ * only. `EcsClusterProps` carries no `scheduledTasks` field, so a mirror here
35
+ * would be unreachable.
36
+ *
29
37
  * @param props - The cluster props to validate
30
38
  * @param annotationsScope - When provided (the `EcsCluster` constructor passes
31
39
  * `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
@@ -32,6 +32,14 @@ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
32
32
  * a field on `EcsServiceProps`, so a direct `new EcsCluster(...)` consumer
33
33
  * cannot pass it and there is no resources-layer code path to validate.
34
34
  *
35
+ * Same applies to `cluster.scheduledTasks`: the whole array is a patterns-
36
+ * layer field on `EcsClusterConfig`, materialised into `Ec2TaskDefinition`s
37
+ * and `app.addSchedule(...)` calls by `materialiseScheduledTasks` in
38
+ * `computeEcs.ts`. Its two per-entry refusals (`networkMode: AWS_VPC`, and
39
+ * `logGroup` together with `logRetention`) therefore run in `validateEcsProps`
40
+ * only. `EcsClusterProps` carries no `scheduledTasks` field, so a mirror here
41
+ * would be unreachable.
42
+ *
35
43
  * @param props - The cluster props to validate
36
44
  * @param annotationsScope - When provided (the `EcsCluster` constructor passes
37
45
  * `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
@@ -3,6 +3,7 @@ import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
4
4
  import type { ITopic } from "aws-cdk-lib/aws-sns";
5
5
  import { Construct } from "constructs";
6
+ import { type BackupTier } from "../../../utils/backupTierMapping.js";
6
7
  import { LambdaFunction } from "./lambda.js";
7
8
  import { SQSQueue } from "../messaging/sqs.js";
8
9
  /**
@@ -53,6 +54,13 @@ export interface PersistentDataVolumeProps {
53
54
  * re-attach queue's DLQ-depth alarm (created only when the topic is set).
54
55
  */
55
56
  alarmTopic?: ITopic;
57
+ /**
58
+ * The application's backup tier. Enrols the volume in the matching AWS
59
+ * Backup plan via the disaster-recovery tier tag. Independent of the
60
+ * volume's `SNAPSHOT` removal policy, which only covers a clean destroy:
61
+ * the plan's recovery points are the copies that reach the DR region.
62
+ */
63
+ appBackupTier?: BackupTier;
56
64
  }
57
65
  /**
58
66
  * Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
@@ -10,6 +10,7 @@ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
10
10
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
11
11
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
12
12
  import { Construct } from "constructs";
13
+ import { tagBackupTier } from "../../../utils/backupTierMapping.js";
13
14
  import { attachInlineAsgLifecycleHook } from "./asgInlineLifecycleHook.js";
14
15
  import { LambdaFunction } from "./lambda.js";
15
16
  import { SQSQueue } from "../messaging/sqs.js";
@@ -104,6 +105,7 @@ export class PersistentDataVolume extends Construct {
104
105
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE, PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE);
105
106
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID, this.ownerLogicalId);
106
107
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_STACK_ID, Aws.STACK_ID);
108
+ tagBackupTier(this.volume, props.appBackupTier);
107
109
  this.queue = new SQSQueue(this, `${id}Queue`, {
108
110
  visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
109
111
  deadLetterQueue: { enabled: true, maxReceiveCount: 5 },
@@ -1,6 +1,6 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
- import { tierDefault } from "../../../utils/backupTierMapping.js";
3
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
4
4
  import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
5
5
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
6
6
  import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
@@ -98,6 +98,7 @@ export class DynamoDBTable extends Construct {
98
98
  removalPolicy: toRemovalPolicy(props.removalPolicy),
99
99
  timeToLiveAttribute: props.timeToLiveAttribute
100
100
  });
101
+ tagBackupTier(this.table, props.appBackupTier);
101
102
  if (props.globalSecondaryIndexes) {
102
103
  for (const gsi of props.globalSecondaryIndexes) {
103
104
  const gsiProps = {
@@ -6,7 +6,7 @@ import { Construct } from "constructs";
6
6
  import { SecurityGroup } from "../networking/securityGroup.js";
7
7
  import { Secret } from "../secrets/index.js";
8
8
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
9
- import { tierDefault } from "../../../utils/backupTierMapping.js";
9
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
10
10
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
11
11
  import { getCdkDatabaseInsightsMode, getDatabaseInsightsRetention } from "./index.js";
12
12
  import { RDS_DEFAULTS, SERVERLESS_V2_AUTO_PAUSE_MIN_SECONDS, SERVERLESS_V2_AUTO_PAUSE_MAX_SECONDS, MIN_AUTO_PAUSE_POSTGRES_VERSION } from "./rdsDefaults.js";
@@ -255,6 +255,11 @@ export class RdsAurora extends Construct {
255
255
  this.databaseCluster = new DatabaseCluster(this, `${id}Database`, clusterProps);
256
256
  }
257
257
  this.cfnCluster = this.databaseCluster.node.defaultChild;
258
+ // The L1, not the L2: AWS Backup protects the Aurora cluster, and a tag on
259
+ // the DatabaseCluster construct would propagate to every member instance,
260
+ // each of which would then be selected as an RDS instance it cannot back
261
+ // up.
262
+ tagBackupTier(this.cfnCluster, props.appBackupTier);
258
263
  // Global secondary clusters can't specify these properties
259
264
  if (props.isGlobalSecondary && this.cfnCluster) {
260
265
  this.cfnCluster.masterUsername = undefined;
@@ -13,7 +13,7 @@ import { RDS_DEFAULTS } from "./rdsDefaults.js";
13
13
  import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation, warnIfSnapshotUsernameAssumed } from "./rdsHelpers.js";
14
14
  import { resolveSecretRotation, isCMKRequested } from "../../../utils/databaseTypes.js";
15
15
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
16
- import { tierDefault } from "../../../utils/backupTierMapping.js";
16
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
17
17
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
18
18
  import { createRdsAlarms } from "../monitoring/index.js";
19
19
  export class RdsInstance extends Construct {
@@ -189,6 +189,11 @@ export class RdsInstance extends Construct {
189
189
  credentials: Credentials.fromSecret(this.databaseCredentials.secret)
190
190
  });
191
191
  }
192
+ // The L1, not the L2: a tag on the DatabaseInstance construct propagates
193
+ // to the subnet group it creates. The read replica added below is
194
+ // deliberately left untagged — AWS Backup would protect it as a second
195
+ // full copy of storage the primary's own recovery points already hold.
196
+ tagBackupTier(this.database.node.defaultChild, props.appBackupTier);
192
197
  }
193
198
  rotateSecret(rotationConfig) {
194
199
  this.masterSecret = addMultiUserSecretRotation({
@@ -21,7 +21,7 @@ export declare const ACCESS_LOG_RETENTION_DAYS = 90;
21
21
  * `open`. The bucket takes the env-aware removal policy like every other
22
22
  * bucket: production RETAINs its audit evidence, non-production DESTROYs.
23
23
  *
24
- * Migration from 33.0.0–36.x: CloudFront standard (legacy) logging granted
24
+ * Migration from 33.0.0–37.x: CloudFront standard (legacy) logging granted
25
25
  * its `awslogsdelivery` canonical user FULL_CONTROL on the bucket ACL and
26
26
  * never removes it, so on a bucket legacy logging wrote to the ownership
27
27
  * flip fails with `InvalidBucketAclWithObjectOwnership`. Cure once, then
@@ -23,7 +23,7 @@ export const ACCESS_LOG_RETENTION_DAYS = 90;
23
23
  * `open`. The bucket takes the env-aware removal policy like every other
24
24
  * bucket: production RETAINs its audit evidence, non-production DESTROYs.
25
25
  *
26
- * Migration from 33.0.0–36.x: CloudFront standard (legacy) logging granted
26
+ * Migration from 33.0.0–37.x: CloudFront standard (legacy) logging granted
27
27
  * its `awslogsdelivery` canonical user FULL_CONTROL on the bucket ACL and
28
28
  * never removes it, so on a bucket legacy logging wrote to the ownership
29
29
  * flip fails with `InvalidBucketAclWithObjectOwnership`. Cure once, then
@@ -42,6 +42,9 @@ export class Trail extends Construct {
42
42
  encryptionKey: this.encryptionKey.key,
43
43
  versioned: true,
44
44
  removalPolicy: storagePolicy,
45
+ // CloudTrail delivers here continuously under the 365-day rule below;
46
+ // the audit record is the trail, not a restorable application state.
47
+ backupExemption: "log-delivery",
45
48
  // Custom lifecycleRules bypass S3Bucket's automatic
46
49
  // noncurrentVersionExpiration pairing — carried explicitly, or
47
50
  // noncurrent versions accumulate forever.
@@ -1,5 +1,7 @@
1
1
  import { type IRuleRef } from "aws-cdk-lib/aws-events";
2
2
  import type { IQueue } from "aws-cdk-lib/aws-sqs";
3
+ /** EventBridge's service principal — the sender every grant in this module admits. */
4
+ export declare const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
3
5
  /**
4
6
  * Let `rule` deliver to `queue`. Not for a queue encrypted with a customer
5
7
  * managed key: naming the rule there closes a dependency cycle through the
@@ -2,7 +2,8 @@ import { Construct } from "constructs";
2
2
  import { Annotations, Lazy, Names, Token, TokenComparison } from "aws-cdk-lib";
3
3
  import { CfnRule } from "aws-cdk-lib/aws-events";
4
4
  import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
- const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
5
+ /** EventBridge's service principal — the sender every grant in this module admits. */
6
+ export const EVENTBRIDGE_SERVICE_PRINCIPAL = "events.amazonaws.com";
6
7
  /** The actions `IQueue.grantSendMessages` grants — what CDK's SQS rule target allows. */
7
8
  const RULE_TARGET_ACTIONS = [
8
9
  "sqs:SendMessage",
@@ -1,9 +1,10 @@
1
1
  import { Construct } from "constructs";
2
- import { Duration } from "aws-cdk-lib";
2
+ import { Annotations, Duration } from "aws-cdk-lib";
3
3
  import { Rule } from "aws-cdk-lib/aws-events";
4
- import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { SQSQueue, SQS_LIMITS } from "./sqs.js";
6
6
  import { resolveTarget } from "./eventTargets.js";
7
+ import { EVENTBRIDGE_SERVICE_PRINCIPAL } from "./eventBridgeQueueGrants.js";
7
8
  import { createScheduleAlarms } from "../monitoring/scheduleAlarms.js";
8
9
  import { createSqsDlqAlarms } from "../monitoring/sqsAlarms.js";
9
10
  /**
@@ -36,7 +37,7 @@ export class EventBridgeRule extends Construct {
36
37
  const dlq = dlqResolution?.queue;
37
38
  if (dlq !== undefined)
38
39
  this.deadLetterQueue = dlq;
39
- grantKmsDecryptToEventBridge(dlq);
40
+ grantKmsDecryptToEventBridge(this, dlq);
40
41
  const retryPolicy = resolveRetryPolicy(props.kind, props.retryPolicy);
41
42
  const targetAdapter = resolveTarget(props.target, {
42
43
  retryPolicy,
@@ -84,13 +85,43 @@ export class EventBridgeRule extends Construct {
84
85
  return this.#rule.ruleArn;
85
86
  }
86
87
  }
87
- function grantKmsDecryptToEventBridge(dlq) {
88
+ /**
89
+ * Actions EventBridge needs on a dead-letter queue's key to encrypt the
90
+ * event it is dropping there.
91
+ */
92
+ const EVENTBRIDGE_DLQ_KEY_ACTIONS = ["kms:Decrypt", "kms:GenerateDataKey"];
93
+ /**
94
+ * Admit EventBridge to a CMK-encrypted dead-letter queue's key.
95
+ *
96
+ * Written as an `addToResourcePolicy` rather than `IKey.grant` because only
97
+ * the former reports whether the statement landed. A key imported by ARN —
98
+ * what `kmsKeyArn` on the patterns layer produces — carries no policy
99
+ * document CDK can write, and `grant` returns a Grant whose `success` is
100
+ * `true` regardless, so the admission would be lost in silence. It is the
101
+ * worst place to lose one: SQS DROPS a send its key refuses instead of
102
+ * returning an error, so the failure surfaces only as a dead letter that
103
+ * never arrives, long after the deploy that caused it.
104
+ *
105
+ * The warning therefore names the statement the key's owner must add. Fjall
106
+ * cannot add it: the key is theirs, and it may not even be in this account.
107
+ */
108
+ function grantKmsDecryptToEventBridge(scope, dlq) {
88
109
  if (dlq === undefined)
89
110
  return;
90
111
  const key = dlq.getQueue().encryptionMasterKey;
91
112
  if (key === undefined)
92
113
  return;
93
- key.grant(new ServicePrincipal("events.amazonaws.com"), "kms:Decrypt", "kms:GenerateDataKey");
114
+ const admitted = key.addToResourcePolicy(new PolicyStatement({
115
+ principals: [new ServicePrincipal(EVENTBRIDGE_SERVICE_PRINCIPAL)],
116
+ actions: EVENTBRIDGE_DLQ_KEY_ACTIONS,
117
+ resources: ["*"]
118
+ })).statementAdded;
119
+ if (admitted)
120
+ return;
121
+ Annotations.of(scope).addWarningV2("@fjall/components-infrastructure:eventBridgeDeadLetterKeyNotAdmitted", `The dead-letter queue's KMS key is imported, so this stack cannot add ` +
122
+ `to its key policy. Add ${EVENTBRIDGE_DLQ_KEY_ACTIONS.join(" + ")} for ` +
123
+ `${EVENTBRIDGE_SERVICE_PRINCIPAL} to the key yourself, or SQS will ` +
124
+ `silently drop every event EventBridge dead-letters to this queue.`);
94
125
  }
95
126
  function resolveRetryPolicy(kind, override) {
96
127
  if (override !== undefined)
@@ -1,6 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { type IQueue } from "aws-cdk-lib/aws-sqs";
3
3
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
4
+ import { type IKey } from "aws-cdk-lib/aws-kms";
4
5
  import type { ITopic } from "aws-cdk-lib/aws-sns";
5
6
  import { type RemovalPolicyString } from "./utils.js";
6
7
  import { type SqsAlarmThresholds } from "../monitoring/index.js";
@@ -57,8 +58,42 @@ export interface SQSQueueProps {
57
58
  maxReceiveCount?: number;
58
59
  queue?: IQueue;
59
60
  };
61
+ /**
62
+ * Server-side encryption at rest. Defaults to `"SSE_SQS"` — the SQS-owned
63
+ * managed key, which is free, needs no key policy and satisfies
64
+ * `sqs-queue-not-encrypted`.
65
+ *
66
+ * `"SSE_KMS"` encrypts with a customer-managed key: `masterKey` when you
67
+ * bring one, else the stack's shared {@link StackEncryptionKey} with the
68
+ * SQS senders admitted to it. It deliberately does NOT fall through to
69
+ * what CDK would do unaided — mint an implicit `AWS::KMS::Key` that is
70
+ * `DeletionPolicy: Retain` with no alias and no tags, admitting only the
71
+ * account root. Such a key is invisible to every Fjall teardown sweep
72
+ * (they find keys by the alias tag `CustomerManagedKey` stamps), so it
73
+ * outlives the stack as a charge nothing can attribute — and because its
74
+ * policy admits no service principal, no EventBridge rule, S3 notification
75
+ * or SNS subscription could send to the queue either.
76
+ *
77
+ * Unlike SNS, this does not follow the app's backup tier. `"SSE_SQS"` is
78
+ * real encryption at rest at every tier and costs nothing, and the tier's
79
+ * `customerManagedKeys` column enumerates the resources it owns (DynamoDB
80
+ * tables, SNS topics, Lambda environment, ECS Exec, log groups) — queues
81
+ * are not among them, so the opt-in here is the caller's.
82
+ */
60
83
  encryption?: "SSE_SQS" | "SSE_KMS" | "NONE";
61
- kmsKeyArn?: string;
84
+ /**
85
+ * Bring your own KMS key. Implies `encryption: "SSE_KMS"` when no
86
+ * encryption is stated, and is REFUSED beside `"SSE_SQS"` or `"NONE"`
87
+ * rather than silently ignored.
88
+ *
89
+ * Every AWS service principal that sends to the queue needs `kms:Decrypt`
90
+ * + `kms:GenerateDataKey*` on this key's RESOURCE policy or its sends are
91
+ * dropped without an error — the key policy is the only place a service
92
+ * principal can be admitted. The stack key's own admission
93
+ * ({@link StackEncryptionKey.forSqsServicePublishers}) is not applied to a
94
+ * key you brought: it is yours to scope.
95
+ */
96
+ masterKey?: IKey;
62
97
  contentBasedDeduplication?: boolean;
63
98
  fifoThroughputLimit?: "perQueue" | "perMessageGroupId";
64
99
  deduplicationScope?: "queue" | "messageGroup";