こんにちは、AWSグループの藤岡(@fuji_kol_ry)です。
本ブログ、マルチテナントSaaS開発ツールのSaaS Builder Toolkit (SBT)についてご紹介するシリーズ物の第3回です。
ここまでの2編(導入編、実践編①)を読んでいない方は、ぜひご一読いただければと思います。
- No. 1、導入編:SBTの概要、アーキテクチャの説明
- No. 2、実践編①:動作検証、テナント作成、ユーザー登録
- No. 3、実践編②:認証を使ったCRUD操作、まとめ
※実装の紹介もあって長文のため、結論はコチラです!
作成するアプリケーションの概要
今回はあくまでシンプルに、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開発を効率化したい
* スケーリングを見越したイベント駆動なアーキテクチャを採用したい
* だけど、ツールにロックインされずにカスタマイズ性も追求したい
最後までお読みいただき、ありがとうございました!