constructor()

in lib/base/tenant-service-stack.ts [29:515]


  constructor(scope: Construct, id: string, props: TenantServiceStackProps) {
    super(scope, id, props);
    const oidcproviderpipelineName = 'Hybrid-SaaS-Identity_OidcProvider_CI-CD_pipeline';
    /**
     * Tenant Infrastructure is created by two state machines:
     * 1. Tenant Onboarding Step Function
     * 2. Tenant Federation Step Function
     *
     * Both are fronted by APIGateway. Tenant Onboarding is open, no-Auth.
     * Tenant Federation however is availble only on /admin to Administrators.
     * These two can be invoked in tandem by an onboarding orchestration workflow as needed.
     *
     */

    /**
     * Tenant Rest API
     */
    const tenantApi = new RestApi(this, 'mysaasapp-tenant-service', {
      restApiName: 'mysaasapp-tenant-service',
      description: 'This tenant microservice handles tenant lifecycle right from onboarding, modifications to offboarding',
      deployOptions: {
        loggingLevel: MethodLoggingLevel.INFO,
        dataTraceEnabled: true,
      },
    });

    /**
     * Authorizer function for tenant federation api
     */
    const authFn = new nodejslambda.NodejsFunction(this, 'tenant-auth-function', {
      entry: `${path.join(path.resolve(__dirname, '..', '..'), 'resources', 'add_tenant_federation_lambda_authorizer')}/index.js`,
      handler: 'authorizerHandler',
      timeout: Duration.seconds(900), // +acm validation wait of 530 seconds
      memorySize: 3008,
      environment: {
        COGNITO_USER_POOL_ID: props.federationCognitoUserpool.userPoolId,
      },
    });

    /**
     * Request authorizer keys on host name, e.g. tenant-2.thinkr.dev will cache policy
     * for all subsequent invocations from the same host name.
     */
    const auth = new RequestAuthorizer(this, 'oidc-authorizer', {
      handler: authFn,
      identitySources: ['method.request.header.Host'],
    });

    /**
     * Create Tenant Infra Lambda Function.
     * This will be called by the onboarding Step function.
     */
    const createTenantInfraFn = new nodejslambda.NodejsFunction(this, 'AddTenantInfraFunction', {
      entry: `${path.join(path.resolve(__dirname, '..', '..'), 'resources', 'add_tenant_infra_lambda')}/handler.js`,
      handler: 'handler',
      timeout: Duration.seconds(900), // +acm validation wait of 530 seconds
      memorySize: 3008,
    });

    // Tenant Infra Lambda Policy to insert/read tenant specific ssm parameters
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/mysaasapp/*`],
      actions: ['ssm:GetParameter*', 'ssm:PutParameter*'],
    }));

    // Tenant Infra Lambda Policy to start federation step function execution
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:states:${this.region}:${this.account}:stateMachine:*TenantFederationStateMachine*`],
      actions: ['states:StartExecution'],
    }));

    // Tenant Infra Lambda Policy to insert/read tenant specific secrets
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:/mysaasapp/*`],
      actions: ['secretsmanager:*Secret*'],
    }));

    // Tenant Infra Lambda Policy to generate tenant secrets
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: ['*'],
      actions: ['secretsmanager:GetRandomPassword'],
    }));

    // Tenant Infra Lambda Policy to create tenant specific Cognito userpool components
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [props.federationCognitoUserpool.userPoolArn],
      actions: ['cognito-idp:*'],
    }));

    // Tenant Infra Lambda Policy to create tenant specific Cognito userpool
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: ['*'],
      actions: ['cognito-idp:Create*'],
    }));

    // Tenant Infra Lambda Policy to write/read tenant specific items
    // into oidcprovider/tenants DynamoDB table
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:dynamodb:*:*:table/${props.oidcProviderTableName}`, `arn:aws:dynamodb:*:*:table/${props.tenantsTableName}`],
      actions: ['dynamodb:Put*', 'dynamodb:G*', 'dynamodb:Q*', 'dynamodb:S*'],
    }));

    // Tenant Infra Lambda Policy to create acm ceertificate for tenant subdomain
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: ['*'],
      actions: ['acm:RequestCertificate', 'acm:AddTagsToCertificate', 'acm:DescribeCertificate'],
    }));

    // Tenant Infra Lambda Policy to create apigw custom domain and attach cloudfront distribution
    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: ['*'],
      actions: ['apigateway:PUT', 'cloudfront:UpdateDistribution'],
    }));

    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:apigateway:${this.region}::/domainnames`, `arn:aws:apigateway:${this.region}::/domainnames/*`],
      actions: ['apigateway:POST'],
    }));

    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:apigateway:${this.region}::/domainnames`, `arn:aws:apigateway:${this.region}::/domainnames/*`],
      actions: ['apigateway:POST'],
    }));

    createTenantInfraFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:route53:::hostedzone/${props.hostedZoneId}`],
      actions: ['route53:ChangeResourceRecordSets'],
    }));

    /**
     * Start OIDC Provider CodePipeline Lambda Function.
     * This will be called by the federation Step function.
     */
    const startOidcProviderPipelineFn = new nodejslambda.NodejsFunction(this, 'StartOidcProviderPipelineFunction', {
      entry: `${path.join(path.resolve(__dirname, '..', '..'), 'resources', 'start_oidc_provider_pipeline_lambda')}/handler.js`,
      handler: 'handler',
      timeout: Duration.seconds(900), // +acm validation wait of 530 seconds
      memorySize: 3008,
    });

    // Tenant Federation Lambda Policy to start oidcprovider codepipeline
    startOidcProviderPipelineFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:codepipeline:${this.region}:${this.account}:${oidcproviderpipelineName}`],
      actions: ['codepipeline:StartPipelineExecution'],
    }));
    startOidcProviderPipelineFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:states:${this.region}:${this.account}:stateMachine:*TenantFederationStateMachine*`],
      actions: ['states:SendTaskSuccess', 'states:SendTaskFailure'],
    }));

    // Tenant Federation Lambda Policy to insert/read tenant specific ssm parameters
    startOidcProviderPipelineFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/mysaasapp/*`],
      actions: ['ssm:GetParameter*', 'ssm:PutParameter*'],
    }));

    const addFederationConfigFn = new nodejslambda.NodejsFunction(this, 'AddFederationConfigFunction', {
      entry: `${path.join(path.resolve(__dirname, '..', '..'), 'resources', 'add_federation_configuration_lambda')}/handler.js`,
      handler: 'handler',
      timeout: Duration.seconds(900), // +acm validation wait of 530 seconds
      memorySize: 3008,
      environment: {
        TENANTS_TABLE_NAME: props.tenantsTableName,
      },
    });
    addFederationConfigFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:dynamodb:*:*:table/${props.oidcProviderTableName}`, `arn:aws:dynamodb:*:*:table/${props.tenantsTableName}`],
      actions: ['dynamodb:Put*', 'dynamodb:G*', 'dynamodb:Q*', 'dynamodb:S*', 'dynamodb:U*'],
    }));

    // Tenant Federation Lambda Policy to insert/read tenant specific secrets
    addFederationConfigFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:/mysaasapp/*`],
      actions: ['secretsmanager:*Secret*'],
    }));

    // Tenant Federation Lambda Policy to generate tenant secrets
    addFederationConfigFn.addToRolePolicy(new PolicyStatement({
      resources: ['*'],
      actions: ['secretsmanager:GetRandomPassword'],
    }));

    // Tenant Federation Lambda Policy to insert/read tenant specific ssm parameters
    addFederationConfigFn.addToRolePolicy(new PolicyStatement({
      resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/mysaasapp/*`],
      actions: ['ssm:GetParameter*', 'ssm:PutParameter*'],
    }));

    // Tenant Federation Lambda Policy to create tenant specific Cognito userpool components
    addFederationConfigFn.addToRolePolicy(new PolicyStatement({
      resources: [props.federationCognitoUserpool.userPoolArn],
      actions: ['cognito-idp:*'],
    }));

    /**
     * Tenant Federation State machine
     */

    // Lambda Invoke step to start the oidc provider code pipeline.
    // This uses a task wait pattern where a token is passed from sfc -> Lambda
    // Lambda stores this token in dynamodb, with the execution id of the codepipeline
    // codepipeline retrieves this token and passes it on to another lambda that it
    // invokes as part of the pipeline at the end to signal completion back to sfn.
    // sfn->lambda->codepipeline->lambda->sfn
    const startPipelineJob = new tasks.LambdaInvoke(this, 'Start OIDC Provider CodePipeline Execution', {
      lambdaFunction: startOidcProviderPipelineFn,
      integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN,
      timeout: Duration.hours(2),
      // Lambda's result is in the attribute `Payload`
      resultPath: '$.taskresult',
      payload: sfn.TaskInput.fromObject({
        token: sfn.JsonPath.taskToken,
        input: sfn.JsonPath.entirePayload,
      }),
    });

    // Lambda Invoke step to add federation config to dynamodb, ssm, secrets manager.
    const addConfigJob = new tasks.LambdaInvoke(this, 'Add Federation Config', {
      lambdaFunction: addFederationConfigFn,
    });

    // Federation state machine definition.
    const definition = startPipelineJob
      .next(addConfigJob);

    // Cloud Watch Log group for Federation state machine definition.
    const sfnLogGroup = new LogGroup(this, 'tenantFederationStateMachineLogGroup');

    // Federation state machine resource declaration.
    // For debugging purpose the log level has been elevated,
    // you may want to lower this once it matures.
    const tenantFederationStateMachine = new sfn.StateMachine(this, 'TenantFederationStateMachine', {
      definition,
      timeout: Duration.minutes(120),
      logs: { level: sfn.LogLevel.ALL, destination: sfnLogGroup },
    });

    // IAM role that APIgateway assumes to call federtion sfn
    const credentialsRoleForTenantFederationStateMachine = new Role(this, 'HsiFederationApiGatewaySfnInvokeRole', {
      assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
    });

    // Giving APIGateway role enough permissions to start the execution of sfn.
    credentialsRoleForTenantFederationStateMachine.attachInlinePolicy(
      new Policy(this, 'credentialsPolicyFortenantFederationStateMachine', {
        statements: [
          new PolicyStatement({
            actions: ['states:StartExecution'],
            effect: Effect.ALLOW,
            resources: [tenantFederationStateMachine.stateMachineArn],
          }),
        ],
      }),
    );

    // Tenant federation resource for rest api
    const federationSfn = tenantApi.root.addResource('federation');

    // rest api integration for Tenant federation
    // Key thing to note here is the request template,
    // where input has the input body, as well as the tenantuuid from the authorizer context.
    const newFederationIntegration = new AwsIntegration({
      service: 'states',
      action: 'StartExecution',
      integrationHttpMethod: 'POST',
      options: {
        credentialsRole: credentialsRoleForTenantFederationStateMachine,
        integrationResponses: [
          {
            statusCode: '200',
            responseTemplates: {
              'application/json': '{"done": true}',
            },
          },
        ],
        requestTemplates: {
          'application/json': `{
            "input": "{\\"body\\":$util.escapeJavaScript($input.json('$')), \\"tenantuuid\\":\\"$context.authorizer.tenantUuid\\"}",
            "stateMachineArn": "${tenantFederationStateMachine.stateMachineArn}"
          }`,
        },
      },
    });

    // Tenant federation Rest API method PUT for tenant federation.
    // currently only insert is handled.
    // existing tenant is detected by existing ssm params and rejected.
    // TODO: add tenant federation remove method DELETE
    // TODO: Currently federation is a insert only operation
    // TODO: Even though the codepipeline does add update behavior
    // TODO: Config is add only, not update in this reference implementaiton.
    federationSfn.addMethod('PUT', newFederationIntegration, {
      authorizer: auth,
      methodResponses: [{ statusCode: '200' }],
    });

    /**
     * Tenant Infra State Machine
     * This statemachine repeatedly calls the same lambda function with a different step name
     * Each step output is appended to the result path.
     * That way it is uniquely accessible in the subsequent steps.
     * Each Step is purposefully supressing the metadata in the response, and only retrieving the payload.
     *
     */

    // This step retrieves base stack config, adds tenant specific params to ssm.
    // Creates necessary secrets like JWKS, Cookie Signing keys
    const addTenantConfig = new tasks.LambdaInvoke(this, 'Add Tenant Config', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.addTenantConfigResult',
      payload: sfn.TaskInput.fromObject({
        step: 'CONFIG',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // This creates the tenant specific Cognito userpool, userpoolclient
    const addTenantUserPool = new tasks.LambdaInvoke(this, 'Add Tenant UserPool', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.addTenantUserPool',
      payload: sfn.TaskInput.fromObject({
        step: 'TENANTAUTH',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    const addFederationToInternalCognitoUserPool = new tasks.StepFunctionsStartExecution(this, 'Internal Cognito federation workflow', {
      stateMachine: tenantFederationStateMachine,
      inputPath: '$.addTenantUserPool',
      integrationPattern: sfn.IntegrationPattern.RUN_JOB,
      resultPath: sfn.JsonPath.DISCARD,
    });

    // This step creates a ACM cert for the tenant subdomain. e.g. tenant-1.thinkr.dev
    const addTenantCert = new tasks.LambdaInvoke(this, 'Add Tenant Cert', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.addTenantCertResult',
      payload: sfn.TaskInput.fromObject({
        step: 'CERT',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // This step checks if the domain validation entries are available on describe cert
    // which may take some time after the cert creation in the previous step.
    const isTenantCertBaked = new tasks.LambdaInvoke(this, 'Is Tenant Cert Baked', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.tenantCertCheckBakedResult',
      payload: sfn.TaskInput.fromObject({
        step: 'CERTBAKED',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // This step adds the CNAME entries from the cert to Route53 hosted zone for DNS validation.
    const addTenantCNAME = new tasks.LambdaInvoke(this, 'Add Tenant CNAME', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.addTenantCNAMEResult',
      payload: sfn.TaskInput.fromObject({
        step: 'CNAME',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // This step checks if the ACM Cert status has settled to 'ISSUED' indicating DNS Validation has completed.
    const isTenantCertValid = new tasks.LambdaInvoke(this, 'Is Tenant Cert Valid', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.tenantCertCheckValidResult',
      payload: sfn.TaskInput.fromObject({
        step: 'CERTVALID',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // This step takes the validated ACM cert, uses it to create a custom domain on apigw for the teanant
    // Creates a A record in route53 hosted zone to point the tenant subdomain to the
    // apigw custom domain cloud front distribution.
    const addTenantIngress = new tasks.LambdaInvoke(this, 'Add Tenant Ingress', {
      lambdaFunction: createTenantInfraFn,
      payloadResponseOnly: true,
      resultPath: '$.addTenantIngressResult',
      payload: sfn.TaskInput.fromObject({
        step: 'INGRESS',
        body: sfn.JsonPath.entirePayload,
      }),
    });

    // 30 second wait , used for ACM Cert baking.
    const waitX = new sfn.Wait(this, 'Wait X Seconds', {
      time: sfn.WaitTime.duration(Duration.seconds(30)),
    });

    // 60 second wait, used for ACM Cert DNS validation.
    const waitY = new sfn.Wait(this, 'Wait Y Seconds', {
      time: sfn.WaitTime.duration(Duration.seconds(60)),
    });

    // clugy stitching together of sfn steps into a definition
    // TODO: refactor/flatten this to avoid this pseudo chainback hell
    // more appealing visual in the docs here at [project root/images/tenant_infra_workflow.png]
    const addTenantInfraSfnDefinition = addTenantConfig
      .next(addTenantUserPool)
      .next(addFederationToInternalCognitoUserPool)
      .next(addTenantCert)
      .next(waitX)
      .next(isTenantCertBaked)
      .next(new sfn.Choice(this, 'Cert Baked?')
        .when(sfn.Condition.booleanEquals('$.tenantCertCheckBakedResult.continuewait', true), waitX)
        .when(sfn.Condition.booleanEquals('$.tenantCertCheckBakedResult.continuewait', false),
          addTenantCNAME.next(waitY).next(isTenantCertValid).next(new sfn.Choice(this, 'Cert Valid?')
            .when(sfn.Condition.booleanEquals('$.tenantCertCheckValidResult.continuewait', true), waitY)
            .when(sfn.Condition.booleanEquals('$.tenantCertCheckValidResult.continuewait', false), addTenantIngress))));

    // log group for tenant infra sfn
    const addTenantInfraSfnLogGroup = new LogGroup(this, 'tenantInfraStateMachineLogGroup');

    // Tenant Onboarding State Machine declaration
    const tenantInfraStateMachine = new sfn.StateMachine(this, 'TenantInfraStateMachine', {
      definition: addTenantInfraSfnDefinition,
      timeout: Duration.minutes(120),
      logs: { level: sfn.LogLevel.ALL, destination: addTenantInfraSfnLogGroup },
    });

    // This role is assumed by apigateway to invoke Tenant Onboarding sfn
    const credentialsRoleFortenantInfraStateMachine = new Role(this, 'HsiTenantInfraApiGatewaySfnInvokeRole', {
      assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
    });

    // giving permission to invoke the sfn.
    credentialsRoleFortenantInfraStateMachine.attachInlinePolicy(
      new Policy(this, 'credentialsPolicyFortenantInfraStateMachine', {
        statements: [
          new PolicyStatement({
            actions: ['states:StartExecution'],
            effect: Effect.ALLOW,
            resources: [tenantInfraStateMachine.stateMachineArn],
          }),
        ],
      }),
    );

    // tenant federation resource for rest api
    const tenantInfraSfn = tenantApi.root.addResource('onboard');

    // rest api integration for tenant federation sfn
    const onboardIntegration = new AwsIntegration({
      service: 'states',
      action: 'StartExecution',
      integrationHttpMethod: 'POST',
      options: {
        credentialsRole: credentialsRoleFortenantInfraStateMachine,
        integrationResponses: [
          {
            statusCode: '200',
            responseTemplates: {
              'application/json': '{"done": true}',
            },
          },
        ],
        requestTemplates: {
          'application/json': `{
            "input": "{\\"body\\":$util.escapeJavaScript($input.json('$')), \\"tenantuuid\\":\\"$context.requestId\\"}",
            "stateMachineArn": "${tenantInfraStateMachine.stateMachineArn}"
          }`,
        },
      },
    });

    // Tenant federation Rest API method PUT for tenant federation.
    // currently only insert is handled.
    // existing tenant is detected by existing ssm params and rejected.
    // TODO: add tenant offboarding method DELETE
    tenantInfraSfn.addMethod('PUT', onboardIntegration, {
      methodResponses: [{ statusCode: '200' }],
    });

    new ssm.StringParameter(this, 'tenantApiEndPoint', {
      parameterName: '/mysaasapp/tenantApiEndPoint',
      stringValue: tenantApi.url,
    });
  }