From 4757d1a7f76e47a4a9cfa26023abdb87839014e8 Mon Sep 17 00:00:00 2001 From: Keshav Biswa Date: Thu, 13 Nov 2025 11:41:44 +0530 Subject: [PATCH] Introduce allow_untenanted_active_storage config to enable ActiveStorage without a tenant context --- CHANGELOG.md | 7 +++ lib/active_record/tenanted/railtie.rb | 12 ++++ lib/active_record/tenanted/storage.rb | 26 +++++--- test/unit/storage_test.rb | 86 +++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eede0b54..a88106de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## next / unreleased +### Added + +- Add `config.active_record_tenanted.allow_untenanted_active_storage` configuration option to enable ActiveStorage without a tenant context. + When enabled, ActiveStorage will fallback to default (non-tenanted) behavior when there is no current tenant, + allowing models in the primary database to use Active Storage alongside tenanted models. + Defaults to `false` for backward compatibility. @keshavbiswa + ### Fixed - `.current_tenant = nil` now clears the tenant context, properly setting the shard to `UNTENANTED_SENTINEL` instead of `""` @flavorjones diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index 423a3512..b4b32d1b 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -57,6 +57,18 @@ class Railtie < ::Rails::Railtie # Defaults to "development-tenant" in development and "test-tenant" in test environments. config.active_record_tenanted.default_tenant = Rails.env.local? ? "#{Rails.env}-tenant" : nil + # Set this to true to allow Active Storage to work without a tenant context. + # + # When enabled, Active Storage will fall back to default behavior (non-tenanted storage) + # when there is no current tenant. This allows models in the primary database to use + # Active Storage alongside tenanted models. + # + # When disabled (default), Active Storage will raise NoTenantError if accessed without + # a tenant context, maintaining strict tenant isolation. + # + # Defaults to `false`. + config.active_record_tenanted.allow_untenanted_active_storage = false + config.before_configuration do ActiveSupport.on_load(:active_record_database_configurations) do ActiveRecord::Tenanted::DatabaseConfigurations.register_db_config_handler diff --git a/lib/active_record/tenanted/storage.rb b/lib/active_record/tenanted/storage.rb index da03343f..73d9b90b 100644 --- a/lib/active_record/tenanted/storage.rb +++ b/lib/active_record/tenanted/storage.rb @@ -6,7 +6,12 @@ module Storage # :nodoc: module DiskService def root if klass = ActiveRecord::Tenanted.connection_class - unless tenant = klass.current_tenant + tenant = klass.current_tenant + allow_untenanted = Rails.application.config.active_record_tenanted.allow_untenanted_active_storage + + if tenant.nil? + return super if allow_untenanted + raise NoTenantError, "Cannot access Active Storage Disk service without a tenant" end @@ -18,12 +23,12 @@ def root def path_for(key) if ActiveRecord::Tenanted.connection_class - # TODO: this is brittle if the key isn't tenanted ... errors in folder_for: - # - # NoMethodError undefined method '[]' for nil (NoMethodError) [ key[0..1], key[2..3] ].join("/") - # - tenant, key = key.split("/", 2) - File.join(root, tenant, folder_for(key), key) + if key.include?("/") + tenant, key = key.split("/", 2) + File.join(root, tenant, folder_for(key), key) + else + super + end else super end @@ -33,7 +38,12 @@ def path_for(key) module Blob def key self[:key] ||= if klass = ActiveRecord::Tenanted.connection_class - unless tenant = klass.current_tenant + tenant = klass.current_tenant + allow_untenanted = Rails.application.config.active_record_tenanted.allow_untenanted_active_storage + + if tenant.nil? + return super if allow_untenanted + raise NoTenantError, "Cannot generate a Blob key without a tenant" end diff --git a/test/unit/storage_test.rb b/test/unit/storage_test.rb index cf2ccc89..d876eb1c 100644 --- a/test/unit/storage_test.rb +++ b/test/unit/storage_test.rb @@ -4,6 +4,17 @@ describe ActiveRecord::Tenanted::Storage do describe "DiskService" do + let(:allow_untenanted_active_storage) { false } + + setup do + @old_allow_untenanted = Rails.application.config.active_record_tenanted.allow_untenanted_active_storage + Rails.application.config.active_record_tenanted.allow_untenanted_active_storage = allow_untenanted_active_storage + end + + teardown do + Rails.application.config.active_record_tenanted.allow_untenanted_active_storage = @old_allow_untenanted + end + describe ".root" do with_active_storage do let(:service) { ActiveStorage::Service::DiskService.new(root: root_path) } @@ -26,6 +37,24 @@ end end end + + describe "with allow_untenanted_active_storage enabled" do + let(:allow_untenanted_active_storage) { true } + + test "allows access without tenant" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + assert_equal("/path/to/%{tenant}/storage", service.root) + end + end + + test "uses tenant when available" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + TenantedApplicationRecord.create_tenant("foo") do + assert_equal("/path/to/foo/storage", service.root) + end + end + end + end end describe "with a non-tenanted root path" do @@ -47,6 +76,55 @@ end end end + + describe "with allow_untenanted_active_storage enabled" do + let(:allow_untenanted_active_storage) { true } + let(:root_path) { "/path/to/storage" } + + test "allows access while untenanted" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + assert_equal("/path/to/storage", service.root) + end + end + + test "uses current tenant while tenanted" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + TenantedApplicationRecord.create_tenant("foo") do + assert_equal("/path/to/storage", service.root) + end + end + end + end + end + end + + describe ".path_for" do + with_active_storage do + let(:service) { ActiveStorage::Service::DiskService.new(root: "/path/to/storage") } + + describe "with allow_untenanted_active_storage enabled" do + let(:allow_untenanted_active_storage) { true } + + test "handles non-tenanted keys" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + non_tenanted_key = "abc123" + + expected_path = "/path/to/storage/ab/c1/#{non_tenanted_key}" + assert_equal expected_path, service.path_for(non_tenanted_key) + end + end + end + + test "handles tenanted keys" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + TenantedApplicationRecord.create_tenant("foo") do + tenanted_key = "foo/abc123" + + expected_path = "/path/to/storage/foo/ab/c1/abc123" + assert_equal expected_path, service.path_for(tenanted_key) + end + end + end end end end @@ -85,6 +163,14 @@ end end end + + test "raises exception without tenant when flag is disabled" do + ActiveRecord::Tenanted.stub(:connection_class, TenantedApplicationRecord) do + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + ActiveStorage::Blob.new(filename: "foo.jpg", byte_size: 100, checksum: "abc123", service_name: service_name).key + end + end + end end end end