diff --git a/backend/package-lock.json b/backend/package-lock.json index 431b674c..853ab10c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -54,7 +54,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vitest/coverage-c8": "^0.33.0", "aws-cdk": "2.139.0", - "aws-cdk-lib": "^2.184.1", + "aws-cdk-lib": "^2.185.0", "dotenv-cli": "^8.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", @@ -72,7 +72,7 @@ "vitest": "^0.33.0" }, "peerDependencies": { - "aws-cdk-lib": "2.184.1" + "aws-cdk-lib": "^2.185.0" } }, "node_modules/@ampproject/remapping": { @@ -4677,9 +4677,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.184.1", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.184.1.tgz", - "integrity": "sha512-No9g0SGadiDz0IEUIeJg4wSV/jFCGcouW2zUOTjV8OU4gTMoGiqC8BYSv7E6ucUtW6rmSFVK+pbc8XOFZOo1cg==", + "version": "2.185.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.185.0.tgz", + "integrity": "sha512-RNcQeNnInumDF1hq3gAf+/A6jhvYDof5a7418gEs/y6359gTYZpTCQkgItC50iV3MmkgerrBAdOE7CDEtQNDWw==", "bundleDependencies": [ "@balena/dockerignore", "case", diff --git a/backend/package.json b/backend/package.json index 7d20ce1f..03c865e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,14 +28,15 @@ }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.758.0", - "@aws-sdk/util-dynamodb": "^3.758.0", "@aws-sdk/client-secrets-manager": "^3.758.0", + "@aws-sdk/util-dynamodb": "^3.758.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", - "@nestjs/platform-express": "^10.0.0", "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.13", "@types/jest": "^29.5.12", "axios": "^1.8.1", "class-transformer": "^0.5.1", @@ -47,15 +48,14 @@ "helmet": "^7.0.0", "jsonwebtoken": "^9.0.2", "jwk-to-pem": "^2.0.5", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "source-map-support": "^0.5.21", - "web-vitals": "^2.1.4", - "aws-cdk-lib": "2.184.1", - "@nestjs/swagger": "^7.1.13", "swagger-ui-express": "^5.0.0", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1" + "web-vitals": "^2.1.4", + "aws-cdk-lib": "^2.185.0" }, "devDependencies": { "@aws-cdk/assert": "^2.68.0", @@ -73,7 +73,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vitest/coverage-c8": "^0.33.0", "aws-cdk": "2.139.0", - "aws-cdk-lib": "^2.184.1", + "aws-cdk-lib": "^2.185.0", "dotenv-cli": "^8.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", @@ -91,6 +91,6 @@ "vitest": "^0.33.0" }, "peerDependencies": { - "aws-cdk-lib": "2.184.1" + "aws-cdk-lib": "^2.185.0" } } diff --git a/backend/src/iac/backend-stack.ts b/backend/src/iac/backend-stack.ts index ae522ffe..a4721a7a 100644 --- a/backend/src/iac/backend-stack.ts +++ b/backend/src/iac/backend-stack.ts @@ -2,8 +2,12 @@ import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as logs from 'aws-cdk-lib/aws-logs'; -import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as servicediscovery from 'aws-cdk-lib/aws-servicediscovery'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; + import { Construct } from 'constructs'; import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'; import { RemovalPolicy } from 'aws-cdk-lib'; @@ -12,8 +16,6 @@ interface BackendStackProps extends cdk.StackProps { environment: string; cognitoClientId: string; cognitoUserPoolId: string; - domainName?: string; // Optional domain name for certificate - hostedZoneId?: string; // Optional hosted zone ID for domain } export class BackendStack extends cdk.Stack { @@ -23,26 +25,34 @@ export class BackendStack extends cdk.Stack { const isProd = props.environment === 'production'; const appName = 'AIMedicalReport'; - // Look up existing VPC or create a new one - const vpc: ec2.IVpc = new ec2.Vpc(this, `${appName}VPC`, { + // VPC + const vpc = new ec2.Vpc(this, `${appName}VPC`, { vpcName: `${appName}VPC-${props.environment}`, maxAzs: 2, + natGateways: isProd ? 2 : 1, }); + // ECS Cluster const cluster = new ecs.Cluster(this, `${appName}Cluster`, { vpc, clusterName: `${appName}Cluster-${props.environment}`, containerInsights: true, + enableFargateCapacityProviders: true, }); - // Create Log Group for container + // CloudMap Namespace for service discovery + cluster.addDefaultCloudMapNamespace({ + name: `${appName.toLowerCase()}.local`, + }); + + // Log Group const logGroup = new logs.LogGroup(this, `${appName}LogGroup`, { logGroupName: `/ecs/${appName}-${props.environment}`, retention: isProd ? logs.RetentionDays.ONE_MONTH : logs.RetentionDays.ONE_WEEK, removalPolicy: isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); - // Create DynamoDB table for reports + // DynamoDB table for reports const reportsTable = new Table(this, `${appName}ReportsTable-${props.environment}`, { tableName: `${appName}ReportsTable${props.environment}`, partitionKey: { @@ -57,7 +67,7 @@ export class BackendStack extends cdk.Stack { removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, }); - // Add a GSI for querying by date (most recent first) + // Add GSI for querying by date reportsTable.addGlobalSecondaryIndex({ indexName: 'userIdDateIndex', partitionKey: { @@ -70,20 +80,50 @@ export class BackendStack extends cdk.Stack { }, }); - // Look up existing Cognito User Pool - const userPoolId = - props.cognitoUserPoolId || - cognito.UserPool.fromUserPoolId(this, `${appName}UserPool`, 'us-east-1_PszlvSmWc').userPoolId; + // Cognito User Pool + const userPool = cognito.UserPool.fromUserPoolId( + this, + `${appName}UserPool`, + props.cognitoUserPoolId || 'us-east-1_PszlvSmWc', + ); - // Create a Cognito domain if it doesn't exist + // Cognito domain const userPoolDomain = cognito.UserPoolDomain.fromDomainName( this, `${appName}ExistingDomain-${props.environment}`, - 'us-east-1pszlvsmwc', // The domain prefix without the .auth.region.amazoncognito.com part + 'us-east-1pszlvsmwc', ); - // Replace the userPoolClient reference with a direct reference to the client ID - const userPoolClientId = props.cognitoClientId; + // User Pool Client + const userPoolClient = cognito.UserPoolClient.fromUserPoolClientId( + this, + `${appName}UserPoolClient-${props.environment}`, + props.cognitoClientId, + ); + + // Security Group for Fargate service + const serviceSecurityGroup = new ec2.SecurityGroup( + this, + `${appName}ServiceSG-${props.environment}`, + { + vpc, + allowAllOutbound: true, + description: 'Security group for Fargate service', + }, + ); + + // Add inbound rules to allow traffic from API Gateway + serviceSecurityGroup.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(3000), + 'Allow inbound HTTP traffic from within VPC', + ); + + serviceSecurityGroup.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(3443), + 'Allow inbound HTTPS traffic from within VPC', + ); // Task Definition const taskDefinition = new ecs.FargateTaskDefinition( @@ -95,6 +135,29 @@ export class BackendStack extends cdk.Stack { }, ); + // Grant DynamoDB permissions to task + reportsTable.grantReadWriteData(taskDefinition.taskRole); + + // Create a secrets manager for the SSL certificate and key + const certificateSecret = new cdk.aws_secretsmanager.Secret( + this, + `${appName}CertSecret-${props.environment}`, + { + secretName: `${appName}/ssl-cert-${props.environment}`, + description: 'SSL certificate and private key for HTTPS', + generateSecretString: { + secretStringTemplate: JSON.stringify({ + // You'll need to populate these values after deployment + certificate: + '-----BEGIN CERTIFICATE-----\nYour certificate here\n-----END CERTIFICATE-----', + privateKey: + '-----BEGIN PRIVATE KEY-----\nYour private key here\n-----END PRIVATE KEY-----', + }), + generateStringKey: 'dummy', // This key won't be used but is required + }, + }, + ); + // Container const container = taskDefinition.addContainer(`${appName}Container-${props.environment}`, { image: ecs.ContainerImage.fromAsset('../backend/', { @@ -107,85 +170,68 @@ export class BackendStack extends cdk.Stack { // Basic environment variables NODE_ENV: props.environment, PORT: '3000', + HTTPS_PORT: '3443', // Add HTTPS port + ENABLE_HTTPS: 'true', // Enable HTTPS // AWS related AWS_REGION: this.region, - AWS_COGNITO_USER_POOL_ID: userPoolId, - AWS_COGNITO_CLIENT_ID: userPoolClientId, + AWS_COGNITO_USER_POOL_ID: userPool.userPoolId, + AWS_COGNITO_CLIENT_ID: userPoolClient.userPoolClientId, DYNAMODB_REPORTS_TABLE: reportsTable.tableName, // Perplexity related PERPLEXITY_API_KEY_SECRET_NAME: `medical-reports-explainer/${props.environment}/perplexity-api-key`, PERPLEXITY_MODEL: 'sonar', PERPLEXITY_MAX_TOKENS: '2048', + + // SSL Certificate secret + SSL_CERT_SECRET_NAME: certificateSecret.secretName, }, logging: ecs.LogDrivers.awsLogs({ streamPrefix: appName, logGroup, }), + /*healthCheck: { + command: ['CMD-SHELL', 'curl -f -k https://localhost:3443/api/health || exit 1'], + interval: cdk.Duration.seconds(30), + timeout: cdk.Duration.seconds(5), + retries: 3, + startPeriod: cdk.Duration.seconds(60), + },*/ }); + // Grant the task role access to read the SSL certificate secret + certificateSecret.grantRead(taskDefinition.taskRole); + container.addPortMappings({ containerPort: 3000, + name: 'http-api', protocol: ecs.Protocol.TCP, }); - // 1. Create ALB - const alb = new elbv2.ApplicationLoadBalancer(this, `${appName}ALB-${props.environment}`, { - vpc, - internetFacing: true, - loadBalancerName: `${appName}-${props.environment}`, - }); - - // 2. Create ALB Target Group - const targetGroup = new elbv2.ApplicationTargetGroup( - this, - `${appName}TargetGroup-${props.environment}`, - { - vpc, - port: 3000, - protocol: elbv2.ApplicationProtocol.HTTP, - targetType: elbv2.TargetType.IP, - healthCheck: { - path: '/api/health', - interval: cdk.Duration.seconds(30), - timeout: cdk.Duration.seconds(5), - }, - }, - ); - - // 3. HTTP 80 Listener - const httpListener = alb.addListener(`${appName}HttpListener-${props.environment}`, { - port: 80, - protocol: elbv2.ApplicationProtocol.HTTP, - defaultAction: elbv2.ListenerAction.forward([targetGroup]), + container.addPortMappings({ + containerPort: 3443, + name: 'https-api', + protocol: ecs.Protocol.TCP, }); - // 4. Create a security group for the Fargate service - const serviceSecurityGroup = new ec2.SecurityGroup( - this, - `${appName}ServiceSG-${props.environment}`, - { - vpc, - allowAllOutbound: true, - }, - ); - - // 5. Create the Fargate service WITHOUT registering it with the target group yet + // Create Fargate Service with CloudMap service discovery const fargateService = new ecs.FargateService(this, `${appName}Service-${props.environment}`, { cluster, taskDefinition, desiredCount: isProd ? 2 : 1, - assignPublicIp: false, securityGroups: [serviceSecurityGroup], + assignPublicIp: false, // Using private subnets with NAT gateway + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + cloudMapOptions: { + name: `${appName.toLowerCase()}-service`, + dnsRecordType: servicediscovery.DnsRecordType.A, + dnsTtl: cdk.Duration.seconds(30), + container: container, + containerPort: 3000, + }, }); - // 6. Add explicit dependency to ensure the listener exists before the service - fargateService.node.addDependency(httpListener); - - // 7. Now register the service with the target group - targetGroup.addTarget(fargateService); - // Add autoscaling for production if (isProd) { const scaling = fargateService.autoScaleTaskCount({ @@ -200,22 +246,188 @@ export class BackendStack extends cdk.Stack { }); } - // Add output for the table name + // Create a Network Load Balancer for the Fargate service + const nlb = new elbv2.NetworkLoadBalancer(this, `${appName}NLB-${props.environment}`, { + vpc, + internetFacing: false, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + }); + + // Add a listener to the NLB + const listener = nlb.addListener(`${appName}Listener-${props.environment}`, { + port: 80, + protocol: elbv2.Protocol.TCP, + }); + + // Add the Fargate service as a target to the listener + listener.addTargets(`${appName}TargetGroup-${props.environment}`, { + targets: [fargateService], + port: 3000, + protocol: elbv2.Protocol.TCP, + healthCheck: { + enabled: true, + protocol: elbv2.Protocol.HTTP, + path: '/api/health', + interval: cdk.Duration.seconds(30), + healthyThresholdCount: 2, + unhealthyThresholdCount: 2, + timeout: cdk.Duration.seconds(5), + }, + }); + + // Create VPC Link for API Gateway using the NLB + const vpcLink = new apigateway.VpcLink(this, `${appName}VpcLink-${props.environment}`, { + targets: [nlb], + description: `VPC Link for ${appName} ${props.environment}`, + }); + + // Create API Gateway + const api = new apigateway.RestApi(this, `${appName}Api-${props.environment}`, { + restApiName: `${appName}-${props.environment}`, + description: `API for ${appName} ${props.environment}`, + deployOptions: { + stageName: props.environment, + loggingLevel: apigateway.MethodLoggingLevel.INFO, + dataTraceEnabled: true, + }, + defaultCorsPreflightOptions: { + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: apigateway.Cors.ALL_METHODS, + allowHeaders: ['Content-Type', 'Authorization', 'X-Amz-Date', 'X-Api-Key'], + }, + }); + + // Create Cognito Authorizer + const authorizer = new apigateway.CognitoUserPoolsAuthorizer( + this, + `${appName}Authorizer-${props.environment}`, + { + cognitoUserPools: [userPool], + authorizerName: `${appName}Authorizer-${props.environment}`, + identitySource: 'method.request.header.Authorization', + }, + ); + + // Use the NLB DNS name for the service URL + const serviceUrl = `http://${nlb.loadBalancerDnsName}`; + + // Create proxy resource with Cognito authorization + const proxyResource = api.root.addResource('{proxy+}'); + + // Integration with Fargate service via VPC Link + const integration = new apigateway.Integration({ + type: apigateway.IntegrationType.HTTP_PROXY, + integrationHttpMethod: 'ANY', + options: { + connectionType: apigateway.ConnectionType.VPC_LINK, + vpcLink: vpcLink, + requestParameters: { + 'integration.request.path.proxy': 'method.request.path.proxy', + }, + }, + uri: `${serviceUrl}/{proxy}`, + }); + + proxyResource.addMethod('ANY', integration, { + authorizer: authorizer, + authorizationType: apigateway.AuthorizationType.COGNITO, + requestParameters: { + 'method.request.path.proxy': true, + }, + }); + + // Add health check endpoint without authorization + const healthResource = api.root.addResource('health'); + healthResource.addMethod( + 'GET', + new apigateway.Integration({ + type: apigateway.IntegrationType.HTTP_PROXY, + integrationHttpMethod: 'GET', + options: { + connectionType: apigateway.ConnectionType.VPC_LINK, + vpcLink: vpcLink, + }, + uri: `${serviceUrl}/api/health`, + }), + ); + + // Add execution role policy to allow API Gateway to access VPC resources + new iam.Role(this, `${appName}APIGatewayVPCRole-${props.environment}`, { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + 'service-role/AmazonAPIGatewayPushToCloudWatchLogs', + ), + iam.ManagedPolicy.fromAwsManagedPolicyName( + 'AmazonVPCCrossAccountNetworkInterfaceOperations', + ), + ], + }); + + const apiResourcePolicy = new iam.PolicyDocument({ + statements: [ + // Allow all users to access the health endpoint in all stages + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + principals: [new iam.AnyPrincipal()], + actions: ['execute-api:Invoke'], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${api.restApiId}/*/GET/health`, + ], + }), + + // Allow only authenticated Cognito users to access all other endpoints + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + principals: [new iam.AnyPrincipal()], + actions: ['execute-api:Invoke'], + resources: [`arn:aws:execute-api:${this.region}:${this.account}:${api.restApiId}/*/*`], + conditions: { + StringEquals: { + 'aws:PrincipalTag/cognito-identity.amazonaws.com:sub': + '${cognito-identity.amazonaws.com:sub}', + }, + }, + }), + + // Deny all non-HTTPS requests + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + principals: [new iam.AnyPrincipal()], + actions: ['execute-api:Invoke'], + resources: [`arn:aws:execute-api:${this.region}:${this.account}:${api.restApiId}/*/*`], + conditions: { + Bool: { + 'aws:SecureTransport': 'false', + }, + }, + }), + ], + }); + + // Apply the policy to the API Gateway using CfnRestApi + const cfnApi = api.node.defaultChild as apigateway.CfnRestApi; + cfnApi.policy = apiResourcePolicy.toJSON(); + + // Outputs new cdk.CfnOutput(this, 'ReportsTableName', { value: reportsTable.tableName, description: 'DynamoDB Reports Table Name', }); - // Add output for Cognito domain new cdk.CfnOutput(this, 'CognitoDomain', { value: `https://${userPoolDomain.domainName}.auth.${this.region}.amazoncognito.com`, description: 'Cognito Domain URL', }); - // Outputs - new cdk.CfnOutput(this, 'LoadBalancerDNS', { - value: alb.loadBalancerDnsName, - description: 'Load Balancer DNS Name', + new cdk.CfnOutput(this, 'ApiGatewayUrl', { + value: api.url, + description: 'API Gateway URL', + }); + + new cdk.CfnOutput(this, 'NetworkLoadBalancerDns', { + value: nlb.loadBalancerDnsName, + description: 'Network Load Balancer DNS Name', }); } }