ActiveRecord::Tenanted

repository·main·Indexed 20 days ago

https://github.com/basecamp/activerecord-tenanted

A Rails gem for implementing multi-tenancy by isolating tenants into separate data stores. It provides tools for tenant-aware database connections, automated tenant switching via TenantSelector middleware, and tenant-isolated Active Storage keys. Currently, only the sqlite3 database adapter is fully supported. Includes Rake tasks for migrating, dropping, and resetting tenanted databases, as well as programmatic methods for creating and destroying tenants.

Tokens
4.4K
Snippets
15
Records
25
Agent score
70%

What's inside activerecord-tenanted

  1. Core concepts of ActiveRecord::Tenanted

    main

    ActiveRecord::Tenanted enables a Rails application to host multiple isolated tenants. It is designed so that developing a multi-tenant app feels as easy as developing a single-tenant app; your application code should not need to be aware of the tenant isolation logic.

    The gem follows these guiding principles:

    • Data at rest: Persisted in separate stores for each tenant, isolated physically or logically.
    • Data in transit: Only sent to users with authenticated access to the specific tenant instance.
    • Isolated execution: All tenant-related code execution happens within a well-defined isolated tenant context with controls around data access.
    IMPORTANT

    Currently, only the sqlite3 database adapter is fully supported. If you need to tenant other database types supported by Rails, contact the maintainers.

  2. How TenantSelector middleware handles requests

    main

    The TenantSelector middleware automates tenant switching based on the request environment. It relies on two configuration settings: connection_class and tenant_resolver.

    Behavioral Logic:

    1. No Tenant Resolved: If the tenant_resolver returns a blank value, the request proceeds without a tenanted context. Application code can manually set the tenant later.
    2. Tenant Resolved & Exists: If the resolver returns a name and the connection_class confirms the tenant exists, the entire request is wrapped in a with_tenant(tenant_name) block, locking the application to that tenant's database.
    3. Tenant Resolved but Missing: If the resolver returns a name but the tenant does not exist in the system, the middleware raises an ActiveRecord::Tenanted::TenantDoesNotExistError (which typically results in a 404 response in a Rails application).
  3. How tenant isolation is handled in Rails tests

    main

    The gem uses several strategies to ensure tests run correctly in a multi-tenant environment:

    Subdomain Simulation

    For integration and system tests, the gem simulates tenant-specific subdomains:

    • Integration Tests: Uses #{klass.current_tenant}.example.com as the host.
    • System Tests: Uses #{klass.current_tenant}.example.localhost for default_url_options.
    • ActionCable: Sets env["HTTP_HOST"] to #{klass.current_tenant}.example.com.

    Transactional Fixture Management

    To avoid sporadic locking errors and ensure schema migrations are visible to the code under test, the gem overrides transactional_tests_for_pool?. It returns false (disabling transactional tests) if:

    1. The database configuration is a Tenanted::DatabaseConfigurations::BaseConfig (the root config).
    2. The configuration is a Tenanted::DatabaseConfigurations::TenantConfig for a tenant that is not the default_tenant defined in Rails.application.config.active_record_tenanted.default_tenant.

    Background Jobs

    When calling perform_enqueued_jobs in ActiveJobTestCase, the gem wraps the execution in klass.without_tenant to ensure jobs run in a clean state without being bound to a specific tenant context that might interfere with the test setup.

  4. Tenant-isolated key structure for Active Storage Blobs

    main

    When ActiveRecord::Tenanted is configured, the key for an ActiveStorage::Blob is automatically prefixed with the current tenant's identifier to ensure file isolation on disk.

    The generated key follows the format: {tenant}/{unique_token}

    This ensures that even if multiple tenants use the same underlying disk service, their files are stored in distinct subdirectories based on the tenant name.

  5. Configure multi-tenancy testing in Rails test suites

    main

    The activerecord-tenanted gem provides testing extensions for various Rails test classes to ensure tenant isolation and correct host routing during tests.

    When using the gem, it automatically hooks into several Rails test modules to:

    1. ActiveSupport::TestCase: Manages tenant lifecycle (creation/destruction) and handles parallelization by assigning unique worker IDs to tenant databases.
    2. ActionDispatch::IntegrationTest: Automatically sets the request host to #{current_tenant}.example.com to simulate tenant-specific subdomains.
    3. ActionDispatch::SystemTestCase: Sets default_url_options host to #{current_tenant}.example.localhost for system/browser tests.
    4. ActionCableTestCase: Injects the tenant's subdomain into the HTTP_HOST environment variable during connection attempts.
    5. ActiveJobTestCase: Ensures enqueued jobs are performed without_tenant to prevent accidental tenant leakage during background job testing.

    Note: To prevent locking errors and visibility issues, the gem disables transactional fixtures for any database configuration that is a Tenanted::DatabaseConfigurations::TenantConfig and is not the default_tenant.

  6. Enable multi-tenancy for ActionCable connections

    main

    To ensure that ActionCable commands (like subscribing to a channel) are executed within the correct tenant context, you must include ActiveRecord::Tenanted::CableConnection::Base in your ActionCable::Connection::Base class.

    This module uses identified_by :current_tenant to track the tenant and around_command :with_tenant to wrap ActionCable commands in a connection_class.with_tenant block.

    For this to work, you must have configured a tenant_resolver and a connection_class in your Rails.application.config.active_record_tenanted settings.

    class ApplicationCable::Connection < ActionCable::Connection::Base
      prepend ActiveRecord::Tenanted::CableConnection::Base
    end
  7. Declare a model as tenanted with `tenanted`

    main
    To make an ActiveRecord model tenanted, call the tenanted method on an abstract connection class. This method configures the class to use a specific database configuration (defaulting to `
  8. Error: NoTenantError when accessing DiskService

    main

    When using the DiskService for Active Storage, ActiveRecord::Tenanted requires an active tenant to be set on the connection class. If you attempt to access the disk service or generate a Blob key while ActiveRecord::Tenanted.connection_class.current_tenant is nil, a NoTenantError will be raised.

    Common scenarios:

    • Accessing Active Storage files in a background job or console session where the tenant context has not been explicitly set.
    • Attempting to generate a new ActiveStorage::Blob without a current tenant context.
    # This will raise NoTenantError if no tenant is set
    ActiveStorage::DiskService.new.root
    
    # Ensure tenant is set before accessing storage
    ActiveRecord::Tenanted.with_tenant(tenant) do
      # Storage operations are now safe
    end
  9. Handle NoTenantError and WrongTenantError

    main

    The library enforces tenant safety to prevent accidental data leakage. You may encounter these errors:

    • NoTenantError: Raised when attempting to access a tenanted model while no tenant context is active (i.e., current_tenant is nil).
    • WrongTenantError: Raised when a model instance belongs to one tenant, but the current active connection context is set to a different tenant. This typically happens if you try to associate or save a record across tenant boundaries.
  10. Register Rake tasks for tenanted databases

    main

    The ActiveRecord::Tenanted::DatabaseTasks class provides a method to register custom Rake tasks that mirror standard Rails database tasks but operate on tenanted databases. Once registered, you can use these tasks to migrate, drop, or reset tenant databases.

    When running these tasks, you can target a specific tenant by setting the ARTENANT environment variable. If ARTENANT is not set, the tasks will default to all tenants (or the default tenant configured in your local environment).

    # Assuming 'tasks' is an instance of ActiveRecord::Tenanted::DatabaseTasks
    tasks.register_rake_tasks