Configuración Multi-tenant con Rails.app.creds

Las aplicaciones SaaS multi-tenant tienen un problema de configuración: los tenants necesitan diferentes ajustes. Diferentes API keys. Diferente acceso a funcionalidades. Diferentes límites de uso.

La solución típica es una columna JSON settings en el modelo Tenant, con código disperso por todos lados:

# Esto envejece rápido
api_key = current_tenant.settings.dig("stripe", "api_key") ||
          Rails.application.credentials.dig(:stripe, :api_key)

CombinedConfiguration de Rails 8.2 ofrece un enfoque más limpio. Si aún no has visto los conceptos básicos, revisa 5 Formas Inesperadas de Usar Rails.app.creds primero. Luego regresa aquí para ver cómo extenderlo para multi-tenancy.

Construye un backend de configuración para tenants, encadénalo con tus valores por defecto, y accede a todo a través de Rails.app.creds. El mismo código funciona ya sea que un tenant tenga sobrescrituras o no.

La Arquitectura

┌─────────────────────────────────────────────────────────┐
│                   Rails.app.creds                       │
│  .option(:stripe, :api_key)                            │
└────────────────────────┬────────────────────────────────┘

         ┌───────────────┼───────────────┐
         ▼               ▼               ▼
┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│   Tenant    │  │    ENV      │  │ Credentials │
│  Settings   │  │             │  │             │
│  (Dinámico) │  │  (Deploy)   │  │  (Default)  │
└─────────────┘  └─────────────┘  └─────────────┘
     El primer valor no-nil gana

Los settings del tenant sobrescriben ENV, que sobrescribe credentials. Tu código de aplicación no sabe ni le importa de dónde vino el valor.

Paso 1: Modelo de Settings del Tenant

Primero, dale a los tenants un lugar para almacenar su configuración:

# db/migrate/xxx_add_settings_to_tenants.rb
class AddSettingsToTenants < ActiveRecord::Migration[8.0]
  def change
    add_column :tenants, :settings, :jsonb, default: {}, null: false
  end
end
# app/models/tenant.rb
class Tenant < ApplicationRecord
  validates :settings, json: true  # Usa un validador de esquema JSON

  # Métodos de conveniencia
  def setting(*keys)
    keys.reduce(settings.deep_symbolize_keys) { |h, k| h.is_a?(Hash) ? h[k] : nil }
  end

  def set_setting(*keys, value)
    current = settings.deep_dup
    *path, final = keys
    target = path.reduce(current) { |h, k| h[k.to_s] ||= {} }
    target[final.to_s] = value
    update!(settings: current)
  end
end

Paso 2: Tenant Actual

Usa CurrentAttributes para rastrear el tenant actual:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant

  def tenant=(tenant)
    super
    # Recargar configuración cuando cambia el tenant
    Rails.app.creds.reload if Rails.app.creds.respond_to?(:reload)
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant

  private

  def set_current_tenant
    Current.tenant = current_user&.tenant
  end
end

Paso 3: Backend de Configuración del Tenant

Ahora construye el backend de configuración:

# lib/tenant_configuration.rb
class TenantConfiguration
  def require(*keys)
    value = option(*keys)
    value.nil? ? nil : value  # Retorna nil para pasar al siguiente backend
  end

  def option(*keys, default: nil)
    return nil unless Current.tenant

    value = Current.tenant.setting(*keys)
    value.nil? ? nil : value  # No uses default aquí, deja que la cadena lo maneje
  end

  def keys
    return [] unless Current.tenant
    flatten_keys(Current.tenant.settings.deep_symbolize_keys)
  end

  def reload
    # Los settings se leen frescos del tenant cada vez
  end

  private

  def flatten_keys(hash, prefix = [])
    return [] unless hash.is_a?(Hash)

    hash.flat_map do |k, v|
      current = prefix + [k]
      v.is_a?(Hash) ? flatten_keys(v, current) : [current]
    end
  end
end

Paso 4: Conéctalo

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  TenantConfiguration.new,  # Sobrescrituras del tenant primero
  Rails.app.envs,           # Luego ENV
  Rails.app.credentials     # Luego valores por defecto
)

Eso es todo. Ahora Rails.app.creds.option(:stripe, :api_key) automáticamente verifica primero los settings del tenant actual.

Usando la Configuración del Tenant

Diferentes API keys por tenant

Permite que tenants enterprise usen su propia cuenta de Stripe:

# app/services/payment_service.rb
class PaymentService
  def initialize
    @stripe_key = Rails.app.creds.require(:stripe, :secret_key)
  end

  def charge(amount)
    Stripe::Charge.create(
      { amount: amount, currency: "usd" },
      { api_key: @stripe_key }
    )
  end
end

UI de admin para que los tenants configuren su key:

# app/controllers/settings/integrations_controller.rb
class Settings::IntegrationsController < ApplicationController
  def update
    Current.tenant.set_setting(:stripe, :secret_key, params[:stripe_secret_key])
    redirect_to settings_integrations_path, notice: "¡Stripe configurado!"
  end
end

Feature flags por tenant

# credentials.yml.enc (valores por defecto)
features:
  advanced_analytics: false
  api_access: false
  white_label: false
# Tenant settings (para tenant premium)
{
  "features": {
    "advanced_analytics": true,
    "api_access": true
  }
}
# En tu código - igual que antes
if Rails.app.creds.option(:features, :advanced_analytics, default: false)
  render_advanced_analytics
end

Los tenants premium ven analytics avanzados. Los demás no. Sin lógica condicional necesaria.

Límites de uso por tenant

# credentials.yml.enc (valores por defecto)
limits:
  api_requests_per_hour: 1000
  storage_gb: 5
  team_members: 10
# Tenant settings (tenant enterprise)
{
  "limits": {
    "api_requests_per_hour": 50000,
    "storage_gb": 500,
    "team_members": -1  # ilimitado
  }
}
# app/services/rate_limiter.rb
class RateLimiter
  def allowed?(action)
    limit = Rails.app.creds.option(:limits, action, default: 100)
    return true if limit == -1  # Ilimitado

    current_count(action) < limit
  end
end

Interfaz de Administración

Construye un panel de admin para gestionar la configuración del tenant:

# app/controllers/admin/tenant_settings_controller.rb
class Admin::TenantSettingsController < AdminController
  def show
    @tenant = Tenant.find(params[:tenant_id])
    @available_settings = available_settings_schema
    @current_settings = @tenant.settings
  end

  def update
    @tenant = Tenant.find(params[:tenant_id])
    @tenant.update!(settings: merged_settings)
    redirect_to admin_tenant_settings_path(@tenant), notice: "Settings actualizados"
  end

  private

  def available_settings_schema
    {
      features: {
        advanced_analytics: { type: :boolean, default: false, plans: [:pro, :enterprise] },
        api_access: { type: :boolean, default: false, plans: [:enterprise] },
        white_label: { type: :boolean, default: false, plans: [:enterprise] }
      },
      limits: {
        api_requests_per_hour: { type: :integer, default: 1000, min: 100, max: 100_000 },
        storage_gb: { type: :integer, default: 5, min: 1, max: 1000 },
        team_members: { type: :integer, default: 10, min: 1, max: 500 }
      },
      integrations: {
        stripe: { secret_key: { type: :secret } },
        sendgrid: { api_key: { type: :secret } }
      }
    }
  end

  def merged_settings
    @tenant.settings.deep_merge(settings_params.to_h)
  end
end

Cache de Settings del Tenant

Para apps de alto tráfico, cachea los settings del tenant:

# lib/cached_tenant_configuration.rb
class CachedTenantConfiguration
  CACHE_TTL = 5.minutes

  def option(*keys, default: nil)
    return nil unless Current.tenant

    settings = cached_settings
    value = keys.reduce(settings) { |h, k| h.is_a?(Hash) ? h[k] : nil }
    value.nil? ? nil : value
  end

  def reload
    return unless Current.tenant
    Rails.cache.delete(cache_key)
  end

  private

  def cached_settings
    Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
      Current.tenant.settings.deep_symbolize_keys
    end
  end

  def cache_key
    "tenant_settings/#{Current.tenant.id}/#{Current.tenant.updated_at.to_i}"
  end
end

La cache key incluye updated_at, así que los cambios de settings invalidan inmediatamente.

Registro de Auditoría para Cumplimiento

Los tenants enterprise a menudo necesitan logs de auditoría. Envuelve la configuración del tenant:

# lib/audited_tenant_configuration.rb
class AuditedTenantConfiguration
  def initialize(backend)
    @backend = backend
  end

  def option(*keys, default: nil)
    value = @backend.option(*keys, default: default)
    log_access(keys, value, :option) if value
    value
  end

  def require(*keys)
    value = @backend.require(*keys)
    log_access(keys, value, :require) if value
    value
  end

  delegate :keys, :reload, to: :@backend

  private

  def log_access(keys, value, method)
    TenantConfigAuditLog.create!(
      tenant: Current.tenant,
      keys: keys.join("."),
      method: method,
      accessed_at: Time.current,
      user: Current.user,
      request_id: Current.request_id
    )
  end
end

Probando Configuración Multi-tenant

# test/lib/tenant_configuration_test.rb
class TenantConfigurationTest < ActiveSupport::TestCase
  setup do
    @tenant = tenants(:acme)
    @config = TenantConfiguration.new
  end

  test "retorna nil sin tenant actual" do
    Current.tenant = nil
    assert_nil @config.option(:features, :custom)
  end

  test "retorna setting del tenant cuando está presente" do
    Current.tenant = @tenant
    @tenant.update!(settings: { "features" => { "custom" => true } })

    assert_equal true, @config.option(:features, :custom)
  end

  test "retorna nil para settings faltantes (pasa al siguiente backend)" do
    Current.tenant = @tenant
    @tenant.update!(settings: {})

    assert_nil @config.option(:features, :custom)
  end
end

# test/integration/tenant_configuration_integration_test.rb
class TenantConfigurationIntegrationTest < ActionDispatch::IntegrationTest
  test "settings del tenant sobrescriben valores por defecto" do
    tenant = tenants(:acme)
    tenant.update!(settings: { "features" => { "dark_mode" => true } })

    sign_in users(:acme_admin)

    # Setting del tenant
    assert Rails.app.creds.option(:features, :dark_mode, default: false)

    # Cae a credentials para valores no configurados
    assert_equal "default_key", Rails.app.creds.option(:api, :key, default: "default_key")
  end
end

Consideraciones de Seguridad

Valida la entrada del tenant

Nunca confíes ciegamente en la configuración proporcionada por el tenant:

class Tenant < ApplicationRecord
  validate :settings_schema_valid

  ALLOWED_SETTINGS = {
    features: %i[advanced_analytics api_access white_label],
    limits: %i[api_requests_per_hour storage_gb team_members],
    integrations: { stripe: %i[secret_key], sendgrid: %i[api_key] }
  }.freeze

  private

  def settings_schema_valid
    settings.each_key do |key|
      unless ALLOWED_SETTINGS.key?(key.to_sym)
        errors.add(:settings, "contiene clave inválida: #{key}")
      end
    end
  end
end

Encripta settings sensibles

Para API keys y secretos, encripta en reposo:

class Tenant < ApplicationRecord
  encrypts :settings  # Encriptación de Rails 7+
end

Limita la tasa de cambios de settings

Previene abusos:

class Settings::IntegrationsController < ApplicationController
  before_action :rate_limit_settings_changes

  private

  def rate_limit_settings_changes
    key = "settings_change:#{Current.tenant.id}"
    if Rails.cache.read(key).to_i > 10
      render json: { error: "Demasiados cambios de settings" }, status: :too_many_requests
    else
      Rails.cache.increment(key, 1, expires_in: 1.hour)
    end
  end
end

El Stack Completo

Aquí está la cadena de configuración completa para una app multi-tenant en producción:

# config/initializers/credentials.rb

tenant_config = CachedTenantConfiguration.new
audited_tenant_config = AuditedTenantConfiguration.new(tenant_config)

Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  audited_tenant_config,  # Settings del tenant (cacheados, auditados)
  Rails.app.envs,          # Sobrescrituras ENV (para ops)
  Rails.app.credentials    # Valores por defecto
)

Orden de búsqueda:

  1. Settings del tenant - Personalizaciones para este tenant específico
  2. Variables ENV - Ops puede sobrescribir cualquier cosa en emergencias
  3. Credentials encriptadas - Valores por defecto sensatos

Tu código de aplicación se mantiene limpio:

# Funciona para cualquier tenant, con cualquier fuente de configuración
api_key = Rails.app.creds.require(:stripe, :secret_key)
limit = Rails.app.creds.option(:limits, :api_requests, default: 1000)
enabled = Rails.app.creds.option(:features, :new_ui, default: false)

Conclusión

La configuración multi-tenant no necesita ser condicionales dispersos y excavación de JSON. Con CombinedConfiguration:

  1. Construye un backend de tenant que lea de Current.tenant.settings
  2. Encadénalo primero para que los settings del tenant sobrescriban los valores por defecto
  3. Accede a la configuración uniformemente vía Rails.app.creds

El resultado: personalización por tenant sin complejidad de código. Los tenants enterprise obtienen sus propias API keys, feature flags y límites. Tu código se mantiene limpio y testeable.

¿Nuevo en Rails.app.creds? Comienza con 5 Formas Inesperadas de Usar Rails.app.creds para los fundamentos.

Mira el PR #56404 para la implementación de CombinedConfiguration.