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

372 lines
14 KiB
TypeScript
Raw Permalink 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.
// Construct ID is 'AuroraCluster' (not 'Database') so CloudFormation gets a NEW
// logical ID for the cluster — the old RDS DBInstance shared logical ID 'Database*'
// and CFN forbids changing a resource's type in place ("Update of resource type is
// not permitted"). A distinct ID makes it a clean replace instead.
const dbCluster = new rds.DatabaseCluster(this, 'AuroraCluster', {
clusterIdentifier: `proposal-system-db${config.stackSuffix}`,
// 15.17 = latest available aurora-postgresql 15.x (15.4 was retired by RDS —
// "Cannot find version 15.4"). Stays on major 15 for pgvector / ADR 0001 compat.
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_15_17,
}),
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,
chore(infra): prep proposal-system for seahaven-prod deployment (#227) * chore(infra): retarget prod to seahaven-prod account + OIDC deploy-role artifacts Retarget the CDK prod env from mgmt (328440206208, now frozen for workloads) to the dedicated seahaven-prod workload account (011934824531). proposal-system is the org's first prod tenant. Hard-block env=staging (still targets frozen mgmt) in resolveConfig until it is retargeted to seahaven-dev (710827005802). Add a WARN-only out-of-pipeline deploy guard in bin/app.ts. Add infra/deploy-role/: OIDC trust policy (sub scoped to Sea-Haven-Industries/proposal-system:ref:refs/heads/main), least-privilege permissions policy (AssumeRole on the verified cdk-hnb659fds bootstrap roles, deterministic site bucket, account-scoped CloudFront invalidation), and an idempotent creation script. Verified against live prod: bootstrap qualifier hnb659fds v32, OIDC provider present. Passed GPT-4.1 cross-review (APPROVE) and workflow red-team (CLEAN). Role NOT yet created — gated on /sh-security-review + the deploy go-ahead. Docs: README + CLAUDE.md reflect the prod account and pipeline-only deploy. * chore(infra): region-bound deploy-role DescribeStacks to us-east-1 (sh-security-review IAM-L2) * feat(infra): Aurora prod backup retention 14d + window; prod-only CDK context Bump Aurora automated-backup (PITR) retention 7->14d and set a preferred backup window for the prod tenant. Dedicated AWS Backup vault + cross-account restore test is a tracked follow-up (no org central-backup design exists yet). Prune the stale mgmt-account AZ context; prod (011934824531) is the only deploy target.
2026-07-15 14:44:35 -04:00
// Aurora native automated backups (PITR). 14-day retention for the prod tenant;
// a dedicated AWS Backup vault + cross-account restore test is a tracked follow-up
// (no org central-backup design exists yet — see the prod-deploy plan Phase 4 fallback).
backup: { retention: cdk.Duration.days(14), preferredWindow: '07:00-08:00' },
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 });
}
}