Multitenance
Résumé. Une application multitenant sert plusieurs clients (les tenants) depuis un seul déploiement, et aucun tenant ne doit jamais lire ni modifier ce qui appartient à un autre. Le kit traite le tenant comme l'identifiant de corrélation : il est résolu une fois, à la frontière, porté par le contexte d'exécution de chaque unité de travail (une requête HTTP, un événement consommé, une exécution de job), et lu par l'infrastructure seule. L'isolation est ensuite imposée là où vivent les données, par l'une de plusieurs stratégies choisie par projet, et prouvée par un contrat qui s'exécute contre chaque adaptateur. Les cas d'usage ne filtrent jamais par tenant à la main : oublier une condition est une fuite, donc cette condition n'est pas à eux de l'écrire.
Ce qu'est un tenant, et ce qu'il n'est pas
Section intitulée « Ce qu'est un tenant, et ce qu'il n'est pas »Un tenant est le client à qui appartiennent les données : une clinique, une entreprise, une école. Ce n'est pas un rôle (ce que quelqu'un a le droit de faire), ni un attribut d'un élément qu'une permission compare (le service d'un document). Le test : si deux tenants étaient deux installations séparées, cette donnée serait-elle dans une seule d'entre elles ? Alors c'est une donnée du tenant.
Certaines données n'appartiennent à aucun tenant : le catalogue de la plateforme, les tenants eux-mêmes, les offres. Elles vivent dans des tables sans colonne de tenant, et le travail qui les lit ne nomme aucun tenant.
Le tenant dans la portée d'exécution
Section intitulée « Le tenant dans la portée d'exécution »| Origine | D'où vient le tenant |
|---|---|
| Requête HTTP | le résolveur de la frontière (sous-domaine, en-tête, segment de chemin ou claim du jeton), vérifié contre les appartenances de l'acteur ; par défaut, le tenant propre de l'acteur |
| Événement d'intégration consommé | le tenantId de l'enveloppe, apposé par le publisher d'après le travail qui l'a publié |
| Job planifié | aucun pour un job global ; chaque tenant du TenantDirectory tour à tour pour un job per-tenant |
| Équipe de la plateforme | aucun qui lui soit propre : elle atteint les tenants par une élévation explicite et auditée |
Un identifiant de tenant a une seule grammaire, isTenantId : lettres, chiffres, _ et -,
63 caractères au plus (un slug ou un UUID). Chaque entrée refuse tout autre : la frontière
répond 404 à une requête HTTP qui en nomme un, un broker traite comme empoisonnée une enveloppe
qui en porte un, executionContext, InMemoryTenantDirectory et les réglages de la sécurité au
niveau des lignes lèvent InvalidTenantIdError, et acrossTenants refuse une élévation qui en
nomme un (InvalidTenantElevationError). Rien de ce que contient un identifiant de tenant ne
peut alors se lire comme un joker (*), une liste (a,b) ou un morceau de clé (a:b).
tenantScopeOf(scope) répond le tenant unique que nomme une écriture et refuse le travail qui
n'en nomme aucun (NoCurrentTenantError) : une instruction ne peut pas s'exécuter sans portée
par oubli. tenantReachOf(scope) est le côté lecture : le tenant du travail, ou les tenants
d'une élévation. Avec le double de test de la portée, ce que chacun répond :
import { executionContext, FixedExecutionScope, NoCurrentTenantError, systemActor, tenantKey, tenantReachOf, tenantScopeOf,} from '@ddd-ts-kit/kernel';
const inAcme = new FixedExecutionScope( executionContext({ correlationId: 'req-42', actor: systemActor('demo'), origin: 'cli', tenantId: 'acme' }),);
console.log(tenantScopeOf(inAcme)); // { tenantId: 'acme' } : le tenant unique que nomme une écritureconsole.log(tenantReachOf(inAcme)); // { kind: 'tenant', tenantId: 'acme' } : ce qu'une lecture peut voirconsole.log(tenantKey(inAcme, 'reminders', 'a:b')); // 'tenant:acme:reminders:a%3Ab'
const inNoTenant = new FixedExecutionScope( executionContext({ correlationId: 'req-43', actor: systemActor('demo'), origin: 'cli' }),);
try { tenantScopeOf(inNoTenant);} catch (error) { console.log(error instanceof NoCurrentTenantError); // true : pas de tenant, pas d'instruction}L'isolation : quatre stratégies
Section intitulée « L'isolation : quatre stratégies »Le tenant atteint le stockage par la portée d'exécution ; la stratégie décide ce qui empêche une instruction de passer chez un autre tenant.
| Stratégie | Ce qui isole | La choisir quand | Ce qu'elle coûte |
|---|---|---|---|
| Tables partagées | une colonne tenant_id, et une condition que le kit ajoute à chaque instruction |
beaucoup de petits tenants, un seul schéma à migrer, des rapports qui traversent les tenants | une condition oubliée est une fuite : ce sont les helpers et une règle du checker qui l'empêchent |
| Tables partagées sous sécurité au niveau des lignes (row-level security) | la même chose, et une politique PostgreSQL qui refuse la ligne d'un autre tenant même à une instruction sans sa condition | le cas des tables partagées, avec une seconde ligne de défense | un rôle qui n'est pas superutilisateur, chaque instruction dans une transaction |
| Un schéma par tenant | une copie des tables d'un contexte par tenant (<schema>__<tenant>) |
des tenants qui veulent leurs données à part, exporter ou supprimer un tenant d'un coup, quelques centaines de tenants | les migrations s'exécutent une fois par tenant ; des milliers de schémas surchargent le catalogue |
| Une base par tenant | une base de données par tenant, un pool borné pour chacune | une séparation stricte (contrats, régions), de gros tenants, sauvegarde et restauration par tenant | les connexions (maxTenants * pool.max par instance), une exécution de migration par tenant |
Ce que fournit chaque adaptateur, prouvé par tenantIsolationContract
(@ddd-ts-kit/testing/contracts) sur PostgreSQL :
| Stratégie | Knex | Drizzle | Prisma | TypeORM |
|---|---|---|---|---|
| Tables partagées | tenantTable, ownTenantTable, whereTenant, derivedTable ; modèles de lecture avec tenant ; addTenantColumn pour migrer |
tenantCondition, ownTenantCondition ; modèles de lecture avec tenant |
à la main : tenantScopeOf et tenantReachOf dans chaque instruction, aucun helper, aucune exécution du contrat |
à la main, comme Prisma |
| Sécurité au niveau des lignes | enableTenantIsolation, tenantSession |
tenantPolicies, forceRowLevelSecurity, tenantSession |
non fourni | non fourni |
| Un schéma par tenant | tenantSchemaTable, migrateTenantSchema, tenantSearchPath |
non fourni : une table drizzle est liée à son schéma là où elle est déclarée | non fourni | non fourni |
| Une base par tenant | TenantDatabases, createTenantRoutedUnitOfWork, migrateEveryTenant |
non fourni | non fourni | non fourni |
init --tenancy shared-table génère la première stratégie sur Knex et Drizzle : chaque
repository passe par les helpers, l'outbox garde le tenant, et la règle
tenancy/scoped-statement du checker signale une instruction qui atteint une table directement
depuis l'exécuteur. Les trois autres se câblent à la main à partir des packages pour l'instant ;
init les refuse et dit quoi ajouter.
Un repository sur tables partagées, version simplifiée de ce qu'écrit le générateur sur Knex (les
colonnes d'audit et le Clock qu'elles demandent sont omis) : chaque instruction
passe par un helper qui lit le tenant dans la portée, lit par tenantTable, écrit par
ownTenantTable, et une insertion nomme son tenant avec tenantRow :
import { AggregateRoot, tenantScopeOf, type ExecutionScope } from '@ddd-ts-kit/kernel';import { insertIgnoringConflict, optimisticUpdate, ownTenantTable, tenantRow, tenantTable, type KnexContext, type TenantRow,} from '@ddd-ts-kit/knex';
interface AppointmentColumns { readonly id: string; readonly practitioner_id: string; readonly status: 'booked' | 'cancelled';}type VersionedAppointment = AppointmentColumns & { readonly version: number };type AppointmentRow = VersionedAppointment & TenantRow;
// L'agrégat et son mapper, dans domain/ et infrastructure/persistence/ :declare class Appointment extends AggregateRoot<string> { private constructor();}declare const AppointmentMapper: { toDomain(row: AppointmentRow): Appointment; toRow(appointment: Appointment): AppointmentColumns;};
const TABLE = { schema: 'appointments', table: 'appointments' };
export class KnexAppointmentRepository { constructor( private readonly database: KnexContext, private readonly scope: ExecutionScope, ) {}
async findById(id: string): Promise<Appointment | undefined> { // Au lieu de : this.database.executor()<AppointmentRow, AppointmentRow[]>('appointments').where({ id }), // qui lit les lignes de tous les tenants (tenancy/scoped-statement). Écrire : const row = await tenantTable<AppointmentRow>(this.database, this.scope, TABLE).where({ id }).first();
return row === undefined ? undefined : AppointmentMapper.toDomain(row); }
async save(appointment: Appointment): Promise<void> { await optimisticUpdate(appointment, async ({ expected, next }) => { const state = { ...AppointmentMapper.toRow(appointment), version: next };
return expected === 0 ? this.insert(state) : this.update(state, expected); }); }
private async insert(state: VersionedAppointment): Promise<number> { const row = { ...state, ...tenantRow(tenantScopeOf(this.scope)) }; // { tenant_id: 'acme' } const table = ownTenantTable<AppointmentRow>(this.database, this.scope, TABLE); const inserted = await insertIgnoringConflict(this.database.executor(), table, { row, conflict: 'id' });
return inserted ? 1 : 0; }
private async update(state: VersionedAppointment, expected: number): Promise<number> { // Le tenant unique du travail, même sous une élévation : les lectures peuvent s'élargir, les écritures jamais. return ownTenantTable<AppointmentRow>(this.database, this.scope, TABLE) .where({ id: state.id, version: expected }) .update(state); }}Un projet d'un seul client qui devient multitenant garde ses données : sur Knex, une migration
appelle addTenantColumn(knex, { schema, table, tenantOfExistingRows }) pour chaque table de
données de tenant (les lignes déjà présentes vont au client qu'elle servait, puis la colonne
devient obligatoire et indexée) et addOutboxTenantColumn(knex, { schema, tenantOfUnpublished })
pour l'outbox (les événements pas encore publiés vont aussi à ce client, pour que leurs
consommateurs s'exécutent dans son tenant). Une table qui a déjà la colonne est laissée telle
quelle, si bien que la migration s'exécute aussi sur une base construite depuis le schéma actuel.
L'application d'exemple a suivi ce chemin (son ADR-0003) : un réseau de cabinets est un tenant.
L'outbox garde le tenant de chaque événement sur Knex (tenant: true) et Drizzle
(tenantOutboxTable) ; les outbox de Prisma et de TypeORM ne le stockent pas encore, donc leurs
consommateurs s'exécutent sans tenant.
Trois règles tiennent quelle que soit la stratégie :
- Les lectures peuvent s'élargir, les écritures jamais. Une élévation élargit ce qu'une
lecture atteint ; une mise à jour ou une suppression nomme toujours le tenant unique du
travail (
ownTenantTable,ownTenantCondition), si bien qu'une session de l'équipe qui lit à travers les tenants ne peut pas écrire à travers eux. Sous sécurité au niveau des lignes, la base le tient aussi : une politique par commande SQL, la sélection lisant l'étendue (reach), l'insertion, la mise à jour et la suppression seulement le tenant (ddd_ts_kit.tenant_id), et l'étendue d'une élévation gardée dans des réglages à part, que le travail non élevé laisse vides. - Pas de tenant, pas de données. Le travail qui ne nomme aucun tenant est refusé avant tout
SQL (
NoCurrentTenantError) sur tables partagées et schémas, et n'atteint aucune base du tout avec une base par tenant. Sous sécurité au niveau des lignes, une instruction sans les réglages de tenant ne voit rien. - Les clés sont uniques entre les tenants. Les identifiants sont générés (des UUID), de sorte qu'une clé ne nomme jamais deux lignes ; le contrat d'isolation vérifie qu'un autre tenant qui enregistre sous un identifiant existant ne change rien.
La propagation : chaque saut garde le tenant
Section intitulée « La propagation : chaque saut garde le tenant »- Événements d'intégration.
scopedPublisherappose le tenant du travail sur chaque événement publié sans tenant ; l'outbox le garde (tenant_id, voir les packages de persistance) ; les brokers le portent dans l'enveloppe et dans un en-tête (x-tenant-id,tenant-idpour Redis Streams) ;executionContextOfEventouvre la portée du consommateur dans ce tenant. Un consommateur lit et écrit donc le tenant du fait qu'il traite, et rien d'autre. - Jobs planifiés. Un job déclare
tenancy: 'global'(par défaut) ou'per-tenant'. Un job par tenant s'exécute une fois par tenant, l'un après l'autre, chaque exécution dans la portée de ce tenant ; l'échec d'un tenant n'arrête pas les autres. - Caches, limites de débit et verrous. Une clé d'un stockage partagé est confinée au
tenant :
tenantCacheKey(scope, ...),tenantKey(scope, ...), chaque segment encodé en pourcentage pour que deux listes de segments ne donnent jamais la même clé. Une clé construite sans eux est partagée par tous les tenants, ce qui ne convient qu'aux données de la plateforme. - Journaux, traces, remontées d'erreurs et journal d'audit.
logFieldsOfécrittenantIdsur chaque ligne ; le journal d'audit garde une chaîne de hachage par tenant (tenant:<id>), pour que l'historique d'un tenant puisse être remis et vérifié seul. Sa recherche est confinée au tenant du travail parTenantAuditReader, queinit --tenancy shared-tablecâble dans le côté lecture du journal : un acteur qui ne nomme aucun tenant ne lit rien, et l'équipe de la plateforme ne lit le journal d'un autre tenant qu'à l'intérieur deacrossTenants. Un refus à la frontière d'un appelant sans justificatif d'identité n'est pas enregistré, pour qu'aucune requête HTTP non authentifiée n'ouvre une chaîne.
La résolution à la frontière
Section intitulée « La résolution à la frontière »Le pipeline HTTP nomme le tenant d'une requête avec un résolveur de @ddd-ts-kit/http : le
sous-domaine (acme.app.example.com), un en-tête (x-tenant-id), un segment de chemin
(/tenants/:tenantId/...) ou le claim du jeton, ou le premier de plusieurs. Quoi qu'il le
nomme, le pipeline le vérifie contre les appartenances de l'appelant avant que quoi que ce soit
ne s'exécute, et répond 403 à un appelant qui n'en fait pas partie : le tenant que demande un
client est une demande, jamais une autorité. Un appelant authentifié est tenu à ses tenants
sur les routes publiques aussi ; un appelant anonyme y garde le tenant auquel il s'est adressé. La garde TenantMember réserve un groupe de routes aux membres du tenant
de la requête.
Lequel choisir : le claim seul quand un utilisateur appartient à un seul tenant ; un
sous-domaine quand les tenants ont leur propre adresse (et leurs propres cookies) ; un segment
de chemin ou un en-tête quand un utilisateur travaille dans plusieurs tenants et en choisit un
par requête HTTP, le jeton listant alors chaque appartenance (un claim de tenant qui contient
une liste, ou le claim organization de Keycloak, donne à l'acteur ses tenants).
Un tenant que le déploiement ne sert pas, que son annuaire des tenants (les tenants pour
lesquels ses jobs s'exécutent) ne liste pas, a intérêt à n'être pas trouvé du tout :
servedTenantOf(resolver, served) répond 404 à quiconque le nomme, pour qu'un justificatif émis
pour un tenant dont le déploiement ne sait rien n'écrive jamais de données qu'aucun job ni
aucun opérateur ne verrait. L'application d'exemple sert ainsi les réseaux de NETWORKS.
L'équipe de la plateforme : l'élévation, jamais implicite
Section intitulée « L'équipe de la plateforme : l'élévation, jamais implicite »Le support et l'exploitation ont parfois besoin de lire à travers les tenants. Le kit refuse de le déduire d'un rôle : un rôle qui élargirait en silence chaque requête ferait d'une session volée une brèche dans chaque tenant. À la place :
- Un cas d'usage ou une garde décide de la permission d'élever, comme de toute permission.
- La plateforme appelle
acrossTenants({ scope, trail, clock, ids }, { reason, tenants }, work): un code de raison stable et les tenants atteints (allou une liste) sont obligatoires. - L'élévation est écrite dans le journal d'audit (action
tenancy:elevate) avant que le travail ne s'exécute ; un journal qui ne peut pas l'enregistrer la refuse. - À l'intérieur, les lectures voient les tenants nommés ; les écritures nomment toujours un seul tenant.
import { acrossTenants, type AuditTrail, type Clock, type ExecutionScope, type IdGenerator } from '@ddd-ts-kit/kernel';
// Résolus depuis le conteneur par la plateforme :declare const scope: ExecutionScope;declare const trail: AuditTrail;declare const clock: Clock;declare const ids: IdGenerator;// Une lecture par tenantTable, qui suit l'étendue de la portée d'exécution :declare function searchAppointments(practitionerId: string): Promise<readonly { readonly id: string }[]>;
export async function appointmentsForATicket(practitionerId: string, tenants: readonly string[]) { // La permission d'élever a été décidée avant cet appel, comme toute permission. return acrossTenants( { scope, trail, clock, ids }, { reason: 'SUPPORT_TICKET', tenants }, // un code stable, et les tenants atteints ('all' ou une liste) () => searchAppointments(practitionerId), // enregistré comme tenancy:elevate avant de s'exécuter );}Limites et quand ne pas l'utiliser
Section intitulée « Limites et quand ne pas l'utiliser »- Un tenant par unité de travail. Une requête HTTP qui doit écrire dans deux tenants fait
deux unités de travail, à dessein : une transaction ne couvre jamais plusieurs tenants. Avec
une base par tenant, le travail d'un autre tenant lancé à l'intérieur d'une unité de
travail ouverte est refusé (
CrossTenantUnitOfWorkError) ; sur tables partagées et schémas, il rejoindrait la transaction du premier tenant, donc ouvrez une unité de travail par tenant (une élévation ne fait que lire, et n'en a pas besoin). - Le tenant appartient à l'infrastructure. Une règle métier qui dépend du tenant (une offre, un réglage) le reçoit comme donnée dans la commande, comme l'acteur ; elle ne lit pas la portée.
- Un produit à client unique n'a besoin de rien de tout cela. Les stratégies coûtent une colonne, une politique, un schéma ou une base par tenant, et la discipline de prouver l'isolation.
- Microsoft, « Tenancy models for SaaS applications » (documentation d'Azure SQL Database) : tables partagées, schéma et base par tenant, et leurs compromis.
- Documentation de PostgreSQL, « Row Security Policies » (section 5.9 du manuel).
- AWS SaaS Lens, « Tenant isolation » (Well-Architected Framework).