active-storage-multitenant
Expert guidance for Active Storage with activerecord-tenanted. Use when blobs 404 after save, Lexxy/Trix uploads, direct uploads, tenant-signed blob IDs, rails/active_storage redirects, or "Cannot generate a Blob key without a tenant". Requires the activerecord-tenanted skill. Not for single-database Active Storage.
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill active-storage-multitenantIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides expert guidance for configuring multitenant Active Storage in Ruby on Rails applications. It focuses on preventing cross-tenant data leaks (IDOR) by signing tenant identifiers into blob IDs and implementing secure lookup algorithms. No malicious patterns or security risks were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Active Storage + activerecord-tenanted
Blobs live in the tenant database (tenanted_rails_records = true). Default Active Storage controllers look in the global connection, so uploads succeed and saved images 404. Direct-upload must create the blob inside with_tenant, and the signed_id must include the tenant key.
Single-database apps: use the active-storage skill only. This skill is the Herald overlay.
Philosophy
- Never create a blob without a current tenant. The storage key is
{tenant_key}/{token}. - Sign blob id + tenant together. A tenant-local numeric/UUID id is not globally unique. Serving "the first match" is a cross-tenant leak.
- Redirect URLs go through your controllers, not a public S3 URL, so tenant checks run.
- Fail closed on ambiguous legacy IDs. One match is ok; two matches are 404.
Config (required)
# config/initializers/tenanted.rb
config.active_record_tenanted.tenanted_rails_records = true
config.active_record_tenanted.connection_class = "ApplicationRecord"
Without those, blob rows land in global and keys have no prefix.
Tenant-signed IDs
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 must pass tenant: so Lexxy doesn't generate tenantless URLs:
<%= 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)
} %>
A ?tenant= query param must not upgrade a legacy signed id. If the signed payload has no tenant, ignore the hint and only serve when that blob id is unique across all tenant DBs.
Direct uploads
Replace /rails/active_storage/direct_uploads with a nested route that knows the tenant:
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
Return a Rails redirect URL, not the S3 URL. previewable: false forces Lexxy to use blob_url_template.
Override AS redirect controllers
Rails autoloads ActiveStorage::Blobs::RedirectController from app/controllers/active_storage/... with the same constant name. No route override.
The concern:
TenantSignedId.verify- If tenant in payload → load blob in that shard only
- If legacy → scan tenants, 404 if
matches.many? ApplicationRecord.current_tenant = @tenant_for_blobfor the rest of the request- Redirect to
@blob.urlwithallow_other_host: true
Same for representations (variants): process inside with_tenant.
Skip account rows whose tenant DB doesn't exist yet (TenantDoesNotExistError) so a new account doesn't abort the scan.
CSP + storage.yml
Add the bucket host to connect_src and img_src. Do not set public: true unless the bucket is actually public — signed headers will break.
Tests
ApplicationRecord.with_tenant(account.tenant_key) do
blob = ActiveStorage::Blob.create_before_direct_upload!(...)
assert blob.key.start_with?("#{account.tenant_key}/")
end
Also: two tenants with the same numeric blob id must not serve each other's file from a tenantless URL.
Anti-Patterns
- Using stock direct uploads — blob created untenanted → 404 later, not at upload time.
- First-match lookup across tenants — IDOR.
- Putting blobs on GlobalRecord —
tenanted_rails_recordsexists so you don't. - Discovering this only in production S3 — disk service hides redirect/CSP issues. Test with the real service.
Related
- Shard setup:
activerecord-tenanted - Vanilla attachments:
active-storage - Lexxy:
action-text
See reference.md for the controller file list and lookup algorithm.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/active-storage-multitenant">View active-storage-multitenant on skillZs</a>