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_azienda dell'utente loggato.
  • segrega le cache server (Application[]) e client (Dexie + localStorage) per tenant.
  • propaga il TenantScope.Current su tutte le query, scheduler e job background.

Abilitazione

In appsettings.json (sezione AppSettings):

  • "multiConnectionEnabled": "true" — attiva il routing tenant-aware in MultiTenantHelpers + middleware TenantScope in Startup.cs.
  • "enableCookieAuthentication": "true" — richiesto dal flusso multi-tenant: il cookie k-user v:2 contiene azienda_id + azienda_id_user per 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 in appsettings.ConnectionStrings per il DB dati del tenant (NON la connection string letterale).
  • Aziende.CONNESSIONE_DB_Meta: NOME di una entry in appsettings.ConnectionStrings per 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'idAzienda non ha entrambe le colonne Aziende.Connessione_DB_Dati / CONNESSIONE_DB_Meta valorizzate.
  • riemette cookie con azienda_id aggiornato, preservando azienda_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 e TenantScope.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> e WuicClientSideCrudDB__a<id>.
  • localStorage keys → menu_<userId>__a<id>, wuic_custom_settings_<userId>__a<id>, ecc.
  • orphan cleanup all'init di MetadataProviderService: enumera indexedDB.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_id DEVE coincidere con cookie.azienda_id_user (manomissione → relogin + security log).
  • i superadmin sono esenti (la loro azienda_id cambia legittimamente via switchAzienda).
  • popola TenantScope.Current con (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 __scheduler creato in ogni DB tenant; cookie session per-tenant via switchAzienda HTTP call.
  • 3 | assembly method: TenantScope.Current propagato via AsyncLocal al metodo invocato.
  • 4 | mailing: _mailing_lists / _mail_recipients letti 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 via Aziende.Connessione_DB_Dati. Tenant senza la tabella fisica vengono skippati con esito SKIPPED: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, oppure mario.rossi.t1 / mario.rossi.t2).
  • ogni utenza vive nel utenti del proprio DB tenant, con id_azienda corrispondente.
  • _login_index mappa ciascun username al proprio id_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_index in ordine di id_azienda ascendente; il primo che valida la password vince. L'utente entra sempre sul tenant con id_azienda più 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 + un Tenant2_Meta con connection string MySQL → SqlException al primo accesso al tenant. Una validazione fail-soft (WARN log) viene eseguita all'avvio (MultiTenantHelpers.ValidateTenantConnectionStrings): per ogni cs referenziata da Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta} rileva tramite heuristic (keyword come Port=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 __scheduler creato sui DB tenant ha id_azienda=NULL (non valorizzato): adeguato per il flow webservice (login parte sempre dal primario), ma non risolvibile via _login_index per casi inversi.
  • ogni nuovo tenant richiede una entry in appsettings.ConnectionStrings + redeploy/reload config.
  • job scheduler in tenant senza tabella scheduler vengono 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.