@fjall/components-infrastructure 31.0.0 → 33.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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/config/audit.js +2 -1
  3. package/dist/lib/patterns/aws/buildkite/pins.js +1 -2
  4. package/dist/lib/patterns/aws/cdn.d.ts +14 -0
  5. package/dist/lib/patterns/aws/cdn.js +31 -3
  6. package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +8 -0
  7. package/dist/lib/patterns/aws/clickhouseDatabase.js +15 -2
  8. package/dist/lib/patterns/aws/compute.js +4 -2
  9. package/dist/lib/patterns/aws/computeEcs.d.ts +7 -0
  10. package/dist/lib/patterns/aws/computeEcs.js +26 -3
  11. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +25 -2
  12. package/dist/lib/patterns/aws/computeLambda.d.ts +12 -0
  13. package/dist/lib/patterns/aws/computeLambda.js +2 -0
  14. package/dist/lib/patterns/aws/computePropApplicability.js +2 -0
  15. package/dist/lib/patterns/aws/database.d.ts +14 -0
  16. package/dist/lib/patterns/aws/database.js +16 -6
  17. package/dist/lib/patterns/aws/fivetranProxy.d.ts +0 -1
  18. package/dist/lib/patterns/aws/fivetranProxy.js +6 -1
  19. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +7 -7
  20. package/dist/lib/patterns/aws/messaging.js +4 -1
  21. package/dist/lib/patterns/aws/patternDomain.d.ts +1 -1
  22. package/dist/lib/patterns/aws/patternDomain.js +1 -1
  23. package/dist/lib/patterns/aws/payload.js +1 -3
  24. package/dist/lib/patterns/aws/staticSite.d.ts +6 -6
  25. package/dist/lib/patterns/aws/staticSite.js +9 -6
  26. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +2 -0
  27. package/dist/lib/resources/aws/cdn/cloudFront.js +1 -0
  28. package/dist/lib/resources/aws/compute/applicationLoadBalancer.d.ts +10 -0
  29. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +7 -1
  30. package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
  31. package/dist/lib/resources/aws/compute/ecs.js +51 -6
  32. package/dist/lib/resources/aws/compute/ecsContext.d.ts +10 -1
  33. package/dist/lib/resources/aws/compute/ecsNetworking.js +3 -1
  34. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +4 -1
  35. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +31 -8
  36. package/dist/lib/resources/aws/compute/lambda.d.ts +18 -1
  37. package/dist/lib/resources/aws/compute/lambda.js +21 -3
  38. package/dist/lib/resources/aws/database/dynamodb.d.ts +19 -0
  39. package/dist/lib/resources/aws/database/dynamodb.js +13 -2
  40. package/dist/lib/resources/aws/database/rdsAurora.d.ts +11 -0
  41. package/dist/lib/resources/aws/database/rdsAurora.js +24 -3
  42. package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +11 -1
  43. package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +1 -0
  44. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +0 -2
  45. package/dist/lib/resources/aws/database/rdsDefaults.js +0 -2
  46. package/dist/lib/resources/aws/database/rdsInstance.d.ts +11 -0
  47. package/dist/lib/resources/aws/database/rdsInstance.js +19 -4
  48. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +22 -0
  49. package/dist/lib/resources/aws/logging/accessLogBucket.js +41 -0
  50. package/dist/lib/resources/aws/logging/index.d.ts +1 -0
  51. package/dist/lib/resources/aws/logging/index.js +1 -0
  52. package/dist/lib/resources/aws/messaging/sns.d.ts +14 -5
  53. package/dist/lib/resources/aws/messaging/sns.js +7 -1
  54. package/dist/lib/resources/aws/secrets/index.d.ts +1 -0
  55. package/dist/lib/resources/aws/secrets/index.js +1 -0
  56. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +47 -0
  57. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +114 -0
  58. package/dist/lib/utils/backupTierMapping.d.ts +10 -2
  59. package/dist/lib/utils/backupTierMapping.js +3 -1
  60. package/package.json +4 -4
@@ -4,8 +4,6 @@ export const RDS_DEFAULTS = Object.freeze({
4
4
  PREFERRED_MAINTENANCE_WINDOW: "Sat:12:30-Sat:20:30",
5
5
  /** 1 minute — balances monitoring granularity against CloudWatch costs */
6
6
  MONITORING_INTERVAL: Duration.minutes(1),
7
- /** Default backup retention in days — applied uniformly across Aurora and RDS Instance */
8
- BACKUP_RETENTION_DEFAULT_DAYS: 14,
9
7
  /** Default instance type — primary and read-replica must stay in lockstep */
10
8
  DEFAULT_INSTANCE_TYPE: "t4g.large",
11
9
  /** Default storage autoscaling ceiling (GiB) — applied to primary and replica */
@@ -7,6 +7,7 @@ import { SecurityGroup } from "../networking/securityGroup.js";
7
7
  import { Secret } from "../secrets/index.js";
8
8
  import { type StackBuilder } from "../base/awsStack.js";
9
9
  import { type EngineConfig, type ProxyConfig, type ReadReplicaConfig, type CredentialsConfig, type EncryptionConfig, type DatabaseInsightsConfig, type SnapshotTarget } from "../../../utils/databaseTypes.js";
10
+ import { type BackupTier } from "../../../utils/backupTierMapping.js";
10
11
  import type { ITopic } from "aws-cdk-lib/aws-sns";
11
12
  import { type RdsAlarmThresholds } from "../monitoring/index.js";
12
13
  interface RdsProps {
@@ -17,7 +18,17 @@ interface RdsProps {
17
18
  instanceType?: string;
18
19
  maxAllocatedStorage?: number;
19
20
  allocatedStorage?: number;
21
+ /**
22
+ * Automated backup retention. Defaults to the app backup tier's column
23
+ * (`tierDefault(appBackupTier, "rdsBackupRetentionDays")`).
24
+ */
20
25
  backupRetention?: Duration;
26
+ /**
27
+ * The owning app's backup tier (`App.getApp({ backup: { tier } })`), which
28
+ * the retention default follows. `DatabaseFactory` fills it from the App;
29
+ * pass it only for direct `new RdsInstance(...)` construction.
30
+ */
31
+ appBackupTier?: BackupTier;
21
32
  preferredBackupWindow?: string;
22
33
  clusterIdentifier?: string;
23
34
  monitoringInterval?: Duration;
@@ -8,11 +8,12 @@ import { Construct } from "constructs";
8
8
  import { SecurityGroup } from "../networking/securityGroup.js";
9
9
  import { CustomerManagedKey, Secret } from "../secrets/index.js";
10
10
  import { CustomResource } from "../utilities/customResource.js";
11
- import { getDatabaseInsightsRetention } from "./index.js";
11
+ import { getCdkDatabaseInsightsMode, getDatabaseInsightsRetention } from "./index.js";
12
12
  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
17
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
17
18
  import { createRdsAlarms } from "../monitoring/index.js";
18
19
  export class RdsInstance extends Construct {
@@ -105,10 +106,15 @@ export class RdsInstance extends Construct {
105
106
  const storageEncryptionKey = resolveStorageEncryptionKey(this, this.databaseNameValue, props.encryption?.storageKey);
106
107
  const { piEnabled, piConfig } = resolveDatabaseInsights(props.databaseInsights);
107
108
  const performanceInsightsEncryptionKey = resolvePerformanceInsightsKey(this, this.databaseNameValue, piEnabled, piConfig?.encryptionKey);
108
- const diMode = piConfig?.mode ?? "standard";
109
+ // The tier owns the Database Insights mode; an explicit `mode` wins.
110
+ const diMode = piConfig?.mode ??
111
+ tierDefault(props.appBackupTier, "databaseInsightsMode");
109
112
  const performanceInsightsRetention = piEnabled
110
113
  ? getDatabaseInsightsRetention(diMode)
111
114
  : undefined;
115
+ // Advanced mode is emitted only when it applies, so a standard template
116
+ // stays byte-identical (CDK would otherwise infer Performance Insights on).
117
+ const advancedInsights = piEnabled && diMode === "advanced";
112
118
  const engine = props.engine ||
113
119
  DatabaseInstanceEngine.postgres({
114
120
  version: PostgresEngineVersion.VER_17_9
@@ -123,7 +129,7 @@ export class RdsInstance extends Construct {
123
129
  ? SubnetType.PUBLIC
124
130
  : SubnetType.PRIVATE_WITH_EGRESS;
125
131
  const backupRetention = props.backupRetention ??
126
- Duration.days(RDS_DEFAULTS.BACKUP_RETENTION_DEFAULT_DAYS);
132
+ Duration.days(tierDefault(props.appBackupTier, "rdsBackupRetentionDays"));
127
133
  // toDays() throws on a fractional Duration — same whole-days contract the
128
134
  // L2 DatabaseInstance enforces, surfaced at synth either way.
129
135
  this.backupRetentionDaysValue = backupRetention.toDays();
@@ -150,6 +156,9 @@ export class RdsInstance extends Construct {
150
156
  enablePerformanceInsights: piEnabled,
151
157
  performanceInsightEncryptionKey: performanceInsightsEncryptionKey,
152
158
  performanceInsightRetention: performanceInsightsRetention,
159
+ ...(advancedInsights && {
160
+ databaseInsightsMode: getCdkDatabaseInsightsMode(diMode)
161
+ }),
153
162
  instanceIdentifier: ResourceNaming.dbInstanceId(this.constructId),
154
163
  instanceType: new InstanceType(props.instanceType ?? RDS_DEFAULTS.DEFAULT_INSTANCE_TYPE),
155
164
  maxAllocatedStorage: props.maxAllocatedStorage ??
@@ -243,6 +252,9 @@ export class RdsInstance extends Construct {
243
252
  props.instanceType ??
244
253
  RDS_DEFAULTS.DEFAULT_INSTANCE_TYPE;
245
254
  const { piEnabled, piConfig } = resolveDatabaseInsights(props.databaseInsights);
255
+ const diMode = piConfig?.mode ??
256
+ tierDefault(props.appBackupTier, "databaseInsightsMode");
257
+ const advancedInsights = piEnabled && diMode === "advanced";
246
258
  const readReplicaPerformanceInsightsKey = piEnabled && isCMKRequested(piConfig?.encryptionKey)
247
259
  ? new CustomerManagedKey(this, `${this.databaseNameValue}ReadReplicaReaderInsightsKey`, {
248
260
  aliasName: ResourceNaming.readReplicaInsightsKeyAlias(this.databaseNameValue),
@@ -326,8 +338,11 @@ exports.handler = async (event) => {
326
338
  enablePerformanceInsights: piEnabled,
327
339
  performanceInsightEncryptionKey: readReplicaPerformanceInsightsKey,
328
340
  performanceInsightRetention: piEnabled
329
- ? getDatabaseInsightsRetention(piConfig?.mode ?? "standard")
341
+ ? getDatabaseInsightsRetention(diMode)
330
342
  : undefined,
343
+ ...(advancedInsights && {
344
+ databaseInsightsMode: getCdkDatabaseInsightsMode(diMode)
345
+ }),
331
346
  instanceIdentifier: ResourceNaming.readReplicaId(this.constructId),
332
347
  instanceType: new InstanceType(replicaInstanceType),
333
348
  availabilityZone: replicaConfig.availabilityZone,
@@ -0,0 +1,22 @@
1
+ import type { Construct } from "constructs";
2
+ import { S3Bucket } from "../storage/s3.js";
3
+ /**
4
+ * How long delivered access logs stay before the lifecycle rule expires
5
+ * them. Access logs are high-volume and low-value past the incident window;
6
+ * an unexpired log bucket is the one S3 cost that grows without bound.
7
+ */
8
+ export declare const ACCESS_LOG_RETENTION_DAYS = 90;
9
+ /**
10
+ * The one bucket an enterprise-tier stack delivers its access logs to —
11
+ * application load balancer access logs (`alb/<name>/`) and CloudFront
12
+ * standard logs (`cloudfront/<id>/`) share it under distinct prefixes.
13
+ *
14
+ * Its shape is dictated by the two delivery services and deliberately NOT
15
+ * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
16
+ * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
17
+ * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
18
+ * versioning would only multiply expired log objects. The bucket takes the
19
+ * env-aware removal policy like every other bucket: production RETAINs its
20
+ * audit evidence, non-production DESTROYs.
21
+ */
22
+ export declare function stackAccessLogBucket(scope: Construct): S3Bucket;
@@ -0,0 +1,41 @@
1
+ import { Duration, Stack } from "aws-cdk-lib";
2
+ import { BucketEncryption, ObjectOwnership } from "aws-cdk-lib/aws-s3";
3
+ import { S3Bucket } from "../storage/s3.js";
4
+ const STACK_ACCESS_LOG_BUCKET_ID = "FjallAccessLogBucket";
5
+ /**
6
+ * How long delivered access logs stay before the lifecycle rule expires
7
+ * them. Access logs are high-volume and low-value past the incident window;
8
+ * an unexpired log bucket is the one S3 cost that grows without bound.
9
+ */
10
+ export const ACCESS_LOG_RETENTION_DAYS = 90;
11
+ /**
12
+ * The one bucket an enterprise-tier stack delivers its access logs to —
13
+ * application load balancer access logs (`alb/<name>/`) and CloudFront
14
+ * standard logs (`cloudfront/<id>/`) share it under distinct prefixes.
15
+ *
16
+ * Its shape is dictated by the two delivery services and deliberately NOT
17
+ * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
18
+ * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
19
+ * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
20
+ * versioning would only multiply expired log objects. The bucket takes the
21
+ * env-aware removal policy like every other bucket: production RETAINs its
22
+ * audit evidence, non-production DESTROYs.
23
+ */
24
+ export function stackAccessLogBucket(scope) {
25
+ const stack = Stack.of(scope);
26
+ const existing = stack.node.tryFindChild(STACK_ACCESS_LOG_BUCKET_ID);
27
+ if (existing !== undefined)
28
+ return existing;
29
+ return new S3Bucket(stack, STACK_ACCESS_LOG_BUCKET_ID, {
30
+ encryption: BucketEncryption.S3_MANAGED,
31
+ objectOwnership: ObjectOwnership.OBJECT_WRITER,
32
+ versioned: false,
33
+ lifecycleRules: [
34
+ {
35
+ id: "ExpireAccessLogs",
36
+ expiration: Duration.days(ACCESS_LOG_RETENTION_DAYS),
37
+ enabled: true
38
+ }
39
+ ]
40
+ });
41
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./accessLogBucket.js";
1
2
  export * from "./cloudTrail.js";
2
3
  export * from "./logGroup.js";
3
4
  export * from "./singletonLogGroup.js";
@@ -1,3 +1,4 @@
1
+ export * from "./accessLogBucket.js";
1
2
  export * from "./cloudTrail.js";
2
3
  export * from "./logGroup.js";
3
4
  export * from "./singletonLogGroup.js";
@@ -2,6 +2,7 @@ import { Construct } from "constructs";
2
2
  import { type IKey } from "aws-cdk-lib/aws-kms";
3
3
  import { type ITopic } from "aws-cdk-lib/aws-sns";
4
4
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
5
+ import { type BackupTier } from "../../../utils/backupTierMapping.js";
5
6
  import { type RemovalPolicyString } from "./utils.js";
6
7
  export interface SNSTopicProps {
7
8
  topicName?: string;
@@ -23,11 +24,13 @@ export interface SNSTopicProps {
23
24
  */
24
25
  signatureVersion?: "1" | "2";
25
26
  /**
26
- * KMS key for server-side encryption at rest. Absent ⇒ unencrypted — the
27
- * accessible default (a customer-managed key costs $1–3/month per
28
- * account-region once annual rotation accrues), so the
29
- * SNS_TOPIC_NOT_ENCRYPTED posture finding stands
30
- * as the signpost to this knob rather than being silently pre-paid.
27
+ * KMS key for server-side encryption at rest. Absent ⇒ the app backup
28
+ * tier decides: enterprise encrypts with the stack's shared
29
+ * [[StackEncryptionKey]] (its SNS service publishers admitted); every other
30
+ * tier stays unencrypted — the accessible default (a customer-managed key
31
+ * costs $1–3/month per account-region once annual rotation accrues), so
32
+ * the SNS_TOPIC_NOT_ENCRYPTED posture finding stands as the signpost to
33
+ * the tier rather than being silently pre-paid.
31
34
  *
32
35
  * Every AWS service principal that publishes to the topic needs
33
36
  * `kms:Decrypt` + `kms:GenerateDataKey*` on this key's RESOURCE policy or
@@ -35,6 +38,12 @@ export interface SNSTopicProps {
35
38
  * used for service publishers precisely because its policy is immutable.
36
39
  */
37
40
  masterKey?: IKey;
41
+ /**
42
+ * The owning app's backup tier, which the encryption default follows.
43
+ * `MessagingFactory` fills it from the App; pass it only for direct
44
+ * `new SNSTopic(...)` construction.
45
+ */
46
+ appBackupTier?: BackupTier;
38
47
  }
39
48
  export declare class SNSTopic extends Construct {
40
49
  readonly id: string;
@@ -1,6 +1,8 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
3
  import { Topic } from "aws-cdk-lib/aws-sns";
4
+ import { tierDefault } from "../../../utils/backupTierMapping.js";
5
+ import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
4
6
  import { toRemovalPolicy } from "./utils.js";
5
7
  export class SNSTopic extends Construct {
6
8
  id;
@@ -9,6 +11,10 @@ export class SNSTopic extends Construct {
9
11
  super(scope, id);
10
12
  this.id = id;
11
13
  const isFifo = props.fifo === true;
14
+ const masterKey = props.masterKey ??
15
+ (tierDefault(props.appBackupTier, "customerManagedKeys")
16
+ ? StackEncryptionKey.of(this).forSnsServicePublishers()
17
+ : undefined);
12
18
  const topicName = props.topicName
13
19
  ? isFifo
14
20
  ? `${props.topicName}.fifo`
@@ -29,7 +35,7 @@ export class SNSTopic extends Construct {
29
35
  // (SharedAlarmTopic carries the cloudwatch.amazonaws.com one).
30
36
  // Same-account IAM publishers are unaffected: identity policies suffice.
31
37
  enforceSSL: true,
32
- masterKey: props.masterKey
38
+ masterKey
33
39
  });
34
40
  // An SNS topic is a transient fan-out medium: it holds no durable state
35
41
  // (subscriptions are re-created by the next deploy), so the wrapper defaults
@@ -2,5 +2,6 @@ export * from "./alias.js";
2
2
  export * from "./kms.js";
3
3
  export * from "./parameter.js";
4
4
  export * from "./secret.js";
5
+ export * from "./stackEncryptionKey.js";
5
6
  export * from "./tlsCaSecret.js";
6
7
  export * from "./tlsServerSecret.js";
@@ -2,5 +2,6 @@ export * from "./alias.js";
2
2
  export * from "./kms.js";
3
3
  export * from "./parameter.js";
4
4
  export * from "./secret.js";
5
+ export * from "./stackEncryptionKey.js";
5
6
  export * from "./tlsCaSecret.js";
6
7
  export * from "./tlsServerSecret.js";
@@ -0,0 +1,47 @@
1
+ import type { IKey } from "aws-cdk-lib/aws-kms";
2
+ import type { Construct } from "constructs";
3
+ import { CustomerManagedKey } from "./kms.js";
4
+ /**
5
+ * The one customer-managed KMS key an enterprise-tier stack encrypts its
6
+ * tier-owned resources with: DynamoDB tables, SNS topics, Lambda environment
7
+ * variables, ECS Exec sessions and the log groups the tier-aware constructs
8
+ * create. One key per stack rather than one per resource — a key is billed
9
+ * per month whether it protects one table or twenty, its policy is the one
10
+ * place service principals are admitted, and a posture scan sees one
11
+ * purposeful key rather than a sprawl of orphans after a destroy.
12
+ *
13
+ * `protects: "outlives-stack"`: a DynamoDB table RETAINs by default in every
14
+ * environment and a production log group RETAINs under D17, so at least one
15
+ * encrypted resource can survive the stack that created it. A retained key
16
+ * costs $1–3/month; a destroyed key would render the surviving table
17
+ * permanently unreadable after its 30-day pending window.
18
+ *
19
+ * Grant-based consumers (DynamoDB, Lambda `environmentEncryption`, ECS Exec)
20
+ * read `key` directly — the SDK/CDK grants they need ride the resource's own
21
+ * IAM path. Service-side encryptors need a resource-policy admission first:
22
+ * CloudWatch Logs through [[forLogGroups]], SNS service publishers through
23
+ * [[forSnsServicePublishers]]. Each admission is added once per stack.
24
+ */
25
+ export declare class StackEncryptionKey extends CustomerManagedKey {
26
+ private logsAdmitted;
27
+ private snsPublishersAdmitted;
28
+ private constructor();
29
+ /** The stack's shared key, created on first use. */
30
+ static of(scope: Construct): StackEncryptionKey;
31
+ /**
32
+ * The key, with CloudWatch Logs admitted to it. The CDK `LogGroup` sets
33
+ * `KmsKeyId` but adds no key-policy statement, and without one the
34
+ * service cannot encrypt the group and the stack fails to create. The
35
+ * encryption-context condition confines the admission to log groups in
36
+ * this account and region (the AWS-documented shape).
37
+ */
38
+ forLogGroups(): IKey;
39
+ /**
40
+ * The key, with the SNS-publishing AWS services admitted to it
41
+ * ([[SNS_SERVICE_PUBLISHERS]]). Without the admission their publishes to
42
+ * an encrypted topic fail silently — the AWS-managed `aws/sns` key cannot
43
+ * be used for service publishers precisely because its policy is
44
+ * immutable.
45
+ */
46
+ forSnsServicePublishers(): IKey;
47
+ }
@@ -0,0 +1,114 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
3
+ import { CustomerManagedKey } from "./kms.js";
4
+ const STACK_ENCRYPTION_KEY_ID = "FjallStackEncryptionKey";
5
+ /**
6
+ * AWS services that publish to an application's SNS topics on the app's
7
+ * behalf and therefore encrypt messages through the topic's key: EventBridge
8
+ * rule targets, CloudWatch alarm actions and S3 event notifications. All
9
+ * three need `kms:Decrypt` + `kms:GenerateDataKey*` on the key's RESOURCE
10
+ * policy (identity policies cannot admit a service principal), and none of
11
+ * them can be narrowed with `aws:SourceAccount` / `aws:SourceArn`: EventBridge
12
+ * does not support the condition keys for encrypted topics, and CloudWatch
13
+ * does not reliably pass them on this path and fails silently when they do
14
+ * not match (alarms page nobody) — the SharedAlarmTopic precedent.
15
+ */
16
+ const SNS_SERVICE_PUBLISHERS = [
17
+ "events.amazonaws.com",
18
+ "cloudwatch.amazonaws.com",
19
+ "s3.amazonaws.com"
20
+ ];
21
+ /**
22
+ * The one customer-managed KMS key an enterprise-tier stack encrypts its
23
+ * tier-owned resources with: DynamoDB tables, SNS topics, Lambda environment
24
+ * variables, ECS Exec sessions and the log groups the tier-aware constructs
25
+ * create. One key per stack rather than one per resource — a key is billed
26
+ * per month whether it protects one table or twenty, its policy is the one
27
+ * place service principals are admitted, and a posture scan sees one
28
+ * purposeful key rather than a sprawl of orphans after a destroy.
29
+ *
30
+ * `protects: "outlives-stack"`: a DynamoDB table RETAINs by default in every
31
+ * environment and a production log group RETAINs under D17, so at least one
32
+ * encrypted resource can survive the stack that created it. A retained key
33
+ * costs $1–3/month; a destroyed key would render the surviving table
34
+ * permanently unreadable after its 30-day pending window.
35
+ *
36
+ * Grant-based consumers (DynamoDB, Lambda `environmentEncryption`, ECS Exec)
37
+ * read `key` directly — the SDK/CDK grants they need ride the resource's own
38
+ * IAM path. Service-side encryptors need a resource-policy admission first:
39
+ * CloudWatch Logs through [[forLogGroups]], SNS service publishers through
40
+ * [[forSnsServicePublishers]]. Each admission is added once per stack.
41
+ */
42
+ export class StackEncryptionKey extends CustomerManagedKey {
43
+ logsAdmitted = false;
44
+ snsPublishersAdmitted = false;
45
+ constructor(stack) {
46
+ super(stack, STACK_ENCRYPTION_KEY_ID, {
47
+ description: `Encrypts the enterprise-tier resources of ${stack.stackName} (DynamoDB tables, SNS topics, Lambda environment, ECS Exec sessions, log groups)`,
48
+ // KMS alias names are unique per account-region, so the stack name is
49
+ // the namespace — every stack in the account carries its own key.
50
+ aliasName: `cmk/${stack.stackName}/${STACK_ENCRYPTION_KEY_ID}`,
51
+ protects: "outlives-stack"
52
+ });
53
+ }
54
+ /** The stack's shared key, created on first use. */
55
+ static of(scope) {
56
+ const stack = Stack.of(scope);
57
+ const existing = stack.node.tryFindChild(STACK_ENCRYPTION_KEY_ID);
58
+ if (existing !== undefined)
59
+ return existing;
60
+ return new StackEncryptionKey(stack);
61
+ }
62
+ /**
63
+ * The key, with CloudWatch Logs admitted to it. The CDK `LogGroup` sets
64
+ * `KmsKeyId` but adds no key-policy statement, and without one the
65
+ * service cannot encrypt the group and the stack fails to create. The
66
+ * encryption-context condition confines the admission to log groups in
67
+ * this account and region (the AWS-documented shape).
68
+ */
69
+ forLogGroups() {
70
+ if (!this.logsAdmitted) {
71
+ const stack = Stack.of(this);
72
+ this.key.addToResourcePolicy(new PolicyStatement({
73
+ sid: "AllowCloudWatchLogs",
74
+ principals: [
75
+ new ServicePrincipal(`logs.${stack.region}.amazonaws.com`)
76
+ ],
77
+ actions: [
78
+ "kms:Encrypt*",
79
+ "kms:Decrypt*",
80
+ "kms:ReEncrypt*",
81
+ "kms:GenerateDataKey*",
82
+ "kms:Describe*"
83
+ ],
84
+ resources: ["*"],
85
+ conditions: {
86
+ ArnLike: {
87
+ "kms:EncryptionContext:aws:logs:arn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:*`
88
+ }
89
+ }
90
+ }));
91
+ this.logsAdmitted = true;
92
+ }
93
+ return this.key;
94
+ }
95
+ /**
96
+ * The key, with the SNS-publishing AWS services admitted to it
97
+ * ([[SNS_SERVICE_PUBLISHERS]]). Without the admission their publishes to
98
+ * an encrypted topic fail silently — the AWS-managed `aws/sns` key cannot
99
+ * be used for service publishers precisely because its policy is
100
+ * immutable.
101
+ */
102
+ forSnsServicePublishers() {
103
+ if (!this.snsPublishersAdmitted) {
104
+ this.key.addToResourcePolicy(new PolicyStatement({
105
+ sid: "AllowSnsServicePublishers",
106
+ principals: SNS_SERVICE_PUBLISHERS.map((service) => new ServicePrincipal(service)),
107
+ actions: ["kms:Decrypt", "kms:GenerateDataKey*"],
108
+ resources: ["*"]
109
+ }));
110
+ this.snsPublishersAdmitted = true;
111
+ }
112
+ return this.key;
113
+ }
114
+ }
@@ -1,7 +1,15 @@
1
+ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
1
2
  /** Tag key used by AWS Backup plan selection rules */
2
3
  export declare const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
3
- /** Valid backup tier names */
4
- export type BackupTier = "standard" | "resilient" | "enterprise";
4
+ /**
5
+ * The application backup tier vocabulary and its defaults table are owned by
6
+ * `@fjall/util` (`BACKUP_TIERS`, `BACKUP_TIER_DEFAULTS`, `tierDefault`) and
7
+ * shared with the generator (`BACKUP_VAULT_TIERS`) and the CLI's synth
8
+ * expectations, so every system derives from one declaration. Re-exported
9
+ * here so construct code keeps one import path for tier concerns.
10
+ */
11
+ export type { BackupTier, BackupTierDefaults };
12
+ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
5
13
  /**
6
14
  * Shared mapping from application backup tier names to AWS Backup plan tag values.
7
15
  *
@@ -1,5 +1,7 @@
1
+ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault } from "@fjall/util";
1
2
  /** Tag key used by AWS Backup plan selection rules */
2
3
  export const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
4
+ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
3
5
  /**
4
6
  * Shared mapping from application backup tier names to AWS Backup plan tag values.
5
7
  *
@@ -22,5 +24,5 @@ export function resolveEffectiveBackupTier(resourceTier, appTier) {
22
24
  }
23
25
  /** Tiers whose S3 buckets version by default. */
24
26
  export function shouldAutoVersion(tier) {
25
- return tier === "resilient" || tier === "enterprise";
27
+ return tierDefault(tier, "s3Versioned");
26
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "31.0.0",
3
+ "version": "33.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -39,7 +39,7 @@
39
39
  "clean": "rm -rf ./dist",
40
40
  "clean:node": "rm -rf ./node_modules",
41
41
  "build:cert-gen-lambda": "node lib/lambda-assets/cert-generator/src/build.mjs",
42
- "build": "npm run build:cert-gen-lambda && tsc && cp -r lib/layers dist/lib/layers && mkdir -p dist/lib/lambda-assets/cert-generator/asset && cp lib/lambda-assets/cert-generator/asset/index.js dist/lib/lambda-assets/cert-generator/asset/index.js && cp lib/lambda-assets/cert-generator/asset/package.json dist/lib/lambda-assets/cert-generator/asset/package.json && mkdir -p dist/lib/lambda-assets/identity-store-user/asset && cp lib/lambda-assets/identity-store-user/asset/index.js dist/lib/lambda-assets/identity-store-user/asset/index.js && cp lib/lambda-assets/identity-store-user/asset/package.json dist/lib/lambda-assets/identity-store-user/asset/package.json && mkdir -p dist/lib/lambda-assets/static-site-forms/asset && cp lib/lambda-assets/static-site-forms/asset/index.js dist/lib/lambda-assets/static-site-forms/asset/index.js && cp lib/lambda-assets/static-site-forms/asset/package.json dist/lib/lambda-assets/static-site-forms/asset/package.json && cp lib/resources/aws/compute/lifecycleHookLambda.source.cjs dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs && cp lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs && cp lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs && cp lib/patterns/aws/devSubstrate.waker.source.cjs dist/lib/patterns/aws/devSubstrate.waker.source.cjs && node ../../scripts/stamp-build.mjs",
42
+ "build": "npm run clean && npm run build:cert-gen-lambda && tsc && cp -r lib/layers dist/lib/layers && mkdir -p dist/lib/lambda-assets/cert-generator/asset && cp lib/lambda-assets/cert-generator/asset/index.js dist/lib/lambda-assets/cert-generator/asset/index.js && cp lib/lambda-assets/cert-generator/asset/package.json dist/lib/lambda-assets/cert-generator/asset/package.json && mkdir -p dist/lib/lambda-assets/identity-store-user/asset && cp lib/lambda-assets/identity-store-user/asset/index.js dist/lib/lambda-assets/identity-store-user/asset/index.js && cp lib/lambda-assets/identity-store-user/asset/package.json dist/lib/lambda-assets/identity-store-user/asset/package.json && mkdir -p dist/lib/lambda-assets/static-site-forms/asset && cp lib/lambda-assets/static-site-forms/asset/index.js dist/lib/lambda-assets/static-site-forms/asset/index.js && cp lib/lambda-assets/static-site-forms/asset/package.json dist/lib/lambda-assets/static-site-forms/asset/package.json && cp lib/resources/aws/compute/lifecycleHookLambda.source.cjs dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs && cp lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs && cp lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs && cp lib/patterns/aws/devSubstrate.waker.source.cjs dist/lib/patterns/aws/devSubstrate.waker.source.cjs && node ../../scripts/stamp-build.mjs",
43
43
  "prepack": "node ../../scripts/check-dist-freshness.mjs",
44
44
  "watch": "tsc -w",
45
45
  "watch:only": "tsc -w",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  "dependencies": {
80
80
  "@aws-sdk/client-organizations": "^3.1098.0",
81
- "@fjall/generator": "^31.0.0",
82
- "@fjall/util": "^31.0.0",
81
+ "@fjall/generator": "^33.0.0",
82
+ "@fjall/util": "^33.0.0",
83
83
  "constructs": "^10.7.2",
84
84
  "zod": "^4.4.3"
85
85
  },