关于 active-storage-multitenant
面向多租户 Rails 应用开发者的 Active Storage 配置技能,适用于 activerecord-tenanted。重点是按租户创建 Blob 并签署 URL,避免跨租户文件访问。
主要功能
- 01在租户上下文中创建 Blob
- 02将 Blob 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 时默认拒绝。**一个匹配项可以接受;两个匹配项则返回 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 的处理步骤:
TenantSignedId.verify- 如果载荷中包含租户 → 仅在该分片中加载 blob
- 如果是旧版 ID → 扫描租户;若
matches.many?则返回 404 - 设置
ApplicationRecord.current_tenant = @tenant_for_blob,供请求的其余部分使用 - 使用
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 访问彼此的文件。
反模式
- 使用原生直接上传——blob 创建时没有租户信息,之后会返回 404,而不是在上传时失败。
- 跨租户查找并返回第一个匹配项——IDOR。
- 将 blob 放在 GlobalRecord 上——已有
tenanted_rails_records,无需这样做。 - 只在生产环境的 S3 上发现问题——磁盘服务会掩盖重定向/CSP 问题。请使用实际服务进行测试。
相关内容
- 分片设置:
activerecord-tenanted - 基本附件:
active-storage - Lexxy:
action-text
控制器文件列表和查找算法请参见 reference.md。