diff --git a/.changeset/late-papers-float.md b/.changeset/late-papers-float.md new file mode 100644 index 00000000..029d687f --- /dev/null +++ b/.changeset/late-papers-float.md @@ -0,0 +1,5 @@ +--- +"@aligent/cdk-aspects": minor +--- + +Add default aspects for S3 and DynamoDB resources to enforce secure configuration defaults diff --git a/packages/cdk-aspects/index.ts b/packages/cdk-aspects/index.ts index 1acda703..50639441 100644 --- a/packages/cdk-aspects/index.ts +++ b/packages/cdk-aspects/index.ts @@ -1,5 +1,7 @@ +export * from "./lib/defaults/dynamodb"; export * from "./lib/defaults/log-group"; export * from "./lib/defaults/nodejs-function"; +export * from "./lib/defaults/s3-bucket"; export * from "./lib/defaults/step-functions"; export * from "./lib/lambda-sfn-versioning"; export * from "./lib/microservice-checks"; diff --git a/packages/cdk-aspects/lib/defaults/dynamodb.ts b/packages/cdk-aspects/lib/defaults/dynamodb.ts new file mode 100644 index 00000000..af280920 --- /dev/null +++ b/packages/cdk-aspects/lib/defaults/dynamodb.ts @@ -0,0 +1,150 @@ +import { RemovalPolicy, type IAspect } from "aws-cdk-lib"; +import { + BillingMode, + CfnTable, + Table, + type TableProps, +} from "aws-cdk-lib/aws-dynamodb"; +import { IConstruct } from "constructs"; + +interface Config { + duration: "SHORT" | "MEDIUM" | "LONG"; +} + +/** + * Aspect that automatically applies configuration-aware defaults to DynamoDB Tables + * + * Visits all constructs in the scope and automatically applies configuration-specific + * removal policies and point-in-time recovery settings to DynamoDB tables. + * Different configurations balance between cost optimization and data retention needs. + * + * @example + * ```typescript + * // Apply configuration-specific defaults to all tables + * Aspects.of(app).add(new DynamoDbDefaultsAspect({ duration: 'SHORT' })); + * + * // Tables automatically inherit configuration defaults + * new Table(stack, 'MyTable', { + * // point-in-time recovery and removal policy applied automatically + * }); + * ``` + * + * @see https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_dynamodb.Table.html + */ +export class DynamoDbDefaultsAspect implements IAspect { + private readonly defaultProps: TableProps; + + /** + * Creates a new DynamoDbDefaultsAspect + * + * @param config - Configuration identifier used to select appropriate defaults. + */ + constructor(config: Config) { + this.defaultProps = this.retentionProperties(config.duration); + } + + /** + * Get duration-specific DynamoDB table properties + * + * @param duration - The duration to get the table properties for + * @returns The table properties for the duration + */ + private retentionProperties( + duration: "SHORT" | "MEDIUM" | "LONG" + ): TableProps { + switch (duration) { + case "SHORT": + return { + billingMode: BillingMode.PROVISIONED, + readCapacity: 1, + writeCapacity: 1, + removalPolicy: RemovalPolicy.DESTROY, + }; + case "MEDIUM": + return { + billingMode: BillingMode.PAY_PER_REQUEST, + maxReadRequestUnits: 100, + maxWriteRequestUnits: 100, + removalPolicy: RemovalPolicy.DESTROY, + }; + default: + return { + billingMode: BillingMode.PAY_PER_REQUEST, + removalPolicy: RemovalPolicy.RETAIN, + }; + } + } + + private isProvisionedThroughputConfigured() { + const { billingMode, readCapacity, writeCapacity } = this.defaultProps; + + return ( + billingMode === BillingMode.PROVISIONED && + readCapacity !== undefined && + writeCapacity !== undefined + ); + } + + private isOnDemandThroughputConfigured() { + const { billingMode, maxReadRequestUnits, maxWriteRequestUnits } = + this.defaultProps; + + return ( + billingMode === BillingMode.PAY_PER_REQUEST && + maxReadRequestUnits !== undefined && + maxWriteRequestUnits !== undefined + ); + } + + /** + * Visits a construct and applies configuration-appropriate defaults + * + * Applies configuration-specific billing mode, throughput, point-in-time recovery, + * and removal policies to tables that don't already have these properties explicitly set. + * + * @param node - The construct to potentially modify + */ + visit(node: IConstruct): void { + if (node instanceof Table) { + const { + billingMode, + readCapacity, + writeCapacity, + maxReadRequestUnits, + maxWriteRequestUnits, + removalPolicy, + } = this.defaultProps; + + if (removalPolicy) { + node.applyRemovalPolicy(removalPolicy); + } + + const cfnTable = node.node.defaultChild as CfnTable; + if (!cfnTable) return; + + if (cfnTable.billingMode === undefined) { + cfnTable.billingMode = billingMode; + } + + if ( + cfnTable.provisionedThroughput === undefined && + this.isProvisionedThroughputConfigured() + ) { + cfnTable.provisionedThroughput = { + readCapacityUnits: readCapacity!, + writeCapacityUnits: writeCapacity!, + }; + } + + if ( + cfnTable.onDemandThroughput === undefined && + this.isOnDemandThroughputConfigured() + ) { + cfnTable.onDemandThroughput = { + maxReadRequestUnits, + maxWriteRequestUnits, + }; + } + } + } +} diff --git a/packages/cdk-aspects/lib/defaults/s3-bucket.ts b/packages/cdk-aspects/lib/defaults/s3-bucket.ts new file mode 100644 index 00000000..d5536217 --- /dev/null +++ b/packages/cdk-aspects/lib/defaults/s3-bucket.ts @@ -0,0 +1,91 @@ +import { Duration, RemovalPolicy, type IAspect } from "aws-cdk-lib"; +import { Bucket, CfnBucket, type BucketProps } from "aws-cdk-lib/aws-s3"; +import { IConstruct } from "constructs"; + +interface Config { + duration: "SHORT" | "MEDIUM" | "LONG"; +} + +/** + * Aspect that automatically applies configuration-aware defaults to S3 Buckets + * + * Visits all constructs in the scope and automatically applies configuration-specific + * lifecycle and removal policies to S3 buckets. Different configurations balance + * between cost optimization and data retention needs. + * + * @example + * ```typescript + * // Apply configuration-specific defaults to all buckets + * Aspects.of(app).add(new S3DefaultsAspect({ autoDelete: true, duration: 'SHORT' })); + * + * // Buckets automatically inherit configuration defaults + * new Bucket(stack, 'MyBucket', { + * // lifecycle and removal policy applied automatically + * }); + * ``` + * + * @see https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.Bucket.html + */ +export class S3DefaultsAspect implements IAspect { + private readonly defaultProps: BucketProps; + + /** + * Creates a new S3DefaultsAspect + * + * @param config - Configuration identifier used to select appropriate defaults. + */ + constructor(config: Config) { + const props = this.retentionProperties(config.duration); + this.defaultProps = { ...props }; + } + + /** + * Get duration-specific object expiration + * + * @param duration - The duration to get the expiration for + * @returns The expiration Duration, or undefined for LONG retention + */ + private retentionProperties(duration: "SHORT" | "MEDIUM" | "LONG") { + switch (duration) { + case "SHORT": + return { + lifecycleRules: [{ expiration: Duration.days(30) }], + removalPolicy: RemovalPolicy.DESTROY, + }; + case "MEDIUM": + return { + lifecycleRules: [{ expiration: Duration.days(90) }], + removalPolicy: RemovalPolicy.DESTROY, + }; + default: + return { + lifecycleRules: [], + removalPolicy: RemovalPolicy.RETAIN, + }; + } + } + + /** + * Visits a construct and applies configuration-appropriate defaults + * + * Applies a removal policy and lifecycle rules to buckets that don't + * already have a lifecycle configuration explicitly set. + * + * @param node - The construct to potentially modify + */ + visit(node: IConstruct): void { + if (node instanceof Bucket) { + const { lifecycleRules, removalPolicy } = this.defaultProps; + if (removalPolicy) { + node.applyRemovalPolicy(removalPolicy); + } + + if (lifecycleRules?.length) { + const cfnBucket = node.node.defaultChild as CfnBucket; + if (cfnBucket && cfnBucket.lifecycleConfiguration === undefined) { + lifecycleRules.forEach(rule => node.addLifecycleRule(rule)); + } + } + } + } +}