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

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ var _a, _b;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const server_1 = require("../accessi/server");
5
+ const Logger_1 = require("../Logger");
6
+ /**
7
+ * Avvia Accessi per un singolo tenant.
8
+ *
9
+ * `ACCESSI_TENANT_ENVIRONMENT` punta al file `*.environment.ts`; `PORT` e la
10
+ * porta interna assegnata dal supervisore o dal proxy.
11
+ */
12
+ const logger = new Logger_1.Logger('startAccessiTenantService');
13
+ const environmentFile = process.env.ACCESSI_TENANT_ENVIRONMENT;
14
+ if (!environmentFile) {
15
+ logger.error('ACCESSI_TENANT_ENVIRONMENT non impostata.');
16
+ process.exitCode = 1;
17
+ }
18
+ else {
19
+ try {
20
+ const { tenant } = (0, server_1.loadAccessiTenantEnvironmentFile)(environmentFile);
21
+ const port = Number((_b = (_a = process.env.PORT) !== null && _a !== void 0 ? _a : tenant.port) !== null && _b !== void 0 ? _b : 3200);
22
+ logger.info(`Avvio tenant ${tenant.id} sulla porta ${port}.`);
23
+ void (0, server_1.startAccessiService)({ accessiOptions: tenant.options, port }).catch((error) => {
24
+ logger.error(`Avvio tenant ${tenant.id} fallito:`, error instanceof Error
25
+ ? { name: error.name, message: error.message, stack: error.stack }
26
+ : { error: String(error) });
27
+ process.exitCode = 1;
28
+ });
29
+ }
30
+ catch (error) {
31
+ logger.error('Configurazione tenant non valida:', error instanceof Error ? { message: error.message } : { error: String(error) });
32
+ process.exitCode = 1;
33
+ }
34
+ }
35
+ //# sourceMappingURL=startAccessiTenantService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"startAccessiTenantService.js","sourceRoot":"","sources":["../../src/scripts/startAccessiTenantService.ts"],"names":[],"mappings":";;;AAAA,8CAA0F;AAC1F,sCAAmC;AAEnC;;;;;GAKG;AACH,MAAM,MAAM,GAAG,IAAI,eAAM,CAAC,2BAA2B,CAAC,CAAC;AACvD,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,0BAA0B,CAAC;AAE/D,IAAI,CAAC,eAAe,EAAE,CAAC;IACrB,MAAM,CAAC,KAAK,CAAC,2CAA2C,CAAC,CAAC;IAC1D,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACvB,CAAC;KAAM,CAAC;IACN,IAAI,CAAC;QACH,MAAM,EAAE,MAAM,EAAE,GAAG,IAAA,yCAAgC,EAAC,eAAe,CAAC,CAAC;QACrE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAA,MAAA,OAAO,CAAC,GAAG,CAAC,IAAI,mCAAI,MAAM,CAAC,IAAI,mCAAI,IAAI,CAAC,CAAC;QAC7D,MAAM,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,EAAE,gBAAgB,IAAI,GAAG,CAAC,CAAC;QAC9D,KAAK,IAAA,4BAAmB,EAAC,EAAE,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;YAC1F,MAAM,CAAC,KAAK,CACV,gBAAgB,MAAM,CAAC,EAAE,WAAW,EACpC,KAAK,YAAY,KAAK;gBACpB,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE;gBAClE,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAC7B,CAAC;YACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,CAAC,KAAK,CACV,mCAAmC,EACnC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAC/E,CAAC;QACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACvB,CAAC;AACH,CAAC","sourcesContent":["import { startAccessiService, loadAccessiTenantEnvironmentFile } from '../accessi/server';\nimport { Logger } from '../Logger';\n\n/**\n * Avvia Accessi per un singolo tenant.\n *\n * `ACCESSI_TENANT_ENVIRONMENT` punta al file `*.environment.ts`; `PORT` e la\n * porta interna assegnata dal supervisore o dal proxy.\n */\nconst logger = new Logger('startAccessiTenantService');\nconst environmentFile = process.env.ACCESSI_TENANT_ENVIRONMENT;\n\nif (!environmentFile) {\n logger.error('ACCESSI_TENANT_ENVIRONMENT non impostata.');\n process.exitCode = 1;\n} else {\n try {\n const { tenant } = loadAccessiTenantEnvironmentFile(environmentFile);\n const port = Number(process.env.PORT ?? tenant.port ?? 3200);\n logger.info(`Avvio tenant ${tenant.id} sulla porta ${port}.`);\n void startAccessiService({ accessiOptions: tenant.options, port }).catch((error: unknown) => {\n logger.error(\n `Avvio tenant ${tenant.id} fallito:`,\n error instanceof Error\n ? { name: error.name, message: error.message, stack: error.stack }\n : { error: String(error) },\n );\n process.exitCode = 1;\n });\n } catch (error) {\n logger.error(\n 'Configurazione tenant non valida:',\n error instanceof Error ? { message: error.message } : { error: String(error) },\n );\n process.exitCode = 1;\n }\n}\n"]}
@@ -28,31 +28,64 @@ schema.** Un tenant è una cartella di configurazione:
28
28
 
29
29
  ```
30
30
  tenants/
31
- acme.env
32
- beta.env
31
+ acme.environment.ts
32
+ beta.environment.ts
33
33
  ```
34
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=...
35
+ Ogni file esporta un oggetto tipizzato `AccessiTenantEnvironment`; i segreti
36
+ possono essere letti da `process.env` o dal secret manager:
37
+
38
+ ```ts
39
+ import type { AccessiTenantEnvironment } from 'emilsoftware-utilities/accessi/server';
40
+ import { defineAccessiTenantEnvironment } from 'emilsoftware-utilities/accessi/server';
41
+
42
+ export default defineAccessiTenantEnvironment({
43
+ tenant: {
44
+ id: 'acme',
45
+ hosts: ['accessi.acme.example.com', '*.acme.example.com'],
46
+ path: '/acme',
47
+ port: 3201,
48
+ enabled: true,
49
+ },
50
+ database: {
51
+ host: 'db-acme',
52
+ database: '/data/acme.fdb',
53
+ user: process.env.ACME_DB_USER ?? 'SYSDBA',
54
+ password: process.env.ACME_DB_PASSWORD!,
55
+ },
56
+ jwt: {
57
+ secret: process.env.ACME_JWT_SECRET!,
58
+ expiresIn: '1h',
59
+ refreshExpiresIn: '30d',
60
+ issuer: 'https://accessi.acme.example.com',
61
+ audience: 'accessi-api',
62
+ },
63
+ email: {
64
+ host: process.env.ACME_SMTP_HOST!,
65
+ port: 587,
66
+ secure: false,
67
+ requireTLS: true,
68
+ tls: { rejectUnauthorized: true },
69
+ from: 'noreply@acme.example.com',
70
+ auth: { user: process.env.ACME_SMTP_USER!, pass: process.env.ACME_SMTP_PASSWORD! },
71
+ },
72
+ encryptionKey: process.env.ACME_ENCRYPTION_KEY!,
73
+ confirmationEmailUrl: 'https://accessi.acme.example.com',
74
+ confirmationEmailReturnUrl: 'https://accessi.acme.example.com',
75
+ } satisfies AccessiTenantEnvironment);
50
76
  ```
51
77
 
52
- `loadAccessiTenants({ directory })` valida l'intera cartella prima di avviare
53
- qualsiasi cosa:
78
+ `defineAccessiTenantEnvironment` è solo un helper di tipizzazione: non esegue
79
+ logica. Il loader usa jiti per eseguire il TypeScript anche nel pacchetto
80
+ compilato, quindi non serve compilare i file tenant separatamente.
81
+
82
+ Campi runtime come `store`, `sink`, `match`, `onDocument` e `logger` sono
83
+ volutamente esclusi dal tipo: vanno collegati dal backend ospitante. Il file
84
+ tenant descrive solo configurazione serializzabile.
85
+
86
+ La validazione controlla:
54
87
 
55
- - `ACCESSI_TENANT_ID` (o nome file) univoco, minuscole/numeri/`-`/`_`;
88
+ - `tenant.id` (o nome file) univoco, minuscole/numeri/`-`/`_`;
56
89
  - almeno host o path; host e path non duplicati;
57
90
  - chiave JWT distinta per tenant (un token non deve valere su due tenant);
58
91
  - issuer/audience condivisi producono un warning;
@@ -73,8 +106,8 @@ dal router, non servita dal "primo" tenant.
73
106
 
74
107
  Ogni tenant gira in un **processo Accessi separato** con il proprio environment,
75
108
  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.
109
+ `node scripts/accessi-tenants.cjs --serve` avvia un figlio per ogni file `.environment.ts`
110
+ abilitato e passa `PORT`, `ACCESSI_TENANT_ENVIRONMENT` (path assoluto) e `ACCESSI_TENANT_ID`.
78
111
 
79
112
  Questo evita di condividere:
80
113
 
@@ -86,16 +119,11 @@ Questo evita di condividere:
86
119
  ### Store condivisi
87
120
 
88
121
  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.
122
+ diverso. `createAccessiTenantStores({ tenantId, client, encryptionKey })` crea
123
+ rate limit, revoche, refresh rotation e ticket console con prefissi
124
+ `accessi:<tenantId>:` e li collega con `applyTo(options)`. I prefissi restano
125
+ personalizzabili con `prefix`/`prefixes`, ma non devono mai coincidere tra
126
+ tenant.
99
127
 
100
128
  ### Osservabilità
101
129
 
@@ -116,7 +144,7 @@ prefissi e per le etichette di metriche e audit.
116
144
  | Schema per tenant | Firebird non offre schema/search_path; sarebbe un database separato con altro nome. |
117
145
  | Due `AccessiModule` nello stesso processo | La configurazione Nest è globale; audit/metriche/logger condividono stato. |
118
146
  | 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. |
147
+ | Hot reload dei file `.environment.ts` | Nest non smonta moduli a caldo; il riavvio del supervisore è più semplice e sicuro. |
120
148
 
121
149
  ## Conseguenze
122
150
 
@@ -124,7 +152,7 @@ Positive:
124
152
 
125
153
  - isolamento forte: database, chiavi e processi separati;
126
154
  - nessuna migrazione dello schema esistente;
127
- - aggiunta tenant = nuovo file `.env` + riavvio del supervisore;
155
+ - aggiunta tenant = nuovo file `.environment.ts` + riavvio del supervisore;
128
156
  - rollback per tenant indipendente.
129
157
 
130
158
  Negative:
@@ -143,7 +171,7 @@ Negative:
143
171
  2. **Fase 2**: template systemd/docker e reverse proxy host/path; health check
144
172
  per tenant nel supervisore.
145
173
  3. **Fase 3**: migrazione del deployment single-tenant esistente: creare
146
- `tenants/<id>.env` dal `.env` attuale, avviare in parallelo, spostare il DNS,
174
+ `tenants/<id>.environment.ts` dalla configurazione attuale, avviare in parallelo, spostare il DNS,
147
175
  spegnere il vecchio servizio.
148
176
  4. **Fase 4 (opzionale)**: router in-process con `createAccessiTenantRouter`
149
177
  solo se il costo dei processi diventa un problema e dopo test di leakage
@@ -1,62 +1,98 @@
1
1
  # Multi-tenancy operativa
2
2
 
3
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`.
4
+ servizio per tenant. La configurazione sta in una cartella di file
5
+ `*.environment.ts` tipizzati; la decisione architetturale è in
6
+ `docs/adr/0002-multi-tenancy.md`.
6
7
 
7
8
  ## Layout
8
9
 
9
10
  ```
10
11
  tenants/
11
- acme.env
12
- beta.env
12
+ acme.environment.ts
13
+ beta.environment.ts
13
14
  ```
14
15
 
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
16
+ `acme.environment.ts`:
17
+
18
+ ```ts
19
+ import type { AccessiTenantEnvironment } from 'emilsoftware-utilities/accessi/server';
20
+ import { defineAccessiTenantEnvironment } from 'emilsoftware-utilities/accessi/server';
21
+
22
+ export default defineAccessiTenantEnvironment({
23
+ tenant: {
24
+ id: 'acme',
25
+ hosts: ['accessi.acme.example.com', '*.acme.example.com'],
26
+ path: '/acme',
27
+ port: 3201,
28
+ enabled: true,
29
+ description: 'Tenant ACME',
30
+ },
31
+
32
+ database: {
33
+ host: 'db-acme',
34
+ port: 3050,
35
+ database: '/data/acme.fdb',
36
+ user: 'SYSDBA',
37
+ password: process.env.ACME_DB_PASSWORD!,
38
+ },
39
+
40
+ jwt: {
41
+ algorithm: 'RS256',
42
+ secret: process.env.ACME_JWT_SECRET!,
43
+ privateKey: process.env.ACME_JWT_PRIVATE_KEY!,
44
+ publicKey: process.env.ACME_JWT_PUBLIC_KEY!,
45
+ keyId: 'acme-2026-10',
46
+ issuer: 'https://accessi.acme.example.com',
47
+ audience: 'accessi-api',
48
+ expiresIn: '1h',
49
+ refreshExpiresIn: '30d',
50
+ },
51
+
52
+ email: {
53
+ host: process.env.ACME_SMTP_HOST!,
54
+ port: 587,
55
+ secure: false,
56
+ requireTLS: true,
57
+ tls: { rejectUnauthorized: true },
58
+ from: 'noreply@acme.example.com',
59
+ auth: {
60
+ user: process.env.ACME_SMTP_USER!,
61
+ pass: process.env.ACME_SMTP_PASSWORD!,
62
+ },
63
+ },
64
+
65
+ encryptionKey: process.env.ACME_ENCRYPTION_KEY!,
66
+ confirmationEmailUrl: 'https://accessi.acme.example.com',
67
+ confirmationEmailReturnUrl: 'https://accessi.acme.example.com',
68
+ publicBaseUrl: 'https://accessi.acme.example.com',
69
+
70
+ // Opzionali, tutti tipizzati da AccessiTenantEnvironment:
71
+ http: { trustProxy: 1 },
72
+ authCookie: { enabled: true, secure: true, sameSite: 'lax' },
73
+ publicAuthRateLimit: { login: { windowMs: 900_000, maxAttempts: 10 } },
74
+ metrics: { enabled: true, prefix: 'accessi' },
75
+ audit: { retentionDays: 90, logger: true },
76
+ } satisfies AccessiTenantEnvironment);
47
77
  ```
48
78
 
49
79
  Regole:
50
80
 
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.
81
+ - `tenant.id` è obbligatorio (minuscole, numeri, `-`, `_`, max 63). Non serve
82
+ derivarlo dal nome file, ma dovrebbe coincidere per leggibilità.
83
+ - Almeno uno tra `tenant.hosts` e `tenant.path`.
84
+ - `tenant.hosts` accetta host esatti e wildcard `*.example.com`.
85
+ - `tenant.path` è un prefisso (`/acme`), non un host.
86
+ - Ogni tenant deve avere database, chiave JWT e `encryptionKey` propri. La
87
+ validazione blocca chiavi JWT condivise: un token di un tenant non deve essere
88
+ accettato da un altro.
89
+ - I segreti vanno letti da `process.env` o dal secret manager: il file
90
+ `.environment.ts` può essere versionato senza contenere credenziali.
91
+ - `store`, `sink`, `match`, `onDocument` e `logger` non sono nel tipo: sono
92
+ oggetti runtime e vanno collegati dal backend ospitante.
93
+
94
+ Il loader usa **jiti** per eseguire TypeScript anche dal pacchetto compilato:
95
+ non serve compilare i file tenant separatamente.
60
96
 
61
97
  ## Validazione
62
98
 
@@ -66,9 +102,9 @@ npm run accessi:tenants -- --dir ./tenants --list
66
102
  npm run accessi:tenants -- --dir ./tenants --resolve accessi.acme.example.com /api/accessi
67
103
  ```
68
104
 
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.
105
+ `--check` esce con codice 1 e stampa tutti gli errori (file, campo, duplicati,
106
+ chiavi condivise). `--list` stampa un summary **senza segreti**: host, path,
107
+ database, issuer, audience, algoritmo e porta.
72
108
 
73
109
  La validazione avviene prima di aprire qualsiasi connessione: un tenant rotto
74
110
  non deve avviarsi a metà.
@@ -83,8 +119,9 @@ npm run accessi:tenants -- --dir ./tenants --serve --base-port 3200
83
119
 
84
120
  Avvia un processo figlio per ogni tenant abilitato:
85
121
 
86
- - `ACCESSI_TENANT_PORT` se presente, altrimenti `base-port + indice`;
87
- - passa `PORT`, `ACCESSI_TENANT_ID` e tutte le variabili del file;
122
+ - `tenant.port` se presente, altrimenti `base-port + indice`;
123
+ - passa `PORT`, `ACCESSI_TENANT_ENVIRONMENT` (path assoluto del file) e
124
+ `ACCESSI_TENANT_ID`;
88
125
  - inoltra `SIGTERM`/`SIGINT` e termina i figli dopo 10 secondi;
89
126
  - non riavvia automaticamente un figlio: un loop su configurazione rotta è
90
127
  peggio di un processo che resta giù; usare systemd/docker/Kubernetes.
@@ -94,8 +131,6 @@ richiede un riavvio**. Non c'è hot reload (Nest non smonta moduli a caldo).
94
131
 
95
132
  ### systemd (consigliato in produzione)
96
133
 
97
- Un'unità template, una istanza per tenant:
98
-
99
134
  ```ini
100
135
  # /etc/systemd/system/accessi@.service
101
136
  [Unit]
@@ -105,10 +140,10 @@ After=network-online.target
105
140
  [Service]
106
141
  Type=simple
107
142
  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
143
+ EnvironmentFile=/etc/accessi/%i.secrets
144
+ Environment=ACCESSI_TENANT_ENVIRONMENT=/etc/accessi/tenants/%i.environment.ts
145
+ Environment=PORT=3201
146
+ ExecStart=/usr/bin/node dist/scripts/startAccessiTenantService.js
112
147
  Restart=on-failure
113
148
  RestartSec=5
114
149
  User=accessi
@@ -122,9 +157,8 @@ ReadWritePaths=/var/lib/accessi
122
157
  WantedBy=multi-user.target
123
158
  ```
124
159
 
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.
160
+ `EnvironmentFile` contiene solo i segreti (`ACME_DB_PASSWORD`, ...), non il
161
+ manifest: il manifest tipizzato resta nel file `.environment.ts`.
128
162
 
129
163
  ### Docker Compose
130
164
 
@@ -132,23 +166,14 @@ lasciare che il supervisore includa gestisca le porte.
132
166
  services:
133
167
  accessi-acme:
134
168
  image: emilsoftware/accessi:2
135
- env_file: ./tenants/acme.env
136
169
  environment:
170
+ ACCESSI_TENANT_ENVIRONMENT: /config/acme.environment.ts
137
171
  PORT: "3201"
138
- ACCESSI_TENANT_ID: acme
172
+ env_file: ./secrets/acme.env
139
173
  volumes:
140
- - ./secrets/acme:/run/secrets:ro
174
+ - ./tenants:/config:ro
141
175
  ports:
142
176
  - "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
177
  ```
153
178
 
154
179
  ## Reverse proxy
@@ -162,7 +187,7 @@ Non fidarsi di un header inviato dal client.
162
187
  map $host $accessi_upstream {
163
188
  accessi.acme.example.com http://127.0.0.1:3201;
164
189
  accessi.beta.example.com http://127.0.0.1:3202;
165
- default http://127.0.0.1:3201; # o 444
190
+ default http://127.0.0.1:3201;
166
191
  }
167
192
 
168
193
  server {
@@ -201,9 +226,9 @@ http:
201
226
 
202
227
  ### Path
203
228
 
204
- Con `ACCESSI_TENANT_PATH=/acme`, il proxy deve rimuovere il prefisso prima di
229
+ Con `tenant.path = '/acme'`, il proxy deve rimuovere il prefisso prima di
205
230
  inoltrare (Accessi non lo conosce) oppure il tenant deve essere servito alla
206
- radice. Esempio Nginx:
231
+ radice:
207
232
 
208
233
  ```nginx
209
234
  location /acme/ {
@@ -218,41 +243,38 @@ Se si usa la risoluzione via header, impostarlo solo nel proxy:
218
243
  proxy_set_header X-Accessi-Tenant acme;
219
244
  ```
220
245
 
221
- e avviare Accessi con `allowHeader: true` solo nel router che si fida del proxy.
246
+ e avviare il router con `allowHeader: true` solo nel servizio che si fida del
247
+ proxy.
222
248
 
223
249
  ## Store Redis condivisi
224
250
 
225
- Con Redis condiviso ogni tenant deve avere un prefisso distinto. Nel codice che
226
- costruisce gli store:
251
+ Con Redis condiviso usare l'helper: crea tutti gli store con prefissi
252
+ `accessi:<tenantId>:` e li collega alle opzioni.
227
253
 
228
254
  ```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
- };
255
+ import { createAccessiTenantStores } from 'emilsoftware-utilities/accessi/server';
256
+
257
+ const stores = createAccessiTenantStores({
258
+ tenantId: tenant.id,
259
+ client: redisClient,
260
+ encryptionKey: tenant.options.encryptionKey,
261
+ });
262
+ const options: AccessiOptions = stores.applyTo(tenant.options);
246
263
  ```
247
264
 
265
+ `applyTo` collega `publicAuthRateLimit`, `serviceTokens`,
266
+ `sessionRevocation`, `accessTokenRevocation`, `refreshTokenRotation` e
267
+ `consoleEntry`, preservando le altre opzioni. I prefissi sono disponibili in
268
+ `stores.prefixes`; `getAccessiTenantStorePrefixes(tenantId)` li espone anche
269
+ senza creare gli store.
270
+
248
271
  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.
272
+ leakage. I test di integrazione Redis verificano l'isolamento.
251
273
 
252
274
  ## Metriche e audit
253
275
 
254
276
  - Ogni processo espone `/health/metrics` sulla propria porta: Prometheus può
255
- scrapare per tenant oppure usare un label `tenant` aggiunto dai child process.
277
+ scrapare per tenant.
256
278
  - `countAccessiMetric` aggiunge `tenant` quando `options.tenantId` è presente.
257
279
  - `audit.tenantId` aggiunge `tenant` a ogni record; configurare un sink per
258
280
  tenant o un sink condiviso che conserva il label.
@@ -262,19 +284,19 @@ va testato.
262
284
 
263
285
  **Aggiungere un tenant**
264
286
 
265
- 1. Creare `tenants/<id>.env` con DB e chiavi propri.
287
+ 1. Creare `tenants/<id>.environment.ts` con DB e chiavi propri.
266
288
  2. `npm run accessi:tenants -- --dir ./tenants --check`.
267
289
  3. Creare il database, eseguire le migrazioni (`db:update:accessi`,
268
- `db:catalog:accessi`) con l'env del tenant.
290
+ `db:catalog:accessi`) con la configurazione del tenant.
269
291
  4. Avviare/riavviare il supervisore o l'unità systemd del tenant.
270
292
  5. Aggiungere la rotta al proxy e verificare `/health/ready`.
271
293
 
272
294
  **Rimuovere un tenant**
273
295
 
274
296
  1. Rimuovere la rotta dal proxy (il traffico si ferma).
275
- 2. Disabilitare `ACCESSI_TENANT_ENABLED=false` e riavviare.
297
+ 2. Impostare `tenant.enabled: false` e riavviare.
276
298
  3. Conservare database e backup secondo la retention; solo dopo eliminare il
277
- file `.env` e i segreti.
299
+ file `.environment.ts` e i segreti.
278
300
 
279
301
  **Ruotare le chiavi**
280
302
 
@@ -283,8 +305,8 @@ chiavi tra tenant; la validazione blocca chiavi JWT condivise.
283
305
 
284
306
  ## Migrazione da single-tenant
285
307
 
286
- 1. Copiare il `.env` attuale in `tenants/<id>.env`; aggiungere
287
- `ACCESSI_TENANT_HOSTS`/`ACCESSI_TENANT_PATH` e `ACCESSI_TENANT_ID`.
308
+ 1. Creare `tenants/<id>.environment.ts` partendo dal `.env` attuale, usando
309
+ `process.env` per i segreti.
288
310
  2. Validare con `--check`.
289
311
  3. Avviare il tenant sulla nuova porta mantenendo il servizio vecchio.
290
312
  4. Verificare login, refresh, JWKS, console, health e metriche sul nuovo.
@@ -303,3 +325,5 @@ schema.
303
325
  - La risoluzione via header è ammessa solo dietro proxy fidato; il client non
304
326
  deve poter scegliere il tenant.
305
327
  - Un tenant con path `/` senza host non può convivere con altri (validazione).
328
+ - I file `.environment.ts` sono eseguiti: trattarli come codice fidato e non
329
+ caricarli da input HTTP.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emilsoftware-utilities",
3
- "version": "2.0.0-dev.80",
3
+ "version": "2.0.0-dev.81",
4
4
  "description": "Utilities for EmilSoftware",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -140,12 +140,12 @@
140
140
  "typecheck": "tsc --noEmit",
141
141
  "lint": "node scripts/lint.cjs",
142
142
  "test": "npm run typecheck && npm run lint && npm run test:unit",
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",
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 test/tenant-stores.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 test/tenant-stores.cjs",
145
145
  "test:accessi": "npm run test:unit",
146
146
  "test:all": "npm test && npm run test:integration && npm run test:integration:redis",
147
147
  "test:integration": "node test/integration/run-firebird.cjs 5",
148
- "test:integration:redis": "node --test test/integration/redis-stores.integration.cjs",
148
+ "test:integration:redis": "node --test --test-concurrency=1 test/integration/redis-stores.integration.cjs test/integration/redis-tenant-stores.integration.cjs",
149
149
  "test:load:redis": "node --test --test-concurrency=1 test/integration/redis-load.integration.cjs",
150
150
  "test:integration:fb5": "node test/integration/run-firebird.cjs 5",
151
151
  "test:integration:fb3": "node test/integration/run-firebird.cjs 3",
@@ -187,6 +187,7 @@
187
187
  "class-transformer": "^0.5.1",
188
188
  "class-validator": "^0.14.1",
189
189
  "colorette": "^2.0.20",
190
+ "jiti": "^2.7.0",
190
191
  "jsonwebtoken": "^9.0.2",
191
192
  "jspdf": "^3.0.0",
192
193
  "jspdf-autotable": "^5.0.2",