From ecf0234aa43587e08570c80c5f6f4e21d16a9cf8 Mon Sep 17 00:00:00 2001 From: Manuwai Korber Date: Fri, 22 Dec 2023 01:18:41 +0000 Subject: [PATCH] Feature: add private chatbot deployment option --- bin/config.ts | 9 +- cli/magic-create.ts | 43 ++- docs/.vitepress/config.mts | 1 + docs/documentation/private-chatbot.md | 28 ++ docs/guide/deploy.md | 1 + lib/aws-genai-llm-chatbot-stack.ts | 33 +++ lib/chatbot-api/appsync-ws.ts | 2 + lib/chatbot-api/index.ts | 1 + lib/shared/index.ts | 85 +++++- lib/shared/types.ts | 11 + lib/user-interface/index.ts | 112 ++------ lib/user-interface/private-website.ts | 257 ++++++++++++++++++ lib/user-interface/public-website.ts | 155 +++++++++++ lib/user-interface/react-app/src/app.tsx | 9 +- .../src/components/app-configured.tsx | 2 +- 15 files changed, 642 insertions(+), 107 deletions(-) create mode 100644 docs/documentation/private-chatbot.md create mode 100644 lib/user-interface/private-website.ts create mode 100644 lib/user-interface/public-website.ts diff --git a/bin/config.ts b/bin/config.ts index 75353892c..c43f31d0e 100644 --- a/bin/config.ts +++ b/bin/config.ts @@ -8,10 +8,12 @@ export function getConfig(): SystemConfig { // Default config return { prefix: "", - /*vpc: { - vpcId: "vpc-00000000000000000", - createVpcEndpoints: true, + /* vpc: { + vpcId: "vpc-00000000000000000", + createVpcEndpoints: true, },*/ + privateWebsite: false, + certificate : "", bedrock: { enabled: true, region: SupportedRegion.US_EAST_1, @@ -32,6 +34,7 @@ export function getConfig(): SystemConfig { kendra: { enabled: false, createIndex: false, + enterprise: false }, }, embeddingsModels: [ diff --git a/cli/magic-create.ts b/cli/magic-create.ts index d36a1864d..671ec8059 100644 --- a/cli/magic-create.ts +++ b/cli/magic-create.ts @@ -9,6 +9,7 @@ import { SupportedRegion, SupportedSageMakerModels, SystemConfig, + SupportedBedrockRegion } from "../lib/shared/types"; import { LIB_VERSION } from "./version.js"; import * as fs from "fs"; @@ -57,6 +58,9 @@ const embeddingModels = [ fs.readFileSync("./bin/config.json").toString("utf8") ); options.prefix = config.prefix; + options.privateWebsite = config.privateWebsite; + options.certificate = config.certificate; + options.domain = config.domain; options.bedrockEnable = config.bedrock?.enabled; options.bedrockRegion = config.bedrock?.region; options.bedrockRoleArn = config.bedrock?.roleArn; @@ -113,6 +117,34 @@ async function processCreateOptions(options: any): Promise { initial: options.prefix, askAnswered: false, }, + { + type: "confirm", + name: "privateWebsite", + message: "Do you want to deploy a private website? I.e only accessible in VPC", + initial: + options.privateWebsite || + false, + }, + { + type: "input", + name: "certificate", + message: "ACM certificate ARN", + initial: + options.certificate, + skip(): boolean { + return !(this as any).state.answers.privateWebsite; + }, + }, + { + type: "input", + name: "domain", + message: "Domain for private website", + initial: + options.domain, + skip(): boolean { + return !(this as any).state.answers.privateWebsite; + }, + }, { type: "confirm", name: "bedrockEnable", @@ -123,13 +155,7 @@ async function processCreateOptions(options: any): Promise { type: "select", name: "bedrockRegion", message: "Region where Bedrock is available", - choices: [ - SupportedRegion.US_EAST_1, - SupportedRegion.US_WEST_2, - SupportedRegion.EU_CENTRAL_1, - SupportedRegion.AP_SOUTHEAST_1, - SupportedRegion.AP_NORTHEAST_1, - ], + choices: Object.values(SupportedBedrockRegion), initial: options.bedrockRegion ?? "us-east-1", skip() { return !(this as any).state.answers.bedrockEnable; @@ -310,6 +336,9 @@ async function processCreateOptions(options: any): Promise { // Create the config object const config = { prefix: answers.prefix, + privateWebsite: answers.privateWebsite, + certificate: answers.certificate, + domain: answers.domain, bedrock: answers.bedrockEnable ? { enabled: answers.bedrockEnable, diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2111e18e0..c595cc5ab 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -44,6 +44,7 @@ export default defineConfig({ { text: 'Documentation', items: [ + { text: 'Private Chatbot', link: '/documentation/private-chatbot' }, { text: 'Model Requirements', link: '/documentation/model-requirements' }, { text: 'Inference Script', link: '/documentation/inference-script' }, { text: 'Document Retrieval', link: '/documentation/retriever' }, diff --git a/docs/documentation/private-chatbot.md b/docs/documentation/private-chatbot.md new file mode 100644 index 000000000..e074c5903 --- /dev/null +++ b/docs/documentation/private-chatbot.md @@ -0,0 +1,28 @@ +# Private Chatbot + +Allows the deployment of a private chatbot via the 'npm run create' CLI setup. + +- VPC only accessible website with an Application Load Balancer in front of an S3 hosted website. +- Private Appsync APIs and Web Sockets +- VPC endpoints for AWS services +- Utilises a AWS Private CA certifice +- Utilises a Amazon Route 53 Private Hosted Zone and Domain + + +### Prerequisites: Private Chatbot Deployment +1. [AWS Private CA issued ACM certificate](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-request-private.html) for your chosen domain. (i.e. chatbot.example.org) +2. A Route 53 [Private Hosted Zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-private.html) (i.e. for example.org) + +### During 'npm run create' +```shellsession +$ ✔ Do you want to deploy a private website? I.e only accessible in VPC (Y/n) · +true +$ ✔ ACM certificate ARN · +arn:aws:acm:us-east-1:1234567890:certificate/12345678-1234-1234-1234-12345678 +$ ✔ Domain for private website · +chatbot.example.org +``` + +### After Private Deployment: +1. In Route 53 [link the created VPC to the Private Hosted Zone (PHZ)](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-associate-vpcs.html) +2. In the PHZ, [add an "A Record"](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-elb-load-balancer.html) with your chosen subdomain (i.e. chatbot.example.org) that points to the website Application Load Balancer Alias. diff --git a/docs/guide/deploy.md b/docs/guide/deploy.md index cbfd49663..022f617ab 100644 --- a/docs/guide/deploy.md +++ b/docs/guide/deploy.md @@ -107,6 +107,7 @@ You'll be prompted to configure the different aspects of the solution, such as: - The LLMs or MLMs to enable (we support all models provided by Bedrock along with SageMaker hosted Idefics, FalconLite, Mistral and more to come) - Setup of the RAG system: engine selection (i.e. Aurora w/ pgvector, OpenSearch, Kendra..) embeddings selection and more to come. +- Private Chatbot: Limit accessibility to website and backend to VPC. When done, answer `Y` to create a new configuration. diff --git a/lib/aws-genai-llm-chatbot-stack.ts b/lib/aws-genai-llm-chatbot-stack.ts index ad0f679b4..589fa0906 100644 --- a/lib/aws-genai-llm-chatbot-stack.ts +++ b/lib/aws-genai-llm-chatbot-stack.ts @@ -415,5 +415,38 @@ export class AwsGenAILLMChatbotStack extends cdk.Stack { reason: "Not yet upgraded from Python 3.11 to 3.12.", }, ]); + + if (props.config.privateWebsite) { + NagSuppressions.addResourceSuppressionsByPath( + this, + [ + `/${this.stackName}/UserInterface/PrivateWebsite/DescribeNetworkInterfaces-0/CustomResourcePolicy/Resource`, + `/${this.stackName}/UserInterface/PrivateWebsite/DescribeNetworkInterfaces-1/CustomResourcePolicy/Resource`, + `/${this.stackName}/UserInterface/PrivateWebsite/DescribeNetworkInterfaces-2/CustomResourcePolicy/Resource`, + `/${this.stackName}/UserInterface/PrivateWebsite/describeVpcEndpoints/CustomResourcePolicy/Resource`, + ], + [ + { + id: "AwsSolutions-IAM5", + reason: + "Custom Resource requires permissions to Describe VPC Endpoint Network Interfaces", + }, + ] + ); + NagSuppressions.addResourceSuppressionsByPath( + this, + [ + `/${this.stackName}/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource` + ], + [ + { + id: "AwsSolutions-IAM4", + reason: + "IAM role implicitly created by CDK.", + }, + ] + ); + + } } } diff --git a/lib/chatbot-api/appsync-ws.ts b/lib/chatbot-api/appsync-ws.ts index 20cb43240..96668ac0c 100644 --- a/lib/chatbot-api/appsync-ws.ts +++ b/lib/chatbot-api/appsync-ws.ts @@ -42,6 +42,7 @@ export class RealtimeResolvers extends Construct { SNS_TOPIC_ARN: props.topic.topicArn, }, layers: [props.shared.powerToolsLayer], + vpc: props.shared.vpc }); const outgoingMessageHandler = new NodejsFunction( @@ -58,6 +59,7 @@ export class RealtimeResolvers extends Construct { environment: { GRAPHQL_ENDPOINT: props.api.graphqlUrl, }, + vpc: props.shared.vpc } ); diff --git a/lib/chatbot-api/index.ts b/lib/chatbot-api/index.ts index 155894112..cf70c51cc 100644 --- a/lib/chatbot-api/index.ts +++ b/lib/chatbot-api/index.ts @@ -79,6 +79,7 @@ export class ChatBotApi extends Construct { role: loggingRole, }, xrayEnabled: true, + visibility: props.config.privateWebsite ? appsync.Visibility.PRIVATE : appsync.Visibility.GLOBAL }); new ApiResolvers(this, "RestApi", { diff --git a/lib/shared/index.ts b/lib/shared/index.ts index 155c9d670..2bcc6742a 100644 --- a/lib/shared/index.ts +++ b/lib/shared/index.ts @@ -7,7 +7,7 @@ import * as logs from "aws-cdk-lib/aws-logs"; import { Construct } from "constructs"; import * as path from "path"; import { Layer } from "../layer"; -import { SystemConfig } from "./types"; +import { SystemConfig, SupportedBedrockRegion } from "./types"; import { SharedAssetBundler } from "./shared-asset-bundler"; import { NagSuppressions } from "cdk-nag"; @@ -30,6 +30,7 @@ export class Shared extends Construct { readonly commonLayer: lambda.ILayerVersion; readonly powerToolsLayer: lambda.ILayerVersion; readonly sharedCode: SharedAssetBundler; + readonly s3vpcEndpoint: ec2.InterfaceVpcEndpoint; constructor(scope: Construct, id: string, props: SharedProps) { super(scope, id); @@ -90,6 +91,8 @@ export class Shared extends Construct { privateDnsEnabled: true, open: true, }); + + this.s3vpcEndpoint = s3vpcEndpoint; s3vpcEndpoint.node.addDependency(s3GatewayEndpoint); @@ -109,6 +112,86 @@ export class Shared extends Construct { service: ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME, open: true, }); + + } + + if (props.config.privateWebsite) { + // Create VPC Endpoint for AppSync + vpc.addInterfaceEndpoint("AppSyncEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.APP_SYNC, + }); + + // Create VPC Endpoint for Lambda + vpc.addInterfaceEndpoint("LambdaEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.LAMBDA, + }); + + // Create VPC Endpoint for SNS + vpc.addInterfaceEndpoint("SNSEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.SNS, + }); + + // Create VPC Endpoint for Step Functions + vpc.addInterfaceEndpoint("StepFunctionsEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.STEP_FUNCTIONS, + }); + + // Create VPC Endpoint for SSM + vpc.addInterfaceEndpoint("SSMEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.SSM, + }); + + // Create VPC Endpoint for KMS + vpc.addInterfaceEndpoint("KMSEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.KMS, + }); + + // Create VPC Endpoint for Bedrock + if (props.config.bedrock?.enabled && Object.values(SupportedBedrockRegion).some(val => val === cdk.Stack.of(this).region)){ + vpc.addInterfaceEndpoint("BedrockEndpoint", { + service: new ec2.InterfaceVpcEndpointService('com.amazonaws.'+cdk.Aws.REGION+'.bedrock-runtime', 443), + privateDnsEnabled: true + }); + } + + // Create VPC Endpoint for Kendra + if (props.config.rag.engines.kendra.enabled){ + vpc.addInterfaceEndpoint("KendraEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.KENDRA, + }); + } + + // Create VPC Endpoint for RDS/Aurora + if (props.config.rag.engines.aurora.enabled) { + vpc.addInterfaceEndpoint("RDSEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.RDS, + }); + + // Create VPC Endpoint for RDS Data + vpc.addInterfaceEndpoint("RDSDataEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.RDS_DATA, + }); + } + + // Create VPC Endpoints needed for Aurora & Opensearch Indexing + if (props.config.rag.engines.aurora.enabled || + props.config.rag.engines.opensearch.enabled) { + // Create VPC Endpoint for ECS + vpc.addInterfaceEndpoint("ECSEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.ECS, + }); + + // Create VPC Endpoint for Batch + vpc.addInterfaceEndpoint("BatchEndpoint", { + service: ec2.InterfaceVpcEndpointAwsService.BATCH, + }); + + // Create VPC Endpoint for EC2 + vpc.addInterfaceEndpoint("EC2Endpoint", { + service: ec2.InterfaceVpcEndpointAwsService.EC2, + }); + } + } const configParameter = new ssm.StringParameter(this, "Config", { diff --git a/lib/shared/types.ts b/lib/shared/types.ts index cec3f44c0..5269c1294 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -42,6 +42,14 @@ export enum SupportedRegion { US_WEST_1 = "us-west-1", US_WEST_2 = "us-west-2", } + +export enum SupportedBedrockRegion { + AP_NORTHEAST_1 = "ap-northeast-1", + AP_SOUTHEAST_1 = "ap-southeast-1", + EU_CENTRAL_1 = "eu-central-1", + US_EAST_1 = "us-east-1", + US_WEST_2 = "us-west-2", +} export enum ModelInterface { LangChain = "langchain", @@ -65,6 +73,9 @@ export interface SystemConfig { vpcId?: string; createVpcEndpoints?: boolean; }; + certificate?: string; + domain?: string; + privateWebsite?: boolean; bedrock?: { enabled?: boolean; region?: SupportedRegion; diff --git a/lib/user-interface/index.ts b/lib/user-interface/index.ts index ff688c6b1..0cc25347d 100644 --- a/lib/user-interface/index.ts +++ b/lib/user-interface/index.ts @@ -14,6 +14,8 @@ import { Shared } from "../shared"; import { SystemConfig } from "../shared/types"; import { Utils } from "../shared/utils"; import { ChatBotApi } from "../chatbot-api"; +import { PrivateWebsite } from "./private-website" +import { PublicWebsite } from "./public-website" import { NagSuppressions } from "cdk-nag"; export interface UserInterfaceProps { @@ -46,83 +48,26 @@ export class UserInterface extends Construct { removalPolicy: cdk.RemovalPolicy.DESTROY, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, autoDeleteObjects: true, + bucketName: props.config.privateWebsite ? props.config.domain : undefined, websiteIndexDocument: "index.html", websiteErrorDocument: "index.html", enforceSSL: true, serverAccessLogsBucket: uploadLogsBucket, }); - - const originAccessIdentity = new cf.OriginAccessIdentity(this, "S3OAI"); - websiteBucket.grantRead(originAccessIdentity); - props.chatbotFilesBucket.grantRead(originAccessIdentity); - - const distributionLogsBucket = new s3.Bucket( - this, - "DistributionLogsBucket", - { - objectOwnership: s3.ObjectOwnership.OBJECT_WRITER, - blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, - removalPolicy: cdk.RemovalPolicy.DESTROY, - autoDeleteObjects: true, - enforceSSL: true, - } - ); - - const distribution = new cf.CloudFrontWebDistribution( - this, - "Distribution", - { - viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - priceClass: cf.PriceClass.PRICE_CLASS_ALL, - httpVersion: cf.HttpVersion.HTTP2_AND_3, - loggingConfig: { - bucket: distributionLogsBucket, - }, - originConfigs: [ - { - behaviors: [{ isDefaultBehavior: true }], - s3OriginSource: { - s3BucketSource: websiteBucket, - originAccessIdentity, - }, - }, - { - behaviors: [ - { - pathPattern: "/chabot/files/*", - allowedMethods: cf.CloudFrontAllowedMethods.ALL, - viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - defaultTtl: cdk.Duration.seconds(0), - forwardedValues: { - queryString: true, - headers: [ - "Referer", - "Origin", - "Authorization", - "Content-Type", - "x-forwarded-user", - "Access-Control-Request-Headers", - "Access-Control-Request-Method", - ], - }, - }, - ], - s3OriginSource: { - s3BucketSource: props.chatbotFilesBucket, - originAccessIdentity, - }, - }, - ], - errorConfigurations: [ - { - errorCode: 404, - errorCachingMinTtl: 0, - responseCode: 200, - responsePagePath: "/index.html", - }, - ], - } - ); + + // Deploy either Private (only accessible within VPC) or Public facing website + let apiEndpoint: string; + let websocketEndpoint: string; + let distribution; + + if (props.config.privateWebsite) { + const privateWebsite = new PrivateWebsite(this, "PrivateWebsite", {...props, websiteBucket: websiteBucket }); + } else { + const publicWebsite = new PublicWebsite(this, "PublicWebsite", {...props, websiteBucket: websiteBucket }); + distribution = publicWebsite.distribution + } + + const exportsAsset = s3deploy.Source.jsonData("aws-exports.json", { aws_project_region: cdk.Aws.REGION, @@ -252,21 +197,15 @@ export class UserInterface extends Construct { prune: false, sources: [asset, exportsAsset], destinationBucket: websiteBucket, - distribution, - }); - - // ################################################### - // Outputs - // ################################################### - new cdk.CfnOutput(this, "UserInterfaceDomainName", { - value: `https://${distribution.distributionDomainName}`, + distribution: props.config.privateWebsite ? undefined : distribution }); + /** * CDK NAG suppression */ NagSuppressions.addResourceSuppressions( - [uploadLogsBucket, distributionLogsBucket], + uploadLogsBucket, [ { id: "AwsSolutions-S1", @@ -274,16 +213,5 @@ export class UserInterface extends Construct { }, ] ); - NagSuppressions.addResourceSuppressions(websiteBucket, [ - { id: "AwsSolutions-S5", reason: "OAI is configured for read." }, - ]); - NagSuppressions.addResourceSuppressions(distribution, [ - { id: "AwsSolutions-CFR1", reason: "No geo restrictions" }, - { - id: "AwsSolutions-CFR2", - reason: "WAF not required due to configured Cognito auth.", - }, - { id: "AwsSolutions-CFR4", reason: "TLS 1.2 is the default." }, - ]); } } diff --git a/lib/user-interface/private-website.ts b/lib/user-interface/private-website.ts new file mode 100644 index 000000000..0e30b4b06 --- /dev/null +++ b/lib/user-interface/private-website.ts @@ -0,0 +1,257 @@ +import * as apigwv2 from "@aws-cdk/aws-apigatewayv2-alpha"; +import * as cognitoIdentityPool from "@aws-cdk/aws-cognito-identitypool-alpha"; +import * as cdk from "aws-cdk-lib"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cf from "aws-cdk-lib/aws-cloudfront"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; +import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import { AwsCustomResource, AwsSdkCall, PhysicalResourceId } from "aws-cdk-lib/custom-resources"; +import { IpTarget } from "aws-cdk-lib/aws-elasticloadbalancingv2-targets"; +import { Construct } from "constructs"; +import { + ExecSyncOptionsWithBufferEncoding, + execSync, +} from "node:child_process"; +import * as path from "node:path"; +import { Shared } from "../shared"; +import { SystemConfig } from "../shared/types"; +import { Utils } from "../shared/utils"; +import { ChatBotApi } from "../chatbot-api"; +import { NagSuppressions } from "cdk-nag"; + + +export interface PrivateWebsiteProps { + readonly config: SystemConfig; + readonly shared: Shared; + readonly userPoolId: string; + readonly userPoolClientId: string; + readonly identityPool: cognitoIdentityPool.IdentityPool; + readonly api: ChatBotApi; + readonly chatbotFilesBucket: s3.Bucket; + readonly crossEncodersEnabled: boolean; + readonly sagemakerEmbeddingsEnabled: boolean; + readonly websiteBucket: s3.Bucket; +} + +export class PrivateWebsite extends Construct { + constructor(scope: Construct, id: string, props: PrivateWebsiteProps) { + super(scope, id); + + // PRIVATE WEBSITE + // REQUIRES: + // 1. ACM Certificate ARN and Domain of website to be input during 'npm run create': + // "privateWebsite" : true, + // "certificate" : "arn:aws:acm:ap-southeast-2:1234567890:certificate/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXX", + // "domain" : "sub.example.com" + // 2. In Route 53 link the VPC to the Private Hosted Zone (PHZ) (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-associate-vpcs.html) + // 3. In the PHZ, add an "A Record" that points to the Application Load Balancer Alias (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-elb-load-balancer.html) + + // Retrieving S3 Endpoint Ips for ALB Target + const s3EndpointId = props.shared.s3vpcEndpoint.vpcEndpointId + const vpc = props.shared.vpc + const vpcEndpointNetworkInterfaceIds = props.shared.s3vpcEndpoint.vpcEndpointNetworkInterfaceIds + + // First, retrieve the VPC Endpoint + const vpcEndpointsCall: AwsSdkCall = { + service: 'EC2', + action: 'describeVpcEndpoints', + parameters: { + VpcEndpointIds: [s3EndpointId] + }, + physicalResourceId: cdk.custom_resources.PhysicalResourceId.of('describeNetworkInterfaces'), //PhysicalResourceId.of('describeVpcEndpoints'), + outputPaths: ['VpcEndpoints.0.NetworkInterfaceIds'] + } + + const vpcEndpoints = new AwsCustomResource( + this, 'describeVpcEndpoints', { + onCreate: vpcEndpointsCall, + onUpdate: vpcEndpointsCall, + policy: { + statements: [ + new iam.PolicyStatement({ + actions: ["ec2:DescribeVpcEndpoints"], + resources: ["*"] + })] + } + }) + + // Then, retrieve the Private IP Addresses for each ENI of the VPC Endpoint + let s3IPs: IpTarget[] = []; + for (let index = 0; index < vpc.availabilityZones.length; index++) { + + const eniId = cdk.Fn.select(index, vpcEndpointNetworkInterfaceIds) + + const sdkCall: AwsSdkCall = { + service: 'EC2', + action: 'describeNetworkInterfaces', + outputPaths: [`NetworkInterfaces.0.PrivateIpAddress`], + parameters: { + NetworkInterfaceIds: [vpcEndpoints.getResponseField(`VpcEndpoints.0.NetworkInterfaceIds.${index}`)], + Filters: [ + { Name: "interface-type", Values: ["vpc_endpoint"] } + ], + }, + physicalResourceId: cdk.custom_resources.PhysicalResourceId.of('describeNetworkInterfaces'), //PhysicalResourceId.of('describeNetworkInterfaces'), + } + + const eni = new AwsCustomResource( + this, + `DescribeNetworkInterfaces-${index}`, + { + onCreate: sdkCall, + onUpdate: sdkCall, + policy: { + statements: [ + new iam.PolicyStatement({ + actions: ["ec2:DescribeNetworkInterfaces"], + resources: ["*"] //[`arn:aws:ec2:${process.env.CDK_DEFAULT_REGION }:${process.env.CDK_DEFAULT_ACCOUNT}:network-interface/${eniId}`] + }), + ], + }, + } + ); + + s3IPs.push(new IpTarget(cdk.Token.asString(eni.getResponseField(`NetworkInterfaces.0.PrivateIpAddress`)), 443)) + } + + + // Website ALB + const albSecurityGroup = new ec2.SecurityGroup(this, 'WebsiteApplicationLoadBalancerSG', { + vpc: props.shared.vpc, + allowAllOutbound: false + }); + + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443) + ); + + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(80) + ); + + albSecurityGroup.addEgressRule( + ec2.Peer.ipv4(props.shared.vpc.vpcCidrBlock), + ec2.Port.tcp(443) + ); + + const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'ALB', { + vpc: props.shared.vpc, + internetFacing: false, + securityGroup: albSecurityGroup, + vpcSubnets: props.shared.vpc.selectSubnets({ + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS + }), + }); + + const albLogBucket = new s3.Bucket(this, 'ALBLoggingBucket', { + + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + enforceSSL: true, + + }); + loadBalancer.logAccessLogs(albLogBucket) + + // Adding Listener + // Using ACM certificate ARN passed in through props/config file + if (props.config.certificate) { + const albListener = loadBalancer.addListener('ALBLHTTPS', + { + protocol: elbv2.ApplicationProtocol.HTTPS, + port: 443, + certificates: [elbv2.ListenerCertificate.fromArn(props.config.certificate)], + sslPolicy: elbv2.SslPolicy.RECOMMENDED_TLS + }); + + // Add ALB targets + albListener.addTargets('s3TargetGroup', + { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + targets: s3IPs, + healthCheck: { + protocol: elbv2.Protocol.HTTPS, + path: '/', + healthyHttpCodes: '307,405' + } + }); + + // The Amazon S3 PrivateLink Endpoint is a REST API Endpoint, which means that trailing slash requests will return XML directory listings by default. + // To work around this, you’ll create a redirect rule to point all requests ending in a trailing slash to index.html. + albListener.addAction('privateLinkRedirectPath', { + priority: 1, + conditions: [ + elbv2.ListenerCondition.pathPatterns(['/']), + ], + action: elbv2.ListenerAction.redirect({ + port: '#{port}', + path: '/index.html', //'/#{path}index.html' // + }) + }); + } + + // Allow access to website bucket from S3 Endpoints + props.websiteBucket.policy?.document.addStatements( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3:GetObject', "s3:List*"], + principals: [new iam.AnyPrincipal()], + resources: [props.websiteBucket.bucketArn, `${props.websiteBucket.bucketArn}/*`], + conditions: { + "StringEquals": { "aws:SourceVpce": s3EndpointId } + } + }) + ); + + // ################################################### + // Outputs + // ################################################### + new cdk.CfnOutput(this, "Domain", { + value: `https://${props.config.domain}`, + }); + + new cdk.CfnOutput(this, "LoadBalancerDNS", { + value: loadBalancer.loadBalancerDnsName, + }); + + NagSuppressions.addResourceSuppressions( + albSecurityGroup, + [ + { + id: "AwsSolutions-EC23", + reason: "Website Application Load Balancer can be open to 0.0.0.0/0 on ports 80 & 443.", + }, + ] + ); + + NagSuppressions.addResourceSuppressions( + props.websiteBucket, + [ + { + id: "AwsSolutions-S5", + reason: "Bucket has conditions to only allow access from S3 VPC Endpoints.", + }, + ] + ); + + NagSuppressions.addResourceSuppressions( + albLogBucket, + [ + { + id: "AwsSolutions-S1", + reason: "Bucket is the server access logs bucket for ALB.", + }, + ] + ); + + + + } +} \ No newline at end of file diff --git a/lib/user-interface/public-website.ts b/lib/user-interface/public-website.ts new file mode 100644 index 000000000..0e4682155 --- /dev/null +++ b/lib/user-interface/public-website.ts @@ -0,0 +1,155 @@ +import * as apigwv2 from "@aws-cdk/aws-apigatewayv2-alpha"; +import * as cognitoIdentityPool from "@aws-cdk/aws-cognito-identitypool-alpha"; +import * as cdk from "aws-cdk-lib"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cf from "aws-cdk-lib/aws-cloudfront"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; +import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as route53 from "aws-cdk-lib/aws-route53"; +import { IpTarget } from "aws-cdk-lib/aws-elasticloadbalancingv2-targets"; +import { Construct } from "constructs"; +import { + ExecSyncOptionsWithBufferEncoding, + execSync, +} from "node:child_process"; +import * as path from "node:path"; +import { Shared } from "../shared"; +import { SystemConfig } from "../shared/types"; +import { Utils } from "../shared/utils"; +import { ChatBotApi } from "../chatbot-api"; +import { NagSuppressions } from "cdk-nag"; + + +export interface PublicWebsiteProps { + readonly config: SystemConfig; + readonly shared: Shared; + readonly userPoolId: string; + readonly userPoolClientId: string; + readonly identityPool: cognitoIdentityPool.IdentityPool; + readonly api: ChatBotApi; + readonly chatbotFilesBucket: s3.Bucket; + readonly crossEncodersEnabled: boolean; + readonly sagemakerEmbeddingsEnabled: boolean; + readonly websiteBucket: s3.Bucket; +} + +export class PublicWebsite extends Construct { + readonly distribution: cf.CloudFrontWebDistribution; + + constructor(scope: Construct, id: string, props: PublicWebsiteProps) { + super(scope, id); + + ///////////////////////////////////// + ///// CLOUDFRONT IMPLEMENTATION ///// + ///////////////////////////////////// + + const originAccessIdentity = new cf.OriginAccessIdentity(this, "S3OAI"); + props.websiteBucket.grantRead(originAccessIdentity); + props.chatbotFilesBucket.grantRead(originAccessIdentity); + + + const distributionLogsBucket = new s3.Bucket( + this, + "DistributionLogsBucket", + { + objectOwnership: s3.ObjectOwnership.OBJECT_WRITER, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + enforceSSL: true, + } + ); + + const distribution = new cf.CloudFrontWebDistribution( + this, + "Distribution", + { + viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + priceClass: cf.PriceClass.PRICE_CLASS_ALL, + httpVersion: cf.HttpVersion.HTTP2_AND_3, + loggingConfig: { + bucket: distributionLogsBucket, + }, + originConfigs: [ + { + behaviors: [{ isDefaultBehavior: true }], + s3OriginSource: { + s3BucketSource: props.websiteBucket, + originAccessIdentity, + }, + }, + { + behaviors: [ + { + pathPattern: "/chabot/files/*", + allowedMethods: cf.CloudFrontAllowedMethods.ALL, + viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + defaultTtl: cdk.Duration.seconds(0), + forwardedValues: { + queryString: true, + headers: [ + "Referer", + "Origin", + "Authorization", + "Content-Type", + "x-forwarded-user", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + ], + }, + }, + ], + s3OriginSource: { + s3BucketSource: props.chatbotFilesBucket, + originAccessIdentity, + }, + }, + ], + errorConfigurations: [ + { + errorCode: 404, + errorCachingMinTtl: 0, + responseCode: 200, + responsePagePath: "/index.html", + }, + ], + } + ); + + this.distribution = distribution; + + // ################################################### + // Outputs + // ################################################### + new cdk.CfnOutput(this, "UserInterfaceDomainName", { + value: `https://${distribution.distributionDomainName}`, + }); + + NagSuppressions.addResourceSuppressions( + distributionLogsBucket, + [ + { + id: "AwsSolutions-S1", + reason: "Bucket is the server access logs bucket for websiteBucket.", + }, + ] + ); + + NagSuppressions.addResourceSuppressions(props.websiteBucket, [ + { id: "AwsSolutions-S5", reason: "OAI is configured for read." }, + ]); + + NagSuppressions.addResourceSuppressions(distribution, [ + { id: "AwsSolutions-CFR1", reason: "No geo restrictions" }, + { + id: "AwsSolutions-CFR2", + reason: "WAF not required due to configured Cognito auth.", + }, + { id: "AwsSolutions-CFR4", reason: "TLS 1.2 is the default." }, + ]); + } + + } diff --git a/lib/user-interface/react-app/src/app.tsx b/lib/user-interface/react-app/src/app.tsx index 0fb4b3992..3933c4081 100644 --- a/lib/user-interface/react-app/src/app.tsx +++ b/lib/user-interface/react-app/src/app.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom"; +import { HashRouter, BrowserRouter, Routes, Route, Outlet } from "react-router-dom"; import GlobalHeader from "./components/global-header"; import Dashboard from "./pages/rag/dashboard/dashboard"; import NotFound from "./pages/not-found"; @@ -16,11 +16,14 @@ import AddData from "./pages/rag/add-data/add-data"; import "./styles/app.scss"; import MultiChatPlayground from "./pages/chatbot/playground/multi-chat-playground"; import RssFeed from "./pages/rag/workspace/rss-feed"; +import * as InfraConfig from '../../../../bin/config.json'; function App() { + const Router = InfraConfig.privateWebsite ? HashRouter : BrowserRouter; + return (
- +
 
@@ -53,7 +56,7 @@ function App() { } />
-
+
); } diff --git a/lib/user-interface/react-app/src/components/app-configured.tsx b/lib/user-interface/react-app/src/components/app-configured.tsx index 01d09484d..636819a06 100644 --- a/lib/user-interface/react-app/src/components/app-configured.tsx +++ b/lib/user-interface/react-app/src/components/app-configured.tsx @@ -156,7 +156,7 @@ export default function AppConfigured() { }, }} > - +