0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

クラウドSDKを用いたマルチテナント認証・認可基盤(RBAC)の実装設計パターンとセキュリティ考慮点

0
Posted at

クラウドSDKを用いたマルチテナント認証・認可基盤(RBAC)の実装設計パターンとセキュリティ考慮点

Feature(189).png

はじめに

こんにちは、NKKTech Global 技術チームです。

B2B SaaSの開発において、避けて通れないのが「マルチテナント管理」と「きめ細やかな権限管理(RBAC)」です。

  • 「テナントAのユーザーが、URLのIDを書き換えてテナントBのデータを見れてしまった(BOLA / IDOR脆弱性)」
  • 「管理者(Admin)と一般メンバー(Member)の認可ロジックが各APIに散乱し、権限チェックの抜け漏れが発生した」

これらは、認証(誰であるか)と認可(何をしてよいか)がアーキテクチャレベルで分離・統一されていないことに起因します。本記事では、クラウドSDK(AWS Cognito / STS / GCP Identity Platform等)を軸にした、堅牢なマルチテナントRBACの実装設計パターンを解説します。


1. テナント分離の3大アーキテクチャパターン

マルチテナントの認証基盤は、コストとセキュリティ要件(コンプライアンス)のトレードオフで3つに分類されます。

[ 1. プール型 (Pooled) ] ── 中小規模SaaS / コスト最小化
  1つのユーザープール / 1つのDB (Row-Level Securityで論理分離)
  トークン内の "tenant_id" クレームで境界を担保

[ 2. サイロ型 (Siloed) ] ── エンタープライズ・金融向け / 完全隔離
  テナントごとに専用のユーザープール & 専用DBをプロビジョニング
  物理的にインフラが分離されているため漏洩リスク最小

[ 3. ハイブリッド型 (Hybrid) ] ── 実務で最も選ばれる構成
  認証プールは全テナント共有 + DBはスキーマ分離または行レベルセキュリティ
  特定の大手顧客のみサイロ型へ切り出す柔軟性を持つ

2. JWTクレーム設計とコンテキスト検証の鉄則

認可エラーやなりすましを防ぐため、JWT(JSON Web Token)に含めるクレーム設計が極めて重要です。

推奨されるアクセストークンのペイロード設計

{
  "sub": "usr_99a8b7c6-d5e4-4f3a",
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXX",
  "exp": 1776300000,
  "custom:tenant_id": "tenant_enterprise_01",
  "custom:roles": ["TenantAdmin"],
  "custom:permissions": [
    "users:read",
    "users:write",
    "billing:read"
  ]
}

【鉄則】リクエストボディやクエリの tenant_id を信用しない

APIエンドポイント(例: GET /api/v1/orders?tenant_id=XXX)でクライアントから渡されるテナントIDをそのまま信用してDBクエリを発行してはいけません。
必ず署名検証済みのJWTペイロードから取り出した tenant_id をバックエンドの実行コンテキストにセットしてください。


3. 実装パターン:クラウドSDKとミドルウェアによる認可制御

以下は、Node.js / Express環境において、AWS SDK(Cognito JWT検証)と連携し、マルチテナントRBACを自動適用するミドルウェアの実装例です。

① 認証・テナントコンテキスト注入ミドルウェア (authMiddleware.ts)

import { Request, Response, NextFunction } from 'express';
import { CognitoJwtVerifier } from 'aws-jwt-verify';

// AWS Cognito JWT Verifierの初期化
const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!,
  tokenUse: 'access',
  clientId: process.env.COGNITO_CLIENT_ID!,
});

export interface TenantContext {
  userId: string;
  tenantId: string;
  roles: string[];
  permissions: string[];
}

declare global {
  namespace Express {
    interface Request {
      tenantContext?: TenantContext;
    }
  }
}

export const authenticateTenant = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing or malformed token' });
    }

    const token = authHeader.split(' ')[1];
    
    // 1. JWTの暗号署名・有効期限の検証
    const payload = await verifier.verify(token);

    // 2. トークンからテナントIDと権限を安全に抽出
    const tenantId = payload['custom:tenant_id'] as string;
    const roles = (payload['custom:roles'] as string[]) || [];
    const permissions = (payload['custom:permissions'] as string[]) || [];

    if (!tenantId) {
      return res.status(403).json({ error: 'Tenant context missing in token' });
    }

    // 3. リクエストスコープにテナントコンテキストをバインド
    req.tenantContext = {
      userId: payload.sub,
      tenantId,
      roles,
      permissions
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token', details: err });
  }
};

② RBAC認可ガードミドルウェア (requirePermission.ts)

import { Request, Response, NextFunction } from 'express';

export const requirePermission = (requiredPermission: string) => {
  return (req: Request, res: Response, next: NextFunction) => {
    const context = req.tenantContext;

    if (!context) {
      return res.status(401).json({ error: 'Unauthenticated' });
    }

    // 管理者ロールは全権限バイパス、または特定パーミッションの保有確認
    const hasRole = context.roles.includes('SuperAdmin') || context.roles.includes('TenantAdmin');
    const hasPermission = context.permissions.includes(requiredPermission);

    if (!hasRole && !hasPermission) {
      return res.status(403).json({ 
        error: 'Forbidden: Insufficient permissions for this tenant resource' 
      });
    }

    next();
  };
};

// APIルート定義での適用例
// app.delete('/api/v1/users/:id', authenticateTenant, requirePermission('users:delete'), deleteUserHandler);

③ PostgreSQL 行レベルセキュリティ(RLS)との連携

ミドルウェアで取得した tenantId をデータベースセッションに設定することで、万が一SQLのWHERE句にテナント指定を書き忘れても、DBエンジンが物理的に他社データの取得を遮断します。

// トランザクション開始時にセッション変数をセット
await db.query('SET LOCAL app.current_tenant_id = $1', [req.tenantContext.tenantId]);

// PostgreSQL側のポリシー定義:
// CREATE POLICY tenant_isolation_policy ON orders
// USING (tenant_id = current_setting('app.current_tenant_id'));

4. マルチテナントSaaSで必須のセキュリティ考慮点

① BOLA / IDOR (Broken Object Level Authorization) の完全防止

攻撃者が GET /api/documents/1005 をリクエストした際、ドキュメント1005が「リクエスト元のテナントに属しているか」を必ず検証しなければなりません。

  • 対策: DBクエリで SELECT * FROM documents WHERE id = 1005 AND tenant_id = :tenantId を強制する、または前述のPostgreSQL RLSを全テーブルに適用する。

② Redis・Memcached等のキャッシュ汚染(Cache Bleed)の防止

分散キャッシュを使用する場合、キー名にテナントIDを含めないと他テナントにキャッシュが漏洩します。

  • 危険: cache.get("user:123")
  • 安全: cache.get("tenant:" + tenantId + ":user:123")

③ テナント単位の監査ログ(Audit Trail)の分離

誰が・いつ・どのテナントで権限を行使したかを、改ざん不可能な形式で記録します。

  • AWS CloudWatch LogsやDatadogにログを送る際、必ず tenant_id を構造化JSONの最上位フィールドに配置し、テナント別のインシデント調査を即座に行えるように設計します。

まとめ:マルチテナントセキュリティは「多層防衛」で完結する

マルチテナント基盤のセキュリティは、アプリケーションコード単体で担保しようとしてはいけません。

  1. クラウドIdP(Cognito等): JWTクレームによる改ざん不能なテナント情報の署名
  2. APIゲートウェイ・ミドルウェア: 一元化されたRBACチェックとコンテキスト注入
  3. データベース(RLS / スキーマ分離): アプリ側のバグを許容しないDB層での物理・論理遮断

この多層防衛アーキテクチャを敷くことで、将来テナント数や開発メンバーが急増しても、安心・安全にスケールできるエンタープライズ品質のSaaS基盤が構築できます。


お問い合わせ先

マルチテナントSaaSの認証・認可設計、クラウドインフラ構築、エンタープライズ向けセキュリティ監査に関するご相談は、下記よりお気軽にお問い合わせください。

著者:NKKTech Global 技術チーム
私たちは、強固なセキュリティ設計とモダンなクラウドアーキテクチャで、グローバルに戦えるSaaSプロダクトの開発を支援します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?