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 { 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; 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(180), 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) 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', ], }, }); // Mobile App Client (PKCE) 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: ['proposalsystem://callback'], logoutUrls: ['proposalsystem://logout'], }, }); // CloudWatch Log Groups const logGroupNames = [ 'proposal-system-api', 'proposal-system-pdf-extract', 'proposal-system-pdf-generate', 'proposal-system-library-ingest', 'proposal-system-suggestions', ]; 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 }); } }