Aller au contenu principal

Architecture Multi-Tenant

La Cafête utilise le modèle schema-per-tenant de PostgreSQL pour garantir une isolation complète des données entre organisations.

Modèle d'isolation​

┌──────────────────────────────────────────────────────────────────────────┐
│ SCHEMA public (global) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ organizations│ │platform_users│ │ roles │ │
│ │ │ │ │ │ (org_id peut être │ │
│ │ id │ │ id │ │ NULL = système) │ │
│ │ slug │ │ email │ └──────────────────────┘ │
│ │ schema_name │ │ password_hash│ │
│ │ short_code │ └──────────────┘ │
│ │ status │ │ │
│ │ settings │ │ │
│ └──────────────┘ │ │
│ │ ┌────────────┴────────────┐ ┌──────────────────────┐ │
│ │ │ platform_user_roles │ │ system_logs │ │
│ │ └─────────────────────────┘ └──────────────────────┘ │
│ │ │
│ │ ┌────────────────┐ ┌──────────────────────┐ │
│ │ │ payment_sessions│ │ public_code_sequences│ │
│ │ └────────────────┘ └──────────────────────┘ │
│ │ │
│ │ ┌────────────────┐ ┌──────────────────────┐ │
│ │ │ outbox_events │ │ audit_log_global │ │
│ │ └────────────────┘ └──────────────────────┘ │
│ │ │
│ │ ┌────────────────┐ ┌──────────────────────┐ │
│ │ │ refresh_tokens │ │ tenant_migrations │ │
│ │ └────────────────┘ └──────────────────────┘ │
│ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ │ anomaly_notifications│ │ anomalies │ │
│ │ └──────────────────────┘ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ SCHEMA tenant_cours_lumiere │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │ sites │ │ card_holders │ │ cards │ │ │
│ │ │ │ │ (porteurs) │ │ (physiques/virtuelles)│ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │ products │ │ tenant_users │ │ reservations │ │ │
│ │ │ │ │ (personnel) │ │ │ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │ menus │ │ menu_products │ │ points_of_sale │ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │cash_registers│ │card_transactions│ stock_movements │ │ │
│ │ │ (caisses) │ │ (ledger immuable)│ (traçabilité) │ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │ tickets │ │ticket_messages│ │ qr_secrets │ │ │
│ │ │ (support) │ │ (commentaires)│ │ (clés QR) │ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ │
│ │ │ audit_log │ │ anomalies │ │ stats_daily (MV) │ │ │
│ │ │ (tenant) │ │ (tenant) │ │ (vue matérialisée) │ │ │
│ │ └─────────────┘ └──────────────┘ └───────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ SCHEMA tenant_acme_corp │ │
│ │ ... (même structure) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘

Conventions de nommage​

Les schémas sont nommés selon le motif tenant_{slug} via la fonction tenant_schema_name(slug) qui convertit les traits d'union en underscores et force les minuscules (ex: tenant_cours_lumiere).

Résolution du tenant par requête​

Le middleware Axum tenant_middleware détermine le tenant pour chaque requête en lisant le JWT ou l'en-tête X-Tenant-Id.

Logique de résolution​

flowchart TD
A[Requête reçue] --> B{JWT présent ?}
B -->|Non| E[CURRENT_TENANT_SCHEMA = scoped<br/>→ handler voit public]
B -->|Oui| C{UserKind ?}

C -->|Platform| D{Header X-Tenant-Id ?}
C -->|Tenant| F[organization_slug depuis JWT claims]
C -->|CardHolder| F

D -->|Oui| G[SELECT schema_name<br/>FROM public.organizations<br/>WHERE id = $1 AND status = 'active']
D -->|Non| E

F --> H[tenant_schema_name(org_slug)<br/>→ tenant_{slug}]

G --> I[CURRENT_TENANT_SCHEMA.scope(schema_name, handler)]
H --> I

I --> J[before_acquire lit CURRENT_TENANT_SCHEMA<br/>→ SET search_path TO tenant_{slug}, public]
J --> K[Handler exécute les requêtes]
K --> L[Connexion retournée au pool]
L --> M[after_connect → RESET search_path]

Détail de la résolution​

  1. Utilisateurs Plateforme (N2) sans X-Tenant-Id : CURRENT_TENANT_SCHEMA non défini. before_acquire ne modifie pas le search_path. Accès aux tables globales seulement via le préfixe public..

  2. Utilisateurs Plateforme avec X-Tenant-Id : Le middleware valide que l'UUID correspond à une organisation active dans public.organizations, puis résout le schema_name et crée un TenantContext. Le handler est exécuté dans CURRENT_TENANT_SCHEMA.scope(schema_name, ...) — le callback before_acquire du pool positionne search_path à chaque checkout de connexion.

  3. Utilisateurs Tenant (personnel d'organisation) : Le slug est extrait du JWT (claims.organization_slug), transformé via tenant_schema_name() en tenant_{slug}, puis scope identique.

  4. Porteurs de carte (CardHolder) : Même logique que les utilisateurs tenant — le slug provient des claims JWT.

Résolution du search_path​

UserKindX-Tenant-IdCURRENT_TENANT_SCHEMAAccès tables publiquesAccès tables tenant
PlatformNonnon définivia public.* explicitenon
PlatformOui (UUID)tenant_{slug}via public.* explicitevia search_path
TenantIgnorétenant_{slug} (depuis JWT)via public.* explicitevia search_path
CardHolderIgnorétenant_{slug} (depuis JWT)via public.* explicitevia search_path

Tables publiques (cross-tenant)​

Ces tables vivent dans le schéma public et sont accessibles depuis tous les tenants :

TableDescription
organizationsToutes les organisations (slug, schema_name, short_code, status, settings)
platform_usersAdministrateurs système (N2)
permissionsCatalogue des 21 codes de permission
rolesRôles système (org_id=NULL) et rôles org (org_id=UUID)
role_permissionsAssignation permission → rôle
platform_user_rolesAssignation rôle → admin système
audit_log_globalLog de toutes les actions cross-tenant
anomaly_notificationsNotifications d'anomalies (balance élevée)
anomaliesAnomalies unifiées (stock, caisse, ledger)
system_logsLogs d'exploitation pour le débogage frontend
tenant_migrationsSuivi des migrations appliquées par tenant
outbox_eventsTransactional outbox pour événements asynchrones
refresh_tokensStockage sécurisé des refresh tokens (hashés)
payment_sessionsSessions de paiement mobile money
public_code_sequencesGénérateur de codes séquentiels par entité
jobsFile d'attente des jobs background (Apalis/Redis)
email_configsConfiguration des emails par organisation
smtp_settingsParamètres SMTP pour l'envoi d'emails

Tables par tenant​

Chaque schéma tenant_{slug} contient :

TableDescription
sitesPoints de vente physiques
tenant_usersPersonnel de l'organisation
tenant_user_rolesAssignation rôle → utilisateur org (site_id optionnel)
card_holdersPorteurs de carte (identité, préférences, notification)
cardsCartes physiques/virtuelles (solde, statut, QR)
card_transactionsLedger immuable de toutes les transactions
qr_secretsClés de signature pour QR codes
product_categoriesCatégories (buvette, reservation_meal, reservation_snack_component)
productsCatalogue produits (prix, stock, seuil, image)
menusMenu du jour (regroupement de produits)
menu_productsProduits composant un menu
points_of_saleTerminaux de vente (fixe/mobile)
cash_registersSessions de caisse (ouvertures/fermetures)
cash_register_transactionsMouvements d'espèces dans la caisse
reservationsRéservations de repas
reservation_itemsLignes de réservation (multi-produit)
stock_movementsTraçabilité du stock (mouvements, lots, péremptions)
purchase_ordersCommandes d'approvisionnement
ticketsTickets de support
ticket_messagesMessages/commentaires sur les tickets
notificationsHistorique des notifications envoyées
notification_templatesTemplates par canal (email, SMS, WhatsApp, webhook)
scheduled_notificationsNotifications planifiées (cron)
audit_logJournal d'audit propre au tenant
anomaliesAnomalies détectées dans le tenant
card_holder_linksRelations parent-enfant (écoles)
holder_documentsDocuments associés aux porteurs
stats_dailyVue matérialisée des stats quotidiennes
stats_cacheCache de statistiques (vue matérialisée)

Provisionnement d'un tenant​

Le provisionnement d'une nouvelle organisation est transactionnel et utilise un fichier de migration unique compilé dans le binaire :

  1. Insertion dans public.organizations (récupère l'UUID et le schema_name généré)
  2. CREATE SCHEMA tenant_{slug} (via CREATE SCHEMA IF NOT EXISTS)
  3. MigrationManager::run_tenant_migrations() : exécute tenant/0001_initial_tenant_schema.sql (883 lignes, compilé via include_str!) qui crée toutes les tables tenant en une seule migration
  4. Seed des 5 rôles système par défaut dans public.roles avec organization_id :
    • admin_n1 — accès complet à l'organisation
    • supervisor — supervision et gestion
    • cashier — encaissement et rechargement
    • seller — vente de produits
    • card_holder — consultation et recharge mobile
  5. Assignation des permissions par défaut à chaque rôle via public.role_permissions
  6. Création de l'utilisateur admin initial
sequenceDiagram
participant Admin
participant API
participant PG

Admin->>API: POST /admin/system/organizations<br/>{slug, name, ...}

API->>PG: BEGIN
API->>PG: INSERT INTO public.organizations<br/>(id, slug, schema_name, ...)
PG-->>API: Organization (with UUID + schema_name)

API->>PG: CREATE SCHEMA tenant_cours_lumiere

Note over API,PG: MigrationManager::run_tenant_migrations()
API->>PG: Exécute tenant/0001_initial_tenant_schema.sql<br/>(883 lignes, include_str! compilé)
Note over PG: CREATE TABLE sites, cards,<br/>card_holders, products,<br/>menus, cash_registers, tickets...<br/>CREATE FUNCTION fn_apply_card_transaction<br/>CREATE MATERIALIZED VIEW stats_daily<br/>--- UNE SEULE MIGRATION, PAS DE SÉRIE ---

Note over API,PG: MigrationManager::seed_tenant_roles()
API->>PG: INSERT INTO public.roles (org_id)<br/>ON CONFLICT DO NOTHING
API->>PG: INSERT INTO public.role_permissions<br/>-- admin_n1 → toutes les permissions
API->>PG: INSERT INTO public.role_permissions<br/>-- supervisor → permissions limitées
API->>PG: INSERT INTO public.role_permissions<br/>-- cashier → rechargement espèces
API->>PG: INSERT INTO public.role_permissions<br/>-- seller → vente uniquement
API->>PG: INSERT INTO public.role_permissions<br/>-- card_holder → consultation + recharge mobile

API->>PG: COMMIT

API->>Admin: 201 {organization}
info

Le fichier de migration tenant est compilé dans le binaire via include_str!(). Chaque provisionnement exécute exactement le même SQL. En cas d'échec après le CREATE SCHEMA, l'organisation est supprimée (rollback applicatif) pour éviter les schémas orphelins.

Cycle de vie du search_path​

sequenceDiagram
participant Client
participant Middleware
participant Pool
participant PG

Client->>Middleware: Requête avec JWT tenant
Middleware->>Middleware: Extraire organization_slug du JWT
Middleware->>Middleware: tenant_schema_name(slug) → tenant_{slug}
Middleware->>Middleware: CURRENT_TENANT_SCHEMA.scope(schema_name, handler)

Note over Middleware,Pool: before_acquire lit CURRENT_TENANT_SCHEMA<br/>et SET search_path automatiquement

Middleware->>Pool: Acquérir connexion
Pool->>PG: before_acquire: SET search_path TO tenant_{slug}, public
PG-->>Pool: OK
Pool-->>Middleware: Connexion prête (search_path déjà positionné)

Middleware->>PG: Handler: SELECT * FROM cards
PG-->>Middleware: → tenant_cours_lumiere.cards

Middleware->>PG: Handler: SELECT * FROM public.permissions
PG-->>Middleware: → public.permissions (préfixe explicite)

Note over Middleware,Pool: Fin du scope CURRENT_TENANT_SCHEMA
Note over Pool: Connexion retournée au pool
Note over Pool: after_connect (nouvelle connexion) : RESET search_path
Middleware->>Client: Réponse

Impact sur les requêtes​

Lecture de données​

Les lectures dans les tables tenant utilisent le search_path (positionné par before_acquire). Les tables publiques sont toujours accédées avec le préfixe public. explicite :

-- CURRENT_TENANT_SCHEMA = scope('tenant_cours_lumiere', ...)
-- before_acquire → SET search_path TO tenant_cours_lumiere, public
SELECT * FROM cards; -- → lit dans tenant_cours_lumiere.cards
SELECT * FROM sites; -- → lit dans tenant_cours_lumiere.sites
SELECT * FROM public.permissions; -- → lit dans public.permissions

-- Les repositories utilisent systématiquement public.*
-- pour les tables globales, afin d'être immunisés
-- contre tout search_path résiduel.

Jointures cross-schema​

-- search_path = tenant_cours_lumiere, public
SELECT r.name, p.code
FROM public.roles r
JOIN public.permissions p ON ...
WHERE r.organization_id = 'uuid-org';

Gestion des jobs background​

Les jobs Apalis (stockés dans Redis) exécutent dans un contexte tenant explicite via CURRENT_TENANT_SCHEMA.scope(). Avant chaque traitement, le job appelle pg.set_search_path_safe(schema_name) qui valide et quote le nom du schéma, puis le before_acquire du pool applique le search_path automatiquement.

attention

Chaque job utilise CURRENT_TENANT_SCHEMA.scope() pour délimiter le contexte tenant. Le pool réinitialise le search_path via after_connect sur les nouvelles connexions.

flowchart LR
A[Job démarré] --> B[Lire (org_id, schema_name)<br/>depuis la base]
B --> C[CURRENT_TENANT_SCHEMA.scope(schema_name,<br/>async { ... logique métier ... })]
C --> D[before_acquire → SET search_path<br/>TO tenant_{slug}, public]
D --> E[Queries dans le schéma tenant]
E --> F[Fin du scope → connexion retournée]
F --> G[after_connect → RESET search_path]

Sécurité et injection SQL​

Les noms de schéma sont systématiquement validés et échappés avant utilisation :

  • validate_and_quote_schema() vérifie que le nom ne contient que des caractères alphanumériques ASCII et underscores
  • Le nom est entouré de guillemets doubles (double quote) avec échappement des quotes internes
  • tenant_schema_name() valide le slug d'entrée (minuscules, chiffres, traits d'union uniquement) et génère tenant_{slug} avec conversion des traits d'union en underscores
  • Toute tentative d'injection via slug est bloquée à la validation