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'; export class FoundationStack extends cdk.Stack { public readonly vpc: ec2.IVpc; public readonly lambdaSecurityGroup: ec2.ISecurityGroup; public readonly dbSecret: secretsmanager.ISecret; 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?: cdk.StackProps) { super(scope, id, props); // VPC: 2 AZs, public + private subnets, single NAT Gateway this.vpc = new ec2.Vpc(this, 'Vpc', { vpcName: 'proposal-system-vpc', 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', 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', description: 'Security group for proposal system RDS instance', allowAllOutbound: false, }); rdsSg.addIngressRule( this.lambdaSecurityGroup, ec2.Port.tcp(5432), 'Allow PostgreSQL from Lambda SG' ); // RDS PostgreSQL 15 const dbInstance = new rds.DatabaseInstance(this, 'Database', { instanceIdentifier: 'proposal-system-db', engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_15, }), instanceType: ec2.InstanceType.of( ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL ), vpc: this.vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, securityGroups: [rdsSg], multiAz: false, allocatedStorage: 20, maxAllocatedStorage: 100, storageEncrypted: true, backupRetention: cdk.Duration.days(7), deletionProtection: true, removalPolicy: cdk.RemovalPolicy.RETAIN, databaseName: 'proposals', credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', { secretName: 'proposal-system/db-credentials', }), publiclyAccessible: false, }); this.dbSecret = dbInstance.secret!; // S3 Buckets this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', { bucketName: `proposal-system-uploads-${this.account}`, encryption: s3.BucketEncryption.S3_MANAGED, 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: ['*'], allowedHeaders: ['*'], maxAge: 3600, }, ], removalPolicy: cdk.RemovalPolicy.RETAIN, }); 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}`, encryption: s3.BucketEncryption.S3_MANAGED, versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: cdk.RemovalPolicy.RETAIN, }); 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}`, encryption: s3.BucketEncryption.S3_MANAGED, versioned: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: cdk.RemovalPolicy.RETAIN, }); 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', retentionPeriod: cdk.Duration.days(14), }); this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { queueName: 'proposal-system-jobs', visibilityTimeout: cdk.Duration.seconds(720), deadLetterQueue: { queue: dlq, maxReceiveCount: 3, }, }); // Cognito User Pool const userPool = new cognito.UserPool(this, 'UserPool', { userPoolName: 'proposal-system-auth', 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: cdk.RemovalPolicy.RETAIN, }); 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: 'proposal-system-seahaven' }, }); // 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: [ 'https://proposals.seahaven.com/callback', 'http://localhost:5173/callback', ], logoutUrls: [ 'https://proposals.seahaven.com', 'http://localhost:5173', ], }, }); 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', displayName: 'Proposal System Alarms', }); alarmTopic.addSubscription( new snsSubscriptions.EmailSubscription('adam@seahavenind.com'), ); 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', 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', alarmDescription: 'RDS CPU utilization above 80%', metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }), threshold: 80, evaluationPeriods: 3, treatMissingData: cloudwatch.TreatMissingData.BREACHING, }), new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', { alarmName: 'proposal-system-rds-connections', alarmDescription: 'RDS database connections above 80', metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }), threshold: 80, evaluationPeriods: 2, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }), new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', { alarmName: 'proposal-system-rds-free-storage', alarmDescription: 'RDS free storage below 2 GB', metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }), threshold: 2_000_000_000, comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.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}`, 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 }); } }