import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as rds from 'aws-cdk-lib/aws-rds'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as sns from 'aws-cdk-lib/aws-sns'; import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions'; import { Construct } from 'constructs'; import { EnvConfig } from './config'; export interface FoundationStackProps extends cdk.StackProps { config: EnvConfig; } export class FoundationStack extends cdk.Stack { public readonly vpc: ec2.IVpc; public readonly lambdaSecurityGroup: ec2.ISecurityGroup; public readonly dbSecret: secretsmanager.ISecret; public readonly dbCluster: rds.IDatabaseCluster; public readonly uploadsBucket: s3.IBucket; public readonly generatedBucket: s3.IBucket; public readonly libraryBucket: s3.IBucket; public readonly jobsQueue: sqs.IQueue; public readonly userPool: cognito.IUserPool; public readonly alarmTopic: sns.ITopic; public readonly webClientId: string; public readonly mobileClientId: string; constructor(scope: Construct, id: string, props: FoundationStackProps) { super(scope, id, props); const { config } = props; // VPC: 2 AZs, public + private subnets, single NAT Gateway this.vpc = new ec2.Vpc(this, 'Vpc', { vpcName: `proposal-system-vpc${config.stackSuffix}`, maxAzs: 2, natGateways: 1, subnetConfiguration: [ { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24, }, { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24, }, ], }); // VPC Endpoints this.vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3, }); this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, }); // Security Groups this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', { vpc: this.vpc, securityGroupName: `proposal-system-lambda-sg${config.stackSuffix}`, description: 'Security group for proposal system Lambda functions', allowAllOutbound: true, }); const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', { vpc: this.vpc, securityGroupName: `proposal-system-rds-sg${config.stackSuffix}`, description: 'Security group for proposal system RDS instance', allowAllOutbound: false, }); rdsSg.addIngressRule( this.lambdaSecurityGroup, ec2.Port.tcp(5432), 'Allow PostgreSQL from Lambda SG' ); // Aurora PostgreSQL Serverless v2 — pgvector store for the Bedrock Knowledge Base. // PR3: replaced the RDS instance + OpenSearch Serverless with Aurora + pgvector // (kills the AOSS OCU floor; scales toward 0 ACU when idle). Data API is required // by Bedrock Knowledge Bases to query the vector table. // Construct ID is 'AuroraCluster' (not 'Database') so CloudFormation gets a NEW // logical ID for the cluster — the old RDS DBInstance shared logical ID 'Database*' // and CFN forbids changing a resource's type in place ("Update of resource type is // not permitted"). A distinct ID makes it a clean replace instead. const dbCluster = new rds.DatabaseCluster(this, 'AuroraCluster', { clusterIdentifier: `proposal-system-db${config.stackSuffix}`, // 15.17 = latest available aurora-postgresql 15.x (15.4 was retired by RDS — // "Cannot find version 15.4"). Stays on major 15 for pgvector / ADR 0001 compat. engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.VER_15_17, }), vpc: this.vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, securityGroups: [rdsSg], writer: rds.ClusterInstance.serverlessV2('writer'), serverlessV2MinCapacity: 0.5, serverlessV2MaxCapacity: 4, enableDataApi: true, storageEncrypted: true, // Aurora native automated backups (PITR). 14-day retention for the prod tenant; // a dedicated AWS Backup vault + cross-account restore test is a tracked follow-up // (no org central-backup design exists yet — see the prod-deploy plan Phase 4 fallback). backup: { retention: cdk.Duration.days(14), preferredWindow: '07:00-08:00' }, deletionProtection: config.retainData, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, defaultDatabaseName: 'proposals', credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', { secretName: `proposal-system/db-credentials${config.stackSuffix}`, }), }); this.dbSecret = dbCluster.secret!; this.dbCluster = dbCluster; // S3 Buckets // Fix: INF-M5 — enforce HTTPS-only access on all S3 buckets this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', { bucketName: `proposal-system-uploads-${this.account}${config.stackSuffix}`, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, lifecycleRules: [ { transitions: [ { storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(90), }, ], }, ], cors: [ { allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], allowedOrigins: config.s3CorsOrigins, allowedHeaders: ['*'], maxAge: 3600, }, ], removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); cdk.Tags.of(this.uploadsBucket).add('Purpose', 'Vendor PDFs and dispatcher attachments'); cdk.Tags.of(this.uploadsBucket).add('ManagedBy', 'proposal-system'); this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', { bucketName: `proposal-system-generated-${this.account}${config.stackSuffix}`, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, // Fix: INF-M5 versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); cdk.Tags.of(this.generatedBucket).add('Purpose', 'Generated proposal PDFs'); cdk.Tags.of(this.generatedBucket).add('ManagedBy', 'proposal-system'); this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', { bucketName: `proposal-system-library-${this.account}${config.stackSuffix}`, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, // Fix: INF-M5 versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); cdk.Tags.of(this.libraryBucket).add('Purpose', 'Historical proposal library for RAG'); cdk.Tags.of(this.libraryBucket).add('ManagedBy', 'proposal-system'); // SQS Queue + DLQ const dlq = new sqs.Queue(this, 'JobsDlq', { queueName: `proposal-system-jobs-dlq${config.stackSuffix}`, retentionPeriod: cdk.Duration.days(14), }); this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { queueName: `proposal-system-jobs${config.stackSuffix}`, visibilityTimeout: cdk.Duration.seconds(720), deadLetterQueue: { queue: dlq, maxReceiveCount: 3, }, }); // Cognito User Pool const userPool = new cognito.UserPool(this, 'UserPool', { userPoolName: `proposal-system-auth${config.stackSuffix}`, selfSignUpEnabled: false, signInAliases: { email: true }, standardAttributes: { email: { required: true, mutable: true }, fullname: { required: true, mutable: true }, }, passwordPolicy: { minLength: 12, requireUppercase: true, requireLowercase: true, requireDigits: true, requireSymbols: false, }, accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); this.userPool = userPool; // Cognito Groups new cognito.CfnUserPoolGroup(this, 'DispatchersGroup', { userPoolId: userPool.userPoolId, groupName: 'dispatchers', description: 'Dispatchers who submit proposal requests', }); new cognito.CfnUserPoolGroup(this, 'AdminsGroup', { userPoolId: userPool.userPoolId, groupName: 'admins', description: 'Admins who review and approve proposals', }); new cognito.CfnUserPoolGroup(this, 'SysadminsGroup', { userPoolId: userPool.userPoolId, groupName: 'sysadmins', description: 'System administrators', }); // Cognito Domain userPool.addDomain('CognitoDomain', { cognitoDomain: { domainPrefix: config.cognitoDomainPrefix }, }); // Web App Client (PKCE) const webClient = userPool.addClient('WebClient', { userPoolClientName: 'proposal-system-web', generateSecret: false, authFlows: { userSrp: true, }, oAuth: { flows: { authorizationCodeGrant: true }, scopes: [ cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE, ], callbackUrls: config.webCallbackUrls, logoutUrls: config.webLogoutUrls, }, }); this.webClientId = webClient.userPoolClientId; // Mobile App Client (PKCE) const mobileClient = userPool.addClient('MobileClient', { userPoolClientName: 'proposal-system-mobile', generateSecret: false, authFlows: { userSrp: true, }, oAuth: { flows: { authorizationCodeGrant: true }, scopes: [ cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE, ], callbackUrls: ['com.seahavenind.proposals://auth/callback'], logoutUrls: ['com.seahavenind.proposals://auth/logout'], }, }); this.webClientId = webClient.userPoolClientId; this.mobileClientId = mobileClient.userPoolClientId; // SNS Alarm Topic const alarmTopic = new sns.Topic(this, 'AlarmTopic', { topicName: `proposal-system-alarms${config.stackSuffix}`, displayName: 'Proposal System Alarms', }); alarmTopic.addSubscription( new snsSubscriptions.EmailSubscription(config.alarmsEmail), ); this.alarmTopic = alarmTopic; const alarmAction = new cloudwatchActions.SnsAction(alarmTopic); // DLQ Alarm: any message landing in DLQ indicates a processing failure const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', { alarmName: `proposal-system-dlq-depth${config.stackSuffix}`, alarmDescription: 'Messages in DLQ — SQS processing failures', metric: dlq.metricApproximateNumberOfMessagesVisible({ period: cdk.Duration.minutes(1), }), threshold: 0, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); dlqAlarm.addAlarmAction(alarmAction); // RDS Alarms const rdsAlarms = [ new cloudwatch.Alarm(this, 'RdsCpuAlarm', { alarmName: `proposal-system-rds-cpu${config.stackSuffix}`, alarmDescription: 'RDS CPU utilization above 80%', metric: dbCluster.metricCPUUtilization({ period: cdk.Duration.minutes(5) }), threshold: 80, evaluationPeriods: 3, treatMissingData: cloudwatch.TreatMissingData.BREACHING, }), new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', { alarmName: `proposal-system-rds-connections${config.stackSuffix}`, alarmDescription: 'RDS database connections above 80', metric: dbCluster.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }), threshold: 80, evaluationPeriods: 2, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }), // Aurora storage auto-scales (no FreeStorageSpace); freeable memory is the // meaningful health signal for a Serverless v2 cluster. new cloudwatch.Alarm(this, 'RdsLowMemoryAlarm', { alarmName: `proposal-system-rds-low-memory${config.stackSuffix}`, alarmDescription: 'Aurora freeable memory below 256 MB', metric: dbCluster.metricFreeableMemory({ period: cdk.Duration.minutes(5) }), threshold: 256_000_000, comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, evaluationPeriods: 3, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }), ]; for (const alarm of rdsAlarms) { alarm.addAlarmAction(alarmAction); } // CloudWatch Log Groups const logGroupNames = [ 'proposal-system-api', 'proposal-system-pdf-extract', 'proposal-system-pdf-generate', 'proposal-system-library-ingest', ]; for (const name of logGroupNames) { new logs.LogGroup(this, `LogGroup-${name}`, { logGroupName: `/aws/lambda/${name}${config.stackSuffix}`, retention: logs.RetentionDays.TWO_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); } // Outputs new cdk.CfnOutput(this, 'VpcId', { value: this.vpc.vpcId }); new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId }); new cdk.CfnOutput(this, 'UserPoolArn', { value: userPool.userPoolArn }); new cdk.CfnOutput(this, 'UploadsBucketName', { value: this.uploadsBucket.bucketName }); new cdk.CfnOutput(this, 'GeneratedBucketName', { value: this.generatedBucket.bucketName }); new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName }); new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl }); new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn }); new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId }); new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId }); new cdk.CfnOutput(this, 'AlarmTopicArn', { value: alarmTopic.topicArn }); } }