mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
6-domain audit (API, web, mobile, lambdas, infra, QA) with fixes: API security: scope internal API key middleware to allowed paths only, return 401 on invalid key instead of falling through, remove unvalidated JWT code path, sanitize error messages, add UpdateProposal validator, remove status field from UpdateProposalRequest to prevent over-posting, log swallowed exceptions in ProposalService. Infrastructure: enforce SSL on all S3 buckets, encrypt SQS queues, enable optional MFA on Cognito, add API Gateway access logging. Lambdas: fix _retry_request undefined variable across all 4 Lambdas, re-raise exceptions in pdf-extract/pdf-generate instead of swallowing, add idempotency guard to suggestions Lambda. Web: add ErrorBoundary, add auth loading state to ProtectedRoute, add mutation error toasts in AdminWorkspace, fix dead Cognito link. Mobile: add mutex to offline queue processing, distinguish permanent vs retryable failures, register all screens for both roles, log sync errors. Swagger/OpenAPI: add Swashbuckle with JWT bearer security definition, add ProducesResponseType attributes to key endpoints. Includes AUDIT-REPORT.md with complete findings and CLAUDE.md project instructions.
368 lines
13 KiB
TypeScript
368 lines
13 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,
|
|
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: [
|
|
'https://proposals.seahaven.com',
|
|
'http://localhost:5173',
|
|
],
|
|
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,
|
|
enforceSSL: true,
|
|
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,
|
|
enforceSSL: true,
|
|
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),
|
|
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
});
|
|
|
|
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
|
|
queueName: 'proposal-system-jobs',
|
|
visibilityTimeout: cdk.Duration.seconds(720),
|
|
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
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 },
|
|
},
|
|
mfa: cognito.Mfa.OPTIONAL,
|
|
mfaSecondFactor: {
|
|
sms: false,
|
|
otp: 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 });
|
|
}
|
|
}
|