mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
Keep both Phase 1 (JWT authorizer, webClientId/mobileClientId props) and Phase 2 (alarmTopic, CloudWatch alarms) changes in CDK stacks.
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
}
|