emilsoftware-utilities 2.0.0-dev.79 → 2.0.0-dev.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/accessi/server/index.d.ts +1 -0
  2. package/dist/accessi/server/index.js +1 -0
  3. package/dist/accessi/server/index.js.map +1 -1
  4. package/dist/accessi/server/tenants.d.ts +97 -0
  5. package/dist/accessi/server/tenants.js +275 -0
  6. package/dist/accessi/server/tenants.js.map +1 -0
  7. package/dist/accessi-module/AccessiModule.d.ts +5 -1
  8. package/dist/accessi-module/AccessiModule.js +3 -1
  9. package/dist/accessi-module/AccessiModule.js.map +1 -1
  10. package/dist/accessi-module/Console/console.js +3 -1
  11. package/dist/accessi-module/Console/wiki.js +2 -0
  12. package/dist/accessi-module/Console/wiki.js.map +1 -1
  13. package/dist/accessi-module/database-updates/accessiOptionsFromEnv.d.ts +7 -3
  14. package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js +32 -28
  15. package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js.map +1 -1
  16. package/dist/accessi-module/observability/metrics.js +6 -2
  17. package/dist/accessi-module/observability/metrics.js.map +1 -1
  18. package/dist/accessi-module/security/audit.d.ts +2 -0
  19. package/dist/accessi-module/security/audit.js +3 -1
  20. package/dist/accessi-module/security/audit.js.map +1 -1
  21. package/dist/emilsoftware-utilities-2.0.0-dev.80.tgz +0 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.js +9 -0
  24. package/dist/index.js.map +1 -1
  25. package/docs/adr/0002-multi-tenancy.md +154 -0
  26. package/docs/import-migration.md +65 -0
  27. package/docs/multi-tenancy.md +305 -0
  28. package/package.json +4 -3
  29. package/dist/emilsoftware-utilities-2.0.0-dev.79.tgz +0 -0
@@ -0,0 +1,154 @@
1
+ # ADR 0002 - Multi-tenancy con database separati
2
+
3
+ - **Stato**: accettato
4
+ - **Data**: 2026-10-30
5
+ - **Contesto**: issue #10
6
+
7
+ ## Contesto
8
+
9
+ Accessi nasce single-tenant: un database Firebird, un issuer JWT, un set di
10
+ store e una console. Due tenant richiedono isolamento esplicito di dati, chiavi
11
+ e osservabilità. Il modello dati non ha una colonna tenant e tutte le query
12
+ assumono un solo insieme di utenti e menu.
13
+
14
+ Vincoli:
15
+
16
+ - Firebird/InterBase: non esistono schema o `search_path` per tenant; un
17
+ database separato è l'unità di isolamento naturale.
18
+ - Le migrazioni e le seed sono per database.
19
+ - `AccessiModule.forRoot` registra una configurazione globale nel contenitore
20
+ Nest: due tenant nello stesso processo condividerebbero DI, audit e logger.
21
+ - L'isolamento richiesto dall'issue è "nessun dato o token di un tenant visibile
22
+ a un altro".
23
+
24
+ ## Decisione
25
+
26
+ **Un database per tenant, nessuna colonna `TENANT`, nessuna modifica allo
27
+ schema.** Un tenant è una cartella di configurazione:
28
+
29
+ ```
30
+ tenants/
31
+ acme.env
32
+ beta.env
33
+ ```
34
+
35
+ Ogni file contiene le normali variabili `ACCESSI_*` già lette da
36
+ `createAccessiOptionsFromEnv`, più un manifest minimo:
37
+
38
+ ```dotenv
39
+ ACCESSI_TENANT_HOSTS=accessi.acme.example.com,*.acme.example.com
40
+ ACCESSI_TENANT_PATH=/acme
41
+ ACCESSI_TENANT_PORT=3201
42
+ ACCESSI_TENANT_ENABLED=true
43
+
44
+ ACCESSI_DB_HOST=db-acme
45
+ ACCESSI_DB_DATABASE=/data/acme.fdb
46
+ ACCESSI_JWT_ISSUER=https://accessi.acme.example.com
47
+ ACCESSI_JWT_AUDIENCE=accessi-api
48
+ ACCESSI_JWT_SECRET=...
49
+ ACCESSI_ENCRYPTION_KEY=...
50
+ ```
51
+
52
+ `loadAccessiTenants({ directory })` valida l'intera cartella prima di avviare
53
+ qualsiasi cosa:
54
+
55
+ - `ACCESSI_TENANT_ID` (o nome file) univoco, minuscole/numeri/`-`/`_`;
56
+ - almeno host o path; host e path non duplicati;
57
+ - chiave JWT distinta per tenant (un token non deve valere su due tenant);
58
+ - issuer/audience condivisi producono un warning;
59
+ - un tenant con path `/` e senza host non può convivere con altri;
60
+ - errori aggregati con nome file, senza stampare segreti.
61
+
62
+ La risoluzione runtime è **host → path → header**:
63
+
64
+ 1. host esatto o wildcard `*.example.com`;
65
+ 2. prefisso path più lungo;
66
+ 3. header `x-accessi-tenant`, **solo** se `allowHeader: true` e quindi solo
67
+ dietro un proxy/API gateway che lo sovrascrive.
68
+
69
+ Nessun fallback implicito: una richiesta senza tenant valido viene rifiutata
70
+ dal router, non servita dal "primo" tenant.
71
+
72
+ ### Processo per tenant
73
+
74
+ Ogni tenant gira in un **processo Accessi separato** con il proprio environment,
75
+ database, pool, chiavi, store Redis e logger. Il supervisore
76
+ `node scripts/accessi-tenants.cjs --serve` avvia un figlio per ogni file `.env`
77
+ abilitato e passa `PORT`, `ACCESSI_TENANT_ID` e le variabili del file.
78
+
79
+ Questo evita di condividere:
80
+
81
+ - `AccessiModule.forRoot` e la DI Nest;
82
+ - il registry globale di audit e metriche;
83
+ - il pool Firebird e la cache delle migrazioni;
84
+ - la chiave JWT e l'issuer.
85
+
86
+ ### Store condivisi
87
+
88
+ Se più tenant condividono lo stesso Redis, ogni store deve usare un prefisso
89
+ diverso:
90
+
91
+ ```ts
92
+ const prefix = getAccessiTenantRedisPrefix('acme'); // accessi:acme:
93
+ createRedisRateLimitStore({ client, keyPrefix: `${prefix}rate:` });
94
+ createRedisSessionRevocationStore({ client, keyPrefix: `${prefix}session:` });
95
+ ```
96
+
97
+ La configurazione del tenant espone `options.tenantId`, utile per comporre i
98
+ prefissi e per le etichette di metriche e audit.
99
+
100
+ ### Osservabilità
101
+
102
+ - Metriche: `countAccessiMetric` e `observeAccessiMetric` aggiungono
103
+ automaticamente il label `tenant` quando `options.tenantId` è impostato.
104
+ - Audit: `configureAccessiAudit` aggiunge `tenant` a ogni record quando il
105
+ modulo è avviato con `tenantId`.
106
+ - Log: il processo figlio ha il proprio `ACCESSI_TENANT_ID`; includerlo nel
107
+ formato del logger dell'host.
108
+ - Health: ogni processo espone `/health/live`, `/health/ready` e
109
+ `/health/metrics` sulla propria porta interna.
110
+
111
+ ## Alternative scartate
112
+
113
+ | Alternativa | Perché no |
114
+ | --- | --- |
115
+ | Colonna `TENANT` su tutte le tabelle | Migrazione invasiva, rischio di dimenticare un filtro, chiavi primarie e vincoli da rifare. Non necessaria con DB separati. |
116
+ | Schema per tenant | Firebird non offre schema/search_path; sarebbe un database separato con altro nome. |
117
+ | Due `AccessiModule` nello stesso processo | La configurazione Nest è globale; audit/metriche/logger condividono stato. |
118
+ | Selezione tenant da header non fidato | Permetterebbe a un client di cambiare tenant e tentare leakage; consentito solo dietro proxy che sovrascrive l'header. |
119
+ | Hot reload dei file `.env` | Nest non smonta moduli a caldo; il riavvio del supervisore è più semplice e sicuro. |
120
+
121
+ ## Conseguenze
122
+
123
+ Positive:
124
+
125
+ - isolamento forte: database, chiavi e processi separati;
126
+ - nessuna migrazione dello schema esistente;
127
+ - aggiunta tenant = nuovo file `.env` + riavvio del supervisore;
128
+ - rollback per tenant indipendente.
129
+
130
+ Negative:
131
+
132
+ - più processi e pool Firebird da dimensionare;
133
+ - migrazioni/seed da eseguire per ogni database;
134
+ - backup, monitoring e rotazione chiavi per tenant;
135
+ - l'header di risoluzione richiede disciplina sul proxy;
136
+ - il supervisore deve essere gestito da systemd/docker/Kubernetes.
137
+
138
+ ## Piano
139
+
140
+ 1. **Fase 1 (questa)**: loader cartella, validazione isolamento, CLI
141
+ `accessi:tenants` (`--check`, `--list`, `--resolve`, `--serve`), documentazione
142
+ e test.
143
+ 2. **Fase 2**: template systemd/docker e reverse proxy host/path; health check
144
+ per tenant nel supervisore.
145
+ 3. **Fase 3**: migrazione del deployment single-tenant esistente: creare
146
+ `tenants/<id>.env` dal `.env` attuale, avviare in parallelo, spostare il DNS,
147
+ spegnere il vecchio servizio.
148
+ 4. **Fase 4 (opzionale)**: router in-process con `createAccessiTenantRouter`
149
+ solo se il costo dei processi diventa un problema e dopo test di leakage
150
+ dedicati; richiede audit/metriche per istanza, non globali.
151
+
152
+ ## Migrazione
153
+
154
+ Vedi `docs/multi-tenancy.md` per la procedura operativa completa.
@@ -0,0 +1,65 @@
1
+ # Migrazione agli entry point Accessi
2
+
3
+ Il root barrel (`emilsoftware-utilities`) e i deep import
4
+ (`emilsoftware-utilities/dist/accessi-module/...`) restano supportati **fino
5
+ alla prossima major**. Da questa minor sono considerati legacy: i consumer
6
+ devono passare agli entry point dichiarati in `package.json#exports`.
7
+
8
+ ## Mappa consigliata
9
+
10
+ | Uso | Import consigliato | Legacy |
11
+ | --- | --- | --- |
12
+ | Avvio Accessi Service | `emilsoftware-utilities/accessi/server` | root barrel, `dist/accessi-module` |
13
+ | Contratti e helper browser-safe | `emilsoftware-utilities/accessi/contracts` | `dist/accessi-module/Dtos` |
14
+ | DTO Nest con validazione | `emilsoftware-utilities/accessi/contracts/dtos` | `dist/accessi-module/Dtos` |
15
+ | Verifica JWT in un backend fratello | `emilsoftware-utilities/accessi/resource-server` | `dist/accessi-module/...` |
16
+ | Client OpenAPI completo | `emilsoftware-utilities/accessi/client` | root barrel |
17
+ | Client solo API pubbliche | `emilsoftware-utilities/accessi/client/public` | — |
18
+ | Client solo API admin | `emilsoftware-utilities/accessi/client/admin` | — |
19
+ | Client service-to-service | `emilsoftware-utilities/accessi/client/service` | — |
20
+ | OpenAPI merge/setup | `emilsoftware-utilities/accessi/openapi` | root barrel |
21
+ | URL console e ticket | `emilsoftware-utilities/accessi/console` | root barrel |
22
+ | Asset UI frameworkless | `emilsoftware-utilities/accessi/ui` | — |
23
+
24
+ ```ts
25
+ // Corretto
26
+ import { initializeAccessiModule } from 'emilsoftware-utilities/accessi/server';
27
+ import { createAccessiTokenVerifier } from 'emilsoftware-utilities/accessi/resource-server';
28
+ import { login } from 'emilsoftware-utilities/accessi/client/public';
29
+
30
+ // Legacy: continua a funzionare, ma verra rimosso in una major
31
+ import { initializeAccessiModule } from 'emilsoftware-utilities';
32
+ ```
33
+
34
+ ## Perché
35
+
36
+ - Il root barrel importa NestJS, modulo Accessi, Allegati, OpenAPI e utility:
37
+ un frontend che importa un solo helper paga tutto il grafo.
38
+ - Gli scope `client/public`, `client/admin` e `client/service` impediscono che
39
+ un bundle pubblico includa operazioni amministrative o service token.
40
+ - `accessi/contracts` è browser-safe: non trascina database, SMTP o Nest.
41
+ - Gli entry point sono parte del contratto del pacchetto: i deep import non lo
42
+ sono e possono cambiare layout in una minor.
43
+
44
+ ## Piano
45
+
46
+ 1. **Ora**: nuovi consumer usano gli entry point; il root barrel e i deep import
47
+ funzionano senza warning runtime.
48
+ 2. **Prossima major**: il root barrel Accessi verrà ridotto ai soli simboli non
49
+ Accessi (Logger, Orm, Utilities, ...). I deep import non saranno più
50
+ supportati.
51
+ 3. **Dopo la major**: eventuale split in pacchetti separati, secondo
52
+ `docs/adr/0001-split-package.md`.
53
+
54
+ Nessuna rimozione avviene in una minor: i consumer hanno almeno un ciclo di
55
+ rilascio per migrare.
56
+
57
+ ## Verifica
58
+
59
+ ```bash
60
+ npm run test:pack # importa il tarball pubblicato e i subpath
61
+ ```
62
+
63
+ Il test `test/import-entrypoints.cjs` carica ogni entry point consigliato,
64
+ verifica i simboli principali e controlla che ogni subpath sia dichiarato in
65
+ `package.json#exports`.
@@ -0,0 +1,305 @@
1
+ # Multi-tenancy operativa
2
+
3
+ Accessi supporta più tenant con **un database per tenant** e un processo di
4
+ servizio per tenant. La configurazione sta in una cartella di file `.env`; la
5
+ decisione architetturale è in `docs/adr/0002-multi-tenancy.md`.
6
+
7
+ ## Layout
8
+
9
+ ```
10
+ tenants/
11
+ acme.env
12
+ beta.env
13
+ ```
14
+
15
+ `acme.env`:
16
+
17
+ ```dotenv
18
+ ACCESSI_TENANT_HOSTS=accessi.acme.example.com,*.acme.example.com
19
+ ACCESSI_TENANT_PATH=/acme
20
+ ACCESSI_TENANT_PORT=3201
21
+ ACCESSI_TENANT_ENABLED=true
22
+
23
+ ACCESSI_DB_HOST=db-acme
24
+ ACCESSI_DB_PORT=3050
25
+ ACCESSI_DB_DATABASE=/data/acme.fdb
26
+ ACCESSI_DB_USER=SYSDBA
27
+ ACCESSI_DB_PASSWORD=...
28
+
29
+ ACCESSI_PUBLIC_BASE_URL=https://accessi.acme.example.com
30
+ ACCESSI_CONFIRMATION_EMAIL_URL=https://accessi.acme.example.com
31
+ ACCESSI_CONFIRMATION_RETURN_EMAIL_URL=https://accessi.acme.example.com
32
+
33
+ ACCESSI_JWT_ALGORITHM=RS256
34
+ ACCESSI_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
35
+ ACCESSI_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
36
+ ACCESSI_JWT_KEY_ID=acme-2026-10
37
+ ACCESSI_JWT_ISSUER=https://accessi.acme.example.com
38
+ ACCESSI_JWT_AUDIENCE=accessi-api
39
+ ACCESSI_JWT_EXPIRES=1h
40
+ ACCESSI_JWT_REFRESH_EXPIRES=30d
41
+
42
+ ACCESSI_ENCRYPTION_KEY=...32+ caratteri casuali...
43
+ ACCESSI_EMAIL_HOST=smtp.acme.example.com
44
+ ACCESSI_EMAIL_USER=...
45
+ ACCESSI_EMAIL_PASSWORD=...
46
+ ACCESSI_EMAIL_FROM=noreply@acme.example.com
47
+ ```
48
+
49
+ Regole:
50
+
51
+ - `ACCESSI_TENANT_ID` è opzionale: se assente vale il nome del file.
52
+ - Almeno uno tra `ACCESSI_TENANT_HOSTS` e `ACCESSI_TENANT_PATH`.
53
+ - `ACCESSI_TENANT_HOSTS` accetta più host separati da virgola e wildcard
54
+ `*.example.com`.
55
+ - `ACCESSI_TENANT_PATH` è un prefisso (`/acme`), non un host.
56
+ - Ogni tenant deve avere chiave JWT e database propri. La validazione blocca
57
+ chiavi condivise: un token di un tenant non deve essere accettato da un altro.
58
+ - I file contengono segreti: non committarli. Permessi `0600`, secret manager o
59
+ volumi montati a runtime.
60
+
61
+ ## Validazione
62
+
63
+ ```bash
64
+ npm run accessi:tenants -- --dir ./tenants --check
65
+ npm run accessi:tenants -- --dir ./tenants --list
66
+ npm run accessi:tenants -- --dir ./tenants --resolve accessi.acme.example.com /api/accessi
67
+ ```
68
+
69
+ `--check` esce con codice 1 e stampa tutti gli errori (file, variabile,
70
+ duplicati, chiavi condivise). `--list` stampa un summary **senza segreti**:
71
+ host, path, database, issuer, audience, algoritmo e porta.
72
+
73
+ La validazione avviene prima di aprire qualsiasi connessione: un tenant rotto
74
+ non deve avviarsi a metà.
75
+
76
+ ## Avvio
77
+
78
+ ### Supervisore incluso
79
+
80
+ ```bash
81
+ npm run accessi:tenants -- --dir ./tenants --serve --base-port 3200
82
+ ```
83
+
84
+ Avvia un processo figlio per ogni tenant abilitato:
85
+
86
+ - `ACCESSI_TENANT_PORT` se presente, altrimenti `base-port + indice`;
87
+ - passa `PORT`, `ACCESSI_TENANT_ID` e tutte le variabili del file;
88
+ - inoltra `SIGTERM`/`SIGINT` e termina i figli dopo 10 secondi;
89
+ - non riavvia automaticamente un figlio: un loop su configurazione rotta è
90
+ peggio di un processo che resta giù; usare systemd/docker/Kubernetes.
91
+
92
+ Il supervisore legge la cartella all'avvio: **aggiungere o rimuovere un tenant
93
+ richiede un riavvio**. Non c'è hot reload (Nest non smonta moduli a caldo).
94
+
95
+ ### systemd (consigliato in produzione)
96
+
97
+ Un'unità template, una istanza per tenant:
98
+
99
+ ```ini
100
+ # /etc/systemd/system/accessi@.service
101
+ [Unit]
102
+ Description=Accessi Service tenant %i
103
+ After=network-online.target
104
+
105
+ [Service]
106
+ Type=simple
107
+ WorkingDirectory=/opt/accessi
108
+ EnvironmentFile=/etc/accessi/tenants/%i.env
109
+ Environment=PORT=%i
110
+ # assegnare una porta per tenant in un drop-in o in un file separato
111
+ ExecStart=/usr/bin/node dist/scripts/startAccessiService.js
112
+ Restart=on-failure
113
+ RestartSec=5
114
+ User=accessi
115
+ Group=accessi
116
+ NoNewPrivileges=true
117
+ PrivateTmp=true
118
+ ProtectSystem=strict
119
+ ReadWritePaths=/var/lib/accessi
120
+
121
+ [Install]
122
+ WantedBy=multi-user.target
123
+ ```
124
+
125
+ Con `PORT` derivato dal nome istanza non è possibile: usare un file
126
+ `/etc/accessi/ports/%i` o un drop-in `Environment=PORT=3201`. In alternativa
127
+ lasciare che il supervisore includa gestisca le porte.
128
+
129
+ ### Docker Compose
130
+
131
+ ```yaml
132
+ services:
133
+ accessi-acme:
134
+ image: emilsoftware/accessi:2
135
+ env_file: ./tenants/acme.env
136
+ environment:
137
+ PORT: "3201"
138
+ ACCESSI_TENANT_ID: acme
139
+ volumes:
140
+ - ./secrets/acme:/run/secrets:ro
141
+ ports:
142
+ - "127.0.0.1:3201:3201"
143
+
144
+ accessi-beta:
145
+ image: emilsoftware/accessi:2
146
+ env_file: ./tenants/beta.env
147
+ environment:
148
+ PORT: "3202"
149
+ ACCESSI_TENANT_ID: beta
150
+ ports:
151
+ - "127.0.0.1:3202:3202"
152
+ ```
153
+
154
+ ## Reverse proxy
155
+
156
+ Il proxy instrada per host o path e **sovrascrive** l'header tenant se lo usa.
157
+ Non fidarsi di un header inviato dal client.
158
+
159
+ ### Nginx (host)
160
+
161
+ ```nginx
162
+ map $host $accessi_upstream {
163
+ accessi.acme.example.com http://127.0.0.1:3201;
164
+ accessi.beta.example.com http://127.0.0.1:3202;
165
+ default http://127.0.0.1:3201; # o 444
166
+ }
167
+
168
+ server {
169
+ listen 443 ssl http2;
170
+ server_name accessi.acme.example.com accessi.beta.example.com;
171
+ location / {
172
+ proxy_pass $accessi_upstream;
173
+ proxy_set_header Host $host;
174
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
175
+ proxy_set_header X-Forwarded-Proto $scheme;
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### Traefik (host)
181
+
182
+ ```yaml
183
+ http:
184
+ routers:
185
+ acme:
186
+ rule: Host(`accessi.acme.example.com`)
187
+ service: acme
188
+ beta:
189
+ rule: Host(`accessi.beta.example.com`)
190
+ service: beta
191
+ services:
192
+ acme:
193
+ loadBalancer:
194
+ servers:
195
+ - url: http://127.0.0.1:3201
196
+ beta:
197
+ loadBalancer:
198
+ servers:
199
+ - url: http://127.0.0.1:3202
200
+ ```
201
+
202
+ ### Path
203
+
204
+ Con `ACCESSI_TENANT_PATH=/acme`, il proxy deve rimuovere il prefisso prima di
205
+ inoltrare (Accessi non lo conosce) oppure il tenant deve essere servito alla
206
+ radice. Esempio Nginx:
207
+
208
+ ```nginx
209
+ location /acme/ {
210
+ rewrite ^/acme/(.*)$ /$1 break;
211
+ proxy_pass http://127.0.0.1:3201;
212
+ }
213
+ ```
214
+
215
+ Se si usa la risoluzione via header, impostarlo solo nel proxy:
216
+
217
+ ```nginx
218
+ proxy_set_header X-Accessi-Tenant acme;
219
+ ```
220
+
221
+ e avviare Accessi con `allowHeader: true` solo nel router che si fida del proxy.
222
+
223
+ ## Store Redis condivisi
224
+
225
+ Con Redis condiviso ogni tenant deve avere un prefisso distinto. Nel codice che
226
+ costruisce gli store:
227
+
228
+ ```ts
229
+ const prefix = getAccessiTenantRedisPrefix(tenant.id); // accessi:acme:
230
+ const options: AccessiOptions = {
231
+ ...tenant.options,
232
+ publicAuthRateLimit: {
233
+ store: createRedisRateLimitStore({ client, keyPrefix: `${prefix}rate:` }),
234
+ },
235
+ sessionRevocation: {
236
+ store: createRedisSessionRevocationStore({ client, keyPrefix: `${prefix}session:` }),
237
+ },
238
+ refreshTokenRotation: {
239
+ store: createRedisRefreshTokenRotationStore({ client, keyPrefix: `${prefix}refresh:` }),
240
+ enabled: true,
241
+ },
242
+ accessTokenRevocation: {
243
+ store: createRedisAccessTokenRevocationStore({ client, keyPrefix: `${prefix}jti:` }),
244
+ },
245
+ };
246
+ ```
247
+
248
+ Senza prefisso due tenant condividerebbero rate limit, revoche e refresh: è un
249
+ leakage. La validazione non può verificarlo (gli store sono codice host), quindi
250
+ va testato.
251
+
252
+ ## Metriche e audit
253
+
254
+ - Ogni processo espone `/health/metrics` sulla propria porta: Prometheus può
255
+ scrapare per tenant oppure usare un label `tenant` aggiunto dai child process.
256
+ - `countAccessiMetric` aggiunge `tenant` quando `options.tenantId` è presente.
257
+ - `audit.tenantId` aggiunge `tenant` a ogni record; configurare un sink per
258
+ tenant o un sink condiviso che conserva il label.
259
+ - Log: includere `ACCESSI_TENANT_ID` nel formato del logger dell'host.
260
+
261
+ ## Aggiungere, rimuovere, ruotare
262
+
263
+ **Aggiungere un tenant**
264
+
265
+ 1. Creare `tenants/<id>.env` con DB e chiavi propri.
266
+ 2. `npm run accessi:tenants -- --dir ./tenants --check`.
267
+ 3. Creare il database, eseguire le migrazioni (`db:update:accessi`,
268
+ `db:catalog:accessi`) con l'env del tenant.
269
+ 4. Avviare/riavviare il supervisore o l'unità systemd del tenant.
270
+ 5. Aggiungere la rotta al proxy e verificare `/health/ready`.
271
+
272
+ **Rimuovere un tenant**
273
+
274
+ 1. Rimuovere la rotta dal proxy (il traffico si ferma).
275
+ 2. Disabilitare `ACCESSI_TENANT_ENABLED=false` e riavviare.
276
+ 3. Conservare database e backup secondo la retention; solo dopo eliminare il
277
+ file `.env` e i segreti.
278
+
279
+ **Ruotare le chiavi**
280
+
281
+ Ogni tenant ha la propria rotazione (`docs/jwt-key-rotation.md`). Non copiare
282
+ chiavi tra tenant; la validazione blocca chiavi JWT condivise.
283
+
284
+ ## Migrazione da single-tenant
285
+
286
+ 1. Copiare il `.env` attuale in `tenants/<id>.env`; aggiungere
287
+ `ACCESSI_TENANT_HOSTS`/`ACCESSI_TENANT_PATH` e `ACCESSI_TENANT_ID`.
288
+ 2. Validare con `--check`.
289
+ 3. Avviare il tenant sulla nuova porta mantenendo il servizio vecchio.
290
+ 4. Verificare login, refresh, JWKS, console, health e metriche sul nuovo.
291
+ 5. Spostare il DNS/rotta del proxy sul nuovo servizio.
292
+ 6. Spegnere il vecchio processo e mantenere il rollback per almeno un ciclo di
293
+ token (`max(expiresIn, refreshExpiresIn)`).
294
+
295
+ Il database non cambia: la migrazione è di configurazione e deployment, non di
296
+ schema.
297
+
298
+ ## Limiti
299
+
300
+ - Un processo per tenant: più RAM, più pool Firebird, più connessioni SMTP.
301
+ - Nessun hot reload: aggiunta/rimozione richiede restart.
302
+ - Migrazioni e backup per database.
303
+ - La risoluzione via header è ammessa solo dietro proxy fidato; il client non
304
+ deve poter scegliere il tenant.
305
+ - Un tenant con path `/` senza host non può convivere con altri (validazione).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emilsoftware-utilities",
3
- "version": "2.0.0-dev.79",
3
+ "version": "2.0.0-dev.80",
4
4
  "description": "Utilities for EmilSoftware",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -131,6 +131,7 @@
131
131
  "db:update:accessi:dist": "npm run build && node dist/accessi-module/database-updates/runAccessiDbUpdate.js",
132
132
  "accessi:serve": "ts-node src/scripts/startAccessiService.ts",
133
133
  "accessi:serve:dist": "node dist/scripts/startAccessiService.js",
134
+ "accessi:tenants": "node scripts/accessi-tenants.cjs",
134
135
  "accessi:generate-jwt-keys": "node scripts/generate-accessi-jwt-keys.cjs",
135
136
  "accessi:rotate-jwt-keys": "node scripts/rotate-accessi-jwt-keys.cjs",
136
137
  "db:catalog:accessi": "ts-node src/accessi-module/database-updates/runAccessiCatalogMigrations.ts",
@@ -139,8 +140,8 @@
139
140
  "typecheck": "tsc --noEmit",
140
141
  "lint": "node scripts/lint.cjs",
141
142
  "test": "npm run typecheck && npm run lint && npm run test:unit",
142
- "test:unit": "node --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs",
143
- "test:coverage": "node --experimental-test-coverage --test-coverage-lines=50 --test-coverage-functions=60 --test-coverage-branches=60 --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs",
143
+ "test:unit": "node --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs test/import-entrypoints.cjs test/tenants.cjs",
144
+ "test:coverage": "node --experimental-test-coverage --test-coverage-lines=50 --test-coverage-functions=60 --test-coverage-branches=60 --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs test/import-entrypoints.cjs test/tenants.cjs",
144
145
  "test:accessi": "npm run test:unit",
145
146
  "test:all": "npm test && npm run test:integration && npm run test:integration:redis",
146
147
  "test:integration": "node test/integration/run-firebird.cjs 5",