ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

実践編②:マルチテナントSaaS開発を加速させるSaaS Builder Toolkit(SBT)の魅力

こんにちは、AWSグループの藤岡(@fuji_kol_ry)です。

本ブログ、マルチテナントSaaS開発ツールのSaaS Builder Toolkit (SBT)についてご紹介するシリーズ物の第3回です。

ここまでの2編(導入編、実践編①)を読んでいない方は、ぜひご一読いただければと思います。

  • No. 1、導入編:SBTの概要、アーキテクチャの説明
  • No. 2、実践編①:動作検証、テナント作成、ユーザー登録
  • No. 3、実践編②:認証を使ったCRUD操作、まとめ

※実装の紹介もあって長文のため、結論はコチラです!

techblog.forgevision.com

techblog.forgevision.com

作成するアプリケーションの概要

今回はあくまでシンプルに、ToDoタスクのCRUD(登録・読込・更新・削除)を行うアプリケーションを作成します。

アプリケーションの概要は以下の通りです。

  • ユーザーはまず、コントロールプレーンで作成されているCognitoを使用した認証を行う
  • 認証情報を付与することで、CRUD操作が実行できる
  • 認証情報が無い場合は、CRUD操作が実行できない
  • 登録したToDoタスクは、DynamoDBに保存する

CRUDアプリケーションの構築

ここまでの構築はCDK+TypeScriptを使ったので、アプリケーションも同様にTypeScriptで作成します。

認証処理(API Gateway+Lambda)

リソース部分(CDK)

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
  
export interface AuthStackProps extends cdk.StackProps {
  userPoolId: string;
  userPoolClientId: string;
}
  
export class AuthStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: AuthStackProps) {
    super(scope, id, props);
  
    const authLambdaProps: NodejsFunctionProps = {
      runtime: lambda.Runtime.NODEJS_20_X,
      entry: 'src/auth_handler.ts',
      handler: 'loginHandler',
      environment: {
        USER_POOL_ID: props.userPoolId,
        USER_POOL_CLIENT_ID: props.userPoolClientId,
      },
    };
  
    const authLambda = new NodejsFunction(this, 'AuthLambda', authLambdaProps);
  
    authLambda.addToRolePolicy(new iam.PolicyStatement({
      actions: [
        'cognito-idp:AdminInitiateAuth',
        'cognito-idp:AdminRespondToAuthChallenge',
      ],
      resources: ['*'],
    }));
  
    const api = new apigateway.RestApi(this, 'AuthApi', {
      restApiName: 'Auth Service',
      description: 'Provides user authentication via Cognito.',
    });
  
    // /login リソース
    const loginResource = api.root.addResource('login');
  
    // POST /login でauthLambdaを呼び出し
    loginResource.addMethod('POST', new apigateway.LambdaIntegration(authLambda), {});
  }
}

ロジック部分(Node.jsのLambda)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { CognitoIdentityProviderClient, InitiateAuthCommand } from '@aws-sdk/client-cognito-identity-provider';
  
const client = new CognitoIdentityProviderClient({});
  
export const loginHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  try {
    const body = event.body ? JSON.parse(event.body) : {};
    const { username, password } = body;
  
    if (!username || !password) {
      return {
        statusCode: 400,
        body: JSON.stringify({ message: 'username and password are required' }),
      };
    }
  
    // Cognitoへログイン要求
    const command = new InitiateAuthCommand({
      AuthFlow: 'USER_PASSWORD_AUTH',
      ClientId: process.env.USER_POOL_CLIENT_ID!,
      AuthParameters: {
        USERNAME: username,
        PASSWORD: password,
      },
    });
  
    const response = await client.send(command);
  
    if (response.AuthenticationResult) {
      const { AccessToken, IdToken, RefreshToken } = response.AuthenticationResult;
      return {
        statusCode: 200,
        body: JSON.stringify({
          accessToken: AccessToken,
          idToken: IdToken,
          refreshToken: RefreshToken,
        }),
      };
    } else {
      return {
        statusCode: 401,
        body: JSON.stringify({ message: 'Authentication failed' }),
      };
    }
  } catch (error) {
    console.error(error);
    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Internal Server Error' }),
    };
  }
};

CRUD処理(API Gateway+Lambda+DynamoDB)

リソース部分(CDK)

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as cognito from 'aws-cdk-lib/aws-cognito';
  
export interface ToDoProps extends cdk.StackProps {
  CognitoUserPool: cognito.UserPool;
  CognitoUserClientId: string;
}
  
export class TodoStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ToDoProps) {
    super(scope, id, props);
  
    const table = new dynamodb.Table(this, 'TodoTable', {
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });
  
    // NodejsFunctionで共通化するプロパティ
    const commonNodejsFunctionProps: NodejsFunctionProps = {
      runtime: lambda.Runtime.NODEJS_20_X,
      environment: {
        TABLE_NAME: table.tableName,
        COGNITO_USER_POOL_ID: props.CognitoUserPool.userPoolId,
      },
      timeout: cdk.Duration.seconds(30),
      memorySize: 256,
    };
  
    // 各種Lambda
    const getTodosFunction = new NodejsFunction(this, 'GetTodosFunction', {
      ...commonNodejsFunctionProps,
      entry: 'src/todo_handler.ts',
      handler: 'getTodosHandler',
    });
    table.grantReadWriteData(getTodosFunction);
  
    const getTodoFunction = new NodejsFunction(this, 'GetTodoFunction', {
      ...commonNodejsFunctionProps,
      entry: 'src/todo_handler.ts',
      handler: 'getTodoHandler',
    });
    table.grantReadWriteData(getTodoFunction);
  
    const createTodoFunction = new NodejsFunction(this, 'CreateTodoFunction', {
      ...commonNodejsFunctionProps,
      entry: 'src/todo_handler.ts',
      handler: 'createTodoHandler',
    });
    table.grantReadWriteData(createTodoFunction);
  
    const updateTodoFunction = new NodejsFunction(this, 'UpdateTodoFunction', {
      ...commonNodejsFunctionProps,
      entry: 'src/todo_handler.ts',
      handler: 'updateTodoHandler',
    });
    table.grantReadWriteData(updateTodoFunction);
  
    const deleteTodoFunction = new NodejsFunction(this, 'DeleteTodoFunction', {
      ...commonNodejsFunctionProps,
      entry: 'src/todo_handler.ts',
      handler: 'deleteTodoHandler',
    });
    table.grantReadWriteData(deleteTodoFunction);
  
    // API Gatewayの作成
    const api = new apigateway.RestApi(this, 'TodoApi', {
      restApiName: 'Todo Service',
      description: 'CRUD operations for Todo tasks.',
    });
  
    // Cognitoオーソライザーの定義
    const cognitoAuthorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'TodoAuthorizer', {
      cognitoUserPools: [props.CognitoUserPool],
    });
  
    // /todos リソース
    const todos = api.root.addResource('todos');
    todos.addMethod('GET', new apigateway.LambdaIntegration(getTodosFunction), {
      authorizer: cognitoAuthorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });
    todos.addMethod('POST', new apigateway.LambdaIntegration(createTodoFunction), {
      authorizer: cognitoAuthorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });
  
    // /todos/{id} リソース
    const todo = todos.addResource('{id}');
    todo.addMethod('GET', new apigateway.LambdaIntegration(getTodoFunction), {
      authorizer: cognitoAuthorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });
    todo.addMethod('PUT', new apigateway.LambdaIntegration(updateTodoFunction), {
      authorizer: cognitoAuthorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });
    todo.addMethod('DELETE', new apigateway.LambdaIntegration(deleteTodoFunction), {
      authorizer: cognitoAuthorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    });
  }
}

ロジック部分(Node.jsのLambda)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { v4 as uuidv4 } from 'uuid';
  
// v3のDynamoDBClientと、DocumentClientユーティリティをインポート
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  ScanCommand,
  GetCommand,
  PutCommand,
  UpdateCommand,
  DeleteCommand
} from '@aws-sdk/lib-dynamodb';
  
const TABLE_NAME = process.env.TABLE_NAME!;
  
const dynamoClient = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(dynamoClient);
  
export const getTodosHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event:', event);
  try {
    const data = await dynamo.send(
      new ScanCommand({
        TableName: TABLE_NAME,
      })
    );
    return {
      statusCode: 200,
      body: JSON.stringify(data.Items),
    };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error' }) };
  }
};
  
export const getTodoHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event:', event);
  try {
    const id = event.pathParameters?.id;
  
    const data = await dynamo.send(
      new GetCommand({
        TableName: TABLE_NAME,
        Key: { id },
      })
    );
  
    if (!data.Item) {
      return { statusCode: 404, body: JSON.stringify({ message: 'Todo not found' }) };
    }
    return { statusCode: 200, body: JSON.stringify(data.Item) };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error' }) };
  }
};
  
export const createTodoHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event:', event);
  try {
    const body = event.body ? JSON.parse(event.body) : {};
    const newTodo = {
      id: uuidv4(),
      title: body.title,
      completed: false,
    };
  
    await dynamo.send(
      new PutCommand({
        TableName: TABLE_NAME,
        Item: newTodo,
      })
    );
  
    return { statusCode: 201, body: JSON.stringify(newTodo) };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error' }) };
  }
};
  
export const updateTodoHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event:', event);
  try {
    const id = event.pathParameters?.id;
    const body = event.body ? JSON.parse(event.body) : {};
  
    const result = await dynamo.send(
      new UpdateCommand({
        TableName: TABLE_NAME,
        Key: { id },
        UpdateExpression: 'set title = :title, completed = :completed',
        ExpressionAttributeValues: {
          ':title': body.title,
          ':completed': body.completed,
        },
        ReturnValues: 'ALL_NEW',
      })
    );
    return { statusCode: 200, body: JSON.stringify(result.Attributes) };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error' }) };
  }
};
  
export const deleteTodoHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event:', event);
  try {
    const id = event.pathParameters?.id;
  
    await dynamo.send(
      new DeleteCommand({
        TableName: TABLE_NAME,
        Key: { id },
      })
    );
    return { statusCode: 204, body: '' };
  } catch (error) {
    console.error(error);
    return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error' }) };
  }
};

動作確認

それでは作成したToDoタスク操作を試してみましょう。

ユーザー作成

まずはパスワード確認済みのユーザーを用意しましょう。

(実践①で使用した)1つ目のテストスクリプトでユーザーを作成し、2つ目のCLIでユーザーを確認済みとしてください。

sh sbt-aws.sh create-user
aws cognito-idp admin-set-user-password \
  --user-pool-id <ユーザープールID> \
  --username <対象ユーザー名> \
  --password <新しいパスワード> \
  --permanent

認証

次に、認証を行い、トークンを取得します。

作成した認証エンドポイントに対して、確認済みユーザーの情報をPOSTします。

curl -X POST "https://<認証エンドポイントのID>.execute-api.<デプロイリージョン>.amazonaws.com/prod/login" \
     -H "Content-Type: application/json" \
     -d '{
           "username": "<対象ユーザー名>",
           "password": "<登録した新しいパスワード>"
         }'

すると以下のようにトークンが返されます。

{
  "accessToken":"eyJ・・・・",
  "idToken":"eyJ・・・・",
  "refreshToken":"eyJ・・・・"
}

得られたidTokenを使って、CRUD操作を行います。

curl -X POST "https://<CRUDエンドポイントのID>.execute-api.<デプロイリージョン>.amazonaws.com/prod/todos" \
     -H "Authorization: Bearer <取得したidToken>" \
     -H "Content-Type: application/json" \
     -d '{"title": "SBTを学習する"}'

同様にAuthorizationヘッダを使用することで、GET/PUT/DELETEも実行できます。

以上のように、コントロールプレーンの認証を活用してCRUD操作を行うアプリケーションを作成・動作確認することが出来ました。SBTで構築された認証機能を、修正ゼロでアプリケーションに組み込むことができたのは、大きな魅力と感じました。

また今回は簡単に検証しましたが、より実務的に実装するならば、以下のような使い方ができるかと思います。

  • ユーザー作成処理イベントに対するトリガーを作成して、provisioningJobScriptでユーザー固有のリソースを作成する
  • Cognitoに登録しているユーザーの属性情報を利用して、処理の可否・分岐を行う

今後ますますSBTがアップデートされるのが楽しみですね!

SBTのメリット・デメリット

全3回のブログでの調査や動作確認を通して分かった、SBTのメリット・デメリットをまとめてみました。

観点 メリット デメリット
開発効率 SaaSのコントロールプレーンを、簡単・高速に構築できる ドキュメントが分かりにくく、手順通りに進めてもエラーが発生することがある
カスタマイズ性 CDKベースなので、ツール依存を抑えつつ、自由にカスタマイズが可能 比較的大規模なツールであり、全体の構造を把握するのが難しい
スケーラビリティ アプリケーションプレーンとの連携がスムーズで、イベント駆動設計によりスケールしやすい 設計思想(イベント駆動)が開発チームにとって適切かどうかの検討が必要

以降でメリット・デメリットの詳細をご説明します。

メリット

1. SaaSのコントロールプレーンを、簡単・高速に構築できる

SBTを利用することで、テナント管理や認証などといったSaaSに不可欠な機能を、ゼロから実装することなく構築できることを確認しました。 特に、CDKスタックを適用するだけで主要なAWSリソースが自動でセットアップされる点は、開発スピードの大幅な向上に貢献できます。

2. CDKベースなので、カスタマイズしやすく、ツールへの過度な依存が起こりにくい

SBTの構成要素はAWS CDKをベースにしているため、独自のビジネス要件に合わせたカスタマイズが容易です。 たとえば、SBTのCDKスタックを継承・拡張することで、デフォルトの機能を活かしつつ、追加のリソースやカスタムロジックを組み込むことが可能です。 また、一般的に「フレームワークを導入するとベンダーロックインの懸念がある」ものの、SBTはAWSネイティブのツール群を活用しており、ブラックボックス化されにくいのも強みといえます。

3. アプリケーションプレーンとの連携がスムーズで、スケーラビリティに優れる

SBTは、コントロールプレーン(認証・管理系)とアプリケーションプレーン(ビジネスロジック系)の役割を明確に分離しており、それらの通信はEventBridgeを中心としたイベント駆動アーキテクチャで構成されています。 これにより、疎結合なシステム設計が可能となり、後々のスケールアップや機能追加にも柔軟に対応できるのが魅力です。

デメリット

1. ドキュメントが分かりにくく、手順通りに進めてもエラーが発生することがある

実際に試したところ、公式のドキュメント(README)が十分に整備されておらず、手順通りに進めてもエラーに直面することがあったのが気になりました。

特に、複数のREADME間で記載に差分があったりと、複数のファイルを突き合わせての試行錯誤が必要でした。 しかしながら、発生するエラーはCDK自体を理解していれば解消できるものが多いので、1つずつ把握・解消していくことで、SBTを活用したインフラ構築ができます。

2. 比較的大規模なツールであり、全体の構造を把握するのが難しい

SBTはコントロールプレーン・アプリケーションプレーンを含むフルスタックのツールであり、その分コンポーネントが多く、初見では全体像を把握しづらいです。 特に、SBTの主要なリソース(Cognito, API Gateway, DynamoDB, Step Functions, EventBridge など)の役割を理解しないまま触ると、想定外の動作になる可能性があります。 SBTに任せきりにならずに、各機能の役割や連携を理解した上で活用することが重要です

3. 設計思想(イベント駆動)が開発チームにとって適切かどうかの検討が必要

たとえSBTが有用であったとしても、設計思想がチームにマッチしない場合には不要な混乱を起こして、逆に開発生産性が下がる懸念があります。また、CDKに対する習熟度も影響するでしょう。

チームの状況を踏まえた上での技術選定を行いましょう。

 

まとめ

AWSが提供する SaaS Builder Toolkit (SBT) は、SaaSのコントロールプレーンを迅速に構築できる強力なツールです。
本シリーズでは、SBTの概要から、実際にデプロイ・動作検証を行い、活用方法を詳しく解説しました。

SBTを活用することで、AWSのサービス群(Cognito、API Gateway、DynamoDB など)を効率的に構築・連携できるのが大きなメリットです。 特に、CDKベースのためカスタマイズ性が高く、SaaSごとの要件に合わせた設計を柔軟に実現できる のは魅力の1つです。

📌 全3回の学び

No. 内容 主な学び・ポイント
1 導入編 SBTの概要・アーキテクチャ
2 実践編① CDKを活用した、コントロールプレーン・アプリケーションプレーンのデプロイ・カスタマイズ性
3 実践編② コントロールプレーンと連携したCRUD操作の実装・デプロイ
まとめ ✅ SBTは「開発効率 × カスタマイズ性 × スケーラビリティ」に優れたツール
✅ AWS CDKの知識があると導入がスムーズ
✅ 公式ドキュメントを確認・補完しながら進めることが重要

以下に当てはまる方は、ぜひ本ブログを参考にしながら、SBTを試してみてください!
* SaaS開発を効率化したい * スケーリングを見越したイベント駆動なアーキテクチャを採用したい * だけど、ツールにロックインされずにカスタマイズ性も追求したい

最後までお読みいただき、ありがとうございました!