mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
The log group already exists — created by the compute stack's logRetention setting on the suggestions Lambda. Adding it to the foundation stack caused a duplicate resource error on deploy.
282 lines
9.1 KiB
TypeScript
282 lines
9.1 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 { 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',
|
|
];
|
|
|
|
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 });
|
|
}
|
|
}
|