本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
非エンジニアの担当者に、特定のバケットの決まった場所へ画像をアップロードしてもらう運用を準備しました。その際に、全権限を持つ管理用キーをそのまま渡すのは避けたかったため、アップロードに必要な操作だけを許可した専用のアクセスキーを用意しました。
対象のストレージは Amazon S3 互換の DDN EXAScaler Access S3(製品名は S3 Data Services)です。S3 互換ではあるものの、アクセス制御まわりで Amazon S3 とは書式が異なる箇所があり、そこで少し時間がかかりました。同じ製品を使う方の参考になればと思い、切り分けの手順と最終的に通った設定をまとめます。
方針
- 専用のアクセスキー(バケット所有者とは別のユーザー)を 1 つ用意します。失効を独立して行え、監査やアクセス元の区別もしやすくなります。
- そのキーに必要な操作(一覧・取得・アップロード)だけをバケットポリシーで許可します。
- アクセスキーとシークレットは 1Password に保存し、実行時だけ
op runで環境変数に展開します。平文をディスクやシェル履歴に残さないためです(1Password CLI による環境変数のインジェクション と同じ考え方です)。
接続まわりの前提(path-style と署名)
このストレージのエンドポイントは独自ホスト名で、バケット名にドット(.)が含まれていました。バケット名にドットがあると、仮想ホスト形式(bucket.endpoint のようにバケット名をホスト名に含める方式)では TLS(Transport Layer Security)証明書の対象名と一致しないため、パススタイル(endpoint/bucket/key の形式)で接続します。
boto3(AWS の Python SDK)では次のように指定します。署名は AWS Signature Version 4(SigV4)です。
import os
import boto3
from botocore.client import Config
def make_client(signature_version):
return boto3.client(
"s3",
endpoint_url=os.environ["S3_ENDPOINT"],
region_name=os.environ.get("S3_REGION", "us-east-1"),
aws_access_key_id=os.environ["S3_ACCESS_KEY"],
aws_secret_access_key=os.environ["S3_SECRET_KEY"],
config=Config(
s3={"addressing_style": "path"}, # パススタイル
signature_version=signature_version,
),
)
実行は op run でキーを注入します。env.list には実値ではなく 1Password の参照だけを書きます。
S3_ENDPOINT=op://Personal/uploader/s3_endpoint
S3_BUCKET=op://Personal/uploader/s3_bucket
S3_ACCESS_KEY=op://Personal/uploader/s3_access_key
S3_SECRET_KEY=op://Personal/uploader/s3_secret_key
S3_REGION=op://Personal/uploader/s3_region
op run --env-file=env.list -- python3 conntest.py
小さな疎通テストで署名とエラーを観察する
S3 互換ストレージでは、署名バージョンや権限の挙動が Amazon S3 と少しずつ違うことがあります。そこで、s3:ListBucket / s3:GetObject / s3:PutObject / s3:DeleteObject を SigV4 と SigV2 の両方で試し、結果を一覧にする小さなスクリプトを用意しました。op run のサブプロセス内でだけキーが展開されるため、値が画面に出ることはありません。
このストレージでは、SigV4 では一覧もアップロードも通り、SigV2 では一覧時に SignatureDoesNotMatch が返りました。GUI クライアント(Cyberduck など)は参照とアップロードで署名設定を共有するため、両方が通る SigV4 を選ぶ、という判断ができます。
署名 LIST PUT GET DELETE
SigV4 OK OK OK OK / AccessDenied
SigV2 FAIL:SignatureDoesNot… OK OK ...
エラーの種類で原因を切り分ける
専用キーで叩いたときに観察したエラーは、原因の切り分けにそのまま使えました。
InvalidAccessKeyId… アクセスキー ID 自体が認識されていない状態です。値の打ち間違いか、キーがまだ有効化されていない可能性があります。サーバーには到達できているので、エンドポイントや署名の問題ではありません。SignatureDoesNotMatch… キー ID は認識されているが、署名(シークレットや署名方式)が合っていない状態です。AccessDenied… 認証は通っているが、その操作の権限がない状態です。
最初に渡したキーは InvalidAccessKeyId、入れ直すと AccessDenied に変わりました。InvalidAccessKeyId(誰だか分からない)から AccessDenied(誰かは分かるが許可がない)へ進んだので、「キーは認識された。あとは権限を与えればよい」と判断できます。
切り分けの精度を上げるため、既知の良好なキー(バケット所有者のキー)で同じエンドポイント・同じ操作を 1 度だけ叩きました。所有者キーで一通り成功すれば、エンドポイント・SDK 設定・ネットワークは問題なく、残る差分は専用キーの権限だけ、と確定できます。
バケットの所有モデル
S3 ではバケットを作成したユーザー(所有者)がそのバケットを保持し、初期状態ではほかのユーザーはアクセスできません。今回の専用キーは所有者とは別のユーザーだったため、AccessDenied になっていました。別ユーザーに権限を渡すには、バケットポリシー(またはバケットの ACL、Access Control List)で明示的に許可します。
このストレージではユーザー識別子が 40 桁の 16 進文字列でした。所有者自身の識別子は、所有者キーでバケットの ACL を読むと確認できます。付与したいユーザーの識別子と同じ形式かを照合する用途に使えます。
acl = s3.get_bucket_acl(Bucket=BUCKET)
print(acl["Owner"]["ID"]) # 例: 40桁の16進文字列
バケットポリシーの書式(Amazon S3 との違い)
ここが本題です。最初は Amazon S3 と同じ書式のポリシーを送りましたが、MalformedPolicy: The policy must contain a valid version string が返り続けました。Version は妥当な値を入れているのにこのエラーが出るため、書式のどこかが受理されていないと考え、製品の API リファレンスガイドを確認しました。
DDN EXAScaler Access S3 のバケットポリシーは、公開されている API リファレンスの範囲では、Amazon S3 と次の点が異なります。
Versionは2008-10-17(または省略)。公式リファレンスでは Version は将来用の置き場所(placeholder)と説明され、有効値として2008-10-17または省略が案内されています。手元では2012-10-17を指定すると、上記のMalformedPolicyが返りました。Principalは{"DDN": ["<canonical id>"]}の形式。Amazon Resource Name(ARN)ではなく、ユーザーの識別子(canonical id)をそのまま指定します。配列でも単一文字列でも受理されます。*や既定のグループ(全ユーザー・認証済みユーザーなど)も指定できます。Resourceは バケット名のみを指定します(ARN 形式ではありません)。Conditionは IP アドレス条件(aws:SourceIpとIpAddress/NotIpAddress、CIDR(Classless Inter-Domain Routing)表記、IPv4)に対応しています。
これらを反映したポリシーが次です。識別子とバケット名はプレースホルダーにしています。
{
"Id": "UploaderPolicy",
"Version": "2008-10-17",
"Statement": [
{
"Sid": "UploaderReadWrite",
"Effect": "Allow",
"Principal": { "DDN": ["<UPLOADER_CANONICAL_ID>"] },
"Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
"Resource": "<BUCKET_NAME>"
}
]
}
バケットポリシーの設定・取得・削除はバケット所有者が行います。所有者キーで適用します。
import json
owner = make_client("s3v4") # バケット所有者のキー
owner.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))
適用後に専用キーで疎通テストを再実行すると、s3:ListBucket / s3:GetObject / s3:PutObject が通り、ポリシーに含めていない s3:DeleteObject は AccessDenied のままになりました。アップロードはできるが削除はできない、という状態です。削除(差し替え・ファイル名の修正など)も任せる場合は、Action に s3:DeleteObject を加えて再適用します。
プレフィックス単位の絞り込みについて
Amazon S3 のバケットポリシーでは、Resource にオブジェクトのプレフィックスを含めたり、Condition の s3:prefix で一覧範囲を絞ったりできます。一方この製品では、公開されている範囲では Resource はバケット名単位の指定で、Condition は IP アドレス条件に対応する、という整理です。そのため、フォルダ(プレフィックス)単位ではなくバケット単位の許可になります。
許可がバケット全体に及ぶため、誤操作に備えてバケットのバージョニングを有効にしておきました。バージョニングが有効なら、上書きや削除があっても以前のバージョンから復旧できます。
owner.put_bucket_versioning(
Bucket=BUCKET,
VersioningConfiguration={"Status": "Enabled"},
)
なお、付与のもう 1 つの方法であるバケット ACL では、書き込み権限(WRITE)はオブジェクトの作成を許可し、バケット所有者や既存オブジェクトの所有者については上書き・削除も含みます。いずれにせよ ACL では作成と削除を別々に制御することはできません。バケットポリシーであれば s3:PutObject だけを許可して s3:DeleteObject を外す、という分け方ができるため、削除を渡したくない場合はバケットポリシーのほうが細かく指定できました。
まとめ
S3 互換ストレージでも、署名やアクセス制御の挙動は実装によって差があります。今回うまくいった進め方は次の 2 点でした。
- 小さな疎通テストを用意し、署名バージョンごとの成否と、
InvalidAccessKeyId/SignatureDoesNotMatch/AccessDeniedというエラーの種類を観察します。既知の良好なキーを対照に置くと、設定の問題かキーの問題かを切り分けられます。 - ポリシーが受理されないときは、対象製品の API リファレンスでバケットポリシーの書式(
Versionの値、Principalの指定方法、Resourceの粒度、Conditionの対応範囲)を確認します。
アクセスキーは 1Password に置き、op run で実行時だけ展開する運用にしておくと、切り分けのために何度もコマンドを叩いても値が画面や履歴に残りません。



コメント
…