STSセッションタグでマルチテナントのS3アクセスを動的に制御する
こんにちは。PKSHA Technology でソフトウェアエンジニアをしている浅尾です。
私が担当しているプロジェクトでは、マルチテナントのアプリケーションを構築しています。Lambda から S3 にアクセスする際、テナント間のデータ分離をセキュアに実現する必要がありました。
本記事では STS セッションタグ を使って、1 つの共通 IAM Role で複数テナントのアクセス制御を動的に実現する方法を紹介します。
背景と課題
マルチテナント構成では、S3 のデータパーティション方式として「テナント専用バケット」と「キープレフィックスによる分割」の 2 つの代表的なアプローチがあります。テナント数が多い場合は後者が適しており、本記事でもこの方式を採用しています。
s3://data-bucket/
└── tenants/
├── tenant-a/
│ ├── input/
│ └── output/
├── tenant-b/
│ ├── input/
│ └── output/
└── tenant-c/
├── input/
└── output/
重要なのは、tenant-a の処理時に tenant-a のディレクトリだけへのアクセスを許可し、誤って tenant-b のデータに触れないようにすることです。
従来のアプローチの問題
シンプルな方法は、テナントごとに IAM Role を作成することです。
TenantA-AccessRole → s3://bucket/tenants/tenant-a/*
TenantB-AccessRole → s3://bucket/tenants/tenant-b/*
TenantC-AccessRole → s3://bucket/tenants/tenant-c/*
しかし、この方式にはいくつかの問題があります。
| 問題 | 内容 |
|---|---|
| IAM Role 数の上限 | AWS アカウントあたり 5,000 Role の上限 |
| 管理が煩雑 | テナント追加・削除のたびに Role 作成・削除が必要 |
| IaC の都度更新が必要 | CDK/Terraform を毎回デプロイ |
| スケーリング困難 | テナント数が増えるほど管理負荷が増加 |
セッションタグ方式とは
この課題を解決するのが STS のセッションタグです。
AssumeRole 時にタグを付与すると、そのタグ値を IAM ポリシーのポリシー変数として参照できます。つまり、1つの共通IAM Role ですべてのテナントに対応できるようになります。
| 項目 | テナント別ロール方式 | セッションタグ方式 |
|---|---|---|
| IAMロール数 | テナント数分(N個) | 1個 |
| テナント追加時 | IAMロール作成が必要 | 即時利用可 |
| IAM上限への懸念 | あり | なし |
アーキテクチャ
全体の処理フローは以下のようになります。
実装
1. セッションタグでアクセス制御を実現する
ポイントは IAM ポリシーで ${aws:PrincipalTag/TenantId} という変数を使う点です。
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::data-bucket/tenants/${aws:PrincipalTag/TenantId}/*"
}
AssumeRole 時に TenantId=tenant-a というタグを付与すると、このポリシーは実行時に以下のように展開されます。
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::data-bucket/tenants/tenant-a/*"
}
つまり tenant-a のディレクトリだけアクセス可能で、tenant-b へのアクセスは自動的に拒否されます。
2. Lambda での AssumeRole
Lambda から AssumeRole を呼び出す際に、Tags パラメータでセッションタグを付与します。
import boto3
from botocore.exceptions import ClientError
sts = boto3.client("sts")
def assume_tenant_role(tenant_id: str, role_arn: str) -> dict:
"""AssumeRole時にセッションタグを付与"""
response = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=f"batch-{tenant_id}",
DurationSeconds=3600,
Tags=[
{'Key': 'TenantId', 'Value': tenant_id} # ← ここが重要
],
)
return response["Credentials"]
def lambda_handler(event: dict, context):
"""S3イベントを受け取ってデータ処理"""
# S3キーからテナントID抽出(例:tenants/tenant-a/input/... → tenant-a)
object_key = event["detail"]["object"]["key"]
tenant_id = object_key.split("/")[1]
# AssumeRoleで一時認証情報を取得
credentials = assume_tenant_role(tenant_id, os.environ["TENANT_ROLE_ARN"])
# テナント専用の S3 クライアント
s3 = boto3.client(
"s3",
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
)
try:
# このクライアントは自動的にテナントのディレクトリのみアクセス可
response = s3.get_object(Bucket=os.environ["BUCKET_NAME"], Key=object_key)
data = response["Body"].read().decode("utf-8")
# データ処理...
result = process_data(data)
# 結果を出力
s3.put_object(
Bucket=os.environ["BUCKET_NAME"],
Key=f"tenants/{tenant_id}/output/result.json",
Body=json.dumps(result),
)
return {"statusCode": 200}
except ClientError as e:
return {"statusCode": 500, "error": str(e)}
重要な点は、AssumeRole 時に付与した TenantId タグが、IAM ポリシーの ${aws:PrincipalTag/TenantId} に展開されるということです。この S3 クライアントでアクセスできるのは自動的にテナントのディレクトリだけになります。
3. テナント分離が正しく機能していることを確認する
セッションタグ方式が本当に機能しているか、実装後は必ず検証しましょう。
def test_tenant_isolation():
"""tenant-a のタグで tenant-b にアクセスできないことを確認"""
# tenant-a でAssumeRole
credentials = assume_tenant_role("tenant-a", role_arn)
s3 = boto3.client("s3", **credentials_to_kwargs(credentials))
# tenant-b のデータにアクセスを試みる
try:
s3.get_object(Bucket=BUCKET, Key="tenants/tenant-b/input/secret.csv")
raise AssertionError("Security issue: Cross-tenant access allowed!")
except ClientError as e:
if e.response["Error"]["Code"] != "AccessDenied":
raise
print("✓ Tenant isolation verified")
ポリシーが正しく設定されていれば、AccessDenied で止まります。
ここで接続できてしまったら問題があるのでセッションタグが正しく展開されているかを確認してください。
まとめ
本記事では、マルチテナント環境における S3 アクセス制御の課題と、STS セッションタグを使った解決策を紹介しました。
従来のテナント別ロール方式では、テナント数に比例して IAM Role が増加し、管理の煩雑さや AWS の上限(5,000 Role)への懸念がありました。セッションタグ方式を採用することで、1 つの共通 IAM Role だけで全テナントに対応でき、テナント追加時も IAM 操作が不要になります。
実装のポイントは、AssumeRole 時に TenantId タグを付与し、IAM ポリシーで ${aws:PrincipalTag/TenantId} を参照することです。これにより、ポリシーが実行時に動的に展開され、テナントごとのアクセス制御が自動的に適用されます。
マルチテナントで S3 アクセス制御が必要な場合、ぜひセッションタグ方式を検討してみてください。
CDK での実装(参考)
参考として、実装に必要な IAM Role を CDK で構築するサンプルを紹介します。
TenantAccessRole(共通ロール)
const tenantRole = new iam.Role(this, 'TenantAccessRole', {
roleName: `${props.systemName}-tenant-access-role`,
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
// S3オブジェクトアクセス
tenantRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'],
resources: [
`arn:aws:s3:::${props.bucketName}/tenants/\${aws:PrincipalTag/TenantId}/*`,
],
}));
// ListBucket(プレフィックス制限付き)
tenantRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:ListBucket'],
resources: [`arn:aws:s3:::${props.bucketName}`],
conditions: {
StringLike: {
's3:prefix': ['tenants/${aws:PrincipalTag/TenantId}/*'],
},
},
}));
Lambda 実行ロール(BatchProcessorRole)
const lambdaRole = new iam.Role(this, 'BatchProcessorRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
// ログ出力
lambdaRole.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
'service-role/AWSLambdaBasicExecutionRole'
)
);
// TenantAccessRole への AssumeRole
// sts:TagSession でセッションタグを付与
lambdaRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['sts:AssumeRole', 'sts:TagSession'],
resources: [tenantRole.roleArn],
}));
TenantAccessRole の信頼ポリシー
// TenantAccessRole の信頼ポリシー
// TenantId タグが必須(空は拒否)
const trustPolicy = new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.ArnPrincipal(lambdaRole.roleArn)],
actions: ['sts:AssumeRole', 'sts:TagSession'],
conditions: {
StringLike: { 'aws:RequestTag/TenantId': '*' },
StringNotEquals: { 'aws:RequestTag/TenantId': '' },
},
}),
],
});
const cfnRole = tenantRole.node.defaultChild as iam.CfnRole;
cfnRole.assumeRolePolicyDocument = trustPolicy.toJSON();
このポリシーでタグなし・空タグでの AssumeRole を防ぎます。
参考資料
Discussion