mcpField
A
active-storage-multitenant

active-storage-multitenant

github.com/thinkoodle開発7158 回インストール7 週間前に更新
リポジトリ

active-storage-multitenant について

activerecord-tenanted を使うマルチテナント Rails アプリ向けの Active Storage 設定スキルです。テナント単位の Blob 作成と署名付き URL により、テナント間のファイルアクセスを防ぎます。

主な機能

  1. 01テナントコンテキスト内で Blob を作成
  2. 02Blob ID とテナントキーをまとめて署名
  3. 03テナント対応コントローラー経由でリダイレクト
  4. 04曖昧な旧 ID を安全に処理
  5. 05テナントのシャード内でバリアントを処理

活用シーン

  1. 01テナント別のダイレクトアップロードを実装
  2. 02添付ファイルへのテナント間アクセスを防止
  3. 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 のオーバーレイです。

基本方針

  1. 現在のテナントなしで blob を作成しないでください。 ストレージキーは {tenant_key}/{token} です。
  2. blob の ID とテナントを一緒に署名してください。 テナント内でのみ有効な数値 ID/UUID は、全体で一意ではありません。「最初に見つかったもの」を配信すると、テナント間の情報漏洩につながります。
  3. リダイレクト URL は、公開 S3 URL ではなく、コントローラーを経由させてください。 これによりテナントのチェックが実行されます。
  4. 曖昧なレガシー 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 の処理は次のとおりです。

  1. TenantSignedId.verify
  2. ペイロードにテナントがある場合 → そのシャードのみで blob を読み込みます
  3. レガシーの場合 → テナントを走査し、matches.many? なら 404 にします
  4. リクエストの残りの処理に備えて ApplicationRecord.current_tenant = @tenant_for_blob を設定します
  5. 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 から互いのファイルを配信できないことも確認してください。

アンチパターン

  1. 標準のダイレクトアップロードを使う — blob がテナントなしで作成されるため、アップロード時ではなく後で 404 になります。
  2. テナント全体を検索して最初に見つかったものを使う — IDOR につながります。
  3. blob を GlobalRecord に保存する — そのために tenanted_rails_records が用意されています。
  4. 本番環境の S3 で初めて問題に気づく — ディスクサービスでは、リダイレクトや CSP の問題が表面化しません。実際のサービスでテストしてください。

関連項目

  • シャードの設定:activerecord-tenanted
  • 標準の添付ファイル:active-storage
  • Lexxy:action-text

コントローラーファイルの一覧と検索アルゴリズムについては、reference.md を参照してください。

コメント