Overview
Multi-tenant
Routing connessioni per-utente: ogni azienda ha le proprie connection string verso DB metadati e dati separati, mantenendo retro-compatibilità totale per le installazioni single-tenant esistenti.
Scopo
Il modulo multi-tenant permette a un singolo backend di servire più aziende — ciascuna su DB metadati e dati distinti — senza duplicare codice o avviare istanze separate. Quando il flag è OFF il comportamento è identico al pre-multi-tenant (zero overhead, cache invariate). Quando il flag è ON il framework:
- risolve a runtime la connection string giusta in base all'
id_aziendadell'utente loggato. - segrega le cache server (
Application[]) e client (Dexie + localStorage) per tenant. - propaga il
TenantScope.Currentsu tutte le query, scheduler e job background.
Abilitazione
In appsettings.json (sezione AppSettings):
"multiConnectionEnabled": "true"— attiva il routing tenant-aware inMultiTenantHelpers+ middlewareTenantScopeinStartup.cs."enableCookieAuthentication": "true"— richiesto dal flusso multi-tenant: il cookiek-userv:2 contieneazienda_id+azienda_id_userper anti-hijack.
Sono disponibili i template stand-alone appsettings.multi-tenant.mssql.json e appsettings.multi-tenant.mysql.json (6 connection string: 2 default + 4 tenant Tenant1_Meta/Tenant1_Data/Tenant2_Meta/Tenant2_Data).
Modello dati
Il mapping id_azienda → (data, meta) vive su due colonne della tabella Aziende del DB metadati primario:
Aziende.Connessione_DB_Dati: NOME di una entry inappsettings.ConnectionStringsper il DB dati del tenant (NON la connection string letterale).Aziende.CONNESSIONE_DB_Meta: NOME di una entry inappsettings.ConnectionStringsper il DB metadati del tenant.
Esempio popolazione:
Aziende.ID_Azienda=1,Connessione_DB_Dati='Tenant1_Data',CONNESSIONE_DB_Meta='Tenant1_Meta'.Aziende.ID_Azienda=100(Sede principale),Connessione_DB_Dati='DataSQLConnection',CONNESSIONE_DB_Meta='MetaDataSQLConnection'(alias dei default → coperto dallo scope 0).
Indirezione via nome entry:
- niente password in chiaro nel DB.
- credenziali portabili per ambiente (stesso DB, diversi
appsettings.<env>.json). - coerenza con
_metadati__tabelle.md_conn_name.
Login fallback
Quando un utente NON esiste sul DB metadati primario il login fa fallback via _login_index:
1. lookup utente su MetaDataSQLConnection (primario) → trovato? login.
2. miss → query _login_index (SHA-256(LOWER(username)) → id_azienda).
3. per ogni id_azienda candidato: push TenantScope, retry metaQuery.login sul DB metadati del tenant.
4. primo match wins → cookie emesso con azienda_id del tenant.
Lo SP sp__login_index_upsert(@username, @idAzienda) popola l'indice. Hash SHA-256 calcolato server-side (allineato col client C# MultiTenantHelpers.ComputeUsernameHash su UTF-8).
Switch azienda (superadmin)
I superadmin possono cambiare l'azienda attiva runtime via MetaService.switchAzienda(idAzienda):
- 401 se non superadmin.
- 400 se l'
idAziendanon ha entrambe le colonneAziende.Connessione_DB_Dati/CONNESSIONE_DB_Metavalorizzate. - riemette cookie con
azienda_idaggiornato, preservandoazienda_id_user(snapshot al login). - componente Angular
<wuic-azienda-switcher>espone il dropdown UI. Si auto-nasconde via@HostBinding('attr.hidden')per utenti non-super o flag OFF.
Cache tenant-scoped
Server-side (Application[] keys):
MultiTenantHelpers.TenantKey(baseKey)aggiunge suffisso__a<id>quando flag ON eTenantScope.Current.AziendaId > 0.- chiavi affette:
userList,roleList,userRoleList,companyList,storedMeta,storedTableMeta,storedTableActionMeta,storedConditionGroupMeta,storedTableStyleMeta,SysInfo.
Client-side (Angular framework lib):
- Dexie name →
MetaDB__a<id>eWuicClientSideCrudDB__a<id>. - localStorage keys →
menu_<userId>__a<id>,wuic_custom_settings_<userId>__a<id>, ecc. - orphan cleanup all'init di
MetadataProviderService: enumeraindexedDB.databases()e droppa DB di tenant non più correnti.
Quando flag OFF: TenantKey ritorna la baseKey invariata → cache identica al pre-multi-tenant.
Anti-hijack
RawHelpers.authenticate() ad ogni request:
- richiede cookie v:2 con
azienda_id+azienda_id_user(cookie legacy → relogin forzato). - per utenti non-super,
cookie.azienda_idDEVE coincidere concookie.azienda_id_user(manomissione → relogin + security log). - i superadmin sono esenti (la loro
azienda_idcambia legittimamente viaswitchAzienda). - popola
TenantScope.Currentcon(azienda_id, isSuperAdmin)per il resto della request.
Il middleware HydrateLegacyPrincipalFromKUserCookie in Startup.cs popola TenantScope anche per endpoint AsmxProxy che non chiamano authenticate() esplicitamente (es. readCustomSettings, getMenuByUserID).
Scheduler tenant-aware
SchedulerHostedService enumera le aziende a ogni ciclo (Scheduler:PollSeconds, default 15s):
- scope 0 (primario / no-tenant) — sempre processato.
- più uno scope per ciascuna azienda con mapping conn ≠ default.
- aziende mappate alle entry default (
MetaDataSQLConnection) sono skippate (coperte dallo scope 0).
Tutti e 4 i tipi action sono supportati per-tenant:
1 | sql: connection dati via routing tenant-aware.2 | webservice: utente__schedulercreato in ogni DB tenant; cookie session per-tenant viaswitchAziendaHTTP call.3 | assembly method:TenantScope.Currentpropagato via AsyncLocal al metodo invocato.4 | mailing:_mailing_lists/_mail_recipientsletti dal DB del tenant corrente; SMTP settings override-able per-tenant via naming convention<key>__a<id>(es.email-host__a1).
Lo schema scheduler (scheduler, scheduler_execution) viene validato per-tenant: tenant senza le tabelle vengono skippati silenziosamente fino al restart.
Propagazione scaffolding cross-tenant
Quando un superadmin scaffolda una tabella o vista su un tenant, di default i metadata (_metadati__tabelle + _metadati__colonne) vengono scritti solo sul DB metadati del tenant corrente. Per evitare di dover ripetere manualmente lo scaffolding su ogni tenant, l'endpoint scaffolding.scaffoldTable / scaffolding.scaffoldView espone il parametro booleano propagateToTenants:
false(default): scaffold solo sul tenant corrente (back-compat).true: dopo aver scaffoldato il tenant corrente, il backend itera su tutti gli altri tenant attivi (MultiTenantHelpers.GetAziendePrimary()) e replica lo scaffolding sul DB metadati di ciascuno. Lo schema viene letto dal DB DATI del tenant target risolto viaAziende.Connessione_DB_Dati. Tenant senza la tabella fisica vengono skippati con esitoSKIPPED:not-found→ operazione best-effort, non transazionale cross-tenant.
Sicurezza:
- gated a superadmin con flag
multiConnectionEnabled=true— per gli altri il parametro viene silenziosamente ignorato. - aziende mappate alle entry default (
MetaDataSQLConnection) sono skippate (coperte dallo scope 0 del tenant corrente se il superadmin è già lì).
Il risultato del scaffold include chiavi tenant_<id>: con valore OK / SKIPPED:<reason> / FAILED:<message> per diagnosi per-tenant.
UI: il dialog di scaffolding (md_id=1556) espone una checkbox Propagate to all tenants sul form; l'action Scaffold Table invia il flag al backend. In single-tenant (multiConnectionEnabled=false) il backend filtra la colonna dalla risposta di getTableMetadata → la checkbox non compare nel form (niente UI inutile da gestire).
Account utente non-superadmin attraverso più tenant
Il selettore <wuic-azienda-switcher> è visibile solo ai superadmin. Per gli utenti non-superadmin il check anti-hijack (cookie.azienda_id != user.azienda_id_user → reject) impedisce di cambiare tenant runtime: il cookie ha azienda_id_user snapshottato al login e immutabile.
Convenzione operativa per scenari in cui la stessa persona deve accedere a più tenant come utente standard:
- creare un'utenza separata per ogni tenant, con username distinto (es.
mario.rossi@T1,mario.rossi@T2, oppuremario.rossi.t1/mario.rossi.t2). - ogni utenza vive nel
utentidel proprio DB tenant, conid_aziendacorrispondente. _login_indexmappa ciascun username al proprioid_azienda: niente ambiguità nel fallback chain.- l'utente sceglie a quale tenant accedere cambiando username al login.
Anti-pattern da evitare:
- stesso username su due tenant: il login fallback prova entrambi i candidati
_login_indexin ordine diid_aziendaascendente; il primo che valida la password vince. L'utente entra sempre sul tenant conid_aziendapiù basso e non può raggiungere l'altro senza widget switcher. La password resta condivisa cross-tenant, esposta a brute-force su entrambi i DB.
Se la persona è un superadmin, basta un'unica utenza sul DB primario (id_azienda = id azienda madre): il widget switcher copre runtime il cambio tenant.
Limiti noti
- DBMS uniforme cross-tenant: tutti i tenant DEVONO condividere lo stesso DBMS del primario (
AppSettings.dbms/meta-dbms). Il routing tenant-aware instrada solo il catalog (quale DB), non quale provider. Es: backend MSSQL + unTenant2_Metacon connection string MySQL →SqlExceptional primo accesso al tenant. Una validazione fail-soft (WARN log) viene eseguita all'avvio (MultiTenantHelpers.ValidateTenantConnectionStrings): per ogni cs referenziata daAziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}rileva tramite heuristic (keyword comePort=3306,Initial Catalog=,(HOST=...), ecc.) il DBMS target e logga warning se differente da quello configurato. Mismatch → boot prosegue ma il tenant disallineato sara' inutilizzabile a runtime. - l'utente
__schedulercreato sui DB tenant haid_azienda=NULL(non valorizzato): adeguato per il flow webservice (login parte sempre dal primario), ma non risolvibile via_login_indexper casi inversi. - ogni nuovo tenant richiede una entry in
appsettings.ConnectionStrings+ redeploy/reload config. - job scheduler in tenant senza tabella
schedulervengono skippati silenziosamente (info-log al primo poll).
AppSettings rilevanti
Snippet (chiavi multi-tenant):
"AppSettings.multiConnectionEnabled": "true""AppSettings.enableCookieAuthentication": "true""ConnectionStrings.MetaDataSQLConnection": "...""ConnectionStrings.DataSQLConnection": "...""ConnectionStrings.Tenant1_Meta": "...""ConnectionStrings.Tenant1_Data": "...""ConnectionStrings.Tenant2_Meta": "...""ConnectionStrings.Tenant2_Data": "..."- (opzionale)
"AppSettings.email-host__a1": "smtp.tenant1.example"— override SMTP per tenant 1.