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:
- Settings del tenant - Personalizaciones para este tenant específico
- Variables ENV - Ops puede sobrescribir cualquier cosa en emergencias
- 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:
- Construye un backend de tenant que lea de
Current.tenant.settings - Encadénalo primero para que los settings del tenant sobrescriban los valores por defecto
- 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.