From 826e4f2ed54bf98617c5beb84aed3d240ce4a005 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:17:06 -0500 Subject: [PATCH] infrastructure updates --- infrastructure/cdk/bin/app.ts | 85 ++++++--- .../cdk/lib/condition-check-lambda-stack.ts | 26 +++ .../cdk/lib/image-processor-lambda-stack.ts | 26 ++- infrastructure/cdk/lib/vpc-stack.ts | 176 ++++++++++++++++++ 4 files changed, 288 insertions(+), 25 deletions(-) create mode 100644 infrastructure/cdk/lib/vpc-stack.ts diff --git a/infrastructure/cdk/bin/app.ts b/infrastructure/cdk/bin/app.ts index 30c68f9..2563f8f 100644 --- a/infrastructure/cdk/bin/app.ts +++ b/infrastructure/cdk/bin/app.ts @@ -3,6 +3,7 @@ import "source-map-support/register"; import * as cdk from "aws-cdk-lib"; import { ConditionCheckLambdaStack } from "../lib/condition-check-lambda-stack"; import { ImageProcessorLambdaStack } from "../lib/image-processor-lambda-stack"; +import { VpcStack } from "../lib/vpc-stack"; const app = new cdk.App(); @@ -17,6 +18,7 @@ const envConfig: Record< frontendUrl: string; sesFromEmail: string; emailEnabled: boolean; + natGateways: number; } > = { staging: { @@ -27,6 +29,7 @@ const envConfig: Record< frontendUrl: "https://staging.villageshare.app", sesFromEmail: "noreply@villageshare.app", emailEnabled: true, + natGateways: 1, // Single NAT gateway for cost optimization in staging }, prod: { databaseUrl: @@ -35,6 +38,7 @@ const envConfig: Record< frontendUrl: "https://villageshare.app", sesFromEmail: "noreply@villageshare.app", emailEnabled: true, + natGateways: 2, // Multi-AZ NAT gateways for high availability in production }, }; @@ -44,38 +48,71 @@ if (!config) { throw new Error(`Unknown environment: ${environment}`); } -// Create the Condition Check Lambda stack -new ConditionCheckLambdaStack(app, `ConditionCheckLambdaStack-${environment}`, { - environment, - databaseUrl: config.databaseUrl, - frontendUrl: config.frontendUrl, - sesFromEmail: config.sesFromEmail, - emailEnabled: config.emailEnabled, +const envProps = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION || "us-east-1", }, - description: `Condition Check Reminder Lambda infrastructure (${environment})`, +}; + +// Create the VPC stack first (other stacks depend on it) +const vpcStack = new VpcStack(app, `VpcStack-${environment}`, { + environment, + natGateways: config.natGateways, + maxAzs: 2, + ...envProps, + description: `VPC infrastructure with private subnets and VPC endpoints (${environment})`, tags: { Environment: environment, Project: "village-share", - Service: "condition-check-reminder", + Service: "networking", }, }); +// Create the Condition Check Lambda stack +const conditionCheckStack = new ConditionCheckLambdaStack( + app, + `ConditionCheckLambdaStack-${environment}`, + { + environment, + databaseUrl: config.databaseUrl, + frontendUrl: config.frontendUrl, + sesFromEmail: config.sesFromEmail, + emailEnabled: config.emailEnabled, + vpc: vpcStack.vpc, + lambdaSecurityGroup: vpcStack.lambdaSecurityGroup, + ...envProps, + description: `Condition Check Reminder Lambda infrastructure (${environment})`, + tags: { + Environment: environment, + Project: "village-share", + Service: "condition-check-reminder", + }, + } +); + +// Add dependency on VPC stack +conditionCheckStack.addDependency(vpcStack); + // Create the Image Processor Lambda stack -new ImageProcessorLambdaStack(app, `ImageProcessorLambdaStack-${environment}`, { - environment, - databaseUrl: config.databaseUrl, - frontendUrl: config.frontendUrl, - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION || "us-east-1", - }, - description: `Image Processor Lambda infrastructure (${environment})`, - tags: { - Environment: environment, - Project: "village-share", - Service: "image-processor", - }, -}); +const imageProcessorStack = new ImageProcessorLambdaStack( + app, + `ImageProcessorLambdaStack-${environment}`, + { + environment, + databaseUrl: config.databaseUrl, + frontendUrl: config.frontendUrl, + vpc: vpcStack.vpc, + lambdaSecurityGroup: vpcStack.lambdaSecurityGroup, + ...envProps, + description: `Image Processor Lambda infrastructure (${environment})`, + tags: { + Environment: environment, + Project: "village-share", + Service: "image-processor", + }, + } +); + +// Add dependency on VPC stack +imageProcessorStack.addDependency(vpcStack); diff --git a/infrastructure/cdk/lib/condition-check-lambda-stack.ts b/infrastructure/cdk/lib/condition-check-lambda-stack.ts index 0f8bcb7..d0cc6c8 100644 --- a/infrastructure/cdk/lib/condition-check-lambda-stack.ts +++ b/infrastructure/cdk/lib/condition-check-lambda-stack.ts @@ -3,6 +3,7 @@ import * as lambda from "aws-cdk-lib/aws-lambda"; import * as iam from "aws-cdk-lib/aws-iam"; import * as scheduler from "aws-cdk-lib/aws-scheduler"; import * as sqs from "aws-cdk-lib/aws-sqs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; import { Construct } from "constructs"; import * as path from "path"; @@ -36,6 +37,16 @@ interface ConditionCheckLambdaStackProps extends cdk.StackProps { * Whether emails are enabled */ emailEnabled?: boolean; + + /** + * VPC for Lambda function (required for network isolation) + */ + vpc: ec2.IVpc; + + /** + * Security group for Lambda function + */ + lambdaSecurityGroup: ec2.ISecurityGroup; } export class ConditionCheckLambdaStack extends cdk.Stack { @@ -73,6 +84,8 @@ export class ConditionCheckLambdaStack extends cdk.Stack { sesFromEmail, sesFromName = "Village Share", emailEnabled = true, + vpc, + lambdaSecurityGroup, } = props; // Dead Letter Queue for failed Lambda invocations @@ -126,6 +139,13 @@ export class ConditionCheckLambdaStack extends cdk.Stack { }) ); + // VPC permissions - use AWS managed policy for Lambda VPC access + lambdaRole.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole" + ) + ); + // Lambda function this.lambdaFunction = new lambda.Function( this, @@ -171,6 +191,12 @@ export class ConditionCheckLambdaStack extends cdk.Stack { deadLetterQueue: this.deadLetterQueue, retryAttempts: 2, description: "Sends condition check reminder emails for rentals", + // VPC configuration for network isolation + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + securityGroups: [lambdaSecurityGroup], } ); diff --git a/infrastructure/cdk/lib/image-processor-lambda-stack.ts b/infrastructure/cdk/lib/image-processor-lambda-stack.ts index 19d1ffd..7f3bb8f 100644 --- a/infrastructure/cdk/lib/image-processor-lambda-stack.ts +++ b/infrastructure/cdk/lib/image-processor-lambda-stack.ts @@ -4,6 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as s3n from "aws-cdk-lib/aws-s3-notifications"; import * as sqs from "aws-cdk-lib/aws-sqs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; import { Construct } from "constructs"; import * as path from "path"; @@ -22,6 +23,16 @@ interface ImageProcessorLambdaStackProps extends cdk.StackProps { * Frontend URL for CORS configuration */ frontendUrl: string; + + /** + * VPC for Lambda function (required for network isolation) + */ + vpc: ec2.IVpc; + + /** + * Security group for Lambda function + */ + lambdaSecurityGroup: ec2.ISecurityGroup; } export class ImageProcessorLambdaStack extends cdk.Stack { @@ -47,7 +58,7 @@ export class ImageProcessorLambdaStack extends cdk.Stack { ) { super(scope, id, props); - const { environment, databaseUrl, frontendUrl } = props; + const { environment, databaseUrl, frontendUrl, vpc, lambdaSecurityGroup } = props; // Dead Letter Queue for failed Lambda invocations this.deadLetterQueue = new sqs.Queue(this, "ImageProcessorDLQ", { @@ -143,6 +154,13 @@ export class ImageProcessorLambdaStack extends cdk.Stack { }) ); + // VPC permissions - use AWS managed policy for Lambda VPC access + lambdaRole.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole" + ) + ); + // Lambda function this.lambdaFunction = new lambda.Function(this, "ImageProcessorLambda", { functionName: `image-processor-${environment}`, @@ -183,6 +201,12 @@ export class ImageProcessorLambdaStack extends cdk.Stack { retryAttempts: 2, description: "Processes uploaded images: extracts metadata and strips EXIF", + // VPC configuration for network isolation + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + securityGroups: [lambdaSecurityGroup], }); // S3 event notification for staging uploads diff --git a/infrastructure/cdk/lib/vpc-stack.ts b/infrastructure/cdk/lib/vpc-stack.ts new file mode 100644 index 0000000..b6cc03d --- /dev/null +++ b/infrastructure/cdk/lib/vpc-stack.ts @@ -0,0 +1,176 @@ +import * as cdk from "aws-cdk-lib"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import { Construct } from "constructs"; + +interface VpcStackProps extends cdk.StackProps { + /** + * Environment name (staging, prod) + */ + environment: string; + + /** + * Maximum number of AZs to use (default: 2) + */ + maxAzs?: number; + + /** + * Number of NAT Gateways (default: 1 for cost optimization) + * Use 2 for high availability in production + */ + natGateways?: number; +} + +export class VpcStack extends cdk.Stack { + /** + * The VPC created by this stack + */ + public readonly vpc: ec2.Vpc; + + /** + * Security group for Lambda functions + */ + public readonly lambdaSecurityGroup: ec2.SecurityGroup; + + /** + * S3 Gateway endpoint (free) + */ + public readonly s3Endpoint: ec2.GatewayVpcEndpoint; + + constructor(scope: Construct, id: string, props: VpcStackProps) { + super(scope, id, props); + + const { environment, maxAzs = 2, natGateways = 1 } = props; + + // Create VPC with public and private subnets + this.vpc = new ec2.Vpc(this, "VillageShareVpc", { + vpcName: `village-share-vpc-${environment}`, + ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"), + maxAzs, + natGateways, + subnetConfiguration: [ + { + name: "Public", + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + mapPublicIpOnLaunch: false, + }, + { + name: "Private", + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + cidrMask: 24, + }, + { + name: "Isolated", + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + cidrMask: 24, + }, + ], + // Enable DNS support for VPC endpoints + enableDnsHostnames: true, + enableDnsSupport: true, + }); + + // Security group for Lambda functions + this.lambdaSecurityGroup = new ec2.SecurityGroup( + this, + "LambdaSecurityGroup", + { + vpc: this.vpc, + securityGroupName: `lambda-sg-${environment}`, + description: "Security group for Lambda functions in VPC", + allowAllOutbound: true, // Lambda needs outbound for AWS services + } + ); + + // Security group for VPC endpoints + const vpcEndpointSecurityGroup = new ec2.SecurityGroup( + this, + "VpcEndpointSecurityGroup", + { + vpc: this.vpc, + securityGroupName: `vpc-endpoint-sg-${environment}`, + description: "Security group for VPC Interface Endpoints", + allowAllOutbound: false, + } + ); + + // Allow HTTPS traffic from Lambda security group to VPC endpoints + vpcEndpointSecurityGroup.addIngressRule( + this.lambdaSecurityGroup, + ec2.Port.tcp(443), + "Allow HTTPS from Lambda functions" + ); + + // S3 Gateway Endpoint (FREE - no NAT charges for S3 traffic) + this.s3Endpoint = this.vpc.addGatewayEndpoint("S3Endpoint", { + service: ec2.GatewayVpcEndpointAwsService.S3, + subnets: [{ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }], + }); + + // SES Interface Endpoint (for sending emails without NAT) + this.vpc.addInterfaceEndpoint("SesEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.SES, + subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [vpcEndpointSecurityGroup], + privateDnsEnabled: true, + }); + + // SQS Interface Endpoint (for DLQ access) + this.vpc.addInterfaceEndpoint("SqsEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.SQS, + subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [vpcEndpointSecurityGroup], + privateDnsEnabled: true, + }); + + // CloudWatch Logs Interface Endpoint + this.vpc.addInterfaceEndpoint("CloudWatchLogsEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, + subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [vpcEndpointSecurityGroup], + privateDnsEnabled: true, + }); + + // Scheduler Interface Endpoint (for EventBridge Scheduler) + // Note: EventBridge Scheduler uses the scheduler.{region}.amazonaws.com endpoint + this.vpc.addInterfaceEndpoint("SchedulerEndpoint", { + service: new ec2.InterfaceVpcEndpointService( + `com.amazonaws.${cdk.Stack.of(this).region}.scheduler` + ), + subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [vpcEndpointSecurityGroup], + privateDnsEnabled: true, + }); + + // Add tags to subnets for easy identification + cdk.Tags.of(this.vpc).add("Environment", environment); + cdk.Tags.of(this.vpc).add("Project", "village-share"); + + // Outputs + new cdk.CfnOutput(this, "VpcId", { + value: this.vpc.vpcId, + description: "VPC ID", + exportName: `VpcId-${environment}`, + }); + + new cdk.CfnOutput(this, "VpcCidr", { + value: this.vpc.vpcCidrBlock, + description: "VPC CIDR block", + exportName: `VpcCidr-${environment}`, + }); + + new cdk.CfnOutput(this, "PrivateSubnetIds", { + value: this.vpc + .selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }) + .subnetIds.join(","), + description: "Private subnet IDs", + exportName: `PrivateSubnetIds-${environment}`, + }); + + new cdk.CfnOutput(this, "LambdaSecurityGroupId", { + value: this.lambdaSecurityGroup.securityGroupId, + description: "Security group ID for Lambda functions", + exportName: `LambdaSecurityGroupId-${environment}`, + }); + } +}