mcpField
A
active-storage-multitenant

active-storage-multitenant

github.com/thinkoodle开发7158 次安装7 周前更新
代码仓库

关于 active-storage-multitenant

面向多租户 Rails 应用开发者的 Active Storage 配置技能,适用于 activerecord-tenanted。重点是按租户创建 Blob 并签署 URL,避免跨租户文件访问。

主要功能

  1. 01在租户上下文中创建 Blob
  2. 02将 Blob 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 时默认拒绝。**一个匹配项可以接受;两个匹配项则返回 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

blob_url_template 必须传入 tenant:,以免 Lexxy 生成不含租户信息的 URL:

<%= 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

返回 Rails 重定向 URL,而非 S3 URL。previewable: false 会强制 Lexxy 使用 blob_url_template。

覆盖 Active Storage 重定向控制器

Rails 会从 app/controllers/active_storage/... 自动加载 ActiveStorage::Blobs::RedirectController,且常量名称相同。无需覆盖路由。

Concern 的处理步骤:

  1. TenantSignedId.verify
  2. 如果载荷中包含租户 → 仅在该分片中加载 blob
  3. 如果是旧版 ID → 扫描租户;若 matches.many? 则返回 404
  4. 设置 ApplicationRecord.current_tenant = @tenant_for_blob,供请求的其余部分使用
  5. 使用 allow_other_host: true 重定向到 @blob.url

Representations(变体)也一样:必须在 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,也不能通过不含租户信息的 URL 访问彼此的文件。

反模式

  1. 使用原生直接上传——blob 创建时没有租户信息,之后会返回 404,而不是在上传时失败。
  2. 跨租户查找并返回第一个匹配项——IDOR。
  3. 将 blob 放在 GlobalRecord 上——已有 tenanted_rails_records,无需这样做。
  4. 只在生产环境的 S3 上发现问题——磁盘服务会掩盖重定向/CSP 问题。请使用实际服务进行测试。

相关内容

  • 分片设置:activerecord-tenanted
  • 基本附件:active-storage
  • Lexxy:action-text

控制器文件列表和查找算法请参见 reference.md。

评论

支持 Markdown