active-storage-multitenant について
activerecord-tenanted を使うマルチテナント Rails アプリ向けの Active Storage 設定スキルです。テナント単位の Blob 作成と署名付き URL により、テナント間のファイルアクセスを防ぎます。
主な機能
- 01テナントコンテキスト内で Blob を作成
- 02Blob ID とテナントキーをまとめて署名
- 03テナント対応コントローラー経由でリダイレクト
- 04曖昧な旧 ID を安全に処理
- 05テナントのシャード内でバリアントを処理
活用シーン
- 01テナント別のダイレクトアップロードを実装
- 02添付ファイルへのテナント間アクセスを防止
- 03旧 Blob ID の曖昧な一致を回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill active-storage-multitenantインストール後、Claude Code で/active-storage-multitenantを入力して読み込まれたか確認します。
同梱ファイルreference.md
詳細
Active Storage + activerecord-tenanted
Blob はテナントデータベースに保存されます(tenanted_rails_records = true)。Active Storage のデフォルトのコントローラーはグローバル接続を参照するため、アップロードは成功しても、保存した画像は 404 になります。ダイレクトアップロードでは with_tenant 内で blob を作成し、signed_id にテナントキーを含める必要があります。
単一データベースのアプリでは、active-storage スキルのみを使用してください。このスキルは Herald のオーバーレイです。
基本方針
- 現在のテナントなしで blob を作成しないでください。 ストレージキーは
{tenant_key}/{token}です。 - blob の ID とテナントを一緒に署名してください。 テナント内でのみ有効な数値 ID/UUID は、全体で一意ではありません。「最初に見つかったもの」を配信すると、テナント間の情報漏洩につながります。
- リダイレクト URL は、公開 S3 URL ではなく、コントローラーを経由させてください。 これによりテナントのチェックが実行されます。
- 曖昧なレガシー ID は安全側に倒して拒否してください。 一致が 1 件なら問題ありませんが、2 件なら 404 にします。
設定(必須)
# config/initializers/tenanted.rb
config.active_record_tenanted.tenanted_rails_records = true
config.active_record_tenanted.connection_class = "ApplicationRecord"
これらがないと、blob の行はグローバルに保存され、キーにプレフィックスが付きません。
テナント署名付き ID
module ActiveStorage::TenantSignedId
PURPOSE = :tenant_blob_id
def self.generate(blob, tenant_key: tenant_key_for(blob))
return blob.signed_id if tenant_key.blank?
ActiveStorage.verifier.generate({ id: blob.id, tenant: tenant_key }, purpose: PURPOSE)
end
def self.verify(signed_id)
if (payload = ActiveStorage.verifier.verified(signed_id, purpose: PURPOSE))
payload = payload.with_indifferent_access
return { id: payload[:id], tenant: payload[:tenant], legacy: false }
end
if (legacy_id = ActiveStorage.verifier.verified(signed_id, purpose: :blob_id))
{ id: legacy_id, tenant: nil, legacy: true }
end
end
def self.tenant_key_for(blob)
tenant_key, blob_token = blob.key.to_s.split("/", 2)
blob_token.present? ? tenant_key : nil
end
end
Lexxy がテナント情報のない URL を生成しないよう、blob_url_template に tenant: を渡す必要があります。
<%= f.rich_text_area :body, data: {
direct_upload_url: project_direct_uploads_path(@project),
blob_url_template: rails_service_blob_path(":signed_id", ":filename", tenant: @project.tenant_key)
} %>
?tenant= クエリパラメーターによってレガシー署名付き ID をアップグレードしてはいけません。署名済みペイロードにテナントが含まれていない場合はヒントを無視し、その blob ID がすべてのテナントデータベースで一意の場合にのみ配信してください。
ダイレクトアップロード
/rails/active_storage/direct_uploads を、テナントを認識するネストされたルートに置き換えてください。
class DirectUploadsController < ApplicationController
include ActiveStorage::SetCurrent
def create
blob = ApplicationRecord.with_tenant(Current.account.tenant_key) do
ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
end
render json: {
signed_id: ActiveStorage::TenantSignedId.generate(blob, tenant_key: Current.account.tenant_key),
url: rails_service_blob_path(signed_id, blob.filename, tenant: Current.account.tenant_key),
direct_upload: {
url: blob.service_url_for_direct_upload,
headers: blob.service_headers_for_direct_upload
}
# plus id, filename, byte_size, checksum, content_type, attachable_sgid
}
end
end
S3 URL ではなく、Rails のリダイレクト URL を返してください。previewable: false にすると、Lexxy は blob_url_template を使用します。
Active Storage のリダイレクトコントローラーをオーバーライドする
Rails は、同じ定数名を使う app/controllers/active_storage/... から ActiveStorage::Blobs::RedirectController をオートロードします。ルートのオーバーライドは不要です。
Concern の処理は次のとおりです。
TenantSignedId.verify- ペイロードにテナントがある場合 → そのシャードのみで blob を読み込みます
- レガシーの場合 → テナントを走査し、
matches.many?なら 404 にします - リクエストの残りの処理に備えて
ApplicationRecord.current_tenant = @tenant_for_blobを設定します allow_other_host: trueを指定して@blob.urlにリダイレクトします
バリアントなどのバリエーションも同様に、with_tenant 内で処理してください。
テナントのデータベースがまだ存在しないアカウント行(TenantDoesNotExistError)はスキャン対象から除外してください。新しいアカウントによってスキャンが中断されるのを防げます。
CSP と storage.yml
バケットのホストを connect_src と img_src に追加してください。バケットが実際に公開されている場合を除き、public: true は設定しないでください。署名付きヘッダーが機能しなくなります。
テスト
ApplicationRecord.with_tenant(account.tenant_key) do
blob = ActiveStorage::Blob.create_before_direct_upload!(...)
assert blob.key.start_with?("#{account.tenant_key}/")
end
また、数値の blob ID が同じ 2 つのテナントが、テナント情報のない URL から互いのファイルを配信できないことも確認してください。
アンチパターン
- 標準のダイレクトアップロードを使う — blob がテナントなしで作成されるため、アップロード時ではなく後で 404 になります。
- テナント全体を検索して最初に見つかったものを使う — IDOR につながります。
- blob を GlobalRecord に保存する — そのために
tenanted_rails_recordsが用意されています。 - 本番環境の S3 で初めて問題に気づく — ディスクサービスでは、リダイレクトや CSP の問題が表面化しません。実際のサービスでテストしてください。
関連項目
- シャードの設定:
activerecord-tenanted - 標準の添付ファイル:
active-storage - Lexxy:
action-text
コントローラーファイルの一覧と検索アルゴリズムについては、reference.md を参照してください。