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
-
Utilisateurs Plateforme (N2) sans X-Tenant-Id :
CURRENT_TENANT_SCHEMAnon défini.before_acquirene modifie pas le search_path. Accès aux tables globales seulement via le préfixepublic.. -
Utilisateurs Plateforme avec X-Tenant-Id : Le middleware valide que l'UUID correspond à une organisation active dans
public.organizations, puis résout leschema_nameet crée unTenantContext. Le handler est exécuté dansCURRENT_TENANT_SCHEMA.scope(schema_name, ...)— le callbackbefore_acquiredu pool positionnesearch_pathà chaque checkout de connexion. -
Utilisateurs Tenant (personnel d'organisation) : Le slug est extrait du JWT (
claims.organization_slug), transformé viatenant_schema_name()entenant_{slug}, puis scope identique. -
Porteurs de carte (CardHolder) : Même logique que les utilisateurs tenant — le slug provient des claims JWT.
Résolution du search_path
| UserKind | X-Tenant-Id | CURRENT_TENANT_SCHEMA | Accès tables publiques | Accès tables tenant |
|---|---|---|---|---|
| Platform | Non | non défini | via public.* explicite | non |
| Platform | Oui (UUID) | tenant_{slug} | via public.* explicite | via search_path |
| Tenant | Ignoré | tenant_{slug} (depuis JWT) | via public.* explicite | via search_path |
| CardHolder | Ignoré | tenant_{slug} (depuis JWT) | via public.* explicite | via search_path |
Tables publiques (cross-tenant)
Ces tables vivent dans le schéma public et sont accessibles depuis tous les tenants :
| Table | Description |
|---|---|
organizations | Toutes les organisations (slug, schema_name, short_code, status, settings) |
platform_users | Administrateurs système (N2) |
permissions | Catalogue des 21 codes de permission |
roles | Rôles système (org_id=NULL) et rôles org (org_id=UUID) |
role_permissions | Assignation permission → rôle |
platform_user_roles | Assignation rôle → admin système |
audit_log_global | Log de toutes les actions cross-tenant |
anomaly_notifications | Notifications d'anomalies (balance élevée) |
anomalies | Anomalies unifiées (stock, caisse, ledger) |
system_logs | Logs d'exploitation pour le débogage frontend |
tenant_migrations | Suivi des migrations appliquées par tenant |
outbox_events | Transactional outbox pour événements asynchrones |
refresh_tokens | Stockage sécurisé des refresh tokens (hashés) |
payment_sessions | Sessions de paiement mobile money |
public_code_sequences | Générateur de codes séquentiels par entité |
jobs | File d'attente des jobs background (Apalis/Redis) |
email_configs | Configuration des emails par organisation |
smtp_settings | Paramètres SMTP pour l'envoi d'emails |
Tables par tenant
Chaque schéma tenant_{slug} contient :
| Table | Description |
|---|---|
sites | Points de vente physiques |
tenant_users | Personnel de l'organisation |
tenant_user_roles | Assignation rôle → utilisateur org (site_id optionnel) |
card_holders | Porteurs de carte (identité, préférences, notification) |
cards | Cartes physiques/virtuelles (solde, statut, QR) |
card_transactions | Ledger immuable de toutes les transactions |
qr_secrets | Clés de signature pour QR codes |
product_categories | Catégories (buvette, reservation_meal, reservation_snack_component) |
products | Catalogue produits (prix, stock, seuil, image) |
menus | Menu du jour (regroupement de produits) |
menu_products | Produits composant un menu |
points_of_sale | Terminaux de vente (fixe/mobile) |
cash_registers | Sessions de caisse (ouvertures/fermetures) |
cash_register_transactions | Mouvements d'espèces dans la caisse |
reservations | Réservations de repas |
reservation_items | Lignes de réservation (multi-produit) |
stock_movements | Traçabilité du stock (mouvements, lots, péremptions) |
purchase_orders | Commandes d'approvisionnement |
tickets | Tickets de support |
ticket_messages | Messages/commentaires sur les tickets |
notifications | Historique des notifications envoyées |
notification_templates | Templates par canal (email, SMS, WhatsApp, webhook) |
scheduled_notifications | Notifications planifiées (cron) |
audit_log | Journal d'audit propre au tenant |
anomalies | Anomalies détectées dans le tenant |
card_holder_links | Relations parent-enfant (écoles) |
holder_documents | Documents associés aux porteurs |
stats_daily | Vue matérialisée des stats quotidiennes |
stats_cache | Cache 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 :
- Insertion dans
public.organizations(récupère l'UUID et le schema_name généré) CREATE SCHEMA tenant_{slug}(viaCREATE SCHEMA IF NOT EXISTS)MigrationManager::run_tenant_migrations(): exécutetenant/0001_initial_tenant_schema.sql(883 lignes, compilé viainclude_str!) qui crée toutes les tables tenant en une seule migration- Seed des 5 rôles système par défaut dans
public.rolesavecorganization_id:admin_n1— accès complet à l'organisationsupervisor— supervision et gestioncashier— encaissement et rechargementseller— vente de produitscard_holder— consultation et recharge mobile
- Assignation des permissions par défaut à chaque rôle via
public.role_permissions - 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}
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.
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èretenant_{slug}avec conversion des traits d'union en underscores- Toute tentative d'injection via slug est bloquée à la validation