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?

はじめに

今回は、AWS が 2024年12月に導入した Storage Browser for Amazon S3 を実際に使ってみたいと思います。その際に、S3 バケットに対してどの粒度でアクセス権限(認可)を付与するかを考慮しながら、初期設定から導入までの流れを簡単にまとめてみました。

なお、今回は Amplify を使いますが、私自身 Amplify を触るのは今回が初めてです。

Storage Browser for Amazon S3 とは

Storage Browser for Amazon S3 は、ウェブアプリケーションに統合可能なオープンソースの UI コンポーネントです。エンドユーザーは、Amazon S3 に保存されたデータに対して、ブラウザベースのインターフェースを通じて操作が可能になります。

このコンポーネントを導入することで、認証されたユーザーがアプリケーションから直接以下の操作を行えるようになります:

  • S3 オブジェクトの閲覧(参照)
  • アップロード/ダウンロード
  • コピー
  • 削除

従来、これらの機能を自前で実装するには、SDK を用いた実装やセキュリティ対策が必要でしたが、Storage Browser により大幅に簡略化されます。

公式で紹介されている認証・認可のパターン

公式ドキュメント(こちら)で紹介されている認証および認可の方法について、以下に整理して紹介します。

1. Amplify Auth(Cognito + IAM)

  • 認証:Amazon Cognito を使用
  • 認可:IAM ポリシーによって制御
  • 特徴:Amplify Gen 2 を使うことで、ユーザーやグループ単位のアクセスルールを柔軟にカスタマイズ可能

この構成は、たとえば顧客や外部パートナーに対して S3 データへのアクセスを提供したいケースに適しています。


2. AWS IAM Identity Center + S3 Access Grants

  • 特徴:IAM プリンシパルだけでなく、企業ディレクトリのユーザー/グループに対しても S3 プレフィックス単位のアクセスを許可可能
  • メリット:アプリケーションが IAM プリンシパルへのマッピングを持たなくても、認証済みユーザーに対して S3 リソースへの操作を委任できる

S3 Access Grants と IAM Identity Center を併用することで、CloudTrail のログには実際のエンドユーザー ID が記録され、追跡可能性が高まります。


3. Customer Managed Auth(カスタム認証)

  • ユーザー認証・認可を自社システムで管理している場合に適したオプション
  • 要件:
    • アクセス可能な S3 ロケーション(プレフィックスなど)の一覧をユーザーに提示
    • AWS STS を用いて、各ユーザーにスコープ付き一時クレデンシャルを発行する仕組みを提供

この方式は最も自由度が高い一方で、認証基盤の整備やセキュリティ面での設計が求められます。

今回やりたいこと

今回の目的は、Amplify + Storage Browser for Amazon S3 を組み合わせて、アプリケーションから S3 データを簡単に操作できる仕組みを構築することです。

加えて、S3 へのアクセス権限については以下のように設計したいと考えています:

  • IAM ポリシーベースで S3 プレフィックス(ディレクトリ)単位のアクセス制御を行う
  • Lambda 関数内で AWS STS により一時的な認証情報(クレデンシャル)を発行し、それをフロントエンド(Amplify)側で使用して S3 にアクセスする

この構成により、ユーザーごとにアクセス可能な S3 の範囲を柔軟に制御しつつ、アプリケーションから直接 S3 のデータを操作することが可能になります。

公式が提示している構成の中では、「Customer Managed Auth」に最も近いアプローチを取る予定です。

構成図

スクリーンショット 2025-07-12 16.21.16.png

実際に構築していく

1. Amplifyの構築

まずは一旦ベースとなるAmplifyの構築を行う。
こちらのクイックスタートの手順通りに進めていく。
詳細な手順は割愛します。
Amplifyにデプロイが完了したら完了!
(手順で言うと、3. View deployed appまで)

2. S3、Lambda、API Gateway等の構築

クレデンシャル発行処理は外部サービスとして作成してしまったのでAmplify内での作成は行なってないです。
また、今回はコンソールから手動で構築していきます。

S3の構築

  • S3のコンソール画面からS3バケット作成します
    • 基本的に設定はデフォルトのままで良いです
  • バケットの作成が終わったら以下のようにCORSの設定をします
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "HEAD",
            "PUT",
            "POST",
            "DELETE"
        ],
        "AllowedOrigins": [
            "http://localhost:3000",
            "自身のAmplifyドメイン"
        ],
        "ExposeHeaders": [
            "last-modified",
            "content-type",
            "content-length",
            "etag",
            "x-amz-version-id",
            "x-amz-request-id",
            "x-amz-id-2",
            "x-amz-cf-id",
            "x-amz-storage-class",
            "date",
            "access-control-expose-headers"
        ],
        "MaxAgeSeconds": 3000
    }
]

Lambda構築

  • Lambdaのコンソール画面 → 関数の作成

  • 今回はJavaScriptを使うので以下のように設定(関数名は自身で決めてください)
    スクリーンショット 2025-07-06 10.01.43.png

  • 以下のようにコードを変更し、デプロイをする

今回は環境変数を直書きしちゃってます。。。

import { STS } from '@aws-sdk/client-sts';

// AWSクライアントの初期化
const sts = new STS();

export const handler = async (event) => {
    try {
        console.log('=== Lambda Function Started ===');
        console.log('Full Event:', JSON.stringify(event, null, 2));
        
        // クエリパラメータから組織IDを取得
        const orgId = event.queryStringParameters?.org;
        console.log('Extracted orgId:', orgId);
        
        if (!orgId) {
            const errorResponse = {
                statusCode: 400,
                headers: {
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Headers': 'Content-Type',
                    'Access-Control-Allow-Methods': 'GET,OPTIONS'
                },
                body: JSON.stringify({
                    error: 'Organization ID is required'
                })
            };
            
            console.log('Error Response (Missing orgId):', JSON.stringify(errorResponse, null, 2));
            return errorResponse;
        }

        const bucketName = "S3_BUCKET_NAME"; // 自身のバケット名に置き換えてください
        
        // 組織専用のS3プレフィックス
        const s3Prefix = `${orgId}/`;
        console.log('S3 prefix for organization:', s3Prefix);
        
        // IAMポリシーの定義(組織IDベースでアクセス制限)
        const policy = {
            Version: '2012-10-17',
            Statement: [
                {
                    Effect: 'Allow',
                    Action: [
                        's3:GetObject',
                        's3:PutObject',
                        's3:DeleteObject'
                    ],
                    Resource: [
                        `arn:aws:s3:::${bucketName}/${s3Prefix}*`
                    ]
                },
                {
                    Effect: 'Allow',
                    Action: [
                        's3:ListBucket'
                    ],
                    Resource: [
                        `arn:aws:s3:::${bucketName}`
                    ],
                    Condition: {
                        StringLike: {
                            's3:prefix': [`${s3Prefix}*`]
                        }
                    }
                }
            ]
        };
        
        // STS AssumeRoleのパラメータ
        const assumeRoleParams = {
            RoleArn: "IAMロール(AssumeRole)ARN", // 後ほど作成するAssumeRoleのARNを指定する
            RoleSessionName: `s3-access-${orgId}-${Date.now()}`,
            Policy: JSON.stringify(policy),
            DurationSeconds: 3600 // 1時間(最大12時間まで設定可能)
        };
        
        // 一時的なクレデンシャルを取得
        const assumeRoleResult = await sts.assumeRole(assumeRoleParams);
        console.log('AssumeRole successful');
        
        const credentials = assumeRoleResult.Credentials;
        
        // レスポンスデータの構築
        const responseData = {
            credentials: {
                accessKeyId: credentials.AccessKeyId,
                secretAccessKey: credentials.SecretAccessKey,
                sessionToken: credentials.SessionToken,
                expiration: credentials.Expiration
            }
        };
        
        const successResponse = {
            statusCode: 200,
            headers: {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
                'Access-Control-Allow-Methods': 'GET,OPTIONS',
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(responseData)
        };
        
        console.log('=== Lambda Function Completed Successfully ===');
        return successResponse;
        
    } catch (error) {
        console.error('=== Lambda Function Error ===');
        console.error('Error details:', {
            name: error.name,
            message: error.message,
            stack: error.stack,
            code: error.code || 'Unknown'
        });
        
        const errorResponse = {
            statusCode: 500,
            headers: {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
                'Access-Control-Allow-Methods': 'GET,OPTIONS'
            },
            body: JSON.stringify({
                error: 'Internal server error',
                message: error.message
            })
        };
        
        console.log('Error Response:', JSON.stringify(errorResponse, null, 2));
        console.log('=== Lambda Function Completed with Error ===');
        return errorResponse;
    }
};

IAMロール、ポリシー等の作成

  1. AssumeRoleに紐付けるIAMポリシーの作成
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::自身のS3バケット名/*",
                "arn:aws:s3:::自身のS3バケット名"
            ]
        }
    ]
}
  1. IAMロール(AssumeRole)の作成
    Lambda関数内でも使用します
    Lambda実行ロールはLambda関数作成時に作成されるロールです。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "自身のLambda実行ロールARN"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
  1. Lambda実行ロールに紐づけるインラインポリシーの作成
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Action": [
				"sts:AssumeRole"
			],
			"Resource": "自身のIAMロール(AssumeRole)ARN"
		},
		{
			"Effect": "Allow",
			"Action": [
				"logs:CreateLogGroup",
				"logs:CreateLogStream",
				"logs:PutLogEvents"
			],
			"Resource": "arn:aws:logs:*:*:*"
		}
	]
}

AssumeRoleの作成が完了したのでARNを先ほど作成したLambda関数内で必要だった場所に指定し、再度デプロイを行う。

API Gatewayの構築

  • API Gatewayのコンソール画面に移動

  • APIの作成 → REST APIの構築を押下
    スクリーンショット 2025-07-06 12.35.45.png

  • 以下のように設定し、APIを作成(API名は自身で決めてください)
    スクリーンショット 2025-07-06 12.35.37.png

  • リソースの作成を押下

  • 以下のように設定し、リソースを作成(リソース名は自身で決めてください)
    スクリーンショット 2025-07-06 12.36.49.png

  • メソッドの作成を押下

  • 以下のように設定し、メソッドを作成

    • Lambdaプロキシ統合にチェックする
    • 先ほど自身で作成したLambda関数を指定
    • URLクエリ文字列パラメータ名は自身で決めてください
      スクリーンショット 2025-07-06 12.38.00.png
  • CORSを有効にするを押下
    スクリーンショット 2025-07-06 12.38.31.png

  • 以下のように設定する
    スクリーンショット 2025-07-06 12.38.45.png

  • APIをデプロイを押下

  • 以下のように設定し、デプロイする
    スクリーンショット 2025-07-06 12.39.15.png

以上

2. Storage Browser for Amazon S3の組み込み

  • ルートディレクトリ内で以下をインストールする
npm install
npm install aws-amplify @aws-amplify/ui-react-storage
  • コンポーネントの追加
    こちらを参考し、今回はpage.tsxに以下を記載した。
    どのような処理をしているかは適時コメントをしています。
page.tsx
import {
  createStorageBrowser,
} from '@aws-amplify/ui-react-storage/browser';
import '@aws-amplify/ui-react-storage/styles.css';
import './App.css';
import config from '../amplify_outputs.json';
import { Amplify } from 'aws-amplify';
import { Authenticator, Button } from '@aws-amplify/ui-react';

// Amplify全体の設定を読み込む
Amplify.configure(config);

// (動作確認用)組織IDとフォルダプレフィックスを定数として定義
// 今回プレフィクスは組織IDごとに行うことを想定している
const orgId = "org-123";  // ユーザーが所属している組織ID(クレデンシャル生成に渡す組織ID)
const prefixFolder = "org-123";  // S3オブジェクトのプレフィックス

// UI上に表示するS3ロケーション(プレフィックス)を返す関数
async function getS3Locations() {
  if (!orgId) {
    throw new Error('Organization ID not found in user attributes');
  }
  return {
    items: [
      {
        id: 'example-folder', // UI上のロケーション識別子
        bucket: '自信のS3バケット名を指定してください', // 実際のS3バケット名に置き換えること
        prefix: `${prefixFolder}/`,  // 対象のS3フォルダ
        type: 'PREFIX' as const, 
        permissions: ['delete', 'get', 'list', 'write'] as Array<'delete' | 'get' | 'list' | 'write'>, //UI上の表示有無
      },
    ],
    nextToken: undefined, // ページネーションが不要な場合はundefined
  };
}

// 一時的なS3アクセス用クレデンシャルをAPI経由で取得する関数
const getLocationCredentials = async () => {
  try {
    // API Gateway のエンドポイントを指定
    const apiUrl = `{API Gatewayのエンドポイント}/dev/create-credential?org=${orgId}`;
    
    // GETリクエストでクレデンシャルを取得
    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    // API呼び出し失敗時のエラーハンドリング
    if (!response.ok) {
      throw new Error(`API request failed: ${response.status} ${response.statusText}`);
    }

    const data = await response.json();

    // レスポンス形式を検証
    if (!data || !data.credentials) {
      throw new Error('Invalid API response: credentials not found');
    }

    const { credentials } = data;

    // 必須プロパティの存在を確認
    if (!credentials.accessKeyId || !credentials.secretAccessKey) {
      throw new Error('Invalid credentials: missing required properties');
    }

    // UIに渡すクレデンシャル形式に変換して返す
    return {
      credentials: {
        accessKeyId: credentials.accessKeyId,
        secretAccessKey: credentials.secretAccessKey,
        sessionToken: credentials.sessionToken || '',
        expiration: credentials.expiration ? new Date(credentials.expiration) : new Date(Date.now() + 3600000), // 1時間有効
      },
      expiration: credentials.expiration ? new Date(credentials.expiration) : new Date(Date.now() + 3600000),
    };
  } catch (error) {
    console.error('Error fetching credentials:', error);
    throw new Error(`Failed to fetch credentials`);
  }
};

// Amplify StorageBrowser用のインスタンスを生成
const { StorageBrowser } = createStorageBrowser({
  config: {
    listLocations: getS3Locations,           // 表示可能なS3ロケーション(フォルダ)を返す関数
    getLocationCredentials: getLocationCredentials, // 一時クレデンシャルを返す関数
    registerAuthListener: () => () => {},    // 認証リスナー(未使用のため空関数)
    region: 'ap-northeast-1',                // 使用するAWSリージョン
  },
});

// アプリのメインコンポーネント
function App() {
  return (
    <Authenticator>
      {({ signOut }) => (
        <>
          <div className="header">
            {/* サインアウトボタン */}
            <Button onClick={signOut}>Sign out</Button>
          </div>
          {/* Amplify Storage Browser UIを表示 */}
          <StorageBrowser />
        </>
      )}
    </Authenticator>
  );
}

export default App;

動作確認

  • 以下のコマンドを実行し、Devサーバーを立ち上げる
npx ampx sandbox
npm run dev
  • http://localhost:3000にアクセスする

  • 以下のように表示されていたら成功
    スクリーンショット 2025-07-06 13.07.25.png

  • 実際にデータをアップロードしてみましょう!
    スクリーンショット 2025-07-06 13.08.14.png

  • アップロード完了しました!
    スクリーンショット 2025-07-06 13.08.43.png

  • 実際に対象のS3バケットにアップロードされているか確認してみましょう!
    スクリーンショット 2025-07-06 13.09.10.png
    問題なくアップロードされていることが確認できました!!!

S3への認可についても動作確認してみたいと思います!

  • 動作確認用に定義したpage.tsxの15行目の値を変更してみましょう!(例えばorg-555など)
    • クレデンシャル生成に渡す組織IDとUI上に表示したい組織ID(プレフィックス)を異なる組織IDにすることでちゃんとアクセス制御できているかを検証していきたいと思います。

スクリーンショット 2025-07-12 14.47.10.png

  • 再度環境を立ち上げて確認してみましょう!
    org-123フォルダーの中身を見に行くと、以下のようにエラーが発生し、アクセスできないようになっています!
    スクリーンショット 2025-07-12 14.50.12.png

以上のことからS3アクセスの認可部分もしっかり制御されていることがわかりました。

まとめ

AmplifyとStorage Browser for Amazon S3を使えば、セキュアかつシンプルなS3ファイル操作UIを構築することができました。
特に、Lambda + STSを組み合わせることで、組織ごとの柔軟な認可が可能になり、柔軟な構成が簡単に実現できました。
Storage Browser for Amazon S3に関する情報はそれほど多く出回っていないのでこれから色々検証していきたいと思います!

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?