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 :

  1. Settings du tenant - Personnalisations pour ce tenant spécifique
  2. Variables ENV - Ops peut surcharger n’importe quoi en cas d’urgence
  3. 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 :

  1. Construisez un backend tenant qui lit depuis Current.tenant.settings
  2. Chaînez-le en premier pour que les settings du tenant surchargent les valeurs par défaut
  3. 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.