proposal-system/infra/lib/foundation-stack.ts

363 lines
14 KiB
TypeScript
Raw Normal View History

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.
const dbCluster = new rds.DatabaseCluster(this, 'Database', {
clusterIdentifier: `proposal-system-db${config.stackSuffix}`,
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_15_4,
}),
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,
backup: { retention: cdk.Duration.days(7) },
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 });
}
}