SaaS Builder Toolkit で最小限のコントロールプレーンとアプリケーションプレーンを実装しテナントオンボーディング機能を試してみた

SaaS Builder Toolkit で最小限のコントロールプレーンとアプリケーションプレーンを実装しテナントオンボーディング機能を試してみた

Clock Icon2025.01.21

いわさです。

先月の re:Invent 2024 では SaaS Builder Toolkit のセッションに参加しました。

https://dev.classmethod.jp/articles/reinvent-2024-sas406/

この SaaS Builder Toolkit、CDK ベースでコントロールプレーンを実装してくれそうな何かというイメージを持つことは出来たのですが、ドキュメントやセッションで情報をインプットしただけだとじゃあこれをどう使うのか、SaaS Boost を比べてどうなのか、などよくわからない点が多いです。
そこで、今回は実際に CDK でアプリケーションスタック一式を作成し、そこに SaaS Builder Toolkit を使ってコントロールプレーンとアプリケーションプレーンを実装し、テナントオンボーディングの動きを確認してみました。

先にまとめると、SaaS Builder Toolkit は CDK 向けに「コントロールプレーンコンストラクト」と「アプリケーションプレーンコンストラクト」を提供しています。(他にも SaaS 向けにコンストラクトを提供しているが)
それらを使うと、主に次のようなリソースがデプロイされます。

Untitled.png

コントロールプレーンは API Gateway を使って API 型で提供されます。
テナントオンボーディングリクエストを API Gateway 経由で受信した際に DynamoDB でテナントデータを管理し、EventBridge にテナント作成イベントを送信します。

アプリケーションプレーンはそのイベントをトリガーに Step Functions 経由で CodeBuild プロジェクトを実行します。
そして CodeBuild プロジェクト内で任意のテナントオンボーディング処理を実行するという形となっています。
アプリケーション側に何か作用する仕組みではなく、CodeBuild で何でもやれるという感じです。
例えばサイロモデルでテナントごとのインフラストラクチャをデプロイしても良いですし、プールモデル向けにテナント追加処理を行うなど、なんでも出来ます。

オンボーディング時のデプロイ機能というよりも、テナントライフサイクル機能の基盤を提供してくれるようなイメージでしょうか。

実装してみる

ではどのように始めたら良いのかというところですが、SaaS Builder Toolkit は GitHub 上でリポジトリが公開されており、次のドキュメントに従って開始することが出来ます。

https://github.com/awslabs/sbt-aws/tree/main/docs/public

詳しいところは上記を見ていただければ概ね良いのですが、今回私が試した際にはこのとおり試してもうまくいかないところがいくつかありました。
おそらく NPM 上公開されているパッケージと main ブランチのドキュメントの同期が出来ておらず古いバージョンの手順になっています。
今回は中心的な部分と詰まったポイントを抜粋して紹介したいと思います。

SaaS Builder Toolkit インストールとコントロールプレーンとアプリケーションプレーンの実装

適当な CDK プロジェクトに SaaS Builder Toolkit をインストールし、コントロールプレーンとアプリケーションプレーンを実装してみます。このあたりはドキュメントどおりで。

まずはインストールを。

% npm install @cdklabs/sbt-aws
npm warn EBADENGINE Unsupported engine {
npm warn EBADENGINE   package: '@cdklabs/sbt-aws@0.5.15',
npm warn EBADENGINE   required: { node: '>= 18.12.0 <= 20.x' },
npm warn EBADENGINE   current: { node: 'v22.11.0', npm: '10.9.0' }
npm warn EBADENGINE }

added 6 packages, and audited 344 packages in 8s

34 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
% 

つづいてコントロールプレーンを実装します。ここもドキュメントどおりです。

hello-cdk/lib/control-plane.ts
import * as sbt from '@cdklabs/sbt-aws';
import { Stack } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class ControlPlaneStack extends Stack {
  public readonly regApiGatewayUrl: string;
  public readonly eventManager: sbt.IEventManager;

  constructor(scope: Construct, id: string, props?: any) {
    super(scope, id, props);
    const cognitoAuth = new sbt.CognitoAuth(this, 'CognitoAuth', {
      // Avoid checking scopes for API endpoints. Done only for testing purposes.
      setAPIGWScopes: false,
    });

    const controlPlane = new sbt.ControlPlane(this, 'ControlPlane', {
      auth: cognitoAuth,
      systemAdminEmail: 'hoge@example.com',
    });

    this.eventManager = controlPlane.eventManager;
    this.regApiGatewayUrl = controlPlane.controlPlaneAPIGatewayUrl;
  }
}

つづいてアプリケーションプレーンなのですが、ここはドキュメントどおりだと次のエラーが発生します。

% npm run build

> hello-cdk@0.1.0 build
> tsc

lib/app-plane.ts:60:7 - error TS2559: Type 'string[]' has no properties in common with type 'EnvironmentVariablesToOutgoingEventProps'.

60       environmentVariablesToOutgoingEvent: [
         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  node_modules/@cdklabs/sbt-aws/lib/core-app-plane/tenant-lifecycle-script-jobs.d.ts:44:14
    44     readonly environmentVariablesToOutgoingEvent?: EnvironmentVariablesToOutgoingEventProps;
                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    The expected type comes from property 'environmentVariablesToOutgoingEvent' which is declared here on type 'TenantLifecycleScriptJobProps'

lib/app-plane.ts:79:21 - error TS2304: Cannot find name 'eventManager'.

79       eventManager: eventManager,
                       ~~~~~~~~~~~~

Found 2 errors in the same file, starting at: lib/app-plane.ts:60

おそらく次のプルリクエスト内容が手順に反映されていなさそうでした。

https://github.com/awslabs/sbt-aws/pull/109

environmentVariablesToOutgoingEvent、コントロールプレーンのeventManagerあたりをちょっと修正しています。

hello-cdk/lib/app-plane.ts
import * as sbt from '@cdklabs/sbt-aws';
import * as cdk from 'aws-cdk-lib';
import { EventBus } from 'aws-cdk-lib/aws-events';
import { PolicyDocument, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam';

export interface AppPlaneProps extends cdk.StackProps {
  eventManager: sbt.IEventManager;
}
export class AppPlaneStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: AppPlaneProps) {
    super(scope, id, props);

    const provisioningScriptJobProps: sbt.TenantLifecycleScriptJobProps = {
      permissions: new PolicyDocument({
        statements: [
          new PolicyStatement({
            actions: [
              'cloudformation:CreateStack',
              'cloudformation:DescribeStacks',
              's3:CreateBucket',
            ],
            resources: ['*'],
            effect: Effect.ALLOW,
          }),
        ],
      }),
      script: `
echo "starting..."

# note that this template.yaml is being created here, but
# it could just as easily be pulled in from an S3 bucket.
cat > template.json << EndOfMessage
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Resources": { "MyBucket":{ "Type": "AWS::S3::Bucket" }},
  "Outputs": { "S3Bucket": { "Value": { "Ref": "MyBucket" }}}
}
EndOfMessage

echo "tenantId: $tenantId"
echo "tier: $tier"

aws cloudformation create-stack --stack-name "tenantTemplateStack-\${tenantId}"  --template-body "file://template.json"
aws cloudformation wait stack-create-complete --stack-name "tenantTemplateStack-\${tenantId}"
export tenantS3Bucket=$(aws cloudformation describe-stacks --stack-name "tenantTemplateStack-\${tenantId}" | jq -r '.Stacks[0].Outputs[0].OutputValue')
export someOtherVariable="this is a test"
echo $tenantS3Bucket

export tenantConfig=$(jq --arg SAAS_APP_USERPOOL_ID "MY_SAAS_APP_USERPOOL_ID" \
--arg SAAS_APP_CLIENT_ID "MY_SAAS_APP_CLIENT_ID" \
--arg API_GATEWAY_URL "MY_API_GATEWAY_URL" \
-n '{"userPoolId":$SAAS_APP_USERPOOL_ID,"appClientId":$SAAS_APP_CLIENT_ID,"apiGatewayUrl":$API_GATEWAY_URL}')

echo $tenantConfig
export tenantStatus="created"

echo "done!"
`,
      environmentStringVariablesFromIncomingEvent: ['tenantId', 'tier'],
    //   environmentVariablesToOutgoingEvent: [
    //     'tenantS3Bucket',
    //     'someOtherVariable',
    //     'tenantConfig',
    //     'tenantStatus',
    //   ],
      environmentVariablesToOutgoingEvent: {
        tenantData: [
          'tenantS3Bucket',
          'someOtherVariable',
          'tenantConfig',
        ],
        tenantRegistrationData: ['tenantStatus'],
      },
      scriptEnvironmentVariables: {
        TEST: 'test',
      },
      eventManager: props.eventManager,
    };

    const provisioningJobScript: sbt.ProvisioningScriptJob = new sbt.ProvisioningScriptJob(
      this,
      'provisioningJobScript',
      provisioningScriptJobProps
    );

    new sbt.CoreApplicationPlane(this, 'CoreApplicationPlane', {
    //   eventManager: eventManager,
      eventManager: props.eventManager,
      scriptJobs: [provisioningJobScript],
    });
  }
}

後ほど確認しますが、上記のscript部分が、テナント追加時に CodeBuild で実行されるテナント追加用のスクリプトです。
サンプルでは S3 バケットをデプロイするための CloudFormation テンプレートがハードコーディングされています。ここは各自のアプリケーションにあわせてカスタマイズが必須ですね。今回はサンプルのままでいきました。

CDK がデプロイされると次のようなスタックが作成されます。
以下がコントロールプレーンです。

6BC02D6D-5EC4-494B-B4CD-F8A94CE853A2_1_105_c.jpeg

コントロールプレーンはテナントオンボーディングに必要な API を提供してくれるような感じです。

9B62CAFC-D097-4DEC-A7A3-12BD25395A9D.png

API Gateway の統合先は Lambda 関数で、あわせてデプロイされる DynamoDB へテナントデータが登録され、イベント発行されるような流れです。

D5B4F5D7-9DFE-4E2F-AF46-B9267A04152E_1_105_c.jpeg

以下がアプリケーションプレーンです。

DFC878F1-EDE0-412F-96BB-047FBDD17203_1_105_c.jpeg

アプリケーションプレーンは EventBridge をトリガーに次のステートマシンが実行され、CodeBuild でデプロイスクリプトが流れるという仕組みのようです。

ED9DC82B-55C8-4480-9884-DDDBF22BF65A.png

テナントを作成してみる

今回のコントロールプレーンの認証基盤に Cognito ユーザープールをデプロイしています。
デプロイ後に管理者ユーザーのパスワードが送信されるので、それを使って認証してアクセストークンを取得後に API Gateway へリクエストを送信します。

96E7BBAD-5636-4E7D-853D-5D19763A3193.png

ここも公式の手順にサンプルスクリプトがあるのでそれに従って API Gateway を呼び出してやります、が Forbidden で失敗しました。後述しますが呼び出している API リソースが正しくないのです。

% ./hoge.sh 
{
    "UserPoolClient": {
        "UserPoolId": "ap-northeast-1_L2j977QKa",
        "ClientName": "CognitoAuthUserPoolUserClient5DD0303C-rUdCsE1hDuyX",
        "ClientId": "4odj9gh8cfb6hr0oui3s3bg15e",
        "LastModifiedDate": "2025-01-18T07:10:29.542000+09:00",
        "CreationDate": "2025-01-18T06:40:09.157000+09:00",
        "RefreshTokenValidity": 30,
        "TokenValidityUnits": {},
        "ExplicitAuthFlows": [
            "USER_PASSWORD_AUTH"
        ],
        "AllowedOAuthFlowsUserPoolClient": false,
        "EnableTokenRevocation": true,
        "EnablePropagateAdditionalUserContextData": false,
        "AuthSessionValidity": 3
    }
}
creating tenant...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   140  100    23  100   117     84    431 --:--:-- --:--:-- --:--:--   514
{
  "message": "Forbidden"
}

retrieving tenants...
{
  "data": []
}

JWT を使って IAM オーソライザーの API を実行しています。これは...

6D7D3287-1740-47B9-A6C4-6CE093A6C562_1_105_c.jpeg

上記を JWT オーソライザーにしてやると呼び出し自体は成功され DynamoDB にレコードが作成されるのですが、今度は EventBridge へのイベント送信がされていませんでした。

解決策ですが、tenantsリソースではなくtenant-registrationsに対して POST してやる必要がありました。
tenant-registrationsでは Lambda 関数からtenantsへさらに POST されており、そういう経緯で IAM オーソライザーが使われているようです。

以下が修正後のスクリプトです。

:

CONTROL_PLANE_API_ENDPOINT=$(aws cloudformation describe-stacks --profile hogeadmin \
    --stack-name "$CONTROL_PLANE_STACK_NAME" \
    --query "Stacks[0].Outputs[?contains(OutputKey,'controlPlaneAPIEndpoint')].OutputValue" \
    --output text)

DATA=$(jq --null-input \
    --arg tenantName "$TENANT_NAME" \
    --arg tenantEmail "$TENANT_EMAIL" \
    '{
    "tenantData": {
      "tenantName": $tenantName,
      "email": $tenantEmail,
      "tier": "basic",
      "tenantStatus": "In progress"
    },
    "tenantRegistrationData": {
      "tenantRegistrationData1": "test"
    }
}')

echo "creating tenant..."
curl --request POST \
    --url "${CONTROL_PLANE_API_ENDPOINT}tenant-registrations" \
    --header "Authorization: Bearer ${ACCESS_TOKEN}" \
    --header 'content-type: application/json' \
    --data "$DATA" | jq
echo "" # add newline


:

これで実行後にコントロールプレーン側で DynamoDB のアイテムが作成され、さらに EventBridge 経由で StepFunctions, CodeBuild が実行され、CloudFormation がデプロイされます。

% ./hoge.sh
{
    "UserPoolClient": {
        "UserPoolId": "ap-northeast-1_L2j977QKa",
        "ClientName": "CognitoAuthUserPoolUserClient5DD0303C-rUdCsE1hDuyX",
        "ClientId": "4odj9gh8cfb6hr0oui3s3bg15e",
        "LastModifiedDate": "2025-01-18T07:59:10.577000+09:00",
        "CreationDate": "2025-01-18T06:40:09.157000+09:00",
        "RefreshTokenValidity": 30,
        "TokenValidityUnits": {},
        "ExplicitAuthFlows": [
            "USER_PASSWORD_AUTH"
        ],
        "AllowedOAuthFlowsUserPoolClient": false,
        "EnableTokenRevocation": true,
        "EnablePropagateAdditionalUserContextData": false,
        "AuthSessionValidity": 3
    }
}
creating tenant...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   385  100   164  100   221    125    168  0:00:01  0:00:01 --:--:--   293
{
  "data": {
    "tenantRegistrationId": "f5344473-3785-4e54-a04e-2ec60fa36096",
    "tenantId": "32ae5911-8b16-496c-bd2e-70e6c0a0c167",
    "message": "Tenant registration initiated"
  }
}

retrieving tenants...
{
  "data": [
    {
      "tenantName": "tenant23289",
      "sbtaws_active": true,
      "email": "tenant@example.com",
      "tenantStatus": "In progress",
      "tenantId": "32ae5911-8b16-496c-bd2e-70e6c0a0c167",
      "tier": "basic"
    },
    {
      "tenantName": "tenant1787",
      "sbtaws_active": true,
      "email": "tenant@example.com",
      "tenantStatus": "In progress",
      "tenantId": "35838cae-9c9c-4c22-ace5-a21185e1c362",
      "tier": "basic"
    }
  ]
}

StepFunctions のステートマシンが実行されています。

90CC459D-DF17-473D-8339-1B75DEFE23C6_1_105_c.jpeg

CodeBuild プロジェクトも実行されました。

910DDD2E-DD61-4F63-A312-F3A4E4952CC5_1_105_c.jpeg

CloudFormation スタックもデプロイされていますね。

8B58E214-3739-4E58-9F95-1FDAA4102387_4_5005_c.jpeg

そして、テナントオンボーディング後にコントロールプレーン側のテナントアイテムの情報が更新されました。なるほど。

435ED046-B94B-4759-ADC2-F2E7C875F4FD_1_105_c.jpeg

さいごに

本日は SaaS Builder Toolkit で最小限のコントロールプレーンとアプリケーションプレーンを実装しテナントオンボーディング機能を試してみました。

今回実際に使ってみることで、SaaS Builder Toolkit がちょっと理解できました。
SaaS Builder Toolkit のみでコントロールプレーンが完結するというわけではなかったですね。ただ、コントロールプレーンを使う際に十分利用できそうだなと思いました。
今回はテナントライフサイクルのオンボーディング部分を試してみましたが、次回以降はビリングやモニタリングなどの他の主要機能も試してみたいと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.