diff --git a/.gitignore b/.gitignore index 4efbcca221..726ce04685 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ build_run # tests coverage -__snapshots__ \ No newline at end of file +__snapshots__ +bin \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 14c97522ec..f3fc0408d4 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,7 +2,6 @@ "recommendations": [ "bradlc.vscode-tailwindcss", "dbaeumer.vscode-eslint", - "rvest.vs-code-prettier-eslint", - "ZixuanChen.vitest-explorer" + "rvest.vs-code-prettier-eslint" ] } diff --git a/bin/app.ts b/bin/app.ts index 3ecf663369..13e1f01865 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node import "source-map-support/register"; import * as cdk from "aws-cdk-lib"; -import { ParentStack } from "../lib/parent-stack"; -import { DeploymentConfig } from "../lib/deployment-config"; +import { ParentStack } from "../lib/stacks/parent"; +import { DeploymentConfig } from "../lib/stacks/deployment-config"; import { validateEnvVariable } from "shared-utils"; async function main() { diff --git a/bun.lockb b/bun.lockb index 5d306968c0..3538fca057 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/docs/services/email.md b/docs/docs/services/email.md index b622f89ab2..ac3bbabdfc 100644 --- a/docs/docs/services/email.md +++ b/docs/docs/services/email.md @@ -40,4 +40,8 @@ The email services uses the serverless-ses-template plugin to manage the email t - name: the template name (note, the stage name is appended to this during deployment so branch templates remain unique to that stage). At this time, the naming standard for email templates is based on the event details. Specifically, the action and the authority values from the decoded event. If action is not included in the event data, "new-submission" is assumed. - subject: the subject line of the email, may contain replacement values using {{name}}. - html: the email body in html, may contain replacement values using {{name}}. -- text: the email body in text, may contain replacement values using {{name}}. \ No newline at end of file +- text: the email body in text, may contain replacement values using {{name}}. + +## Email Sending Service with AWS CDK + +This guide provides an overview and implementation of a robust email sending service using AWS Cloud Development Kit (CDK). The service includes features such as dedicated IP pools, configuration sets, verified email identities, and monitoring through SNS topics. diff --git a/lib/auth-stack.ts b/lib/auth-stack.ts deleted file mode 100644 index 00f056b8a9..0000000000 --- a/lib/auth-stack.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { readFileSync } from "fs"; -import { join } from "path"; - -import { - CfnOutput, - Duration, - NestedStack, - NestedStackProps, - RemovalPolicy, -} from "aws-cdk-lib"; -import { IVpc, ISubnet, ISecurityGroup } from "aws-cdk-lib/aws-ec2"; -import { - Effect, - FederatedPrincipal, - ManagedPolicy, - PolicyDocument, - PolicyStatement, - Role, - ServicePrincipal, -} from "aws-cdk-lib/aws-iam"; -import { Runtime } from "aws-cdk-lib/aws-lambda"; -import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; -import { LogGroup } from "aws-cdk-lib/aws-logs"; -import { Secret } from "aws-cdk-lib/aws-secretsmanager"; -import { - CfnIdentityPool, - CfnIdentityPoolRoleAttachment, - CfnUserPoolClient, - CfnUserPoolDomain, - StringAttribute, - UserPool, - UserPoolEmail, - UserPoolIdentityProviderOidc, - UserPoolOperation, - OidcAttributeRequestMethod, - ProviderAttribute, -} from "aws-cdk-lib/aws-cognito"; -import { RestApi } from "aws-cdk-lib/aws-apigateway"; -import { Construct } from "constructs"; - -import { DeploymentConfigProperties } from "./deployment-config"; -import { ManageUsers } from "local-constructs"; -import path = require("path"); - -interface AuthStackProps extends NestedStackProps { - project: string; - stage: string; - stack: string; - isDev: boolean; - vpc: IVpc; - privateSubnets: ISubnet[]; - lambdaSecurityGroup: ISecurityGroup; - apiGateway: RestApi; - applicationEndpointUrl: string; - idmEnable: DeploymentConfigProperties["idmEnable"]; - idmClientSecretArn: DeploymentConfigProperties["idmClientSecretArn"]; - idmClientId: DeploymentConfigProperties["idmClientId"]; - idmClientIssuer: DeploymentConfigProperties["idmClientIssuer"]; - idmAuthzApiEndpoint: DeploymentConfigProperties["idmAuthzApiEndpoint"]; - idmAuthzApiKeyArn: DeploymentConfigProperties["idmAuthzApiKeyArn"]; - devPasswordArn: DeploymentConfigProperties["devPasswordArn"]; -} - -export class AuthStack extends NestedStack { - public readonly userPool: UserPool; - public readonly userPoolClient: CfnUserPoolClient; - public readonly userPoolClientDomain: string; - public readonly identityPool: CfnIdentityPool; - - constructor(scope: Construct, id: string, props: AuthStackProps) { - super(scope, id, props); - const resources = this.initializeResources(props); - resources.userPool; - this.userPool = resources.userPool; - this.userPoolClient = resources.userPoolClient; - this.userPoolClientDomain = `${resources.userPoolDomain.domain}.auth.${this.region}.amazoncognito.com`; - this.identityPool = resources.identityPool; - } - - private initializeResources(props: AuthStackProps): { - userPool: UserPool; - userPoolClient: CfnUserPoolClient; - userPoolDomain: CfnUserPoolDomain; - identityPool: CfnIdentityPool; - } { - const { project, stage, stack, isDev } = props; - const { - apiGateway, - applicationEndpointUrl, - vpc, - privateSubnets, - lambdaSecurityGroup, - idmEnable, - idmClientId, - idmClientIssuer, - idmAuthzApiEndpoint, - devPasswordArn, - idmClientSecretArn, - idmAuthzApiKeyArn, - } = props; - const idmClientSecret = Secret.fromSecretCompleteArn( - this, - "IdmInfo", - idmClientSecretArn, - ); - - // Cognito User Pool - const userPool = new UserPool(this, "CognitoUserPool", { - userPoolName: `${project}-${stage}-${stack}`, - removalPolicy: RemovalPolicy.DESTROY, - signInAliases: { - email: true, - }, - autoVerify: { - email: true, - }, - selfSignUpEnabled: false, // This corresponds to allowAdminCreateUserOnly: true - email: UserPoolEmail.withCognito("no-reply@yourdomain.com"), - standardAttributes: { - givenName: { - required: true, - mutable: true, - }, - familyName: { - required: true, - mutable: true, - }, - }, - customAttributes: { - state: new StringAttribute({ mutable: true }), - "cms-roles": new StringAttribute({ mutable: true }), - }, - }); - let userPoolIdentityProviderOidc: UserPoolIdentityProviderOidc | undefined = - undefined; - if (idmEnable) { - userPoolIdentityProviderOidc = new UserPoolIdentityProviderOidc( - this, - "UserPoolIdentityProviderIDM", - { - userPool, - name: "IDM", - clientId: idmClientId, - clientSecret: idmClientSecret.secretValue.unsafeUnwrap(), - issuerUrl: idmClientIssuer, - attributeMapping: { - email: ProviderAttribute.other("email"), - givenName: ProviderAttribute.other("given_name"), - familyName: ProviderAttribute.other("family_name"), - custom: { - "custom:username": ProviderAttribute.other("preferred_username"), - }, - }, - attributeRequestMethod: OidcAttributeRequestMethod.GET, - scopes: ["email", "openid", "profile", "phone"], - identifiers: ["IDM"], - }, - ); - } - - // Cognito User Pool Client - const userPoolClient = new CfnUserPoolClient( - this, - "CognitoUserPoolClient", - { - clientName: `${project}-${stage}-${stack}`, - userPoolId: userPool.userPoolId, - explicitAuthFlows: ["ADMIN_NO_SRP_AUTH"], - generateSecret: false, - allowedOAuthFlows: ["code"], - allowedOAuthFlowsUserPoolClient: true, - allowedOAuthScopes: [ - "email", - "openid", - "aws.cognito.signin.user.admin", - ], - callbackUrLs: [applicationEndpointUrl, "http://localhost:5000/"], - defaultRedirectUri: applicationEndpointUrl, - logoutUrLs: [applicationEndpointUrl, "http://localhost:5000/"], - supportedIdentityProviders: userPoolIdentityProviderOidc - ? ["COGNITO", userPoolIdentityProviderOidc.providerName] - : ["COGNITO"], - accessTokenValidity: 30, - idTokenValidity: 30, - refreshTokenValidity: 12, - tokenValidityUnits: { - accessToken: "minutes", - idToken: "minutes", - refreshToken: "hours", - }, - }, - ); - - const userPoolDomain = new CfnUserPoolDomain(this, "UserPoolDomain", { - domain: `${stage}-login-${userPoolClient.ref}`, - userPoolId: userPool.userPoolId, - }); - - // Cognito Identity Pool - const identityPool = new CfnIdentityPool(this, "CognitoIdentityPool", { - identityPoolName: `${project}-${stage}-${stack}`, - allowUnauthenticatedIdentities: false, - cognitoIdentityProviders: [ - { - clientId: userPoolClient.ref, - providerName: userPool.userPoolProviderName, - }, - ], - }); - - // IAM Role for Cognito Authenticated Users - const authRole = new Role(this, "CognitoAuthRole", { - assumedBy: new FederatedPrincipal( - "cognito-identity.amazonaws.com", - { - StringEquals: { - "cognito-identity.amazonaws.com:aud": identityPool.ref, - }, - "ForAnyValue:StringLike": { - "cognito-identity.amazonaws.com:amr": "authenticated", - }, - }, - "sts:AssumeRoleWithWebIdentity", - ), - inlinePolicies: { - CognitoAuthorizedPolicy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - actions: ["execute-api:Invoke"], - resources: [ - `arn:aws:execute-api:${this.region}:${this.account}:${apiGateway.restApiId}/*`, - ], - effect: Effect.ALLOW, - }), - ], - }), - }, - }); - - new CfnIdentityPoolRoleAttachment(this, "CognitoIdentityPoolRoles", { - identityPoolId: identityPool.ref, - roles: { authenticated: authRole.roleArn }, - }); - - const manageUsers = new ManageUsers( - this, - "ManageUsers", - userPool, - JSON.parse( - readFileSync(join(__dirname, "../test/users/app-users.json"), "utf8"), - ), - devPasswordArn, - ); - - if (idmEnable) { - const postAuthLambdaLogGroup = new LogGroup( - this, - "PostAuthLambdaLogGroup", - { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-postAuth`, - removalPolicy: RemovalPolicy.DESTROY, - }, - ); - - const postAuthLambdaRole = new Role(this, "PostAuthLambdaRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaVPCAccessExecutionRole", - ), - ], - inlinePolicies: { - DataStackLambdarole: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "cognito-idp:AdminGetUser", - "cognito-idp:AdminCreateUser", - "cognito-idp:AdminSetUserPassword", - "cognito-idp:AdminUpdateUserAttributes", - ], - resources: [ - `arn:aws:cognito-idp:${this.region}:${this.account}:userpool/us-east-*`, - ], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "secretsmanager:DescribeSecret", - "secretsmanager:GetSecretValue", - ], - resources: [idmAuthzApiKeyArn], - }), - ], - }), - }, - }); - const postAuthLambda = new NodejsFunction(this, "PostAuthLambda", { - runtime: Runtime.NODEJS_18_X, - entry: join(__dirname, "lambda/postAuth.ts"), - handler: "handler", - role: postAuthLambdaRole, - depsLockFilePath: join(__dirname, "../bun.lockb"), - environment: { - idmAuthzApiEndpoint, - idmAuthzApiKeyArn, - }, - timeout: Duration.seconds(30), - memorySize: 1024, - retryAttempts: 0, - vpc: vpc, - securityGroups: [lambdaSecurityGroup], - vpcSubnets: { subnets: privateSubnets }, - logGroup: postAuthLambdaLogGroup, - bundling: { - minify: true, - sourceMap: true, - }, - }); - - userPool.addTrigger( - UserPoolOperation.PRE_TOKEN_GENERATION, - postAuthLambda, - ); - } - - return { userPool, userPoolClient, userPoolDomain, identityPool }; - } -} diff --git a/lib/data-stack.ts b/lib/data-stack.ts deleted file mode 100644 index 0352068425..0000000000 --- a/lib/data-stack.ts +++ /dev/null @@ -1,911 +0,0 @@ -import { readFileSync } from "fs"; -import { join } from "path"; - -import { - CustomResource, - Duration, - NestedStack, - NestedStackProps, - RemovalPolicy, - Stack, -} from "aws-cdk-lib"; -import { - AwsCustomResource, - AwsCustomResourcePolicy, - PhysicalResourceId, - Provider, -} from "aws-cdk-lib/custom-resources"; -import { - IVpc, - ISecurityGroup, - ISubnet, - SecurityGroup, - Peer, - Port, -} from "aws-cdk-lib/aws-ec2"; -import { - AccountPrincipal, - ArnPrincipal, - Effect, - FederatedPrincipal, - ManagedPolicy, - PolicyDocument, - PolicyStatement, - Role, - ServicePrincipal, -} from "aws-cdk-lib/aws-iam"; -import { Alias, Function, Runtime } from "aws-cdk-lib/aws-lambda"; -import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; -import { LogGroup } from "aws-cdk-lib/aws-logs"; -import { - CfnIdentityPool, - CfnIdentityPoolRoleAttachment, - UserPoolClient, - UserPoolDomain, - UserPool, -} from "aws-cdk-lib/aws-cognito"; -import { CfnDomain } from "aws-cdk-lib/aws-opensearchservice"; -import { - Choice, - Condition, - Fail, - LogLevel, - StateMachine, - Succeed, - TaskInput, - Wait, - WaitTime, -} from "aws-cdk-lib/aws-stepfunctions"; -import { LambdaInvoke } from "aws-cdk-lib/aws-stepfunctions-tasks"; - -import { Construct } from "constructs"; - -import { CleanupKafka, CreateTopics, ManageUsers } from "local-constructs"; -import path = require("path"); - -interface DataStackProps extends NestedStackProps { - project: string; - stage: string; - stack: string; - isDev: boolean; - vpc: IVpc; - privateSubnets: ISubnet[]; - brokerString: string; - lambdaSecurityGroup: ISecurityGroup; - topicNamespace: string; - indexNamespace: string; - sharedOpenSearchDomainEndpoint: string; - sharedOpenSearchDomainArn: string; - devPasswordArn: string; -} - -export class DataStack extends NestedStack { - public readonly openSearchDomainArn: string; - public readonly openSearchDomainEndpoint: string; - private mapRoleCustomResource: CustomResource; - - constructor(scope: Construct, id: string, props: DataStackProps) { - super(scope, id, props); - const resources = this.initializeResources(props); - this.openSearchDomainEndpoint = resources.openSearchDomainEndpoint; - this.openSearchDomainArn = resources.openSearchDomainArn; - } - - private initializeResources(props: DataStackProps): { - openSearchDomainArn: string; - openSearchDomainEndpoint: string; - } { - const { - project, - stage, - stack, - isDev, - vpc, - privateSubnets, - brokerString, - lambdaSecurityGroup, - topicNamespace, - indexNamespace, - sharedOpenSearchDomainEndpoint, - sharedOpenSearchDomainArn, - devPasswordArn, - } = props; - const consumerGroupPrefix = `--${project}--${stage}--`; - - let openSearchDomainEndpoint; - let openSearchDomainArn; - - const usingSharedOpenSearch = - sharedOpenSearchDomainEndpoint && sharedOpenSearchDomainArn; - - if (usingSharedOpenSearch) { - openSearchDomainEndpoint = sharedOpenSearchDomainEndpoint; - openSearchDomainArn = sharedOpenSearchDomainArn; - } else { - const userPool = new UserPool(this, "UserPool", { - userPoolName: `${project}-${stage}-search`, - removalPolicy: RemovalPolicy.DESTROY, - selfSignUpEnabled: false, - signInAliases: { email: true }, - autoVerify: { email: true }, - standardAttributes: { email: { required: true, mutable: true } }, - }); - - const userPoolDomain = new UserPoolDomain(this, "UserPoolDomain", { - userPool, - cognitoDomain: { - domainPrefix: `${project}-${stage}-search`, - }, - }); - - const userPoolClient = new UserPoolClient(this, "UserPoolClient", { - userPool, - authFlows: { adminUserPassword: true }, - }); - - const identityPool = new CfnIdentityPool(this, "IdentityPool", { - allowUnauthenticatedIdentities: false, - cognitoIdentityProviders: [ - { - clientId: userPoolClient.userPoolClientId, - providerName: userPool.userPoolProviderName, - }, - ], - }); - - const cognitoAuthRole = new Role(this, "CognitoAuthRole", { - assumedBy: new FederatedPrincipal( - "cognito-identity.amazonaws.com", - { - StringEquals: { - "cognito-identity.amazonaws.com:aud": identityPool.ref, - }, - "ForAnyValue:StringLike": { - "cognito-identity.amazonaws.com:amr": "authenticated", - }, - }, - "sts:AssumeRoleWithWebIdentity", - ), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName("AmazonCognitoReadOnly"), - ], - }); - - cognitoAuthRole.assumeRolePolicy?.addStatements( - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [new ServicePrincipal("es.amazonaws.com")], - actions: ["sts:AssumeRole"], - }), - ); - - new CfnIdentityPoolRoleAttachment(this, "IdentityPoolRoleAttachment", { - identityPoolId: identityPool.ref, - roles: { authenticated: cognitoAuthRole.roleArn }, - }); - - const openSearchSecurityGroup = new SecurityGroup( - this, - "OpenSearchSecurityGroup", - { - vpc, - description: "Security group for OpenSearch", - }, - ); - openSearchSecurityGroup.addIngressRule( - Peer.ipv4("10.0.0.0/8"), - Port.tcp(443), - "Allow HTTPS access from VPC", - ); - - const openSearchRole = new Role(this, "OpenSearchRole", { - assumedBy: new ServicePrincipal("es.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "AmazonOpenSearchServiceCognitoAccess", - ), - ], - }); - - const openSearchMasterRole = new Role(this, "OpenSearchMasterRole", { - assumedBy: new ServicePrincipal("es.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "AmazonOpenSearchServiceFullAccess", - ), - ], - }); - - openSearchMasterRole.assumeRolePolicy?.addStatements( - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [new AccountPrincipal(Stack.of(this).account)], - actions: ["sts:AssumeRole"], - }), - ); - - const openSearchDomain = new CfnDomain(this, "OpenSearchDomain", { - ebsOptions: { ebsEnabled: true, volumeType: "gp3", volumeSize: 20 }, - clusterConfig: { - instanceType: "or1.medium.search", - instanceCount: 3, - dedicatedMasterEnabled: false, - zoneAwarenessEnabled: true, - zoneAwarenessConfig: { availabilityZoneCount: 3 }, - }, - encryptionAtRestOptions: { enabled: true }, - nodeToNodeEncryptionOptions: { enabled: true }, - domainEndpointOptions: { - enforceHttps: true, - tlsSecurityPolicy: "Policy-Min-TLS-1-2-2019-07", - }, - cognitoOptions: { - enabled: true, - identityPoolId: identityPool.ref, - roleArn: openSearchRole.roleArn, - userPoolId: userPool.userPoolId, - }, - accessPolicies: new PolicyDocument({ - statements: [ - new PolicyStatement({ - actions: ["es:*"], - principals: [new ArnPrincipal(cognitoAuthRole.roleArn)], - resources: ["*"], - }), - ], - }), - advancedSecurityOptions: { - enabled: true, - internalUserDatabaseEnabled: false, - masterUserOptions: { masterUserArn: openSearchMasterRole.roleArn }, - }, - vpcOptions: { - securityGroupIds: [openSearchSecurityGroup.securityGroupId], - subnetIds: privateSubnets - .slice(0, 3) - .map((subnet) => subnet.subnetId), - }, - }); - new ManageUsers( - this, - "ManageUsers", - userPool, - JSON.parse( - readFileSync( - join(__dirname, "../test/users/kibana-users.json"), - "utf8", - ), - ), - devPasswordArn, - ); - - const mapRole = new NodejsFunction(this, "MapRoleLambdaFunction", { - functionName: `${project}-${stage}-${stack}-mapRole`, - entry: join(__dirname, "lambda/mapRole.ts"), - handler: "handler", - depsLockFilePath: join(__dirname, "../bun.lockb"), - runtime: Runtime.NODEJS_18_X, - role: new Role(this, "MapRoleLambdaExecutionRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaVPCAccessExecutionRole", - ), - ], - inlinePolicies: { - LambdaAssumeRolePolicy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "es:ESHttpHead", - "es:ESHttpPost", - "es:ESHttpGet", - "es:ESHttpPatch", - "es:ESHttpDelete", - "es:ESHttpPut", - ], - resources: [`${openSearchDomain.attrArn}/*`], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["sts:AssumeRole"], - resources: [openSearchMasterRole.roleArn], - }), - ], - }), - }, - }), - vpc, - vpcSubnets: { - subnets: privateSubnets, - }, - securityGroups: [lambdaSecurityGroup], - environment: { - brokerString, - region: this.region, - osDomain: `https://${openSearchDomain.attrDomainEndpoint}`, - }, - bundling: { - minify: true, - sourceMap: true, - }, - }); - - const customResourceProvider = new Provider( - this, - "CustomResourceProvider", - { - onEventHandler: mapRole, - }, - ); - - this.mapRoleCustomResource = new CustomResource(this, "MapRole", { - serviceToken: customResourceProvider.serviceToken, - properties: { - OsDomain: `https://${openSearchDomain.attrDomainEndpoint}`, - IamRoleName: `arn:aws:iam::${this.account}:role/*`, - MasterRoleToAssume: openSearchMasterRole.roleArn, - OsRoleName: "all_access", - }, - }); - - openSearchDomainEndpoint = openSearchDomain.attrDomainEndpoint; - openSearchDomainArn = openSearchDomain.attrArn; - } - - new CreateTopics(this, "createTopics", { - brokerString, - privateSubnets, - securityGroups: [lambdaSecurityGroup], - topics: [ - { - topic: `${topicNamespace}aws.onemac.migration.cdc`, - }, - ], - vpc, - }); - - if (isDev) { - new CleanupKafka(this, "cleanupKafka", { - vpc, - privateSubnets, - securityGroups: [lambdaSecurityGroup], - brokerString, - topicPatternsToDelete: [`${topicNamespace}aws.onemac.migration.cdc`], - }); - } - - const createLambda = ({ - id, - entry = `${id}.ts`, - role, - useVpc = false, - environment = {}, - timeout = Duration.minutes(5), - memorySize = 1024, - provisionedConcurrency = 0, - }: { - id: string; - entry?: string; - role: Role; - useVpc?: boolean; - environment?: { [key: string]: string }; - timeout?: Duration; - memorySize?: number; - provisionedConcurrency?: number; - }) => { - const logGroup = new LogGroup(this, `${id}LogGroup`, { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-${id}`, - removalPolicy: RemovalPolicy.DESTROY, - }); - const fn = new NodejsFunction(this, id, { - functionName: `${project}-${stage}-${stack}-${id}`, - depsLockFilePath: join(__dirname, "../bun.lockb"), - entry: join(__dirname, `lambda/${entry}`), - handler: "handler", - runtime: Runtime.NODEJS_18_X, - role, - memorySize, - vpc: useVpc ? vpc : undefined, - vpcSubnets: useVpc ? { subnets: privateSubnets } : undefined, - securityGroups: useVpc ? [lambdaSecurityGroup] : undefined, - environment, - logGroup, - timeout, - bundling: { - minify: true, - sourceMap: true, - }, - }); - - if (provisionedConcurrency > 0) { - const version = fn.currentVersion; - - // Configure provisioned concurrency - new Alias(this, `FunctionAlias${id}`, { - aliasName: "prod", - version: version, - provisionedConcurrentExecutions: provisionedConcurrency, - }); - } - - return fn; - }; - - const sharedLambdaRole = new Role(this, "SharedLambdaExecutionRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaVPCAccessExecutionRole", - ), - ], - inlinePolicies: { - DataStackLambdarole: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "es:ESHttpHead", - "es:ESHttpPost", - "es:ESHttpGet", - "es:ESHttpPatch", - "es:ESHttpDelete", - "es:ESHttpPut", - ], - resources: [`${openSearchDomainArn}/*`], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "lambda:CreateEventSourceMapping", - "lambda:ListEventSourceMappings", - "lambda:PutFunctionConcurrency", - "lambda:DeleteEventSourceMapping", - "lambda:UpdateEventSourceMapping", - "lambda:GetEventSourceMapping", - ], - resources: ["*"], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["ec2:DescribeSecurityGroups", "ec2:DescribeVpcs"], - resources: ["*"], - }), - ], - }), - }, - }); - - const functionConfigs = { - sinkChangelog: { provisionedConcurrency: 2 }, - sinkInsights: { provisionedConcurrency: 0 }, - sinkLegacyInsights: { provisionedConcurrency: 0 }, - sinkMain: { provisionedConcurrency: 2 }, - sinkSubtypes: { provisionedConcurrency: 0 }, - sinkTypes: { provisionedConcurrency: 0 }, - sinkCpocs: { provisionedConcurrency: 0 }, - }; - - const lambdaFunctions = Object.entries(functionConfigs).reduce( - (acc, [name, config]) => { - acc[name] = createLambda({ - id: name, - role: sharedLambdaRole, - useVpc: true, - environment: { - osDomain: `https://${openSearchDomainEndpoint}`, - indexNamespace, - }, - provisionedConcurrency: !props.isDev - ? config.provisionedConcurrency - : 0, - }); - return acc; - }, - {} as { [key: string]: NodejsFunction }, - ); - - const stateMachineRole = new Role(this, "StateMachineRole", { - assumedBy: new ServicePrincipal("states.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"), - ], - inlinePolicies: { - StateMachinePolicy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - actions: ["lambda:InvokeFunction"], - resources: [ - `arn:aws:lambda:${this.region}:${this.account}:function:${project}-${stage}-${stack}-*`, - ], - }), - ], - }), - }, - }); - - const cfnNotify = createLambda({ - id: "cfnNotify", - entry: "cfnNotify.ts", - role: sharedLambdaRole, - }); - const createTriggers = createLambda({ - id: "createTriggers", - role: sharedLambdaRole, - timeout: Duration.minutes(15), - }); - const checkConsumerLag = createLambda({ - id: "checkConsumerLag", - role: sharedLambdaRole, - useVpc: true, - }); - const deleteTriggers = createLambda({ - id: "deleteTriggers", - role: sharedLambdaRole, - }); - const deleteIndex = createLambda({ - id: "deleteIndex", - role: sharedLambdaRole, - useVpc: true, - }); - const setupIndex = createLambda({ - id: "setupIndex", - role: sharedLambdaRole, - useVpc: true, - }); - - const notifyState = (name: string, success: boolean) => - new LambdaInvoke(this, name, { - lambdaFunction: cfnNotify, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - Success: success, - }), - }); - const failureState = new Fail(this, "FailureState"); - const notifyOfFailureStep = new LambdaInvoke(this, "NotifyOfFailure", { - lambdaFunction: cfnNotify, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - Success: false, - }), - }).next(failureState); - - const checkDataProgressTask = new LambdaInvoke(this, "CheckDataProgress", { - lambdaFunction: checkConsumerLag, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - brokerString, - triggers: [ - { - function: lambdaFunctions.sinkMain.functionName, - topics: [ - "aws.seatool.ksql.onemac.agg.State_Plan", - "aws.onemac.migration.cdc", - `${topicNamespace}aws.onemac.migration.cdc`, - "aws.seatool.debezium.changed_date.SEA.dbo.State_Plan", - ], - }, - { - function: lambdaFunctions.sinkChangelog.functionName, - topics: [ - "aws.onemac.migration.cdc", - `${topicNamespace}aws.onemac.migration.cdc`, - ], - }, - { - function: lambdaFunctions.sinkTypes.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.SPA_Type"], - batchSize: 10000, - }, - { - function: lambdaFunctions.sinkSubtypes.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.Type"], - batchSize: 10000, - }, - { - function: lambdaFunctions.sinkCpocs.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.Officers"], - }, - ], - }), - }).addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }); - - const definition = new LambdaInvoke(this, "DeleteAllTriggers", { - lambdaFunction: deleteTriggers, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - functions: Object.values(lambdaFunctions).map((fn) => fn.functionName), - }), - }) - .addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }) - .next( - new LambdaInvoke(this, "DeleteIndex", { - lambdaFunction: deleteIndex, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - osDomain: `https://${openSearchDomainEndpoint}`, - indexNamespace, - }), - }).addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }), - ) - .next( - new LambdaInvoke(this, "SetupIndex", { - lambdaFunction: setupIndex, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - osDomain: `https://${openSearchDomainEndpoint}`, - indexNamespace, - }), - }).addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }), - ) - .next( - new LambdaInvoke(this, "StartIndexingData", { - lambdaFunction: createTriggers, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - osDomain: `https://${openSearchDomainEndpoint}`, - brokerString, - securityGroup: lambdaSecurityGroup.securityGroupId, - consumerGroupPrefix, - subnets: privateSubnets - .slice(0, 3) - .map((subnet) => subnet.subnetId), - triggers: [ - { - function: lambdaFunctions.sinkMain.functionName, - topics: [ - "aws.seatool.ksql.onemac.agg.State_Plan", - "aws.onemac.migration.cdc", - `${topicNamespace}aws.onemac.migration.cdc`, - "aws.seatool.debezium.changed_date.SEA.dbo.State_Plan", - ], - }, - { - function: lambdaFunctions.sinkChangelog.functionName, - topics: [ - "aws.onemac.migration.cdc", - `${topicNamespace}aws.onemac.migration.cdc`, - ], - }, - { - function: lambdaFunctions.sinkTypes.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.SPA_Type"], - batchSize: 10000, - }, - { - function: lambdaFunctions.sinkSubtypes.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.Type"], - batchSize: 10000, - }, - { - function: lambdaFunctions.sinkCpocs.functionName, - topics: ["aws.seatool.debezium.cdc.SEA.dbo.Officers"], - }, - ], - }), - }).addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }), - ) - .next(checkDataProgressTask) - .next( - new Choice(this, "IsDataReady") - .when( - Condition.booleanEquals("$.ready", true), - new LambdaInvoke(this, "StartIndexingInsights", { - lambdaFunction: createTriggers, - outputPath: "$.Payload", - payload: TaskInput.fromObject({ - "Context.$": "$$", - osDomain: `https://${openSearchDomainEndpoint}`, - brokerString, - securityGroup: lambdaSecurityGroup.securityGroupId, - consumerGroupPrefix, - subnets: privateSubnets - .slice(0, 3) - .map((subnet) => subnet.subnetId), - triggers: [ - { - function: lambdaFunctions.sinkInsights.functionName, - topics: ["aws.seatool.ksql.onemac.agg.State_Plan"], - }, - { - function: lambdaFunctions.sinkLegacyInsights.functionName, - topics: [ - "aws.onemac.migration.cdc", - `${topicNamespace}aws.onemac.migration.cdc`, - ], - }, - ], - }), - }) - .addCatch(notifyOfFailureStep, { - errors: ["States.ALL"], - resultPath: "$.error", - }) - .next(notifyState("NotifyOfSuccess", true)) - .next(new Succeed(this, "SuccessState")), - ) - .when( - Condition.booleanEquals("$.ready", false), - new Wait(this, "WaitForData", { - time: WaitTime.duration(Duration.seconds(3)), - }).next(checkDataProgressTask), - ), - ); - - const stateMachineLogGroup = new LogGroup(this, "StateMachineLogGroup", { - logGroupName: `/aws/vendedlogs/states/${project}-${stage}-${stack}-reindex`, - removalPolicy: RemovalPolicy.DESTROY, - }); - - const reindexStateMachine = new StateMachine( - this, - "ReindexDataStateMachine", - { - definition, - role: stateMachineRole, - stateMachineName: `${project}-${stage}-${stack}-reindex`, - logs: { - destination: stateMachineLogGroup, - level: LogLevel.ALL, - includeExecutionData: true, - }, - }, - ); - - const runReindexLogGroup = new LogGroup(this, `runReindexLogGroup`, { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-runReindex`, - removalPolicy: RemovalPolicy.DESTROY, - }); - - const runReindexLambda = new NodejsFunction( - this, - "runReindexLambdaFunction", - { - functionName: `${project}-${stage}-${stack}-runReindex`, - entry: join(__dirname, "lambda/runReindex.ts"), - handler: "handler", - depsLockFilePath: join(__dirname, "../bun.lockb"), - runtime: Runtime.NODEJS_18_X, - timeout: Duration.minutes(5), - role: new Role(this, "RunReindexLambdaExecutionRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ], - inlinePolicies: { - LambdaAssumeRolePolicy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["states:StartExecution"], - resources: [ - `arn:aws:states:${this.region}:${this.account}:stateMachine:${project}-${stage}-${stack}-reindex`, - ], - }), - new PolicyStatement({ - effect: Effect.DENY, - actions: ["logs:CreateLogGroup"], - resources: ["*"], - }), - ], - }), - }, - }), - logGroup: runReindexLogGroup, - bundling: { - minify: true, - sourceMap: true, - }, - }, - ); - - const runReindexProviderProvider = new Provider( - this, - "RunReindexProvider", - { - onEventHandler: runReindexLambda, - }, - ); - - const runReindexCustomResource = new CustomResource(this, "RunReindex", { - serviceToken: runReindexProviderProvider.serviceToken, - properties: { - stateMachine: reindexStateMachine.stateMachineArn, - }, - }); - - if (!usingSharedOpenSearch) { - reindexStateMachine.node.addDependency(this.mapRoleCustomResource); - } - - const deleteTriggersOnStackDeleteCustomResourceLogGroup = new LogGroup( - this, - "deleteTriggersOnStackDeleteCustomResourceLogGroup", - { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-deleteTriggersOnDeleteCustomResource`, - removalPolicy: RemovalPolicy.DESTROY, - }, - ); - const deleteTriggersOnStackDeleteCustomResource = new AwsCustomResource( - this, - "DeleteTriggersOnStackDeleteCustomResource", - { - onDelete: { - service: "Lambda", - action: "invoke", - parameters: { - FunctionName: deleteTriggers.functionName, - InvocationType: "RequestResponse", - Payload: JSON.stringify({ - RequestType: "Delete", - functions: Object.values(lambdaFunctions).map( - (fn) => fn.functionName, - ), - }), - }, - physicalResourceId: PhysicalResourceId.of( - "delete-triggers-on-stack-deletes", - ), - }, - logGroup: deleteTriggersOnStackDeleteCustomResourceLogGroup, - policy: AwsCustomResourcePolicy.fromStatements([ - new PolicyStatement({ - actions: ["lambda:InvokeFunction"], - resources: [deleteTriggers.functionArn], - }), - new PolicyStatement({ - effect: Effect.DENY, - actions: ["logs:CreateLogGroup"], - resources: ["*"], - }), - ]), - }, - ); - const deleteTriggersOnDeleteCustomResourcePolicy = - deleteTriggersOnStackDeleteCustomResource.node.findChild( - "CustomResourcePolicy", - ); - deleteTriggersOnStackDeleteCustomResource.node.addDependency( - deleteTriggersOnDeleteCustomResourcePolicy, - ); - deleteTriggersOnStackDeleteCustomResourceLogGroup.node.addDependency( - deleteTriggersOnDeleteCustomResourcePolicy, - ); - - return { openSearchDomainEndpoint, openSearchDomainArn }; - } -} diff --git a/lib/email-stack.ts b/lib/email-stack.ts deleted file mode 100644 index d59b6b67dc..0000000000 --- a/lib/email-stack.ts +++ /dev/null @@ -1,319 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import * as path from "path"; -import { Duration } from "aws-cdk-lib"; -import { - Role, - ServicePrincipal, - ManagedPolicy, - PolicyDocument, - PolicyStatement, - Effect, -} from "aws-cdk-lib/aws-iam"; -import { CfnTopic, CfnTopicPolicy, CfnSubscription } from "aws-cdk-lib/aws-sns"; -import { - CfnConfigurationSet, - CfnConfigurationSetEventDestination, -} from "aws-cdk-lib/aws-ses"; -import { Runtime, Code, CfnEventSourceMapping } from "aws-cdk-lib/aws-lambda"; -import { ISubnet, IVpc, SecurityGroup } from "aws-cdk-lib/aws-ec2"; -import { Construct } from "constructs"; -import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; -import { LogGroup } from "aws-cdk-lib/aws-logs"; -import { join } from "path"; - -interface EmailServiceStackProps extends cdk.NestedStackProps { - project: string; - stage: string; - vpc: IVpc; - privateSubnets: ISubnet[]; - brokerString: string; - topicNamespace: string; - indexNamespace: string; - osDomainArn: string; - lambdaSecurityGroupId: string; - applicationEndpoint: string; - stack: string; - cognitoUserPoolId: string; - emailAddressLookupSecretName: string; -} - -export class EmailStack extends cdk.NestedStack { - constructor(scope: Construct, id: string, props: EmailServiceStackProps) { - super(scope, id, props); - - const { - project, - stage, - stack, - vpc, - privateSubnets, - brokerString, - topicNamespace, - indexNamespace, - osDomainArn, - lambdaSecurityGroupId, - applicationEndpoint, - cognitoUserPoolId, - emailAddressLookupSecretName, - } = props; - - // IAM Role for Lambda - const lambdaRole = new Role(this, "LambdaExecutionRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaBasicExecutionRole", - ), - ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AWSLambdaVPCAccessExecutionRole", - ), - ], - inlinePolicies: { - EmailServicePolicy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "sts:AssumeRole", - "ses:ListIdentities", - "ses:ListConfigurationSets", - "ses:SendTemplatedEmail", - "sns:Subscribe", - "sns:Publish", - "ec2:CreateNetworkInterface", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeVpcs", - "ec2:DeleteNetworkInterface", - "ec2:DescribeSubnets", - "ec2:DescribeSecurityGroups", - "es:ESHttpGet", - "cognito-idp:ListUsers", - "secretsmanager:GetSecretValue", - ], - resources: ["*"], - }), - ], - }), - }, - }); - - // SNS Topic - const emailEventTopic = new CfnTopic(this, "EmailEventTopic", { - topicName: `${topicNamespace}-email-events`, - displayName: "Monitoring the sending of emails", - }); - - // KMS Key for SNS Topic - const kmsKeyForEmails = new cdk.aws_kms.Key(this, "KmsKeyForEmails", { - enableKeyRotation: true, - policy: new PolicyDocument({ - statements: [ - new PolicyStatement({ - sid: "Allow access for Root User", - effect: Effect.ALLOW, - principals: [new cdk.aws_iam.AccountRootPrincipal()], - actions: ["kms:*"], - resources: ["*"], - }), - new PolicyStatement({ - sid: "Allow access for Key User (SNS Service Principal)", - effect: Effect.ALLOW, - principals: [new ServicePrincipal("sns.amazonaws.com")], - actions: ["kms:GenerateDataKey", "kms:Decrypt"], - resources: ["*"], - }), - new PolicyStatement({ - sid: "Allow CloudWatch events to use the key", - effect: Effect.ALLOW, - principals: [new ServicePrincipal("events.amazonaws.com")], - actions: ["kms:Decrypt", "kms:GenerateDataKey"], - resources: ["*"], - }), - new PolicyStatement({ - sid: "Allow CloudWatch for CMK", - effect: Effect.ALLOW, - principals: [new ServicePrincipal("cloudwatch.amazonaws.com")], - actions: ["kms:Decrypt", "kms:GenerateDataKey*"], - resources: ["*"], - }), - new PolicyStatement({ - sid: "Allow SES events to use the key", - effect: Effect.ALLOW, - principals: [new ServicePrincipal("ses.amazonaws.com")], - actions: ["kms:Decrypt", "kms:GenerateDataKey*"], - resources: ["*"], - }), - ], - }), - }); - - // SNS Topic Policy - new CfnTopicPolicy(this, "EmailEventTopicPolicy", { - topics: [emailEventTopic.ref], - policyDocument: { - Statement: [ - { - Effect: "Allow", - Principal: { - Service: ["lambda.amazonaws.com", "ses.amazonaws.com"], - }, - Action: ["sns:Subscribe", "sns:Publish"], - Resource: emailEventTopic.ref, - }, - ], - }, - }); - - const processEmailsLogGroup = new LogGroup(this, `processEmailsLogGroup`, { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-processEmails`, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - - // Lambda Function: ProcessEmails - const processEmailsLambda = new NodejsFunction( - this, - "ProcessEmailsLambdaFunction", - { - functionName: `${topicNamespace}-processEmails`, - runtime: Runtime.NODEJS_18_X, - depsLockFilePath: join(__dirname, "../bun.lockb"), - handler: "handler", - entry: path.join(__dirname, "lambda/processEmails.ts"), - role: lambdaRole, - memorySize: 1024, - timeout: Duration.seconds(60), - environment: { - region: cdk.Aws.REGION, - stage: stage, - osDomain: osDomainArn, - indexNamespace, - cognitoPoolId: cognitoUserPoolId, - emailConfigSet: `${topicNamespace}-configuration`, - applicationEndpoint: applicationEndpoint, - emailAddressLookupSecretName, - }, - vpc, - securityGroups: [ - SecurityGroup.fromSecurityGroupId( - this, - "LambdaSecurityGroup", - lambdaSecurityGroupId, - ), - ], - vpcSubnets: { - subnets: privateSubnets, - }, - logGroup: processEmailsLogGroup, - bundling: { - minify: true, - sourceMap: true, - }, - }, - ); - - const processEmailEventsLogGroup = new LogGroup( - this, - `processEmailEventsLogGroup`, - { - logGroupName: `/aws/lambda/${project}-${stage}-${stack}-processEmailEvents`, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }, - ); - - // Lambda Function: ProcessEmailEvents - const processEmailEventsLambda = new NodejsFunction( - this, - "ProcessEmailEventsLambdaFunction", - { - functionName: `${topicNamespace}-processEmailEvents`, - runtime: Runtime.NODEJS_18_X, - handler: "main", - depsLockFilePath: join(__dirname, "../bun.lockb"), - entry: path.join(__dirname, "lambda/processEmailEvents.ts"), - role: lambdaRole, - environment: { - region: cdk.Aws.REGION, - stage: stage, - osDomain: osDomainArn, - cognitoPoolId: cognitoUserPoolId, - emailConfigSet: `${topicNamespace}-configuration`, - applicationEndpoint: applicationEndpoint, - }, - logGroup: processEmailEventsLogGroup, - bundling: { - minify: true, - sourceMap: true, - }, - }, - ); - - // SNS Subscription - new CfnSubscription(this, "EmailEventSubscription", { - topicArn: emailEventTopic.ref, - protocol: "lambda", - endpoint: processEmailEventsLambda.functionArn, - }); - - // SES Configuration Set - const emailEventConfigurationSet = new CfnConfigurationSet( - this, - "EmailEventConfigurationSet", - { - name: `${topicNamespace}-configuration`, - }, - ); - - // SES Configuration Set Event Destination - new CfnConfigurationSetEventDestination( - this, - "EmailEventConfigurationSetEventDestination", - { - configurationSetName: emailEventConfigurationSet.ref, - eventDestination: { - enabled: true, - name: `${topicNamespace}-destination`, - matchingEventTypes: [ - "send", - "reject", - "bounce", - "complaint", - "delivery", - "open", - "click", - "renderingFailure", - "deliveryDelay", - "subscription", - ], - snsDestination: { - topicArn: emailEventTopic.ref, - }, - }, - }, - ); - - const accessConfigSubnets = privateSubnets - .slice(0, 3) - .map((subnet) => ({ type: "VPC_SUBNET", uri: subnet.subnetId })); - - // Lambda Event Source Mapping for Kafka - new CfnEventSourceMapping(this, "SinkEmailTrigger", { - batchSize: 10, - enabled: true, - selfManagedEventSource: { - endpoints: { - kafkaBootstrapServers: brokerString.split(","), - }, - }, - functionName: processEmailsLambda.functionArn, - sourceAccessConfigurations: [ - ...accessConfigSubnets, - { - type: "VPC_SECURITY_GROUP", - uri: lambdaSecurityGroupId, - }, - ], - startingPosition: "LATEST", - topics: [`${topicNamespace}aws.onemac.migration.cdc`], - }); - } -} diff --git a/lib/lambda/processEmails.ts b/lib/lambda/processEmails.ts index 6601d98bfc..90ff7bb419 100644 --- a/lib/lambda/processEmails.ts +++ b/lib/lambda/processEmails.ts @@ -1,51 +1,90 @@ import { SESClient, SendTemplatedEmailCommand } from "@aws-sdk/client-ses"; -import * as E from "libs/email/handler-lib"; -import { LambdaResponse } from "libs/email/handler-lib"; -import type { DecodedRecord } from "libs/email/handler-lib"; -import { getBundle } from "libs/email/bundle-lib"; -import { buildDestination } from "libs/email/address-lib"; -import { buildEmailData } from "libs/email/data-lib"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import * as EmailLib from "../libs/email"; -const SES = new SESClient({ region: process.env.region }); +const SES = new SESClient({ region: process.env.REGION }); +const S3 = new S3Client({ region: process.env.REGION }); + +// Helper function to prepare data for S3 +const prepareS3Data = ( + result: any, + command: any, + status: string, + error?: string, +) => ({ + emailId: result?.MessageId || "unknown", + eventType: error ? "ERROR" : "SEND", + timestamp: new Date().toISOString(), + destination: command.Destination, + template: command.Template, + status, + ...(error && { error }), +}); + +// Helper function to write data to S3 +const writeToS3 = async (data: any, key: string) => { + await S3.send( + new PutObjectCommand({ + Bucket: process.env.EMAIL_DATA_BUCKET_NAME, + Key: key, + Body: JSON.stringify(data), + ContentType: "application/json", + }), + ); +}; export const handler = E.emailHandler( async ( - record: DecodedRecord, - ): Promise => { - // get the bundle of emails associated with this action - const emailBundle = getBundle(record, process.env.stage!!) as any; + record: EmailLib.DecodedRecord, + ): Promise => { + const emailBundle = EmailLib.getBundle(record, process.env.STAGE!!) as any; - // not every event has a bundle, and that's ok! - if (!emailBundle || !!emailBundle?.message || !emailBundle?.emailCommands) + if (!emailBundle || !!emailBundle?.message || !emailBundle?.emailCommands) { return { message: "no eventToEmailMapping found, no email sent" }; + } - // data is at bundle level since often identical between emails and saves on lookups - const emailData = await buildEmailData(emailBundle, record); + const emailData = await EmailLib.buildEmailData(emailBundle, record); const sendResults = await Promise.allSettled( emailBundle.emailCommands.map(async (command: any) => { try { - return await SES.send( + const result = await SES.send( new SendTemplatedEmailCommand({ - Source: process.env.emailSource ?? "kgrue@fearless.tech", - Destination: buildDestination(command, emailData), + Source: process.env.EMAIL_SOURCE ?? "kgrue@fearless.tech", + Destination: EmailLib.buildDestination(command, emailData), TemplateData: JSON.stringify(emailData), Template: command.Template, - ConfigurationSetName: process.env.emailConfigSet, + ConfigurationSetName: process.env.EMAIL_CONFIG_SET, }), ); + + const s3Data = prepareS3Data(result, command, "Success"); + await writeToS3(s3Data, `email_events/${result.MessageId}.json`); + + return { statusCode: 200, data: result }; } catch (err) { console.log( "Failed to process the email.", err, JSON.stringify(command, null, 4), ); - return Promise.resolve({ message: err.message }); + + const s3ErrorData = prepareS3Data( + null, + command, + "Failed", + err.message, + ); + await writeToS3( + s3ErrorData, + `email_events/${new Date().getTime()}_error.json`, + ); + + return { statusCode: 400, data: null, reason: err.message }; } }), ); - // Transform the sendResults into the expected LambdaResponse format const transformedResults = sendResults.map((result) => { if (result.status === "fulfilled") { return { statusCode: 200, data: result.value }; diff --git a/lib/libs/email/index.ts b/lib/libs/email/index.ts new file mode 100644 index 0000000000..b9fbf862a4 --- /dev/null +++ b/lib/libs/email/index.ts @@ -0,0 +1,7 @@ +export * from "./address-lib"; +export * from "./bundle-lib"; +export * from "./cognito-lib"; +export * from "./data-lib"; +export * from "./handler-lib"; +export * from "./lookup-lib"; +export * from "./os-lib"; diff --git a/lib/libs/webforms/ABP6/v202401.ts b/lib/libs/webforms/ABP6/v202401.ts index 1c119a1c80..153479e1bd 100644 --- a/lib/libs/webforms/ABP6/v202401.ts +++ b/lib/libs/webforms/ABP6/v202401.ts @@ -47,12 +47,14 @@ export const v202401: FormSchema = { value: /^[0-9]\d*$/, message: "Must be a positive integer value", }, - validate: { - greaterThanValueAbove: (v, vals) => - parseInt(v) > parseInt(vals?.["agg-actuarial-ben-plan"]) || - "Must be greater than value entered above", - }, }, + addtnlRules: [ + { + type: "greaterThanField", + fieldName: "abp6_desc-of-ben_agg-actuarial-ben-plan", + message: "Must be greater than value entered above.", + }, + ], }, { rhf: "Checkbox", diff --git a/lib/local-constructs/manage-users/index.test.ts b/lib/local-constructs/manage-users/index.test.ts index 3ea9b7bf59..1f5e47b8be 100644 --- a/lib/local-constructs/manage-users/index.test.ts +++ b/lib/local-constructs/manage-users/index.test.ts @@ -14,7 +14,7 @@ describe("ManageUsers", () => { // Mock properties const userPool = new cognito.UserPool(stack, "UserPool"); const users = [{ username: "user1" }, { username: "user2" }]; - const passwordSecretArn = "mockPasswordSecretArn"; + const passwordSecretArn = "mockPasswordSecretArn"; // pragma: allowlist secret const manageUsers = new ManageUsers( stack, diff --git a/lib/local-constructs/manage-users/src/manageUsers.test.ts b/lib/local-constructs/manage-users/src/manageUsers.test.ts index 698861bb67..890aa240b0 100644 --- a/lib/local-constructs/manage-users/src/manageUsers.test.ts +++ b/lib/local-constructs/manage-users/src/manageUsers.test.ts @@ -50,7 +50,7 @@ describe("Cognito User Lambda Handler", () => { ], }, ], - passwordSecretArn: "passwordSecretArn", + passwordSecretArn: "passwordSecretArn", // pragma: allowlist secret }, }; @@ -77,7 +77,7 @@ describe("Cognito User Lambda Handler", () => { MessageAction: "SUPPRESS", }); expect(mockSetPassword).toHaveBeenCalledWith({ - Password: "devUserPassword", + Password: "devUserPassword", // pragma: allowlist secret UserPoolId: "userPoolId", Username: "user1", Permanent: true, @@ -117,7 +117,7 @@ describe("Cognito User Lambda Handler", () => { ], }, ], - passwordSecretArn: "passwordSecretArn", + passwordSecretArn: "passwordSecretArn", // pragma: allowlist secret }, }; @@ -128,7 +128,7 @@ describe("Cognito User Lambda Handler", () => { await handler(event, context); - expect(mockGetSecret).toHaveBeenCalledWith("passwordSecretArn"); + expect(mockGetSecret).toHaveBeenCalledWith("passwordSecretArn"); // pragma: allowlist secret expect(mockSend).toHaveBeenCalledWith( event, context, diff --git a/lib/networking-stack.ts b/lib/networking-stack.ts deleted file mode 100644 index a5901ba7a4..0000000000 --- a/lib/networking-stack.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NestedStack, NestedStackProps, RemovalPolicy } from "aws-cdk-lib"; -import { IVpc, SecurityGroup } from "aws-cdk-lib/aws-ec2"; -import { Construct } from "constructs"; - -interface NetworkingStackProps extends NestedStackProps { - project: string; - stage: string; - stack: string; - isDev: boolean; - vpc: IVpc; -} - -export class NetworkingStack extends NestedStack { - public readonly lambdaSecurityGroup: SecurityGroup; - constructor(scope: Construct, id: string, props: NetworkingStackProps) { - super(scope, id, props); - this.lambdaSecurityGroup = this.initializeResources(props); - } - - private initializeResources(props: NetworkingStackProps): SecurityGroup { - const { project, stage, stack, isDev } = props; - const { vpc } = props; - - const lambdaSecurityGroup = new SecurityGroup(this, `LambdaSecurityGroup`, { - vpc, - description: `Outbound permissive sg for lambdas in ${project}-${stage}.`, - allowAllOutbound: true, // Set to false to customize egress rules - }); - - lambdaSecurityGroup.applyRemovalPolicy(RemovalPolicy.RETAIN); - - return lambdaSecurityGroup; - } -} diff --git a/lib/packages/shared-types/forms.ts b/lib/packages/shared-types/forms.ts index 446d539232..ea0630740a 100644 --- a/lib/packages/shared-types/forms.ts +++ b/lib/packages/shared-types/forms.ts @@ -21,6 +21,24 @@ export interface FormSchema { sections: Section[]; } +export type AdditionalRule = + | { + type: "lessThanField" | "greaterThanField"; + strictGreater?: boolean; + fieldName: string; + message: string; + } + | { + type: "cannotCoexist"; + fieldName: string; + message: string; + }; + +export type RuleGenerator = ( + rules?: RegisterOptions, + addtnlRules?: AdditionalRule[], +) => RegisterOptions | undefined; + export type RHFSlotProps = { name: string; label?: RHFTextField; @@ -33,6 +51,7 @@ export type RHFSlotProps = { descriptionClassName?: string; dependency?: DependencyRule; rules?: RegisterOptions; + addtnlRules?: AdditionalRule[]; horizontalLayout?: boolean; } & { [K in keyof RHFComponentMap]: { diff --git a/lib/packages/shared-utils/decode.test.ts b/lib/packages/shared-utils/decode.test.ts index a4543121c5..b99176f865 100644 --- a/lib/packages/shared-utils/decode.test.ts +++ b/lib/packages/shared-utils/decode.test.ts @@ -18,7 +18,7 @@ describe("decodeBase64WithUtf8", () => { it("should handle base64 encoded non-ASCII characters", () => { const base64Encoded = - "dGhlIHdvcmQgZW1vamkgY29tZXMgZnJvbSBKYXBhbmVzZSBlICjntbUsICdwaWN0dXJlJykgKyBtb2ppICjmloflrZcsICdjaGFyYWN0ZXInKQ=="; + "dGhlIHdvcmQgZW1vamkgY29tZXMgZnJvbSBKYXBhbmVzZSBlICjntbUsICdwaWN0dXJlJykgKyBtb2ppICjmloflrZcsICdjaGFyYWN0ZXInKQ=="; // pragma: allowlist secret const expectedDecodedString = "the word emoji comes from Japanese e (絵, 'picture') + moji (文字, 'character')"; const result = decodeBase64WithUtf8(base64Encoded); diff --git a/lib/packages/shared-utils/secrets-manager.test.ts b/lib/packages/shared-utils/secrets-manager.test.ts index b2e02a12e3..ae9846eb6c 100644 --- a/lib/packages/shared-utils/secrets-manager.test.ts +++ b/lib/packages/shared-utils/secrets-manager.test.ts @@ -20,7 +20,7 @@ describe("getSecret", () => { it("should return the secret value if the secret exists and is not marked for deletion", async () => { const secretId = "test-secret"; - const expectedSecretValue = "test-secret-value"; + const expectedSecretValue = "test-secret-value"; // pragma: allowlist secret mockSend .mockResolvedValueOnce({ DeletedDate: null }) // Mock DescribeSecretCommand response diff --git a/lib/alerts-stack.ts b/lib/stacks/alerts.ts similarity index 53% rename from lib/alerts-stack.ts rename to lib/stacks/alerts.ts index 5dafcf73ee..b40953fd73 100644 --- a/lib/alerts-stack.ts +++ b/lib/stacks/alerts.ts @@ -1,64 +1,59 @@ -import { Aws, CfnOutput, NestedStack, NestedStackProps } from "aws-cdk-lib"; -import { - AccountPrincipal, - Effect, - PolicyStatement, - ServicePrincipal, -} from "aws-cdk-lib/aws-iam"; -import { Key } from "aws-cdk-lib/aws-kms"; -import { CfnTopicPolicy, Topic } from "aws-cdk-lib/aws-sns"; +import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; -interface AlertsStackProps extends NestedStackProps { +interface AlertsStackProps extends cdk.NestedStackProps { project: string; stage: string; stack: string; isDev: boolean; } -export class AlertsStack extends NestedStack { - public readonly topic: Topic; +export class Alerts extends cdk.NestedStack { + public readonly topic: cdk.aws_sns.Topic; + constructor(scope: Construct, id: string, props: AlertsStackProps) { super(scope, id, props); this.topic = this.initializeResources(props); } - private initializeResources(props: AlertsStackProps): Topic { - const { project, stage, stack, isDev } = props; + + private initializeResources(props: AlertsStackProps): cdk.aws_sns.Topic { + const { project, stage } = props; + // Create Alerts Topic with KMS Key - const alertsTopic = new Topic(this, "AlertsTopic", { + const alertsTopic = new cdk.aws_sns.Topic(this, "AlertsTopic", { topicName: `Alerts-${project}-${stage}`, }); - const kmsKeyForSns = new Key(this, "KmsKeyForSns", { + const kmsKeyForSns = new cdk.aws_kms.Key(this, "KmsKeyForSns", { enableKeyRotation: true, }); // KMS Key Policy kmsKeyForSns.addToResourcePolicy( - new PolicyStatement({ + new cdk.aws_iam.PolicyStatement({ sid: "Allow access for Root User", - effect: Effect.ALLOW, - principals: [new AccountPrincipal(Aws.ACCOUNT_ID)], + effect: cdk.aws_iam.Effect.ALLOW, + principals: [new cdk.aws_iam.AccountPrincipal(cdk.Aws.ACCOUNT_ID)], actions: ["kms:*"], resources: ["*"], }), ); kmsKeyForSns.addToResourcePolicy( - new PolicyStatement({ + new cdk.aws_iam.PolicyStatement({ sid: "Allow access for Key User (SNS Service Principal)", - effect: Effect.ALLOW, - principals: [new ServicePrincipal("sns.amazonaws.com")], + effect: cdk.aws_iam.Effect.ALLOW, + principals: [new cdk.aws_iam.ServicePrincipal("sns.amazonaws.com")], actions: ["kms:GenerateDataKey", "kms:Decrypt"], resources: ["*"], }), ); kmsKeyForSns.addToResourcePolicy( - new PolicyStatement({ + new cdk.aws_iam.PolicyStatement({ sid: "Allow CloudWatch events to use the key", - effect: Effect.ALLOW, + effect: cdk.aws_iam.Effect.ALLOW, principals: [ - new ServicePrincipal("events.amazonaws.com"), - new ServicePrincipal("cloudwatch.amazonaws.com"), + new cdk.aws_iam.ServicePrincipal("events.amazonaws.com"), + new cdk.aws_iam.ServicePrincipal("cloudwatch.amazonaws.com"), ], actions: ["kms:Decrypt", "kms:GenerateDataKey"], resources: ["*"], @@ -66,14 +61,14 @@ export class AlertsStack extends NestedStack { ); // Output the Alerts Topic ARN - new CfnOutput(this, "AlertsTopicArn", { + new cdk.CfnOutput(this, "AlertsTopicArn", { description: "Alerts Topic ARN", value: alertsTopic.topicArn, }); // EventBridge to SNS Topic Policy - new CfnTopicPolicy(this, "EventBridgeToToSnsPolicy", { - topics: [alertsTopic.topicArn], // Provide the topic ARN + new cdk.aws_sns.CfnTopicPolicy(this, "EventBridgeToToSnsPolicy", { + topics: [alertsTopic.topicArn], policyDocument: { Statement: [ { @@ -82,11 +77,12 @@ export class AlertsStack extends NestedStack { Service: ["events.amazonaws.com", "cloudwatch.amazonaws.com"], }, Action: "sns:Publish", - Resource: alertsTopic.topicArn, // Use the topic ARN + Resource: alertsTopic.topicArn, }, ], }, }); + return alertsTopic; } } diff --git a/lib/api-stack.ts b/lib/stacks/api.ts similarity index 67% rename from lib/api-stack.ts rename to lib/stacks/api.ts index 1d99e857c8..bacc2174e5 100644 --- a/lib/api-stack.ts +++ b/lib/stacks/api.ts @@ -1,72 +1,33 @@ -import { join } from "path"; - -import { - Duration, - NestedStack, - NestedStackProps, - RemovalPolicy, -} from "aws-cdk-lib"; -import { - Alarm, - ComparisonOperator, - Metric, - TreatMissingData, -} from "aws-cdk-lib/aws-cloudwatch"; -import { IVpc, ISubnet, ISecurityGroup } from "aws-cdk-lib/aws-ec2"; -import { - Effect, - ManagedPolicy, - PolicyDocument, - PolicyStatement, - Role, - ServicePrincipal, -} from "aws-cdk-lib/aws-iam"; -import { Alias, Function, Runtime } from "aws-cdk-lib/aws-lambda"; +import * as cdk from "aws-cdk-lib"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; -import { LogGroup } from "aws-cdk-lib/aws-logs"; -import { Bucket } from "aws-cdk-lib/aws-s3"; -import { Topic } from "aws-cdk-lib/aws-sns"; -import { - RestApi, - MethodLoggingLevel, - Cors, - EndpointType, - LogGroupLogDestination, - AccessLogFormat, - LambdaIntegration, - AuthorizationType, - CfnGatewayResponse, -} from "aws-cdk-lib/aws-apigateway"; import { Construct } from "constructs"; - +import { join } from "path"; import { DeploymentConfigProperties } from "./deployment-config"; -import { CloudWatchToS3 } from "local-constructs"; -import { EmptyBuckets } from "local-constructs"; -import { RegionalWaf } from "local-constructs"; -import path = require("path"); +import * as LC from "local-constructs"; -interface ApiStackProps extends NestedStackProps { +interface ApiStackProps extends cdk.NestedStackProps { project: string; stage: string; stack: string; isDev: boolean; - vpc: IVpc; - privateSubnets: ISubnet[]; - lambdaSecurityGroup: ISecurityGroup; + vpc: cdk.aws_ec2.IVpc; + privateSubnets: cdk.aws_ec2.ISubnet[]; + lambdaSecurityGroup: cdk.aws_ec2.ISecurityGroup; topicNamespace: string; indexNamespace: string; openSearchDomainArn: string; openSearchDomainEndpoint: string; - alertsTopic: Topic; - attachmentsBucket: Bucket; + alertsTopic: cdk.aws_sns.Topic; + attachmentsBucket: cdk.aws_s3.Bucket; brokerString: DeploymentConfigProperties["brokerString"]; dbInfoSecretName: DeploymentConfigProperties["dbInfoSecretName"]; legacyS3AccessRoleArn: DeploymentConfigProperties["legacyS3AccessRoleArn"]; } -export class ApiStack extends NestedStack { - public readonly apiGateway: RestApi; +export class Api extends cdk.NestedStack { + public readonly apiGateway: cdk.aws_apigateway.RestApi; public readonly apiGatewayUrl: string; + constructor(scope: Construct, id: string, props: ApiStackProps) { super(scope, id, props); const resources = this.initializeResources(props); @@ -75,7 +36,7 @@ export class ApiStack extends NestedStack { } private initializeResources(props: ApiStackProps): { - apiGateway: RestApi; + apiGateway: cdk.aws_apigateway.RestApi; } { const { project, stage, stack, isDev } = props; const { @@ -96,22 +57,24 @@ export class ApiStack extends NestedStack { const topicName = `${topicNamespace}aws.onemac.migration.cdc`; // Define IAM role - const lambdaRole = new Role(this, "LambdaExecutionRole", { - assumedBy: new ServicePrincipal("lambda.amazonaws.com"), + const lambdaRole = new cdk.aws_iam.Role(this, "LambdaExecutionRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName( + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaBasicExecutionRole", ), - ManagedPolicy.fromAwsManagedPolicyName( + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaVPCAccessExecutionRole", ), - ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "CloudWatchLogsFullAccess", + ), ], inlinePolicies: { - LambdaPolicy: new PolicyDocument({ + LambdaPolicy: new cdk.aws_iam.PolicyDocument({ statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: [ "es:ESHttpHead", "es:ESHttpPost", @@ -122,18 +85,18 @@ export class ApiStack extends NestedStack { ], resources: [`${openSearchDomainArn}/*`], }), - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: ["cognito-idp:GetUser", "cognito-idp:ListUsers"], resources: ["*"], }), - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: ["sts:AssumeRole"], resources: [legacyS3AccessRoleArn], }), - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: [ "s3:PutObject", "s3:PutObjectTagging", @@ -142,8 +105,8 @@ export class ApiStack extends NestedStack { ], resources: [`${attachmentsBucket.bucketArn}/*`], }), - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: [ "secretsmanager:DescribeSecret", "secretsmanager:GetSecretValue", @@ -162,9 +125,9 @@ export class ApiStack extends NestedStack { id: string, entry: string, environment: { [key: string]: string | undefined }, - vpc?: IVpc, - securityGroup?: ISecurityGroup, - subnets?: ISubnet[], + vpc?: cdk.aws_ec2.IVpc, + securityGroup?: cdk.aws_ec2.ISecurityGroup, + subnets?: cdk.aws_ec2.ISubnet[], provisionedConcurrency: number = 0, ) => { // Remove any undefined values from the environment object @@ -175,20 +138,20 @@ export class ApiStack extends NestedStack { } } - const logGroup = new LogGroup(this, `${id}LogGroup`, { + const logGroup = new cdk.aws_logs.LogGroup(this, `${id}LogGroup`, { logGroupName: `/aws/lambda/${project}-${stage}-${stack}-${id}`, - removalPolicy: RemovalPolicy.DESTROY, + removalPolicy: cdk.RemovalPolicy.DESTROY, }); const fn = new NodejsFunction(this, id, { - runtime: Runtime.NODEJS_18_X, + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, functionName: `${project}-${stage}-${stack}-${id}`, - depsLockFilePath: join(__dirname, "../bun.lockb"), + depsLockFilePath: join(__dirname, "../../bun.lockb"), entry, handler: "handler", role: lambdaRole, environment: sanitizedEnvironment, - timeout: Duration.seconds(30), + timeout: cdk.Duration.seconds(30), memorySize: 1024, retryAttempts: 0, vpc: vpc, @@ -205,7 +168,7 @@ export class ApiStack extends NestedStack { const version = fn.currentVersion; // Configure provisioned concurrency - new Alias(this, `FunctionAlias${id}`, { + new cdk.aws_lambda.Alias(this, `FunctionAlias${id}`, { aliasName: "prod", version: version, provisionedConcurrentExecutions: provisionedConcurrency, @@ -217,7 +180,7 @@ export class ApiStack extends NestedStack { const lambdaDefinitions = [ { id: "getUploadUrl", - entry: join(__dirname, "lambda/getUploadUrl.ts"), + entry: join(__dirname, "../lambda/getUploadUrl.ts"), environment: { attachmentsBucketName: attachmentsBucket.bucketName, attachmentsBucketRegion: this.region, @@ -225,7 +188,7 @@ export class ApiStack extends NestedStack { }, { id: "search", - entry: join(__dirname, "lambda/search.ts"), + entry: join(__dirname, "../lambda/search.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -234,7 +197,7 @@ export class ApiStack extends NestedStack { }, { id: "getPackageActions", - entry: join(__dirname, "lambda/getPackageActions.ts"), + entry: join(__dirname, "../lambda/getPackageActions.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, legacyS3AccessRoleArn, @@ -243,7 +206,7 @@ export class ApiStack extends NestedStack { }, { id: "getAttachmentUrl", - entry: join(__dirname, "lambda/getAttachmentUrl.ts"), + entry: join(__dirname, "../lambda/getAttachmentUrl.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, legacyS3AccessRoleArn, @@ -253,7 +216,7 @@ export class ApiStack extends NestedStack { }, { id: "item", - entry: join(__dirname, "lambda/item.ts"), + entry: join(__dirname, "../lambda/item.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -262,7 +225,7 @@ export class ApiStack extends NestedStack { }, { id: "submit", - entry: join(__dirname, "lambda/submit.ts"), + entry: join(__dirname, "../lambda/submit.ts"), environment: { dbInfoSecretName, topicName, @@ -274,7 +237,7 @@ export class ApiStack extends NestedStack { }, { id: "action", - entry: join(__dirname, "lambda/action.ts"), + entry: join(__dirname, "../lambda/action.ts"), environment: { dbInfoSecretName, topicName, @@ -285,7 +248,7 @@ export class ApiStack extends NestedStack { }, { id: "getTypes", - entry: join(__dirname, "lambda/getTypes.ts"), + entry: join(__dirname, "../lambda/getTypes.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -293,7 +256,7 @@ export class ApiStack extends NestedStack { }, { id: "getSubTypes", - entry: join(__dirname, "lambda/getSubTypes.ts"), + entry: join(__dirname, "../lambda/getSubTypes.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -301,7 +264,7 @@ export class ApiStack extends NestedStack { }, { id: "getCpocs", - entry: join(__dirname, "lambda/getCpocs.ts"), + entry: join(__dirname, "../lambda/getCpocs.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -309,7 +272,7 @@ export class ApiStack extends NestedStack { }, { id: "itemExists", - entry: join(__dirname, "lambda/itemExists.ts"), + entry: join(__dirname, "../lambda/itemExists.ts"), environment: { osDomain: `https://${openSearchDomainEndpoint}`, indexNamespace, @@ -317,17 +280,17 @@ export class ApiStack extends NestedStack { }, { id: "forms", - entry: join(__dirname, "lambda/getForm.ts"), + entry: join(__dirname, "../lambda/getForm.ts"), environment: {}, }, { id: "getAllForms", - entry: join(__dirname, "lambda/getAllForms.ts"), + entry: join(__dirname, "../lambda/getAllForms.ts"), environment: {}, }, { id: "appkNewSubmission", - entry: join(__dirname, "lambda/appkNewSubmission.ts"), + entry: join(__dirname, "../lambda/appkNewSubmission.ts"), environment: { dbInfoSecretName, topicName, @@ -353,18 +316,18 @@ export class ApiStack extends NestedStack { }, {} as { [key: string]: NodejsFunction }); // Create IAM role for API Gateway to invoke Lambda functions - const apiGatewayRole = new Role(this, "ApiGatewayRole", { - assumedBy: new ServicePrincipal("apigateway.amazonaws.com"), + const apiGatewayRole = new cdk.aws_iam.Role(this, "ApiGatewayRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("apigateway.amazonaws.com"), inlinePolicies: { - InvokeLambdaPolicy: new PolicyDocument({ + InvokeLambdaPolicy: new cdk.aws_iam.PolicyDocument({ statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, actions: ["lambda:InvokeFunction"], resources: ["arn:aws:lambda:*:*:function:*"], }), - new PolicyStatement({ - effect: Effect.DENY, + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, actions: ["logs:CreateLogGroup"], resources: ["*"], }), @@ -373,40 +336,41 @@ export class ApiStack extends NestedStack { }, }); - const apiExecutionLogsLogGroup = new LogGroup( + const apiExecutionLogsLogGroup = new cdk.aws_logs.LogGroup( this, "ApiGatewayExecutionLogsLogGroup", { - removalPolicy: RemovalPolicy.DESTROY, + removalPolicy: cdk.RemovalPolicy.DESTROY, }, ); // Define API Gateway - const api = new RestApi(this, "APIGateway", { + const api = new cdk.aws_apigateway.RestApi(this, "APIGateway", { restApiName: `${project}-${stage}`, deployOptions: { stageName: stage, - loggingLevel: MethodLoggingLevel.INFO, + loggingLevel: cdk.aws_apigateway.MethodLoggingLevel.INFO, dataTraceEnabled: true, metricsEnabled: true, - accessLogDestination: new LogGroupLogDestination( + accessLogDestination: new cdk.aws_apigateway.LogGroupLogDestination( apiExecutionLogsLogGroup, ), - accessLogFormat: AccessLogFormat.jsonWithStandardFields({ - caller: true, - httpMethod: true, - ip: true, - protocol: true, - requestTime: true, - resourcePath: true, - responseLength: true, - status: true, - user: true, - }), + accessLogFormat: + cdk.aws_apigateway.AccessLogFormat.jsonWithStandardFields({ + caller: true, + httpMethod: true, + ip: true, + protocol: true, + requestTime: true, + resourcePath: true, + responseLength: true, + status: true, + user: true, + }), }, defaultCorsPreflightOptions: { - allowOrigins: Cors.ALL_ORIGINS, - allowMethods: Cors.ALL_METHODS, + allowOrigins: cdk.aws_apigateway.Cors.ALL_ORIGINS, + allowMethods: cdk.aws_apigateway.Cors.ALL_METHODS, allowHeaders: [ "Content-Type", "Authorization", @@ -418,29 +382,37 @@ export class ApiStack extends NestedStack { allowCredentials: true, }, endpointConfiguration: { - types: [EndpointType.EDGE], + types: [cdk.aws_apigateway.EndpointType.EDGE], }, }); // Add GatewayResponse for 4XX errors - new CfnGatewayResponse(this, "GatewayResponseDefault4XX", { - restApiId: api.restApiId, - responseType: "DEFAULT_4XX", - responseParameters: { - "gatewayresponse.header.Access-Control-Allow-Origin": "'*'", - "gatewayresponse.header.Access-Control-Allow-Headers": "'*'", + new cdk.aws_apigateway.CfnGatewayResponse( + this, + "GatewayResponseDefault4XX", + { + restApiId: api.restApiId, + responseType: "DEFAULT_4XX", + responseParameters: { + "gatewayresponse.header.Access-Control-Allow-Origin": "'*'", + "gatewayresponse.header.Access-Control-Allow-Headers": "'*'", + }, }, - }); + ); // Add GatewayResponse for 5XX errors - new CfnGatewayResponse(this, "GatewayResponseDefault5XX", { - restApiId: api.restApiId, - responseType: "DEFAULT_5XX", - responseParameters: { - "gatewayresponse.header.Access-Control-Allow-Origin": "'*'", - "gatewayresponse.header.Access-Control-Allow-Headers": "'*'", + new cdk.aws_apigateway.CfnGatewayResponse( + this, + "GatewayResponseDefault5XX", + { + restApiId: api.restApiId, + responseType: "DEFAULT_5XX", + responseParameters: { + "gatewayresponse.header.Access-Control-Allow-Origin": "'*'", + "gatewayresponse.header.Access-Control-Allow-Headers": "'*'", + }, }, - }); + ); const apiResources = { search: { @@ -505,20 +477,23 @@ export class ApiStack extends NestedStack { const addApiResource = ( path: string, - lambdaFunction: Function, + lambdaFunction: cdk.aws_lambda.Function, method: string = "POST", ) => { const resource = api.root.resourceForPath(path); // Define the integration for the Lambda function - const integration = new LambdaIntegration(lambdaFunction, { - proxy: true, - credentialsRole: apiGatewayRole, - }); + const integration = new cdk.aws_apigateway.LambdaIntegration( + lambdaFunction, + { + proxy: true, + credentialsRole: apiGatewayRole, + }, + ); // Add method for specified HTTP method resource.addMethod(method, integration, { - authorizationType: AuthorizationType.IAM, + authorizationType: cdk.aws_apigateway.AuthorizationType.IAM, apiKeyRequired: false, methodResponses: [ { @@ -538,23 +513,27 @@ export class ApiStack extends NestedStack { }); // Define CloudWatch Alarms - const createCloudWatchAlarm = (id: string, lambdaFunction: Function) => { - const alarm = new Alarm(this, id, { + const createCloudWatchAlarm = ( + id: string, + lambdaFunction: cdk.aws_lambda.Function, + ) => { + const alarm = new cdk.aws_cloudwatch.Alarm(this, id, { alarmName: `${project}-${stage}-${id}Alarm`, - metric: new Metric({ + metric: new cdk.aws_cloudwatch.Metric({ namespace: `${project}-api/Errors`, metricName: "LambdaErrorCount", dimensionsMap: { FunctionName: lambdaFunction.functionName, }, statistic: "Sum", - period: Duration.minutes(5), + period: cdk.Duration.minutes(5), }), threshold: 1, evaluationPeriods: 1, comparisonOperator: - ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treatMissingData: TreatMissingData.NOT_BREACHING, + cdk.aws_cloudwatch.ComparisonOperator + .GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: cdk.aws_cloudwatch.TreatMissingData.NOT_BREACHING, }); alarm.addAlarmAction({ @@ -568,16 +547,20 @@ export class ApiStack extends NestedStack { createCloudWatchAlarm(`${lambdaFunc.node.id}ErrorAlarm`, lambdaFunc); }); - const waf = new RegionalWaf(this, "WafConstruct", { + const waf = new LC.RegionalWaf(this, "WafConstruct", { name: `${project}-${stage}-${stack}`, apiGateway: api, }); - const cloudwatchToS3 = new CloudWatchToS3(this, "CloudWatchToS3Construct", { - logGroup: waf.logGroup, - }); + const cloudwatchToS3 = new LC.CloudWatchToS3( + this, + "CloudWatchToS3Construct", + { + logGroup: waf.logGroup, + }, + ); - new EmptyBuckets(this, "EmptyBuckets", { + new LC.EmptyBuckets(this, "EmptyBuckets", { buckets: [cloudwatchToS3.logBucket], }); diff --git a/lib/stacks/auth.ts b/lib/stacks/auth.ts new file mode 100644 index 0000000000..0e616a4cd5 --- /dev/null +++ b/lib/stacks/auth.ts @@ -0,0 +1,323 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; + +import { DeploymentConfigProperties } from "./deployment-config"; +import { ManageUsers } from "local-constructs"; + +interface AuthStackProps extends cdk.NestedStackProps { + project: string; + stage: string; + stack: string; + isDev: boolean; + vpc: cdk.aws_ec2.IVpc; + privateSubnets: cdk.aws_ec2.ISubnet[]; + lambdaSecurityGroup: cdk.aws_ec2.ISecurityGroup; + apiGateway: cdk.aws_apigateway.RestApi; + applicationEndpointUrl: string; + idmEnable: DeploymentConfigProperties["idmEnable"]; + idmClientSecretArn: DeploymentConfigProperties["idmClientSecretArn"]; + idmClientId: DeploymentConfigProperties["idmClientId"]; + idmClientIssuer: DeploymentConfigProperties["idmClientIssuer"]; + idmAuthzApiEndpoint: DeploymentConfigProperties["idmAuthzApiEndpoint"]; + idmAuthzApiKeyArn: DeploymentConfigProperties["idmAuthzApiKeyArn"]; + devPasswordArn: DeploymentConfigProperties["devPasswordArn"]; +} + +export class Auth extends cdk.NestedStack { + public readonly userPool: cdk.aws_cognito.UserPool; + public readonly userPoolClient: cdk.aws_cognito.CfnUserPoolClient; + public readonly userPoolClientDomain: string; + public readonly identityPool: cdk.aws_cognito.CfnIdentityPool; + + constructor(scope: Construct, id: string, props: AuthStackProps) { + super(scope, id, props); + const resources = this.initializeResources(props); + this.userPool = resources.userPool; + this.userPoolClient = resources.userPoolClient; + this.userPoolClientDomain = `${resources.userPoolDomain.domain}.auth.${this.region}.amazoncognito.com`; + this.identityPool = resources.identityPool; + } + + private initializeResources(props: AuthStackProps): { + userPool: cdk.aws_cognito.UserPool; + userPoolClient: cdk.aws_cognito.CfnUserPoolClient; + userPoolDomain: cdk.aws_cognito.CfnUserPoolDomain; + identityPool: cdk.aws_cognito.CfnIdentityPool; + } { + const { project, stage, stack, isDev } = props; + const { + apiGateway, + applicationEndpointUrl, + vpc, + privateSubnets, + lambdaSecurityGroup, + idmEnable, + idmClientId, + idmClientIssuer, + idmAuthzApiEndpoint, + devPasswordArn, + idmClientSecretArn, + idmAuthzApiKeyArn, + } = props; + const idmClientSecret = cdk.aws_secretsmanager.Secret.fromSecretCompleteArn( + this, + "IdmInfo", + idmClientSecretArn, + ); + + // Cognito User Pool + const userPool = new cdk.aws_cognito.UserPool(this, "CognitoUserPool", { + userPoolName: `${project}-${stage}-${stack}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + signInAliases: { + email: true, + }, + autoVerify: { + email: true, + }, + selfSignUpEnabled: false, // This corresponds to allowAdminCreateUserOnly: true + email: cdk.aws_cognito.UserPoolEmail.withCognito( + "no-reply@yourdomain.com", + ), + standardAttributes: { + givenName: { + required: true, + mutable: true, + }, + familyName: { + required: true, + mutable: true, + }, + }, + customAttributes: { + state: new cdk.aws_cognito.StringAttribute({ mutable: true }), + "cms-roles": new cdk.aws_cognito.StringAttribute({ mutable: true }), + }, + }); + let userPoolIdentityProviderOidc: + | cdk.aws_cognito.UserPoolIdentityProviderOidc + | undefined = undefined; + if (idmEnable) { + userPoolIdentityProviderOidc = + new cdk.aws_cognito.UserPoolIdentityProviderOidc( + this, + "UserPoolIdentityProviderIDM", + { + userPool, + name: "IDM", + clientId: idmClientId, + clientSecret: idmClientSecret.secretValue.unsafeUnwrap(), + issuerUrl: idmClientIssuer, + attributeMapping: { + email: cdk.aws_cognito.ProviderAttribute.other("email"), + givenName: cdk.aws_cognito.ProviderAttribute.other("given_name"), + familyName: + cdk.aws_cognito.ProviderAttribute.other("family_name"), + custom: { + "custom:username": + cdk.aws_cognito.ProviderAttribute.other("preferred_username"), + }, + }, + attributeRequestMethod: + cdk.aws_cognito.OidcAttributeRequestMethod.GET, + scopes: ["email", "openid", "profile", "phone"], + identifiers: ["IDM"], + }, + ); + } + + // Cognito User Pool Client + const userPoolClient = new cdk.aws_cognito.CfnUserPoolClient( + this, + "CognitoUserPoolClient", + { + clientName: `${project}-${stage}-${stack}`, + userPoolId: userPool.userPoolId, + explicitAuthFlows: ["ADMIN_NO_SRP_AUTH"], + generateSecret: false, + allowedOAuthFlows: ["code"], + allowedOAuthFlowsUserPoolClient: true, + allowedOAuthScopes: [ + "email", + "openid", + "aws.cognito.signin.user.admin", + ], + callbackUrLs: [applicationEndpointUrl, "http://localhost:5000/"], + defaultRedirectUri: applicationEndpointUrl, + logoutUrLs: [applicationEndpointUrl, "http://localhost:5000/"], + supportedIdentityProviders: userPoolIdentityProviderOidc + ? ["COGNITO", userPoolIdentityProviderOidc.providerName] + : ["COGNITO"], + accessTokenValidity: 30, + idTokenValidity: 30, + refreshTokenValidity: 12, + tokenValidityUnits: { + accessToken: "minutes", + idToken: "minutes", + refreshToken: "hours", + }, + }, + ); + + const userPoolDomain = new cdk.aws_cognito.CfnUserPoolDomain( + this, + "UserPoolDomain", + { + domain: `${stage}-login-${userPoolClient.ref}`, + userPoolId: userPool.userPoolId, + }, + ); + + // Cognito Identity Pool + const identityPool = new cdk.aws_cognito.CfnIdentityPool( + this, + "CognitoIdentityPool", + { + identityPoolName: `${project}-${stage}-${stack}`, + allowUnauthenticatedIdentities: false, + cognitoIdentityProviders: [ + { + clientId: userPoolClient.ref, + providerName: userPool.userPoolProviderName, + }, + ], + }, + ); + + // IAM Role for Cognito Authenticated Users + const authRole = new cdk.aws_iam.Role(this, "CognitoAuthRole", { + assumedBy: new cdk.aws_iam.FederatedPrincipal( + "cognito-identity.amazonaws.com", + { + StringEquals: { + "cognito-identity.amazonaws.com:aud": identityPool.ref, + }, + "ForAnyValue:StringLike": { + "cognito-identity.amazonaws.com:amr": "authenticated", + }, + }, + "sts:AssumeRoleWithWebIdentity", + ), + inlinePolicies: { + CognitoAuthorizedPolicy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + actions: ["execute-api:Invoke"], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${apiGateway.restApiId}/*`, + ], + effect: cdk.aws_iam.Effect.ALLOW, + }), + ], + }), + }, + }); + + new cdk.aws_cognito.CfnIdentityPoolRoleAttachment( + this, + "CognitoIdentityPoolRoles", + { + identityPoolId: identityPool.ref, + roles: { authenticated: authRole.roleArn }, + }, + ); + + const manageUsers = new ManageUsers( + this, + "ManageUsers", + userPool, + JSON.parse( + readFileSync( + join(__dirname, "../../test/users/app-users.json"), + "utf8", + ), + ), + devPasswordArn, + ); + + if (idmEnable) { + const postAuthLambdaLogGroup = new cdk.aws_logs.LogGroup( + this, + "PostAuthLambdaLogGroup", + { + logGroupName: `/aws/lambda/${project}-${stage}-${stack}-postAuth`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }, + ); + + const postAuthLambdaRole = new cdk.aws_iam.Role( + this, + "PostAuthLambdaRole", + { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole", + ), + ], + inlinePolicies: { + DataStackLambdarole: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: [ + "cognito-idp:AdminGetUser", + "cognito-idp:AdminCreateUser", + "cognito-idp:AdminSetUserPassword", + "cognito-idp:AdminUpdateUserAttributes", + ], + resources: [ + `arn:aws:cognito-idp:${this.region}:${this.account}:userpool/us-east-*`, + ], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + ], + resources: [idmAuthzApiKeyArn], + }), + ], + }), + }, + }, + ); + const postAuthLambda = new NodejsFunction(this, "PostAuthLambda", { + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + entry: join(__dirname, "../lambda/postAuth.ts"), + handler: "handler", + role: postAuthLambdaRole, + depsLockFilePath: join(__dirname, "../../bun.lockb"), + environment: { + idmAuthzApiEndpoint, + idmAuthzApiKeyArn, + }, + timeout: cdk.Duration.seconds(30), + memorySize: 1024, + retryAttempts: 0, + vpc: vpc, + securityGroups: [lambdaSecurityGroup], + vpcSubnets: { subnets: privateSubnets }, + logGroup: postAuthLambdaLogGroup, + bundling: { + minify: true, + sourceMap: true, + }, + }); + + userPool.addTrigger( + cdk.aws_cognito.UserPoolOperation.PRE_TOKEN_GENERATION, + postAuthLambda, + ); + } + + return { userPool, userPoolClient, userPoolDomain, identityPool }; + } +} diff --git a/lib/stacks/data.ts b/lib/stacks/data.ts new file mode 100644 index 0000000000..885b8fa782 --- /dev/null +++ b/lib/stacks/data.ts @@ -0,0 +1,928 @@ +import * as cdk from "aws-cdk-lib"; +import * as cr from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; +import * as LC from "local-constructs"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; + +interface DataStackProps extends cdk.NestedStackProps { + project: string; + stage: string; + stack: string; + isDev: boolean; + vpc: cdk.aws_ec2.IVpc; + privateSubnets: cdk.aws_ec2.ISubnet[]; + brokerString: string; + lambdaSecurityGroup: cdk.aws_ec2.ISecurityGroup; + topicNamespace: string; + indexNamespace: string; + sharedOpenSearchDomainEndpoint: string; + sharedOpenSearchDomainArn: string; + devPasswordArn: string; +} + +export class Data extends cdk.NestedStack { + public readonly openSearchDomainArn: string; + public readonly openSearchDomainEndpoint: string; + private mapRoleCustomResource: cdk.CustomResource; + + constructor(scope: Construct, id: string, props: DataStackProps) { + super(scope, id, props); + const resources = this.initializeResources(props); + this.openSearchDomainEndpoint = resources.openSearchDomainEndpoint; + this.openSearchDomainArn = resources.openSearchDomainArn; + } + + private initializeResources(props: DataStackProps): { + openSearchDomainArn: string; + openSearchDomainEndpoint: string; + } { + const { + project, + stage, + stack, + isDev, + vpc, + privateSubnets, + brokerString, + lambdaSecurityGroup, + topicNamespace, + indexNamespace, + sharedOpenSearchDomainEndpoint, + sharedOpenSearchDomainArn, + devPasswordArn, + } = props; + const consumerGroupPrefix = `--${project}--${stage}--`; + + let openSearchDomainEndpoint; + let openSearchDomainArn; + + const usingSharedOpenSearch = + sharedOpenSearchDomainEndpoint && sharedOpenSearchDomainArn; + + if (usingSharedOpenSearch) { + openSearchDomainEndpoint = sharedOpenSearchDomainEndpoint; + openSearchDomainArn = sharedOpenSearchDomainArn; + } else { + const userPool = new cdk.aws_cognito.UserPool(this, "UserPool", { + userPoolName: `${project}-${stage}-search`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + selfSignUpEnabled: false, + signInAliases: { email: true }, + autoVerify: { email: true }, + standardAttributes: { email: { required: true, mutable: true } }, + }); + + const userPoolDomain = new cdk.aws_cognito.UserPoolDomain( + this, + "UserPoolDomain", + { + userPool, + cognitoDomain: { + domainPrefix: `${project}-${stage}-search`, + }, + }, + ); + + const userPoolClient = new cdk.aws_cognito.UserPoolClient( + this, + "UserPoolClient", + { + userPool, + authFlows: { adminUserPassword: true }, + }, + ); + + const identityPool = new cdk.aws_cognito.CfnIdentityPool( + this, + "IdentityPool", + { + allowUnauthenticatedIdentities: false, + cognitoIdentityProviders: [ + { + clientId: userPoolClient.userPoolClientId, + providerName: userPool.userPoolProviderName, + }, + ], + }, + ); + + const cognitoAuthRole = new cdk.aws_iam.Role(this, "CognitoAuthRole", { + assumedBy: new cdk.aws_iam.FederatedPrincipal( + "cognito-identity.amazonaws.com", + { + StringEquals: { + "cognito-identity.amazonaws.com:aud": identityPool.ref, + }, + "ForAnyValue:StringLike": { + "cognito-identity.amazonaws.com:amr": "authenticated", + }, + }, + "sts:AssumeRoleWithWebIdentity", + ), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "AmazonCognitoReadOnly", + ), + ], + }); + + cognitoAuthRole.assumeRolePolicy?.addStatements( + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + principals: [new cdk.aws_iam.ServicePrincipal("es.amazonaws.com")], + actions: ["sts:AssumeRole"], + }), + ); + + new cdk.aws_cognito.CfnIdentityPoolRoleAttachment( + this, + "IdentityPoolRoleAttachment", + { + identityPoolId: identityPool.ref, + roles: { authenticated: cognitoAuthRole.roleArn }, + }, + ); + + const openSearchSecurityGroup = new cdk.aws_ec2.SecurityGroup( + this, + "OpenSearchSecurityGroup", + { + vpc, + description: "Security group for OpenSearch", + }, + ); + openSearchSecurityGroup.addIngressRule( + cdk.aws_ec2.Peer.ipv4("10.0.0.0/8"), + cdk.aws_ec2.Port.tcp(443), + "Allow HTTPS access from VPC", + ); + + const openSearchRole = new cdk.aws_iam.Role(this, "OpenSearchRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("es.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "AmazonOpenSearchServiceCognitoAccess", + ), + ], + }); + + const openSearchMasterRole = new cdk.aws_iam.Role( + this, + "OpenSearchMasterRole", + { + assumedBy: new cdk.aws_iam.ServicePrincipal("es.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "AmazonOpenSearchServiceFullAccess", + ), + ], + }, + ); + + openSearchMasterRole.assumeRolePolicy?.addStatements( + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + principals: [ + new cdk.aws_iam.AccountPrincipal(cdk.Stack.of(this).account), + ], + actions: ["sts:AssumeRole"], + }), + ); + + const openSearchDomain = new cdk.aws_opensearchservice.CfnDomain( + this, + "OpenSearchDomain", + { + ebsOptions: { ebsEnabled: true, volumeType: "gp3", volumeSize: 20 }, + clusterConfig: { + instanceType: "or1.medium.search", + instanceCount: 3, + dedicatedMasterEnabled: false, + zoneAwarenessEnabled: true, + zoneAwarenessConfig: { availabilityZoneCount: 3 }, + }, + encryptionAtRestOptions: { enabled: true }, + nodeToNodeEncryptionOptions: { enabled: true }, + domainEndpointOptions: { + enforceHttps: true, + tlsSecurityPolicy: "Policy-Min-TLS-1-2-2019-07", + }, + cognitoOptions: { + enabled: true, + identityPoolId: identityPool.ref, + roleArn: openSearchRole.roleArn, + userPoolId: userPool.userPoolId, + }, + accessPolicies: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + actions: ["es:*"], + principals: [ + new cdk.aws_iam.ArnPrincipal(cognitoAuthRole.roleArn), + ], + resources: ["*"], + }), + ], + }), + advancedSecurityOptions: { + enabled: true, + internalUserDatabaseEnabled: false, + masterUserOptions: { masterUserArn: openSearchMasterRole.roleArn }, + }, + vpcOptions: { + securityGroupIds: [openSearchSecurityGroup.securityGroupId], + subnetIds: privateSubnets + .slice(0, 3) + .map((subnet) => subnet.subnetId), + }, + }, + ); + + new LC.ManageUsers( + this, + "ManageUsers", + userPool, + JSON.parse( + readFileSync( + join(__dirname, "../../test/users/kibana-users.json"), + "utf8", + ), + ), + devPasswordArn, + ); + + const mapRole = new NodejsFunction(this, "MapRoleLambdaFunction", { + functionName: `${project}-${stage}-${stack}-mapRole`, + entry: join(__dirname, "../lambda/mapRole.ts"), + handler: "handler", + depsLockFilePath: join(__dirname, "../../bun.lockb"), + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + role: new cdk.aws_iam.Role(this, "MapRoleLambdaExecutionRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole", + ), + ], + inlinePolicies: { + LambdaAssumeRolePolicy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: [ + "es:ESHttpHead", + "es:ESHttpPost", + "es:ESHttpGet", + "es:ESHttpPatch", + "es:ESHttpDelete", + "es:ESHttpPut", + ], + resources: [`${openSearchDomain.attrArn}/*`], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: ["sts:AssumeRole"], + resources: [openSearchMasterRole.roleArn], + }), + ], + }), + }, + }), + vpc, + vpcSubnets: { + subnets: privateSubnets, + }, + securityGroups: [lambdaSecurityGroup], + environment: { + brokerString, + region: this.region, + osDomain: `https://${openSearchDomain.attrDomainEndpoint}`, + }, + bundling: { + minify: true, + sourceMap: true, + }, + }); + + const customResourceProvider = new cdk.custom_resources.Provider( + this, + "CustomResourceProvider", + { + onEventHandler: mapRole, + }, + ); + + this.mapRoleCustomResource = new cdk.CustomResource(this, "MapRole", { + serviceToken: customResourceProvider.serviceToken, + properties: { + OsDomain: `https://${openSearchDomain.attrDomainEndpoint}`, + IamRoleName: `arn:aws:iam::${this.account}:role/*`, + MasterRoleToAssume: openSearchMasterRole.roleArn, + OsRoleName: "all_access", + }, + }); + + openSearchDomainEndpoint = openSearchDomain.attrDomainEndpoint; + openSearchDomainArn = openSearchDomain.attrArn; + } + + new LC.CreateTopics(this, "createTopics", { + brokerString, + privateSubnets, + securityGroups: [lambdaSecurityGroup], + topics: [ + { + topic: `${topicNamespace}aws.onemac.migration.cdc`, + }, + ], + vpc, + }); + + if (isDev) { + new LC.CleanupKafka(this, "cleanupKafka", { + vpc, + privateSubnets, + securityGroups: [lambdaSecurityGroup], + brokerString, + topicPatternsToDelete: [`${topicNamespace}aws.onemac.migration.cdc`], + }); + } + + const createLambda = ({ + id, + entry = `${id}.ts`, + role, + useVpc = false, + environment = {}, + timeout = cdk.Duration.minutes(5), + memorySize = 1024, + provisionedConcurrency = 0, + }: { + id: string; + entry?: string; + role: cdk.aws_iam.Role; + useVpc?: boolean; + environment?: { [key: string]: string }; + timeout?: cdk.Duration; + memorySize?: number; + provisionedConcurrency?: number; + }) => { + const logGroup = new cdk.aws_logs.LogGroup(this, `${id}LogGroup`, { + logGroupName: `/aws/lambda/${project}-${stage}-${stack}-${id}`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const fn = new NodejsFunction(this, id, { + functionName: `${project}-${stage}-${stack}-${id}`, + depsLockFilePath: join(__dirname, "../../bun.lockb"), + entry: join(__dirname, `../lambda/${entry}`), + handler: "handler", + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + role, + memorySize, + vpc: useVpc ? vpc : undefined, + vpcSubnets: useVpc ? { subnets: privateSubnets } : undefined, + securityGroups: useVpc ? [lambdaSecurityGroup] : undefined, + environment, + logGroup, + timeout, + bundling: { + minify: true, + sourceMap: true, + }, + }); + + if (provisionedConcurrency > 0) { + const version = fn.currentVersion; + + // Configure provisioned concurrency + new cdk.aws_lambda.Alias(this, `FunctionAlias${id}`, { + aliasName: "prod", + version: version, + provisionedConcurrentExecutions: provisionedConcurrency, + }); + } + + return fn; + }; + + const sharedLambdaRole = new cdk.aws_iam.Role( + this, + "SharedLambdaExecutionRole", + { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole", + ), + ], + inlinePolicies: { + DataStackLambdarole: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: [ + "es:ESHttpHead", + "es:ESHttpPost", + "es:ESHttpGet", + "es:ESHttpPatch", + "es:ESHttpDelete", + "es:ESHttpPut", + ], + resources: [`${openSearchDomainArn}/*`], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: [ + "lambda:CreateEventSourceMapping", + "lambda:ListEventSourceMappings", + "lambda:PutFunctionConcurrency", + "lambda:DeleteEventSourceMapping", + "lambda:UpdateEventSourceMapping", + "lambda:GetEventSourceMapping", + ], + resources: ["*"], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: ["ec2:DescribeSecurityGroups", "ec2:DescribeVpcs"], + resources: ["*"], + }), + ], + }), + }, + }, + ); + + const functionConfigs = { + sinkChangelog: { provisionedConcurrency: 2 }, + sinkInsights: { provisionedConcurrency: 0 }, + sinkLegacyInsights: { provisionedConcurrency: 0 }, + sinkMain: { provisionedConcurrency: 2 }, + sinkSubtypes: { provisionedConcurrency: 0 }, + sinkTypes: { provisionedConcurrency: 0 }, + sinkCpocs: { provisionedConcurrency: 0 }, + }; + + const lambdaFunctions = Object.entries(functionConfigs).reduce( + (acc, [name, config]) => { + acc[name] = createLambda({ + id: name, + role: sharedLambdaRole, + useVpc: true, + environment: { + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }, + provisionedConcurrency: !props.isDev + ? config.provisionedConcurrency + : 0, + }); + return acc; + }, + {} as { [key: string]: NodejsFunction }, + ); + + const stateMachineRole = new cdk.aws_iam.Role(this, "StateMachineRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("states.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "CloudWatchLogsFullAccess", + ), + ], + inlinePolicies: { + StateMachinePolicy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: [ + `arn:aws:lambda:${this.region}:${this.account}:function:${project}-${stage}-${stack}-*`, + ], + }), + ], + }), + }, + }); + + const cfnNotify = createLambda({ + id: "cfnNotify", + entry: "cfnNotify.ts", + role: sharedLambdaRole, + }); + const createTriggers = createLambda({ + id: "createTriggers", + role: sharedLambdaRole, + timeout: cdk.Duration.minutes(15), + }); + const checkConsumerLag = createLambda({ + id: "checkConsumerLag", + role: sharedLambdaRole, + useVpc: true, + }); + const deleteTriggers = createLambda({ + id: "deleteTriggers", + role: sharedLambdaRole, + }); + const deleteIndex = createLambda({ + id: "deleteIndex", + role: sharedLambdaRole, + useVpc: true, + }); + const setupIndex = createLambda({ + id: "setupIndex", + role: sharedLambdaRole, + useVpc: true, + }); + + const notifyState = (name: string, success: boolean) => + new cdk.aws_stepfunctions_tasks.LambdaInvoke(this, name, { + lambdaFunction: cfnNotify, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + Success: success, + }), + }); + const failureState = new cdk.aws_stepfunctions.Fail(this, "FailureState"); + const notifyOfFailureStep = new cdk.aws_stepfunctions_tasks.LambdaInvoke( + this, + "NotifyOfFailure", + { + lambdaFunction: cfnNotify, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + Success: false, + }), + }, + ).next(failureState); + + const checkDataProgressTask = new cdk.aws_stepfunctions_tasks.LambdaInvoke( + this, + "CheckDataProgress", + { + lambdaFunction: checkConsumerLag, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + brokerString, + triggers: [ + { + function: lambdaFunctions.sinkMain.functionName, + topics: [ + "aws.seatool.ksql.onemac.agg.State_Plan", + "aws.onemac.migration.cdc", + `${topicNamespace}aws.onemac.migration.cdc`, + "aws.seatool.debezium.changed_date.SEA.dbo.State_Plan", + ], + }, + { + function: lambdaFunctions.sinkChangelog.functionName, + topics: [ + "aws.onemac.migration.cdc", + `${topicNamespace}aws.onemac.migration.cdc`, + ], + }, + { + function: lambdaFunctions.sinkTypes.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.SPA_Type"], + batchSize: 10000, + }, + { + function: lambdaFunctions.sinkSubtypes.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.Type"], + batchSize: 10000, + }, + { + function: lambdaFunctions.sinkCpocs.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.Officers"], + }, + ], + }), + }, + ).addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }); + + const definition = new cdk.aws_stepfunctions_tasks.LambdaInvoke( + this, + "DeleteAllTriggers", + { + lambdaFunction: deleteTriggers, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + functions: Object.values(lambdaFunctions).map( + (fn) => fn.functionName, + ), + }), + }, + ) + .addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }) + .next( + new cdk.aws_stepfunctions_tasks.LambdaInvoke(this, "DeleteIndex", { + lambdaFunction: deleteIndex, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }), + }).addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }), + ) + .next( + new cdk.aws_stepfunctions_tasks.LambdaInvoke(this, "SetupIndex", { + lambdaFunction: setupIndex, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + osDomain: `https://${openSearchDomainEndpoint}`, + indexNamespace, + }), + }).addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }), + ) + .next( + new cdk.aws_stepfunctions_tasks.LambdaInvoke( + this, + "StartIndexingData", + { + lambdaFunction: createTriggers, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + osDomain: `https://${openSearchDomainEndpoint}`, + brokerString, + securityGroup: lambdaSecurityGroup.securityGroupId, + consumerGroupPrefix, + subnets: privateSubnets + .slice(0, 3) + .map((subnet) => subnet.subnetId), + triggers: [ + { + function: lambdaFunctions.sinkMain.functionName, + topics: [ + "aws.seatool.ksql.onemac.agg.State_Plan", + "aws.onemac.migration.cdc", + `${topicNamespace}aws.onemac.migration.cdc`, + "aws.seatool.debezium.changed_date.SEA.dbo.State_Plan", + ], + }, + { + function: lambdaFunctions.sinkChangelog.functionName, + topics: [ + "aws.onemac.migration.cdc", + `${topicNamespace}aws.onemac.migration.cdc`, + ], + }, + { + function: lambdaFunctions.sinkTypes.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.SPA_Type"], + batchSize: 10000, + }, + { + function: lambdaFunctions.sinkSubtypes.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.Type"], + batchSize: 10000, + }, + { + function: lambdaFunctions.sinkCpocs.functionName, + topics: ["aws.seatool.debezium.cdc.SEA.dbo.Officers"], + }, + ], + }), + }, + ).addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }), + ) + .next(checkDataProgressTask) + .next( + new cdk.aws_stepfunctions.Choice(this, "IsDataReady") + .when( + cdk.aws_stepfunctions.Condition.booleanEquals("$.ready", true), + new cdk.aws_stepfunctions_tasks.LambdaInvoke( + this, + "StartIndexingInsights", + { + lambdaFunction: createTriggers, + outputPath: "$.Payload", + payload: cdk.aws_stepfunctions.TaskInput.fromObject({ + "Context.$": "$$", + osDomain: `https://${openSearchDomainEndpoint}`, + brokerString, + securityGroup: lambdaSecurityGroup.securityGroupId, + consumerGroupPrefix, + subnets: privateSubnets + .slice(0, 3) + .map((subnet) => subnet.subnetId), + triggers: [ + { + function: lambdaFunctions.sinkInsights.functionName, + topics: ["aws.seatool.ksql.onemac.agg.State_Plan"], + }, + { + function: lambdaFunctions.sinkLegacyInsights.functionName, + topics: [ + "aws.onemac.migration.cdc", + `${topicNamespace}aws.onemac.migration.cdc`, + ], + }, + ], + }), + }, + ) + .addCatch(notifyOfFailureStep, { + errors: ["States.ALL"], + resultPath: "$.error", + }) + .next(notifyState("NotifyOfSuccess", true)) + .next(new cdk.aws_stepfunctions.Succeed(this, "SuccessState")), + ) + .when( + cdk.aws_stepfunctions.Condition.booleanEquals("$.ready", false), + new cdk.aws_stepfunctions.Wait(this, "WaitForData", { + time: cdk.aws_stepfunctions.WaitTime.duration( + cdk.Duration.seconds(3), + ), + }).next(checkDataProgressTask), + ), + ); + + const stateMachineLogGroup = new cdk.aws_logs.LogGroup( + this, + "StateMachineLogGroup", + { + logGroupName: `/aws/vendedlogs/states/${project}-${stage}-${stack}-reindex`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }, + ); + + const reindexStateMachine = new cdk.aws_stepfunctions.StateMachine( + this, + "ReindexDataStateMachine", + { + definition, + role: stateMachineRole, + stateMachineName: `${project}-${stage}-${stack}-reindex`, + logs: { + destination: stateMachineLogGroup, + level: cdk.aws_stepfunctions.LogLevel.ALL, + includeExecutionData: true, + }, + }, + ); + + const runReindexLogGroup = new cdk.aws_logs.LogGroup( + this, + `runReindexLogGroup`, + { + logGroupName: `/aws/lambda/${project}-${stage}-${stack}-runReindex`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }, + ); + + const runReindexLambda = new NodejsFunction( + this, + "runReindexLambdaFunction", + { + functionName: `${project}-${stage}-${stack}-runReindex`, + entry: join(__dirname, "../lambda/runReindex.ts"), + handler: "handler", + depsLockFilePath: join(__dirname, "../../bun.lockb"), + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + timeout: cdk.Duration.minutes(5), + role: new cdk.aws_iam.Role(this, "RunReindexLambdaExecutionRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + ], + inlinePolicies: { + LambdaAssumeRolePolicy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: ["states:StartExecution"], + resources: [ + `arn:aws:states:${this.region}:${this.account}:stateMachine:${project}-${stage}-${stack}-reindex`, + ], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + actions: ["logs:CreateLogGroup"], + resources: ["*"], + }), + ], + }), + }, + }), + logGroup: runReindexLogGroup, + bundling: { + minify: true, + sourceMap: true, + }, + }, + ); + + const runReindexProviderProvider = new cdk.custom_resources.Provider( + this, + "RunReindexProvider", + { + onEventHandler: runReindexLambda, + }, + ); + + const runReindexCustomResource = new cdk.CustomResource( + this, + "RunReindex", + { + serviceToken: runReindexProviderProvider.serviceToken, + properties: { + stateMachine: reindexStateMachine.stateMachineArn, + }, + }, + ); + + if (!usingSharedOpenSearch) { + reindexStateMachine.node.addDependency(this.mapRoleCustomResource); + } + + const deleteTriggersOnStackDeleteCustomResourceLogGroup = + new cdk.aws_logs.LogGroup( + this, + "deleteTriggersOnStackDeleteCustomResourceLogGroup", + { + logGroupName: `/aws/lambda/${project}-${stage}-${stack}-deleteTriggersOnDeleteCustomResource`, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }, + ); + const deleteTriggersOnStackDeleteCustomResource = new cr.AwsCustomResource( + this, + "DeleteTriggersOnStackDeleteCustomResource", + { + onDelete: { + service: "Lambda", + action: "invoke", + parameters: { + FunctionName: deleteTriggers.functionName, + InvocationType: "RequestResponse", + Payload: JSON.stringify({ + RequestType: "Delete", + functions: Object.values(lambdaFunctions).map( + (fn) => fn.functionName, + ), + }), + }, + physicalResourceId: cr.PhysicalResourceId.of( + "delete-triggers-on-stack-deletes", + ), + }, + logGroup: deleteTriggersOnStackDeleteCustomResourceLogGroup, + policy: cr.AwsCustomResourcePolicy.fromStatements([ + new cdk.aws_iam.PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: [deleteTriggers.functionArn], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + actions: ["logs:CreateLogGroup"], + resources: ["*"], + }), + ]), + }, + ); + const deleteTriggersOnDeleteCustomResourcePolicy = + deleteTriggersOnStackDeleteCustomResource.node.findChild( + "CustomResourcePolicy", + ); + deleteTriggersOnStackDeleteCustomResource.node.addDependency( + deleteTriggersOnDeleteCustomResourcePolicy, + ); + deleteTriggersOnStackDeleteCustomResourceLogGroup.node.addDependency( + deleteTriggersOnDeleteCustomResourcePolicy, + ); + + return { openSearchDomainEndpoint, openSearchDomainArn }; + } +} diff --git a/lib/deployment-config.test.ts b/lib/stacks/deployment-config.test.ts similarity index 83% rename from lib/deployment-config.test.ts rename to lib/stacks/deployment-config.test.ts index 6db1e38554..fa190edda0 100644 --- a/lib/deployment-config.test.ts +++ b/lib/stacks/deployment-config.test.ts @@ -16,23 +16,25 @@ describe("DeploymentConfig", () => { const project = "test-project"; const defaultSecret = JSON.stringify({ brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", - devPasswordArn: "devPasswordArn", + dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret + devPasswordArn: "devPasswordArn", // pragma: allowlist secret domainCertificateArn: "domainCertificateArn", domainName: "domainName", - emailAddressLookupSecretName: "emailAddressLookupSecretName", + emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret googleAnalyticsDisable: "true", googleAnalyticsGTag: "googleAnalyticsGTag", idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", + idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret idmClientId: "idmClientId", idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", + idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret idmEnable: "true", idmHomeUrl: "idmHomeUrl", legacyS3AccessRoleArn: "legacyS3AccessRoleArn", useSharedOpenSearch: "true", vpcName: "vpcName", + emailFromIdentity: "test@cms.hhs.gov", + emailIdentityDomain: "cms.hhs.gov", }); const stageSecret = JSON.stringify({ @@ -74,18 +76,18 @@ describe("DeploymentConfig", () => { const expectedConfig: DeploymentConfigProperties = { brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", - devPasswordArn: "devPasswordArn", + dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret + devPasswordArn: "devPasswordArn", // pragma: allowlist secret domainCertificateArn: "domainCertificateArn", domainName: "stage-domainName", // Overridden by stage secret - emailAddressLookupSecretName: "emailAddressLookupSecretName", + emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret googleAnalyticsDisable: false, // Converted to boolean and overridden by stage secret googleAnalyticsGTag: "googleAnalyticsGTag", idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", + idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret idmClientId: "idmClientId", idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", + idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret idmEnable: true, // Converted to boolean idmHomeUrl: "idmHomeUrl", legacyS3AccessRoleArn: "legacyS3AccessRoleArn", @@ -97,6 +99,8 @@ describe("DeploymentConfig", () => { sharedOpenSearchDomainEndpoint: "sharedOpenSearchDomainEndpoint", stage: "dev", terminationProtection: false, + emailFromIdentity: "test@cms.hhs.gov", + emailIdentityDomain: "cms.hhs.gov", }; expect(deploymentConfig.config).toEqual(expectedConfig); @@ -138,18 +142,18 @@ describe("DeploymentConfig", () => { const expectedConfig: DeploymentConfigProperties = { brokerString: "brokerString", - dbInfoSecretName: "dbInfoSecretName", - devPasswordArn: "devPasswordArn", + dbInfoSecretName: "dbInfoSecretName", // pragma: allowlist secret + devPasswordArn: "devPasswordArn", // pragma: allowlist secret domainCertificateArn: "domainCertificateArn", domainName: "domainName", - emailAddressLookupSecretName: "emailAddressLookupSecretName", + emailAddressLookupSecretName: "emailAddressLookupSecretName", // pragma: allowlist secret googleAnalyticsDisable: true, googleAnalyticsGTag: "googleAnalyticsGTag", idmAuthzApiEndpoint: "idmAuthzApiEndpoint", - idmAuthzApiKeyArn: "idmAuthzApiKeyArn", + idmAuthzApiKeyArn: "idmAuthzApiKeyArn", // pragma: allowlist secret idmClientId: "idmClientId", idmClientIssuer: "idmClientIssuer", - idmClientSecretArn: "idmClientSecretArn", + idmClientSecretArn: "idmClientSecretArn", // pragma: allowlist secret idmEnable: true, idmHomeUrl: "idmHomeUrl", legacyS3AccessRoleArn: "legacyS3AccessRoleArn", @@ -161,6 +165,8 @@ describe("DeploymentConfig", () => { sharedOpenSearchDomainEndpoint: "sharedOpenSearchDomainEndpoint", stage: "dev", terminationProtection: false, + emailFromIdentity: "test@cms.hhs.gov", + emailIdentityDomain: "cms.hhs.gov", }; expect(deploymentConfig.config).toEqual(expectedConfig); diff --git a/lib/deployment-config.ts b/lib/stacks/deployment-config.ts similarity index 95% rename from lib/deployment-config.ts rename to lib/stacks/deployment-config.ts index 0a5bd58689..b4f623f3aa 100644 --- a/lib/deployment-config.ts +++ b/lib/stacks/deployment-config.ts @@ -13,6 +13,8 @@ export type InjectedConfigProperties = { domainCertificateArn: string; domainName: string; emailAddressLookupSecretName: string; + emailFromIdentity: string; + emailIdentityDomain: string; googleAnalyticsDisable: boolean; googleAnalyticsGTag: string; idmAuthzApiEndpoint: string; @@ -135,7 +137,9 @@ export class DeploymentConfig { typeof config.idmHomeUrl === "string" && typeof config.legacyS3AccessRoleArn === "string" && typeof config.useSharedOpenSearch === "boolean" && - typeof config.vpcName === "string" + typeof config.vpcName === "string" && + typeof config.emailFromIdentity === "string" && + typeof config.emailIdentityDomain === "string" ); } diff --git a/lib/stacks/email.ts b/lib/stacks/email.ts new file mode 100644 index 0000000000..b6d71c3e01 --- /dev/null +++ b/lib/stacks/email.ts @@ -0,0 +1,244 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as path from "path"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { ISubnet } from "aws-cdk-lib/aws-ec2"; +import { CfnEventSourceMapping } from "aws-cdk-lib/aws-lambda"; + +interface EmailServiceStackProps extends cdk.StackProps { + project: string; + stage: string; + stack: string; + vpc: cdk.aws_ec2.IVpc; + applicationEndpoint: string; + emailIdentityDomain: string; + emailFromIdentity: string; + indexNamespace: string; + emailAddressLookupSecretName: string; + topicNamespace: string; + privateSubnets: ISubnet[]; + lambdaSecurityGroupId: string; + brokerString: string; + lambdaSecurityGroup: cdk.aws_ec2.SecurityGroup; +} + +export class Email extends cdk.NestedStack { + constructor(scope: Construct, id: string, props: EmailServiceStackProps) { + super(scope, id, props); + + const { + project, + stage, + stack, + vpc, + emailFromIdentity, + emailIdentityDomain, + applicationEndpoint, + topicNamespace, + indexNamespace, + emailAddressLookupSecretName, + brokerString, + lambdaSecurityGroupId, + privateSubnets, + lambdaSecurityGroup, + } = props; + + // KMS Key for SNS Topic + const kmsKeyForEmails = new cdk.aws_kms.Key(this, "KmsKeyForEmails", { + enableKeyRotation: true, + policy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + actions: ["kms:*"], + principals: [new cdk.aws_iam.AccountRootPrincipal()], + resources: ["*"], + }), + new cdk.aws_iam.PolicyStatement({ + actions: ["kms:GenerateDataKey", "kms:Decrypt"], + principals: [new cdk.aws_iam.ServicePrincipal("sns.amazonaws.com")], + resources: ["*"], + }), + new cdk.aws_iam.PolicyStatement({ + actions: ["kms:GenerateDataKey", "kms:Decrypt"], + principals: [new cdk.aws_iam.ServicePrincipal("ses.amazonaws.com")], + resources: ["*"], + }), + ], + }), + }); + + // SNS Topic for Email Events + const emailEventTopic = new cdk.aws_sns.Topic(this, "EmailEventTopic", { + displayName: "Monitoring the sending of emails", + masterKey: kmsKeyForEmails, + }); + + // Allow SES to publish to the SNS topic + const snsPublishPolicyStatement = new cdk.aws_iam.PolicyStatement({ + actions: ["sns:Publish"], + principals: [new cdk.aws_iam.ServicePrincipal("ses.amazonaws.com")], + resources: [emailEventTopic.topicArn], + effect: cdk.aws_iam.Effect.ALLOW, + }); + emailEventTopic.addToResourcePolicy(snsPublishPolicyStatement); + const snsTopicPolicy = emailEventTopic.node.tryFindChild( + "Policy", + ) as cdk.CfnResource; + + // S3 Bucket for storing email event data + const emailDataBucket = new cdk.aws_s3.Bucket(this, "EmailDataBucket", { + versioned: true, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // SES Configuration Set + const configurationSet = new cdk.aws_ses.CfnConfigurationSet( + this, + "ConfigurationSet", + { + name: `${project}-${stage}-${stack}-email-configuration-set`, + reputationOptions: { + reputationMetricsEnabled: true, + }, + sendingOptions: { + sendingEnabled: true, + }, + suppressionOptions: { + suppressedReasons: ["BOUNCE", "COMPLAINT"], + }, + }, + ); + + // SES Event Destination for Configuration Set + const eventDestination = + new cdk.aws_ses.CfnConfigurationSetEventDestination( + this, + "ConfigurationSetEventDestination", + { + configurationSetName: configurationSet.name!, + eventDestination: { + enabled: true, + matchingEventTypes: [ + "send", + "reject", + "bounce", + "complaint", + "delivery", + "open", + "click", + "renderingFailure", + ], + snsDestination: { + topicArn: emailEventTopic.topicArn, + }, + }, + }, + ); + + eventDestination.node.addDependency(snsTopicPolicy); + + // IAM Role for Lambda + const lambdaRole = new cdk.aws_iam.Role(this, "LambdaExecutionRole", { + assumedBy: new cdk.aws_iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + cdk.aws_iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole", + ), + ], + inlinePolicies: { + EmailServicePolicy: new cdk.aws_iam.PolicyDocument({ + statements: [ + new cdk.aws_iam.PolicyStatement({ + actions: [ + "ses:SendEmail", + "ses:SendRawEmail", + "ses:ListIdentities", + "ses:ListConfigurationSets", + "sns:Subscribe", + "sns:Publish", + "s3:PutObject", + ], + resources: ["*"], + }), + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + actions: ["ec2:DescribeSecurityGroups", "ec2:DescribeVpcs"], + resources: ["*"], + }), + ], + }), + }, + }); + + // Lambda Function for Processing Emails + const processEmailsLambda = new NodejsFunction( + this, + "ProcessEmailsLambda", + { + functionName: `${project}-${stage}-${stack}-processEmails`, + depsLockFilePath: path.join(__dirname, "../../bun.lockb"), + entry: path.join(__dirname, "../lambda/processEmails.ts"), + handler: "handler", + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + memorySize: 1024, + timeout: cdk.Duration.seconds(60), + role: lambdaRole, + vpc: vpc, + vpcSubnets: { + subnets: privateSubnets, + }, + securityGroups: [lambdaSecurityGroup], + environment: { + EMAIL_IDENTITY: emailFromIdentity, + CONFIGURATION_SET: configurationSet.name!, + REGION: cdk.Aws.REGION, + indexNamespace, + applicationEndpoint: applicationEndpoint, + emailAddressLookupSecretName: emailAddressLookupSecretName, + EMAIL_DATA_BUCKET_NAME: emailDataBucket.bucketName, + }, + }, + ); + + // Grant permissions to the Lambda function to write to the S3 bucket + emailDataBucket.grantPut(processEmailsLambda); + + // Additional configurations (e.g., Kafka event source mapping)... + // Example of Kafka Event Source Mapping + new CfnEventSourceMapping(this, "SinkEmailTrigger", { + batchSize: 10, + enabled: true, + selfManagedEventSource: { + endpoints: { + kafkaBootstrapServers: brokerString.split(","), + }, + }, + functionName: processEmailsLambda.functionArn, + sourceAccessConfigurations: [ + ...privateSubnets.slice(0, 3).map((subnet) => ({ + type: "VPC_SUBNET", + uri: subnet.subnetId, + })), + { + type: "VPC_SECURITY_GROUP", + uri: `security_group:${lambdaSecurityGroupId}`, + }, + ], + startingPosition: "LATEST", + topics: [`${topicNamespace}aws.onemac.migration.cdc`], + }); + + // Grant permissions to the Lambda function to send emails using SES + processEmailsLambda.addToRolePolicy( + new cdk.aws_iam.PolicyStatement({ + actions: ["ses:SendEmail", "ses:SendRawEmail"], + resources: [ + `arn:aws:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:identity/${emailIdentityDomain}`, + ], + }), + ); + } +} diff --git a/lib/stacks/index.ts b/lib/stacks/index.ts new file mode 100644 index 0000000000..c5e3f23cd4 --- /dev/null +++ b/lib/stacks/index.ts @@ -0,0 +1,9 @@ +export * from "./alerts"; +export * from "./api"; +export * from "./auth"; +export * from "./data"; +export * from "./email"; +export * from "./networking"; +export * from "./parent"; +export * from "./ui-infra"; +export * from "./uploads"; diff --git a/lib/stacks/networking.ts b/lib/stacks/networking.ts new file mode 100644 index 0000000000..e447be43be --- /dev/null +++ b/lib/stacks/networking.ts @@ -0,0 +1,40 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; + +interface NetworkingStackProps extends cdk.NestedStackProps { + project: string; + stage: string; + stack: string; + isDev: boolean; + vpc: cdk.aws_ec2.IVpc; +} + +export class Networking extends cdk.NestedStack { + public readonly lambdaSecurityGroup: cdk.aws_ec2.SecurityGroup; + + constructor(scope: Construct, id: string, props: NetworkingStackProps) { + super(scope, id, props); + this.lambdaSecurityGroup = this.initializeResources(props); + } + + private initializeResources( + props: NetworkingStackProps, + ): cdk.aws_ec2.SecurityGroup { + const { project, stage } = props; + const { vpc } = props; + + const lambdaSecurityGroup = new cdk.aws_ec2.SecurityGroup( + this, + `LambdaSecurityGroup`, + { + vpc, + description: `Outbound permissive sg for lambdas in ${project}-${stage}.`, + allowAllOutbound: true, // Set to false to customize egress rules + }, + ); + + lambdaSecurityGroup.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + + return lambdaSecurityGroup; + } +} diff --git a/lib/parent-stack.ts b/lib/stacks/parent.ts similarity index 77% rename from lib/parent-stack.ts rename to lib/stacks/parent.ts index a032f06ee2..3d4029805e 100644 --- a/lib/parent-stack.ts +++ b/lib/stacks/parent.ts @@ -1,25 +1,15 @@ -import { Fn, Stack, StackProps } from "aws-cdk-lib"; -import { Vpc, ISubnet } from "aws-cdk-lib/aws-ec2"; +import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; +import { CloudWatchLogsResourcePolicy } from "local-constructs"; import { DeploymentConfigProperties } from "./deployment-config"; -import { AlertsStack } from "./alerts-stack"; -import { ApiStack } from "./api-stack"; -import { AuthStack } from "./auth-stack"; -import { DataStack } from "./data-stack"; -import { NetworkingStack } from "./networking-stack"; -import { UiInfraStack } from "./ui-infra-stack"; -import { UploadsStack } from "./uploads-stack"; - -import { CloudWatchLogsResourcePolicy } from "local-constructs"; -import { StringParameter } from "aws-cdk-lib/aws-ssm"; -import { EmailStack } from "./email-stack"; +import * as Stacks from "../stacks"; -export class ParentStack extends Stack { +export class ParentStack extends cdk.Stack { constructor( scope: Construct, id: string, - props: StackProps & DeploymentConfigProperties, + props: cdk.StackProps & DeploymentConfigProperties, ) { super(scope, id, props); @@ -33,7 +23,7 @@ export class ParentStack extends Stack { : ""; const indexNamespace = props.stage; - const vpc = Vpc.fromLookup(this, "Vpc", { + const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", { vpcName: props.vpcName, }); const privateSubnets = sortSubnets(vpc.privateSubnets); @@ -44,18 +34,18 @@ export class ParentStack extends Stack { }); } - const networkingStack = new NetworkingStack(this, "networking", { + const networkingStack = new Stacks.Networking(this, "networking", { ...commonProps, stack: "networking", vpc, }); - const alertsStack = new AlertsStack(this, "alerts", { + const alertsStack = new Stacks.Alerts(this, "alerts", { ...commonProps, stack: "alerts", }); - const uiInfraStack = new UiInfraStack(this, "ui-infra", { + const uiInfraStack = new Stacks.UiInfra(this, "ui-infra", { ...commonProps, stack: "ui-infra", isDev: props.isDev, @@ -63,12 +53,12 @@ export class ParentStack extends Stack { domainName: props.domainName, }); - const uploadsStack = new UploadsStack(this, "uploads", { + const uploadsStack = new Stacks.Uploads(this, "uploads", { ...commonProps, stack: "uploads", }); - const dataStack = new DataStack(this, "data", { + const dataStack = new Stacks.Data(this, "data", { ...commonProps, stack: "data", vpc, @@ -82,7 +72,7 @@ export class ParentStack extends Stack { sharedOpenSearchDomainEndpoint: props.sharedOpenSearchDomainEndpoint, }); - const apiStack = new ApiStack(this, "api", { + const apiStack = new Stacks.Api(this, "api", { ...commonProps, stack: "api", vpc, @@ -99,7 +89,7 @@ export class ParentStack extends Stack { attachmentsBucket: uploadsStack.attachmentsBucket, }); - const authStack = new AuthStack(this, "auth", { + const authStack = new Stacks.Auth(this, "auth", { ...commonProps, stack: "auth", apiGateway: apiStack.apiGateway, @@ -116,7 +106,7 @@ export class ParentStack extends Stack { devPasswordArn: props.devPasswordArn, }); - const emailStack = new EmailStack(this, "email", { + const emailStack = new Stacks.Email(this, "email", { ...commonProps, stack: "email", vpc, @@ -124,15 +114,16 @@ export class ParentStack extends Stack { brokerString: props.brokerString, topicNamespace, indexNamespace, - osDomainArn: dataStack.openSearchDomainArn, lambdaSecurityGroupId: networkingStack.lambdaSecurityGroup.securityGroupId, applicationEndpoint: uiInfraStack.applicationEndpointUrl, - cognitoUserPoolId: authStack.userPool.userPoolId, emailAddressLookupSecretName: props.emailAddressLookupSecretName, + emailIdentityDomain: props.emailIdentityDomain, + lambdaSecurityGroup: networkingStack.lambdaSecurityGroup, + emailFromIdentity: props.emailFromIdentity, }); - new StringParameter(this, "DeploymentOutput", { + new cdk.aws_ssm.StringParameter(this, "DeploymentOutput", { parameterName: `/${props.project}/${props.stage}/deployment-output`, stringValue: JSON.stringify({ apiGatewayRestApiUrl: apiStack.apiGatewayUrl, @@ -149,7 +140,7 @@ export class ParentStack extends Stack { description: `Deployment output for the ${props.stage} environment.`, }); - new StringParameter(this, "DeploymentConfig", { + new cdk.aws_ssm.StringParameter(this, "DeploymentConfig", { parameterName: `/${props.project}/${props.stage}/deployment-config`, stringValue: JSON.stringify(props), description: `Deployment config for the ${props.stage} environment.`, @@ -171,7 +162,7 @@ function getSubnetSize(cidrBlock: string): number { return Math.pow(2, 32 - subnetMask); } -function sortSubnets(subnets: ISubnet[]): ISubnet[] { +function sortSubnets(subnets: cdk.aws_ec2.ISubnet[]): cdk.aws_ec2.ISubnet[] { return subnets.sort((a, b) => { const sizeA = getSubnetSize(a.ipv4CidrBlock); const sizeB = getSubnetSize(b.ipv4CidrBlock); diff --git a/lib/ui-infra-stack.ts b/lib/stacks/ui-infra.ts similarity index 55% rename from lib/ui-infra-stack.ts rename to lib/stacks/ui-infra.ts index 81a0ff510a..06406c45cd 100644 --- a/lib/ui-infra-stack.ts +++ b/lib/stacks/ui-infra.ts @@ -1,38 +1,8 @@ -import { NestedStack, NestedStackProps, Aws, RemovalPolicy } from "aws-cdk-lib"; -import { - AccountRootPrincipal, - AnyPrincipal, - Effect, - PolicyStatement, - ServicePrincipal, -} from "aws-cdk-lib/aws-iam"; -import { - BlockPublicAccess, - Bucket, - BucketEncryption, - ObjectOwnership, -} from "aws-cdk-lib/aws-s3"; -import { - CloudFrontAllowedMethods, - CloudFrontWebDistribution, - Function, - FunctionCode, - FunctionEventType, - HttpVersion, - OriginAccessIdentity, - SecurityPolicyProtocol, - SSLMethod, - ViewerCertificate, - ViewerProtocolPolicy, -} from "aws-cdk-lib/aws-cloudfront"; -import { Certificate } from "aws-cdk-lib/aws-certificatemanager"; +import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; +import * as LC from "local-constructs"; -import { CloudWatchToS3 } from "local-constructs"; -import { EmptyBuckets } from "local-constructs"; -import { CloudFrontWaf } from "local-constructs"; - -interface UiInfraStackProps extends NestedStackProps { +interface UiInfraStackProps extends cdk.NestedStackProps { project: string; stage: string; stack: string; @@ -41,11 +11,11 @@ interface UiInfraStackProps extends NestedStackProps { domainName?: string; } -export class UiInfraStack extends NestedStack { - public readonly distribution: CloudFrontWebDistribution; +export class UiInfra extends cdk.NestedStack { + public readonly distribution: cdk.aws_cloudfront.CloudFrontWebDistribution; public readonly applicationEndpointUrl: string; public readonly cloudfrontEndpointUrl: string; - public readonly bucket: Bucket; + public readonly bucket: cdk.aws_s3.Bucket; constructor(scope: Construct, id: string, props: UiInfraStackProps) { super(scope, id, props); @@ -59,40 +29,38 @@ export class UiInfraStack extends NestedStack { } private initializeResources(props: UiInfraStackProps): { - distribution: CloudFrontWebDistribution; - bucket: Bucket; + distribution: cdk.aws_cloudfront.CloudFrontWebDistribution; + bucket: cdk.aws_s3.Bucket; } { - const { project, stage, stack, isDev, domainCertificateArn, domainName } = - props; + const { project, stage, domainCertificateArn, domainName } = props; const domainCertificate = domainCertificateArn && domainCertificateArn.trim() - ? Certificate.fromCertificateArn( + ? cdk.aws_certificatemanager.Certificate.fromCertificateArn( this, "Certificate", domainCertificateArn, ) : null; - // Ensure the domain name is valid const sanitizedDomainName = domainName && domainName.trim() ? domainName.trim() : null; // S3 Bucket for hosting static website - const bucket = new Bucket(this, "S3Bucket", { - bucketName: `${project}-${stage}-${Aws.ACCOUNT_ID}`, + const bucket = new cdk.aws_s3.Bucket(this, "S3Bucket", { + bucketName: `${project}-${stage}-${cdk.Aws.ACCOUNT_ID}`, versioned: true, websiteIndexDocument: "index.html", websiteErrorDocument: "index.html", - encryption: BucketEncryption.S3_MANAGED, - removalPolicy: RemovalPolicy.DESTROY, + encryption: cdk.aws_s3.BucketEncryption.S3_MANAGED, + removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Deny insecure requests to the bucket bucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.DENY, - principals: [new AnyPrincipal()], + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + principals: [new cdk.aws_iam.AnyPrincipal()], actions: ["s3:*"], resources: [bucket.bucketArn, `${bucket.bucketArn}/*`], conditions: { @@ -102,21 +70,21 @@ export class UiInfraStack extends NestedStack { ); // S3 Bucket for CloudFront logs - const loggingBucket = new Bucket(this, "LoggingBucket", { - bucketName: `${project}-${stage}-cloudfront-logs-${Aws.ACCOUNT_ID}`, + const loggingBucket = new cdk.aws_s3.Bucket(this, "LoggingBucket", { + bucketName: `${project}-${stage}-cloudfront-logs-${cdk.Aws.ACCOUNT_ID}`, versioned: true, - encryption: BucketEncryption.S3_MANAGED, + encryption: cdk.aws_s3.BucketEncryption.S3_MANAGED, publicReadAccess: false, - blockPublicAccess: BlockPublicAccess.BLOCK_ALL, - removalPolicy: RemovalPolicy.DESTROY, - objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED, + blockPublicAccess: cdk.aws_s3.BlockPublicAccess.BLOCK_ALL, + removalPolicy: cdk.RemovalPolicy.DESTROY, + objectOwnership: cdk.aws_s3.ObjectOwnership.BUCKET_OWNER_PREFERRED, }); // Deny insecure requests to the bucket loggingBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.DENY, - principals: [new AnyPrincipal()], + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + principals: [new cdk.aws_iam.AnyPrincipal()], actions: ["s3:*"], resources: [loggingBucket.bucketArn, `${loggingBucket.bucketArn}/*`], conditions: { @@ -127,23 +95,29 @@ export class UiInfraStack extends NestedStack { // Add bucket policy to allow CloudFront to write logs loggingBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - principals: [new ServicePrincipal("cloudfront.amazonaws.com")], + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.ALLOW, + principals: [ + new cdk.aws_iam.ServicePrincipal("cloudfront.amazonaws.com"), + ], actions: ["s3:PutObject"], resources: [`${loggingBucket.bucketArn}/*`], }), ); // CloudFront Origin Access Identity - const cloudFrontOAI = new OriginAccessIdentity(this, "CloudFrontOAI", { - comment: "OAI to prevent direct public access to the bucket", - }); + const cloudFrontOAI = new cdk.aws_cloudfront.OriginAccessIdentity( + this, + "CloudFrontOAI", + { + comment: "OAI to prevent direct public access to the bucket", + }, + ); // HSTS Function - const hstsFunction = new Function(this, "HstsFunction", { + const hstsFunction = new cdk.aws_cloudfront.Function(this, "HstsFunction", { comment: "This function adds headers to implement HSTS", - code: FunctionCode.fromInline(` + code: cdk.aws_cloudfront.FunctionCode.fromInline(` function handler(event) { var response = event.response; var headers = response.headers; @@ -153,20 +127,24 @@ export class UiInfraStack extends NestedStack { `), }); - const waf = new CloudFrontWaf(this, "WafConstruct", { - name: `${project}-${stage}-${stack}`, + const waf = new LC.CloudFrontWaf(this, "WafConstruct", { + name: `${project}-${stage}-${props.stack}`, }); // CloudFront Distribution const viewerCertificate = domainCertificate - ? ViewerCertificate.fromAcmCertificate(domainCertificate, { - aliases: sanitizedDomainName ? [sanitizedDomainName] : [], - securityPolicy: SecurityPolicyProtocol.TLS_V1_2_2021, - sslMethod: SSLMethod.SNI, - }) - : ViewerCertificate.fromCloudFrontDefaultCertificate(); - - const distribution = new CloudFrontWebDistribution( + ? cdk.aws_cloudfront.ViewerCertificate.fromAcmCertificate( + domainCertificate, + { + aliases: sanitizedDomainName ? [sanitizedDomainName] : [], + securityPolicy: + cdk.aws_cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, + sslMethod: cdk.aws_cloudfront.SSLMethod.SNI, + }, + ) + : cdk.aws_cloudfront.ViewerCertificate.fromCloudFrontDefaultCertificate(); + + const distribution = new cdk.aws_cloudfront.CloudFrontWebDistribution( this, "CloudFrontDistribution", { @@ -179,12 +157,15 @@ export class UiInfraStack extends NestedStack { behaviors: [ { isDefaultBehavior: true, - allowedMethods: CloudFrontAllowedMethods.GET_HEAD, - viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + allowedMethods: + cdk.aws_cloudfront.CloudFrontAllowedMethods.GET_HEAD, + viewerProtocolPolicy: + cdk.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, functionAssociations: [ { function: hstsFunction, - eventType: FunctionEventType.VIEWER_RESPONSE, + eventType: + cdk.aws_cloudfront.FunctionEventType.VIEWER_RESPONSE, }, ], }, @@ -193,7 +174,7 @@ export class UiInfraStack extends NestedStack { ], comment: `CloudFront Distro for the static website hosted in S3 for ${project}-${stage}`, defaultRootObject: "index.html", - httpVersion: HttpVersion.HTTP2, + httpVersion: cdk.aws_cloudfront.HttpVersion.HTTP2, viewerCertificate, loggingConfig: { bucket: loggingBucket, @@ -218,11 +199,15 @@ export class UiInfraStack extends NestedStack { }, ); - const cloudwatchToS3 = new CloudWatchToS3(this, "CloudWatchToS3Construct", { - logGroup: waf.logGroup, - }); + const cloudwatchToS3 = new LC.CloudWatchToS3( + this, + "CloudWatchToS3Construct", + { + logGroup: waf.logGroup, + }, + ); - new EmptyBuckets(this, "EmptyBuckets", { + new LC.EmptyBuckets(this, "EmptyBuckets", { buckets: [bucket, loggingBucket, cloudwatchToS3.logBucket], }); diff --git a/lib/uploads-stack.ts b/lib/stacks/uploads.ts similarity index 57% rename from lib/uploads-stack.ts rename to lib/stacks/uploads.ts index ff6599cc27..6cd2c48f7f 100644 --- a/lib/uploads-stack.ts +++ b/lib/stacks/uploads.ts @@ -1,20 +1,17 @@ -import { NestedStack, NestedStackProps, Aws, RemovalPolicy } from "aws-cdk-lib"; -import { Bucket, HttpMethods } from "aws-cdk-lib/aws-s3"; -import { AnyPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; +import * as LC from "local-constructs"; -import { EmptyBuckets } from "local-constructs"; -import { ClamScanScanner } from "local-constructs"; - -interface UploadsStackProps extends NestedStackProps { +interface UploadsStackProps extends cdk.NestedStackProps { project: string; stage: string; stack: string; isDev: boolean; } -export class UploadsStack extends NestedStack { - public readonly attachmentsBucket: Bucket; +export class Uploads extends cdk.NestedStack { + public readonly attachmentsBucket: cdk.aws_s3.Bucket; + constructor(scope: Construct, id: string, props: UploadsStackProps) { super(scope, id, props); const resources = this.initializeResources(props); @@ -22,12 +19,13 @@ export class UploadsStack extends NestedStack { } private initializeResources(props: UploadsStackProps): { - attachmentsBucket: Bucket; + attachmentsBucket: cdk.aws_s3.Bucket; } { - const { project, stage, stack, isDev } = props; - const attachmentsBucketName = `${project}-${stage}-attachments-${Aws.ACCOUNT_ID}`; + const { project, stage, isDev } = props; + const attachmentsBucketName = `${project}-${stage}-attachments-${cdk.Aws.ACCOUNT_ID}`; + // S3 Buckets - const attachmentsBucket = new Bucket(this, "AttachmentsBucket", { + const attachmentsBucket = new cdk.aws_s3.Bucket(this, "AttachmentsBucket", { bucketName: attachmentsBucketName, versioned: true, cors: [ @@ -35,23 +33,25 @@ export class UploadsStack extends NestedStack { allowedOrigins: ["*"], allowedHeaders: ["*"], allowedMethods: [ - HttpMethods.GET, - HttpMethods.PUT, - HttpMethods.POST, - HttpMethods.DELETE, - HttpMethods.HEAD, + cdk.aws_s3.HttpMethods.GET, + cdk.aws_s3.HttpMethods.PUT, + cdk.aws_s3.HttpMethods.POST, + cdk.aws_s3.HttpMethods.DELETE, + cdk.aws_s3.HttpMethods.HEAD, ], exposedHeaders: ["ETag"], maxAge: 3000, }, ], - removalPolicy: isDev ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN, + removalPolicy: isDev + ? cdk.RemovalPolicy.DESTROY + : cdk.RemovalPolicy.RETAIN, }); attachmentsBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.DENY, - principals: [new AnyPrincipal()], + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + principals: [new cdk.aws_iam.AnyPrincipal()], actions: ["s3:*"], resources: [ attachmentsBucket.bucketArn, @@ -63,14 +63,14 @@ export class UploadsStack extends NestedStack { }), ); - const scanner = new ClamScanScanner(this, "ClamScan", { + const scanner = new LC.ClamScanScanner(this, "ClamScan", { fileBucket: attachmentsBucket, }); attachmentsBucket.addToResourcePolicy( - new PolicyStatement({ - effect: Effect.DENY, - principals: [new AnyPrincipal()], + new cdk.aws_iam.PolicyStatement({ + effect: cdk.aws_iam.Effect.DENY, + principals: [new cdk.aws_iam.AnyPrincipal()], actions: ["s3:GetObject"], resources: [`${attachmentsBucket.bucketArn}/*`], conditions: { @@ -82,7 +82,7 @@ export class UploadsStack extends NestedStack { }), ); - new EmptyBuckets(this, "EmptyBuckets", { + new LC.EmptyBuckets(this, "EmptyBuckets", { buckets: [attachmentsBucket, scanner.clamDefsBucket], }); diff --git a/react-app/package.json b/react-app/package.json index 0f1c4e3fba..bf46b7589b 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -74,7 +74,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.8.3", - "@playwright/test": "^1.38.0", + "@playwright/test": "^1.45.3", "@tailwindcss/typography": "^0.5.10", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", diff --git a/react-app/src/components/RHF/Field.tsx b/react-app/src/components/RHF/Field.tsx index 117c79dd6e..dfc75a5564 100644 --- a/react-app/src/components/RHF/Field.tsx +++ b/react-app/src/components/RHF/Field.tsx @@ -1,4 +1,4 @@ -import { FieldValues } from "react-hook-form"; +import { FieldValues, RegisterOptions } from "react-hook-form"; import { FieldArrayProps, FieldGroupProps, @@ -6,7 +6,7 @@ import { RHFTextField, } from "shared-types"; import { FormField, FormLabel } from "../Inputs/form"; -import { DependencyWrapper, RHFSlot, RHFTextDisplay } from "./"; +import { DependencyWrapper, RHFSlot, RHFTextDisplay, ruleGenerator } from "./"; interface FieldProps extends FieldGroupProps, @@ -15,6 +15,12 @@ interface FieldProps SLOT: RHFSlotProps; } +// Define a type for the rules if not already defined +type CustomRegisterOptions = Omit< + RegisterOptions, + "disabled" | "valueAsNumber" | "valueAsDate" | "setValueAs" +>; + export const Field = ({ name, index, @@ -62,8 +68,10 @@ export const Field = ({ key={adjustedSlotName} // @ts-ignore control={control} + rules={ + ruleGenerator(SLOT.rules, SLOT.addtnlRules) as CustomRegisterOptions + } name={adjustedSlotName as never} - {...(SLOT.rules && { rules: SLOT.rules })} render={RHFSlot({ ...SLOT, control: control, diff --git a/react-app/src/components/RHF/FormGroup.tsx b/react-app/src/components/RHF/FormGroup.tsx index b5eaf29d5d..e76eb47be3 100644 --- a/react-app/src/components/RHF/FormGroup.tsx +++ b/react-app/src/components/RHF/FormGroup.tsx @@ -4,6 +4,7 @@ import * as TRhf from "shared-types"; import { FormLabel, FormField } from "../Inputs"; import { DependencyWrapper } from "./dependencyWrapper"; import { RHFSlot } from "./Slot"; +import { ruleGenerator } from "./utils"; import { cn } from "@/utils"; export const RHFFormGroup = (props: { @@ -30,6 +31,7 @@ export const RHFFormGroup = (props: { {(props as RHFComponentMap["Radio"]).options.map((OPT) => { return ( @@ -285,6 +288,7 @@ export const OptChildren = ({ diff --git a/react-app/src/components/RHF/tests/additionalRules.test.ts b/react-app/src/components/RHF/tests/additionalRules.test.ts index 3ecc1556bf..99d493a9fe 100644 --- a/react-app/src/components/RHF/tests/additionalRules.test.ts +++ b/react-app/src/components/RHF/tests/additionalRules.test.ts @@ -1,20 +1,116 @@ import { describe, test, expect } from "vitest"; -import { sortFunctions } from "../utils/additionalRules"; +import { sortFunctions, ruleGenerator } from "../utils/additionalRules"; + +type VO = Record; describe("Additional Rules Tests", () => { describe("Sort Function Tests", () => { const dataArray = ["Florida, Ohio, Washington, Maine"]; - test("no sort", () => { const testArr = [...dataArray].sort(sortFunctions.noSort); expect(testArr.toString()).toBe(dataArray.toString()); }); - test("reverse sort", () => { const testArr = [...dataArray].sort(sortFunctions.reverseSort); const compareArr = [...dataArray].sort().reverse(); - expect(compareArr.toString()).toBe(testArr.toString()); }); }); + + describe("Custom Validation Tests", () => { + const testData = { + testCompField1: "0", + testCompField2: "10", + testCompField3: undefined, + }; + + test("Less than field", () => { + const rules = ruleGenerator(undefined, [ + { + fieldName: "testCompField1", + type: "lessThanField", + message: "Validation Failed 1", + }, + { + fieldName: "testCompField1", + type: "lessThanField", + message: "Validation Failed 2", + strictGreater: true, + }, + ]); + + if (!rules) throw new Error("Failed to create rule set."); + + const valFunc1 = (rules.validate as VO)["lessThanField_0"]; + expect(valFunc1).toBeTruthy(); + expect(valFunc1(0, testData)).toBeTruthy(); + expect(valFunc1(10, testData)).toBeTruthy(); + expect(valFunc1(100, testData)).toBe("Validation Failed 1"); + + const valFunc2 = (rules.validate as VO)["lessThanField_1"]; + expect(valFunc2).toBeTruthy(); + expect(valFunc2(0, testData)).toBeTruthy(); + expect(valFunc2(10, testData)).toBe("Validation Failed 2"); + expect(valFunc2(100, testData)).toBe("Validation Failed 2"); + }); + + test("Greater than field", () => { + const rules = ruleGenerator(undefined, [ + { + fieldName: "testCompField2", + type: "greaterThanField", + message: "Validation Failed 1", + }, + { + fieldName: "testCompField2", + type: "greaterThanField", + message: "Validation Failed 2", + strictGreater: true, + }, + ]); + + if (!rules) throw new Error("Failed to create rule set."); + + const valFunc1 = (rules.validate as VO)["greaterThanField_0"]; + expect(valFunc1).toBeTruthy(); + expect(valFunc1(0, testData)).toBeTruthy(); + expect(valFunc1(10, testData)).toBeTruthy(); + expect(valFunc1(100, testData)).toBeTruthy(); + expect(valFunc1(-1, testData)).toBe("Validation Failed 1"); + + const valFunc2 = (rules.validate as VO)["greaterThanField_1"]; + expect(valFunc2).toBeTruthy(); + expect(valFunc2(10, testData)).toBeTruthy(); + expect(valFunc2(100, testData)).toBeTruthy(); + expect(valFunc2(0, testData)).toBe("Validation Failed 2"); + expect(valFunc2(-1, testData)).toBe("Validation Failed 2"); + }); + + test("Cannot coexist", () => { + const rules = ruleGenerator(undefined, [ + { + fieldName: "testCompField1", + type: "cannotCoexist", + message: "Validation Failed 1", + }, + { + fieldName: "testCompField3", + type: "cannotCoexist", + message: "Validation Failed 2", + }, + ]); + + if (!rules) throw new Error("Failed to create rule set."); + + const valFunc1 = (rules.validate as VO)["cannotCoexist_0"]; + expect(valFunc1).toBeTruthy(); + expect(valFunc1(undefined, testData)).toBeTruthy(); + expect(valFunc1("test", testData)).toBe("Validation Failed 1"); + + const valFunc2 = (rules.validate as VO)["cannotCoexist_1"]; + expect(valFunc2).toBeTruthy(); + expect(valFunc2(undefined, testData)).toBeTruthy(); + expect(valFunc2("test", testData)).toBeTruthy(); + }); + }); }); diff --git a/react-app/src/components/RHF/utils/additionalRules.ts b/react-app/src/components/RHF/utils/additionalRules.ts index 08c08e1ce0..a00dfc29b9 100644 --- a/react-app/src/components/RHF/utils/additionalRules.ts +++ b/react-app/src/components/RHF/utils/additionalRules.ts @@ -1,4 +1,5 @@ -import { SortFuncs } from "shared-types"; +import { RegisterOptions } from "react-hook-form"; +import { RuleGenerator, SortFuncs, AdditionalRule } from "shared-types"; export const sortFunctions: { [x in SortFuncs]: (a: string, b: string) => number; @@ -6,3 +7,65 @@ export const sortFunctions: { noSort: () => 0, reverseSort: (a, b) => b.localeCompare(a), }; + +export const ruleGenerator: RuleGenerator = (rules, addtnlRules) => { + if (!rules && !addtnlRules) return undefined; + const simpleRules = rules ?? {}; + const customRules = addtnlRules + ? { validate: addtnlRules.reduce(valReducer, {}) } + : {}; + + return { ...simpleRules, ...customRules }; +}; + +export const valReducer = ( + valSet: RegisterOptions["validate"], + rule: AdditionalRule, + index: number, +): RegisterOptions["validate"] => { + const valName = `${rule.type}_${index}`; + + switch (rule.type) { + case "lessThanField": + return { + ...valSet, + [valName]: (value, fields) => { + if ( + !rule.strictGreater && + parseFloat(value) <= parseFloat(fields[rule.fieldName]) + ) + return true; + else if (parseFloat(value) < parseFloat(fields[rule.fieldName])) + return true; + return rule.message; + }, + }; + case "greaterThanField": + return { + ...valSet, + [valName]: (value, fields) => { + if ( + !rule.strictGreater && + parseFloat(value) >= parseFloat(fields[rule.fieldName]) + ) + return true; + else if (parseFloat(value) > parseFloat(fields[rule.fieldName])) + return true; + return rule.message; + }, + }; + case "cannotCoexist": + return { + ...valSet, + [valName]: (value, fields) => { + if (value !== undefined && fields[rule.fieldName] === undefined) + return true; + if (value === undefined && fields[rule.fieldName] !== undefined) + return true; + return rule.message; + }, + }; + default: + return { ...valSet }; + } +}; diff --git a/tsconfig.json b/tsconfig.json index d31766329a..1ea73f50be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "commonjs", "lib": ["es2020", "dom"], - "declaration": true, + "jsx": "react", "strict": false, "noImplicitAny": true, "strictNullChecks": true, @@ -17,7 +17,9 @@ "inlineSources": true, "experimentalDecorators": true, "strictPropertyInitialization": false, - "typeRoots": ["./node_modules/@types"] + "typeRoots": ["./node_modules/@types"], + "noEmit": true }, - "exclude": ["node_modules", "cdk.out", ".cdk"] + "exclude": ["node_modules", "cdk.out", ".cdk"], + "include": ["lib/**/*", "react-app/**/*", "test/**/*"] }