Overview
Multi-tenant
Per-user connection routing: each tenant has its own connection strings to separate metadata and data DBs, while preserving full backward compatibility for existing single-tenant installations.
Scope
The multi-tenant module allows a single backend to serve multiple tenants — each on distinct metadata and data DBs — without duplicating code or starting separate instances. When the flag is OFF behavior is identical to pre-multi-tenant (zero overhead, caches unchanged). When ON the framework:
- resolves the right connection string at runtime based on the logged-in user's
id_azienda. - segregates server caches (
Application[]) and client caches (Dexie + localStorage) per tenant. - propagates
TenantScope.Currentacross all queries, scheduler and background jobs.
Enablement
In appsettings.json (AppSettings section):
"multiConnectionEnabled": "true"— activates tenant-aware routing inMultiTenantHelpers+TenantScopemiddleware inStartup.cs."enableCookieAuthentication": "true"— required by the multi-tenant flow: thek-userv:2 cookie carriesazienda_id+azienda_id_userfor anti-hijack.
Stand-alone templates appsettings.multi-tenant.mssql.json and appsettings.multi-tenant.mysql.json are available (6 connection strings: 2 default + 4 tenant Tenant1_Meta/Tenant1_Data/Tenant2_Meta/Tenant2_Data).
Data model
The id_azienda → (data, meta) mapping lives on two columns of the Aziende table on the primary metadata DB:
Aziende.Connessione_DB_Dati: NAME of an entry inappsettings.ConnectionStringsfor the tenant data DB (NOT the literal connection string).Aziende.CONNESSIONE_DB_Meta: NAME of an entry inappsettings.ConnectionStringsfor the tenant metadata DB.
Example population:
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'(default aliases → covered by scope 0).
Entry-name indirection:
- no clear-text passwords in the DB.
- credentials portable per environment (same DB, different
appsettings.<env>.json). - consistent with
_metadati__tabelle.md_conn_name.
Login fallback
When a user does NOT exist on the primary metadata DB, login falls back via _login_index:
1. user lookup on MetaDataSQLConnection (primary) → found? login.
2. miss → query _login_index (SHA-256(LOWER(username)) → id_azienda).
3. for each candidate id_azienda: push TenantScope, retry metaQuery.login on the tenant metadata DB.
4. first match wins → cookie issued with the tenant's azienda_id.
The sp__login_index_upsert(@username, @idAzienda) stored procedure populates the index. SHA-256 hash computed server-side (aligned with C# client MultiTenantHelpers.ComputeUsernameHash on UTF-8).
Tenant switch (superadmin)
Superadmins can change the active tenant at runtime via MetaService.switchAzienda(idAzienda):
- 401 if not superadmin.
- 400 if
idAziendadoesn't have bothAziende.Connessione_DB_Dati/CONNESSIONE_DB_Metacolumns populated. - re-emits cookie with updated
azienda_id, preservingazienda_id_user(snapshot at login). - Angular component
<wuic-azienda-switcher>exposes the dropdown UI. Self-hides via@HostBinding('attr.hidden')for non-super users or flag OFF.
Tenant-scoped caches
Server-side (Application[] keys):
MultiTenantHelpers.TenantKey(baseKey)appends__a<id>suffix when flag ON andTenantScope.Current.AziendaId > 0.- affected keys:
userList,roleList,userRoleList,companyList,storedMeta,storedTableMeta,storedTableActionMeta,storedConditionGroupMeta,storedTableStyleMeta,SysInfo.
Client-side (Angular framework lib):
- Dexie name →
MetaDB__a<id>andWuicClientSideCrudDB__a<id>. - localStorage keys →
menu_<userId>__a<id>,wuic_custom_settings_<userId>__a<id>, etc. - orphan cleanup at
MetadataProviderServiceinit: enumeratesindexedDB.databases()and drops DBs of tenants no longer current.
When flag OFF: TenantKey returns the baseKey unchanged → caches identical to pre-multi-tenant.
Anti-hijack
RawHelpers.authenticate() on every request:
- requires cookie v:2 with
azienda_id+azienda_id_user(legacy cookie → forced relogin). - for non-super users,
cookie.azienda_idMUST matchcookie.azienda_id_user(tampering → relogin + security log). - superadmins are exempt (their
azienda_idlegitimately changes viaswitchAzienda). - populates
TenantScope.Currentwith(azienda_id, isSuperAdmin)for the rest of the request.
The HydrateLegacyPrincipalFromKUserCookie middleware in Startup.cs also populates TenantScope for AsmxProxy endpoints that don't call authenticate() explicitly (e.g. readCustomSettings, getMenuByUserID).
Tenant-aware scheduler
SchedulerHostedService enumerates tenants on every cycle (Scheduler:PollSeconds, default 15s):
- scope 0 (primary / no-tenant) — always processed.
- plus one scope per tenant with conn mapping ≠ default.
- tenants mapped to default entries (
MetaDataSQLConnection) are skipped (covered by scope 0).
All 4 action types are supported per-tenant:
1 | sql: data connection via tenant-aware routing.2 | webservice:__scheduleruser created in each tenant DB; per-tenant session cookie viaswitchAziendaHTTP call.3 | assembly method:TenantScope.Currentpropagated via AsyncLocal to the invoked method.4 | mailing:_mailing_lists/_mail_recipientsread from the current tenant DB; SMTP settings overridable per-tenant via naming convention<key>__a<id>(e.g.email-host__a1).
The scheduler schema (scheduler, scheduler_execution) is validated per-tenant: tenants missing the tables are silently skipped until restart.
Cross-tenant scaffolding propagation
When a superadmin scaffolds a table or view on one tenant, by default the metadata (_metadati__tabelle + _metadati__colonne) is written only to the current tenant's metadata DB. To avoid manually repeating the scaffolding on every tenant, the scaffolding.scaffoldTable / scaffolding.scaffoldView endpoint exposes the boolean parameter propagateToTenants:
false(default): scaffold only on the current tenant (back-compat).true: after scaffolding the current tenant, the backend iterates over all other active tenants (MultiTenantHelpers.GetAziendePrimary()) and replicates the scaffolding on each one's metadata DB. The schema is read from the target tenant's DATA DB resolved viaAziende.Connessione_DB_Dati. Tenants without the physical table are skipped with outcomeSKIPPED:not-found→ best-effort operation, not cross-tenant transactional.
Security:
- gated to superadmin with
multiConnectionEnabled=trueflag — for others the parameter is silently ignored. - companies mapped to default entries (
MetaDataSQLConnection) are skipped (covered by scope 0 of the current tenant if the superadmin is already there).
The scaffold result includes tenant_<id>: keys with value OK / SKIPPED:<reason> / FAILED:<message> for per-tenant diagnosis.
UI: the scaffolding dialog (md_id=1556) exposes a Propagate to all tenants checkbox on the form; the Scaffold Table action sends the flag to the backend. In single-tenant mode (multiConnectionEnabled=false) the backend filters the column out of the getTableMetadata response → the checkbox does not appear in the form (no useless UI to handle).
Non-superadmin user accounts across multiple tenants
The <wuic-azienda-switcher> selector is visible only to superadmins. For non-superadmin users the anti-hijack check (cookie.azienda_id != user.azienda_id_user → reject) prevents changing tenant at runtime: the cookie has azienda_id_user snapshotted at login and immutable.
Operational convention for scenarios where the same person needs to access multiple tenants as a standard user:
- create a separate user per tenant, with a distinct username (e.g.
mario.rossi@T1,mario.rossi@T2, ormario.rossi.t1/mario.rossi.t2). - each user lives in
utentiof its own tenant DB, with matchingid_azienda. _login_indexmaps each username to itsid_azienda: no ambiguity in the fallback chain.- the user picks which tenant to access by changing username at login.
Anti-pattern to avoid:
- same username on two tenants: the fallback login tries both
_login_indexcandidates in ascendingid_aziendaorder; the first one that validates the password wins. The user always lands on the tenant with the lowestid_aziendaand cannot reach the other without the switcher widget. The password remains shared cross-tenant, exposed to brute-force on both DBs.
If the person is a superadmin, a single user on the primary DB (id_azienda = parent company id) is enough: the switcher widget covers runtime tenant changes.
Known limits
- Uniform DBMS cross-tenant: all tenants MUST share the same DBMS as the primary (
AppSettings.dbms/meta-dbms). The tenant-aware routing only routes the catalog (which DB), not which provider. Example: MSSQL backend + aTenant2_Metawith a MySQL connection string →SqlExceptionon first tenant access. A fail-soft validation (WARN log) runs at startup (MultiTenantHelpers.ValidateTenantConnectionStrings): for each cs referenced byAziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}it heuristically detects the target DBMS (keywords likePort=3306,Initial Catalog=,(HOST=...), etc.) and logs a warning if it differs from the configured one. Mismatch → boot proceeds but the mismatched tenant is unusable at runtime. - the
__scheduleruser created on tenant DBs hasid_azienda=NULL(not populated): adequate for the webservice flow (login always starts on primary) but not resolvable via_login_indexfor reverse cases. - each new tenant requires an entry in
appsettings.ConnectionStrings+ redeploy/reload config. - scheduler jobs in tenants without the
schedulertable are silently skipped (info-log at first poll).
Relevant AppSettings
Snippet (multi-tenant keys):
"AppSettings.multiConnectionEnabled": "true""AppSettings.enableCookieAuthentication": "true""ConnectionStrings.MetaDataSQLConnection": "...""ConnectionStrings.DataSQLConnection": "...""ConnectionStrings.Tenant1_Meta": "...""ConnectionStrings.Tenant1_Data": "...""ConnectionStrings.Tenant2_Meta": "...""ConnectionStrings.Tenant2_Data": "..."- (optional)
"AppSettings.email-host__a1": "smtp.tenant1.example"— SMTP override for tenant 1.