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.
- package/dist/accessi/server/index.d.ts +1 -0
- package/dist/accessi/server/index.js +1 -0
- package/dist/accessi/server/index.js.map +1 -1
- package/dist/accessi/server/tenants.d.ts +97 -0
- package/dist/accessi/server/tenants.js +275 -0
- package/dist/accessi/server/tenants.js.map +1 -0
- package/dist/accessi-module/AccessiModule.d.ts +5 -1
- package/dist/accessi-module/AccessiModule.js +3 -1
- package/dist/accessi-module/AccessiModule.js.map +1 -1
- package/dist/accessi-module/Console/console.js +3 -1
- package/dist/accessi-module/Console/wiki.js +2 -0
- package/dist/accessi-module/Console/wiki.js.map +1 -1
- package/dist/accessi-module/database-updates/accessiOptionsFromEnv.d.ts +7 -3
- package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js +32 -28
- package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js.map +1 -1
- package/dist/accessi-module/observability/metrics.js +6 -2
- package/dist/accessi-module/observability/metrics.js.map +1 -1
- package/dist/accessi-module/security/audit.d.ts +2 -0
- package/dist/accessi-module/security/audit.js +3 -1
- package/dist/accessi-module/security/audit.js.map +1 -1
- package/dist/emilsoftware-utilities-2.0.0-dev.80.tgz +0 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/docs/adr/0002-multi-tenancy.md +154 -0
- package/docs/import-migration.md +65 -0
- package/docs/multi-tenancy.md +305 -0
- package/package.json +4 -3
- 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.
|
|
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",
|
|
Binary file
|