👻

S3xCognitoでマルチテナント認証付きファイルストレージを作る

2024/07/30に公開

はじめに

SaaS系のWeb開発なんかしてると大体必要になってきますよねマルチテナント対応。
みんな同じ1つのアプリを使ってるんだけど、A会社の人はB会社のデータみれないようにするみたいな話ですね。
ビジネス向けだとこういうグループ(会社)単位がほとんどですが、これがコンシューマー向けだとユーザー単位になってたり。
まあ大体のアプリがしれっとこうなってるケースは多いんじゃないでしょうか。

画像を初めとしたファイルなどはデータベースとは別になにかしらのストレージを用意して保存することが多いと思いますのでストレージへのアクセスに認証が必要ですね。

例えばAWS S3は期限付きURLが発行できるので、これを使えばURLが流出しない限り、仮にもし流出したとしても期限を短く設定していれば被害を最小に抑えることができそうです。
ですが、URLがわかれば誰でもアクセスできてしまうというのはセキュアではないですし、ましてビジネス向けSaaSなんかになると扱うファイルは偉い大人達のスーパー機密情報でしかないので、流出なんかしたらボコボコにされます。それはなんとか避けたい。

そこで今回はAWS上でサーバレスなマルチテナントアクセス認証付きのファイルストレージを構築していきたいと思います。

対象読者

  • マルチテナントアプリケーションの開発者
  • Cognitoでアカウント認証を実装している方
  • S3に認証機能を実装したい方
  • API Gateway及びAWS LambdaでのAPI実装に関心がある方
  • IaCでインフラ管理したい方

技術スタック

  • AWS Chalice
    • API Gateway
    • AWS Lambda(Python)
  • AWS S3
  • AWS Cognito
  • AWS CDK(TypeScript)

今回、APIをAWS LambdaとAPI Gatewayで実装していくにあたってChaliceというAWSが提供しているPythonフレームワークを使用します。
Flaskのような書き心地でLambdaを実装でき、API Gatewayを初めとして、EventBridgeやS3などのLambdaと連携するサービスをIaCでデプロイすることができます。
ミニマムなアプリにはけっこうおすすめです。

https://aws.github.io/chalice/

概要

こちらが今回作成するアプリケーションのざっくりなアーキテクチャになります。

また、今回のアプリケーションおよび、インフラのソースはGithubに公開していますので、後述するコード解説ではこちらを使って解説していきます。
それぞれワンコマンドでデプロイ可能になってるのでぜひお試しください。

https://github.com/Tomoaki-Moriya/aws-multitenant-s3-sample

前提

CognitoユーザープールにIDとパスワードで認証されているユーザーが対象となることが前提になります。
ユースケースとしては、既存のアプリケーションで認証されてるユーザーがフロントエンドから今回作成するAPIを通してファイルへGETリクエストを送信するイメージです。

APIはリクエストで受け取ったCognitoユーザープール発行のIdトークンを検証した後、Idトークンを使用して、CognitoIDプールからS3へアクセスするための一時的なIamロールを取得してS3へアクセスします。
このIamロールが今回のマルチテナント認証における鍵になってきます。

ABACについて

このIamロールの認証はABAC(Attribute Based Access Control)と呼ばれる認証手法を使用しており、AWSもドキュメントを公開しています。

属性(タグ)ベースのアクセス制御を意味しており、今回はこのタグにテナントをユニークにする文字列(これ以降はテナントIDと呼びます)を割り当てることでABAC認証を実現します。

  • Cognitoユーザープールが発行するIDトークンにカスタム属性でtenant_idを追加
  • CognitoIdプールのプロバイダーでプリンシパルタグとしてIDトークンのtenant_idを設定
  • 対象のS3へのアクセス制限が設定されたIAMを作成し、CognitoIdプールに紐づける
    • プリンシパルタグを使うことで動的なアクセスコントロールが可能
    • arn:aws:s3:::bucket-name/${aws:PrincipalTag/tenant_id}/*

コード解説

実際のコードの解説をしていきます。

AWS CDK

前述したABACを実現するための以下3点を作成します。

  • Cognito
  • IAM
  • S3

createBucket関数ではデフォルトなS3バケットを作成しているだけなので割愛します。

project/infrastructure/lib/sample-stack.ts
const PREFIX = "aws-multitenant-s3-sample";

export class SampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const identityPool = this.createCognitoAuthentification();
    const bucket = this.createBucket();
    this.attachRole(bucket, identityPool);
  }

  /**
   * 
   * IDプールに設定したプリンシパルタグを使用したIAMロールを作成。
   * S3バケットに対して、プリンシパルタグのtenant_idを使用してアクセス制御を行う。
   * 作成したロールをIDプールに紐づける。
   */
  private attachRole(
    bucket: cdk.aws_s3.Bucket,
    identityPool: cdk.aws_cognito.CfnIdentityPool
  ) {
    // IAM作成
    const role = new iam.Role(this, `${PREFIX}-role`, {
      // 作成したCognitoIDプールのみ使用可能なIAMロールを作成
      assumedBy: new iam.FederatedPrincipal(
        "cognito-identity.amazonaws.com",
        {
          StringEquals: {
            "cognito-identity.amazonaws.com:aud": identityPool.ref,
          },
          "ForAnyValue:StringLike": {
            // 認証されたユーザー用のロールにする
            "cognito-identity.amazonaws.com:amr": "authenticated",
          },
        },
        // 外部で認証された(今回はCognitoユーザープール)ユーザーが一時的な認証情報を取得するための権限
        "sts:AssumeRoleWithWebIdentity"
        // sts:TagSessionが追加される。
        // テナントIDを参照するために必要
      ).withSessionTags(),
    });
    // IAMにポリシーを追加
    role.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        // 今回はファイルのダウンロードなのでGetObjectのみ許可
        actions: ["s3:GetObject"],
        // 作成したバケットに対してテナントIDごとにアクセス制御を行う
        // ${aws:PrincipalTag/tenant_id(IDプールに設定したkey)}と書くことでIDプールから渡されるプリンシパルタグ(テナントID)を動的に参照できる
        resources: [`${bucket.bucketArn}/\${aws:PrincipalTag/tenant_id}/*`],
      })
    );

    // IDプールにロールをアタッチ
    new cognito.CfnIdentityPoolRoleAttachment(
      this,
      `${PREFIX}-identity-pool-role-attachment-id`,
      {
        identityPoolId: identityPool.ref,
        roles: {
          // 認証されたユーザー用のロールを設定
          authenticated: role.roleArn,
        },
      }
    );
  }

  /**
   * 以下のリソースを作成します。
   * - Cognitoユーザープール
   *   - アプリケーションクライアント
   * - CognitoIDプール
   *   - CognitoIDプールプロバイダー
   *   - プリンシパルタグ
   */
  private createCognitoAuthentification(): cognito.CfnIdentityPool {

    // Cognitoユーザープール作成
    const userPool = new cognito.UserPool(this, `${PREFIX}-userpool-id`, {
      userPoolName: `${PREFIX}-userpool`,
      signInAliases: { email: true },
      standardAttributes: {
        // 今回はメールアドレスをユーザー名として使用します
        email: {
          required: true,
          mutable: false,
        },
      },
      // テナントIDをカスタム属性として追加します
      customAttributes: {
        tenant_id: new cognito.StringAttribute(),
      },
    });

    // Cognitoユーザープールに紐づくアプリケーションクライアント作成
    const userPoolClient = new cognito.UserPoolClient(
      this,
      `${PREFIX}-userpool-client-id`,
      {
        userPool,
        userPoolClientName: `${PREFIX}-userpool-client`,
        authFlows: {
          userPassword: true,
          userSrp: true,
        },
      }
    );

    // CognitoIDプール作成
    const identityPool = new cognito.CfnIdentityPool(
      this,
      `${PREFIX}-identity-pool-id`,
      {
        // 認証済みユーザーのみがアクセスできるためfalseに設定
        allowUnauthenticatedIdentities: false,
        // ユーザープールを紐づけたIDプールプロバイダーを設定
        cognitoIdentityProviders: [
          {
            clientId: userPoolClient.userPoolClientId,
            providerName: userPool.userPoolProviderName,
          },
        ],
      }
    );

    // ユーザープールに設定したカスタム属性をプリンシパルタグとして設定
    new cognito.CfnIdentityPoolPrincipalTag(
      this,
      `${PREFIX}-identity-pool-tag-id`,
      {
        identityPoolId: identityPool.ref,
        identityProviderName: userPool.userPoolProviderName,
        // tenant_idというkeyでユーザープールのカスタム属性をマッピング
        // Cognitoのカスタム属性はcustom:で始まるため注意
        principalTags: {
          tenant_id: "custom:tenant_id",
        },
      }
    );
    return identityPool;
  }

  .....
}

API

次に実際にリクエストを受け付け、S3にアクセスするAPIの実装です。
Chaliceを使用すると、このapp.pyさえ実装すればあとは裏でCloudFormationを勝手に作ってデプロイしてくれます。
例えば今回S3やCognitoへのアクセスをboto3で実装しているのですが、Chaliceはboto3で使っているクライアントを判断してLambdaに必要なRoleを勝手に作ってくれます。(これ便利)

project/aws-multitenant-s3-sample-api/app.py
import os

import boto3
from botocore.exceptions import ClientError
from chalice import (
    BadRequestError,
    Chalice,
    CognitoUserPoolAuthorizer,
    CORSConfig,
    NotFoundError,
    Response,
    UnauthorizedError,
)

USER_POOL_ID = os.environ["USER_POOL_ID"]
USER_POOL_CLIENT_ID = os.environ["USER_POOL_CLIENT_ID"]
IDENTITY_POOL_ID = os.environ["IDENTITY_POOL_ID"]
BUCKET_NAME = os.environ["BUCKET_NAME"]
AWS_ACCOUNT_ID = os.environ["AWS_ACCOUNT_ID"]
REGION = "ap-northeast-1"

app = Chalice(app_name="aws-multitenant-s3-sample-api")
app.api.binary_types = ["*/*"]
cors_config = CORSConfig(
    allow_origin="*",  # 後の検証のために一旦全てのオリジンを許可
    allow_headers=[],
    max_age=600,
    expose_headers=[],
    allow_credentials=True,
)

# Chaliceを使用するとAPI GatewayにCognitoのオーソライザーを簡単に設定できる
authorizer = CognitoUserPoolAuthorizer(
    name="AwsMultitenantS3SampleAuthorizer",
    provider_arns=[
        f"arn:aws:cognito-idp:{REGION}:{AWS_ACCOUNT_ID}:userpool/{USER_POOL_ID}"
    ],
)

cognito_idp_client = boto3.client("cognito-idp")
cognito_id_client = boto3.client("cognito-identity")


def get_temporary_credentials(id_token: str):
    """
    ユーザープールが発行したIDトークンから一時的な認証情報を取得する
    """
    identity_response = cognito_id_client.get_id(
        IdentityPoolId=IDENTITY_POOL_ID,
        Logins={f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}": id_token},
    )
    identity_id = identity_response["IdentityId"]

    credentials_response = cognito_id_client.get_credentials_for_identity(
        IdentityId=identity_id,
        Logins={f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}": id_token},
    )
    credentials = credentials_response["Credentials"]
    return credentials


@app.route("/files/{key+}", methods=["GET"], authorizer=authorizer, cors=cors_config)
def index():
    """
    S3アクセスのエンドポイント用のAPI関数。
    事前に作成したオーソライザーを設定しているため、Idトークンが不正ならAPI Gatewayで弾かれる。

    {key+}のところが動的なパスパラメータになっている。
    たとえば、files/tenant-1/sample.jpgというリクエストが来た場合、
    keyには"tenant-1/sample.jpg"が入る。
    """
    key = (
        app.current_request.uri_params.get("key")
        if app.current_request and app.current_request.uri_params
        else None
    )
    if key is None:
        raise NotFoundError()

    bearer_token = (
        app.current_request.headers.get("Authorization")
        if app.current_request
        else None
    )
    id_token = bearer_token.replace("Bearer ", "") if bearer_token else None

    if not id_token:
        raise UnauthorizedError()

    # IDプールから取得したIAMでS3にアクセスするクライアントを作成
    credentials = get_temporary_credentials(id_token)
    s3_client = boto3.client(
        "s3",
        aws_access_key_id=credentials["AccessKeyId"],
        aws_secret_access_key=credentials["SecretKey"],
        aws_session_token=credentials["SessionToken"],
    )

    try:
        # リクエストで渡されたパスパラメータをそのままS3のキーとして利用
        obj = s3_client.get_object(Bucket=BUCKET_NAME, Key=key)
        return Response(
            body=obj["Body"].read(),
            status_code=200,
            headers={"Content-Type": obj["ContentType"]},
        )
    except ClientError:
        raise NotFoundError()

検証

では実際にデプロイして検証していきます。
異なるテナントのユーザーを2名登録し、S3に配置した画像がそれぞれ以下のようにアクセスできれば成功です。

まず以下のコマンドでインフラ、API共に作成します。
(ユーザー作成、S3ファイル配置などは割愛)

# CDK
cdk deploy SampleStack
# API(Chalice)
chalice deploy --stage prod

今回は検証用の画面をGithub Copilot君にさくっと作ってもらいました。
作成したAPI GatewayのURLを入力、ログインするとそれぞれの画像にアクセスしてimgタグに表示されます。

ユーザー1(テナントID: tenant-1)でログイン

tenant-1が表示され、tenant-2は表示されない。
(Tenant2_Black.jpgへのAPIリクエストのレスポンスが404であることを確認)

ユーザー2(テナントID: tenant-2)でログイン

tenant-2が表示され、tenant-1は表示されない。
(Tenant1_Black.jpgへのAPIリクエストのレスポンスが404であることを確認)

よろしい!!

最後に

いかがだったでしょうか。
今回の検証ではわかりやすく画像を使ってみましたが、PDFのダウンロードだったり他の用途にも活用できると思います。

実装した後に思ったのは別にIDトークンがテナントIDもってるわけだし、一時認証IAMを発行しなくても、リクエストURLとテナントIDを文字列で検証するだけで一応アクセス制御はできそうですね。
ですがプログラム上での制御ではなく、インフラレベルでアクセスが制限されているほうがより堅牢かと。

データ流出、ダメ、ゼッタイ。

Discussion