SaaS Builder Toolkit で最小限のコントロールプレーンとアプリケーションプレーンを実装しテナントオンボーディング機能を試してみた
いわさです。
先月の re:Invent 2024 では SaaS Builder Toolkit のセッションに参加しました。
この SaaS Builder Toolkit、CDK ベースでコントロールプレーンを実装してくれそうな何かというイメージを持つことは出来たのですが、ドキュメントやセッションで情報をインプットしただけだとじゃあこれをどう使うのか、SaaS Boost を比べてどうなのか、などよくわからない点が多いです。
そこで、今回は実際に CDK でアプリケーションスタック一式を作成し、そこに SaaS Builder Toolkit を使ってコントロールプレーンとアプリケーションプレーンを実装し、テナントオンボーディングの動きを確認してみました。
先にまとめると、SaaS Builder Toolkit は CDK 向けに「コントロールプレーンコンストラクト」と「アプリケーションプレーンコンストラクト」を提供しています。(他にも SaaS 向けにコンストラクトを提供しているが)
それらを使うと、主に次のようなリソースがデプロイされます。
コントロールプレーンは API Gateway を使って API 型で提供されます。
テナントオンボーディングリクエストを API Gateway 経由で受信した際に DynamoDB でテナントデータを管理し、EventBridge にテナント作成イベントを送信します。
アプリケーションプレーンはそのイベントをトリガーに Step Functions 経由で CodeBuild プロジェクトを実行します。
そして CodeBuild プロジェクト内で任意のテナントオンボーディング処理を実行するという形となっています。
アプリケーション側に何か作用する仕組みではなく、CodeBuild で何でもやれるという感じです。
例えばサイロモデルでテナントごとのインフラストラクチャをデプロイしても良いですし、プールモデル向けにテナント追加処理を行うなど、なんでも出来ます。
オンボーディング時のデプロイ機能というよりも、テナントライフサイクル機能の基盤を提供してくれるようなイメージでしょうか。
実装してみる
ではどのように始めたら良いのかというところですが、SaaS Builder Toolkit は GitHub 上でリポジトリが公開されており、次のドキュメントに従って開始することが出来ます。
詳しいところは上記を見ていただければ概ね良いのですが、今回私が試した際にはこのとおり試してもうまくいかないところがいくつかありました。
おそらく 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
%
つづいてコントロールプレーンを実装します。ここもドキュメントどおりです。
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
おそらく次のプルリクエスト内容が手順に反映されていなさそうでした。
environmentVariablesToOutgoingEvent
、コントロールプレーンのeventManager
あたりをちょっと修正しています。
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 がデプロイされると次のようなスタックが作成されます。
以下がコントロールプレーンです。
コントロールプレーンはテナントオンボーディングに必要な API を提供してくれるような感じです。
API Gateway の統合先は Lambda 関数で、あわせてデプロイされる DynamoDB へテナントデータが登録され、イベント発行されるような流れです。
以下がアプリケーションプレーンです。
アプリケーションプレーンは EventBridge をトリガーに次のステートマシンが実行され、CodeBuild でデプロイスクリプトが流れるという仕組みのようです。
テナントを作成してみる
今回のコントロールプレーンの認証基盤に Cognito ユーザープールをデプロイしています。
デプロイ後に管理者ユーザーのパスワードが送信されるので、それを使って認証してアクセストークンを取得後に API Gateway へリクエストを送信します。
ここも公式の手順にサンプルスクリプトがあるのでそれに従って 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 を実行しています。これは...
上記を 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 のステートマシンが実行されています。
CodeBuild プロジェクトも実行されました。
CloudFormation スタックもデプロイされていますね。
そして、テナントオンボーディング後にコントロールプレーン側のテナントアイテムの情報が更新されました。なるほど。
さいごに
本日は SaaS Builder Toolkit で最小限のコントロールプレーンとアプリケーションプレーンを実装しテナントオンボーディング機能を試してみました。
今回実際に使ってみることで、SaaS Builder Toolkit がちょっと理解できました。
SaaS Builder Toolkit のみでコントロールプレーンが完結するというわけではなかったですね。ただ、コントロールプレーンを使う際に十分利用できそうだなと思いました。
今回はテナントライフサイクルのオンボーディング部分を試してみましたが、次回以降はビリングやモニタリングなどの他の主要機能も試してみたいと思います。