Configuration Multi-tenant avec Rails.app.creds
Les applications SaaS multi-tenant ont un problème de configuration : les tenants ont besoin de paramètres différents. Différentes clés API. Différents accès aux fonctionnalités. Différentes limites d’utilisation.
La solution typique est une colonne JSON settings sur le modèle Tenant, avec du code dispersé partout :
# Ça devient vite pénible
api_key = current_tenant.settings.dig("stripe", "api_key") ||
Rails.application.credentials.dig(:stripe, :api_key)
CombinedConfiguration de Rails 8.2 offre une approche plus propre. Si vous n’avez pas encore vu les bases, consultez d’abord 5 Utilisations Inattendues de Rails.app.creds. Puis revenez ici pour voir comment l’étendre pour le multi-tenant.
Construisez un backend de configuration tenant, chaînez-le avec vos valeurs par défaut, et accédez à tout via Rails.app.creds. Le même code fonctionne que le tenant ait des surcharges ou non.
L’Architecture
┌─────────────────────────────────────────────────────────┐
│ Rails.app.creds │
│ .option(:stripe, :api_key) │
└────────────────────────┬────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Tenant │ │ ENV │ │ Credentials │
│ Settings │ │ │ │ │
│ (Dynamique) │ │ (Deploy) │ │ (Défaut) │
└─────────────┘ └─────────────┘ └─────────────┘
La première valeur non-nil gagne
Les settings du tenant surchargent ENV, qui surcharge credentials. Votre code applicatif ne sait pas et ne se soucie pas d’où vient la valeur.
Étape 1 : Modèle de Settings du Tenant
D’abord, donnez aux tenants un endroit pour stocker leur configuration :
# 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 # Utilisez un validateur de schéma JSON
# Méthodes utilitaires
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
Étape 2 : Tenant Courant
Utilisez CurrentAttributes pour suivre le tenant courant :
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :tenant
def tenant=(tenant)
super
# Recharger la configuration quand le tenant change
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
Étape 3 : Backend de Configuration du Tenant
Maintenant construisez le backend de configuration :
# lib/tenant_configuration.rb
class TenantConfiguration
def require(*keys)
value = option(*keys)
value.nil? ? nil : value # Retourne nil pour passer au backend suivant
end
def option(*keys, default: nil)
return nil unless Current.tenant
value = Current.tenant.setting(*keys)
value.nil? ? nil : value # N'utilisez pas default ici, laissez la chaîne le gérer
end
def keys
return [] unless Current.tenant
flatten_keys(Current.tenant.settings.deep_symbolize_keys)
end
def reload
# Les settings sont lus frais du tenant à chaque fois
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
Étape 4 : Connectez le Tout
# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
TenantConfiguration.new, # Surcharges tenant en premier
Rails.app.envs, # Puis ENV
Rails.app.credentials # Puis les valeurs par défaut
)
C’est tout. Maintenant Rails.app.creds.option(:stripe, :api_key) vérifie automatiquement d’abord les settings du tenant courant.
Utiliser la Configuration du Tenant
Différentes clés API par tenant
Permettez aux tenants enterprise d’utiliser leur propre compte 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
Interface admin pour que les tenants configurent leur clé :
# 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 configuré !"
end
end
Feature flags par tenant
# credentials.yml.enc (valeurs par défaut)
features:
advanced_analytics: false
api_access: false
white_label: false
# Tenant settings (pour tenant premium)
{
"features": {
"advanced_analytics": true,
"api_access": true
}
}
# Dans votre code - identique à avant
if Rails.app.creds.option(:features, :advanced_analytics, default: false)
render_advanced_analytics
end
Les tenants premium voient les analytics avancés. Les autres non. Aucune logique conditionnelle nécessaire.
Limites d’utilisation par tenant
# credentials.yml.enc (valeurs par défaut)
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 # illimité
}
}
# app/services/rate_limiter.rb
class RateLimiter
def allowed?(action)
limit = Rails.app.creds.option(:limits, action, default: 100)
return true if limit == -1 # Illimité
current_count(action) < limit
end
end
Interface d’Administration
Construisez un panneau admin pour gérer la configuration du 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 mis à jour"
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
Mise en Cache des Settings du Tenant
Pour les applications à fort trafic, mettez en cache les settings du 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 clé de cache inclut updated_at, donc les changements de settings invalident immédiatement.
Journal d’Audit pour la Conformité
Les tenants enterprise ont souvent besoin de journaux d’audit. Enveloppez la configuration du 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
Tester la Configuration Multi-tenant
# test/lib/tenant_configuration_test.rb
class TenantConfigurationTest < ActiveSupport::TestCase
setup do
@tenant = tenants(:acme)
@config = TenantConfiguration.new
end
test "retourne nil sans tenant courant" do
Current.tenant = nil
assert_nil @config.option(:features, :custom)
end
test "retourne le setting du tenant quand présent" do
Current.tenant = @tenant
@tenant.update!(settings: { "features" => { "custom" => true } })
assert_equal true, @config.option(:features, :custom)
end
test "retourne nil pour les settings manquants (passe au backend suivant)" 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 "les settings du tenant surchargent les valeurs par défaut" do
tenant = tenants(:acme)
tenant.update!(settings: { "features" => { "dark_mode" => true } })
sign_in users(:acme_admin)
# Setting du tenant
assert Rails.app.creds.option(:features, :dark_mode, default: false)
# Tombe sur credentials pour les valeurs non configurées
assert_equal "default_key", Rails.app.creds.option(:api, :key, default: "default_key")
end
end
Considérations de Sécurité
Validez les entrées du tenant
Ne faites jamais aveuglément confiance à la configuration fournie par le 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, "contient une clé invalide : #{key}")
end
end
end
end
Chiffrez les settings sensibles
Pour les clés API et secrets, chiffrez au repos :
class Tenant < ApplicationRecord
encrypts :settings # Chiffrement Rails 7+
end
Limitez le taux de changements de settings
Prévenez les abus :
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: "Trop de changements de settings" }, status: :too_many_requests
else
Rails.cache.increment(key, 1, expires_in: 1.hour)
end
end
end
La Stack Complète
Voici la chaîne de configuration complète pour une application multi-tenant en production :
# 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 du tenant (cachés, audités)
Rails.app.envs, # Surcharges ENV (pour ops)
Rails.app.credentials # Valeurs par défaut
)
Ordre de recherche :
- Settings du tenant - Personnalisations pour ce tenant spécifique
- Variables ENV - Ops peut surcharger n’importe quoi en cas d’urgence
- Credentials chiffrées - Valeurs par défaut sensées
Votre code applicatif reste propre :
# Fonctionne pour n'importe quel tenant, avec n'importe quelle source de configuration
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)
Conclusion
La configuration multi-tenant n’a pas besoin d’être des conditionnels dispersés et de la fouille JSON. Avec CombinedConfiguration :
- Construisez un backend tenant qui lit depuis
Current.tenant.settings - Chaînez-le en premier pour que les settings du tenant surchargent les valeurs par défaut
- Accédez à la configuration uniformément via
Rails.app.creds
Le résultat : personnalisation par tenant sans complexité de code. Les tenants enterprise obtiennent leurs propres clés API, feature flags et limites. Votre code reste propre et testable.
Nouveau avec Rails.app.creds ? Commencez par 5 Utilisations Inattendues de Rails.app.creds pour les fondamentaux.
Voir le PR #56404 pour l’implémentation de CombinedConfiguration.