emilsoftware-utilities 2.0.0-dev.80 → 2.0.0-dev.82
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/deploy/README.md +45 -0
- package/deploy/docker/docker-compose.yml +51 -0
- package/deploy/nginx/accessi-tenants.conf +41 -0
- package/deploy/systemd/accessi-tenant@.service +46 -0
- package/deploy/systemd/tenant.secrets.example +11 -0
- package/deploy/traefik/dynamic.yml +57 -0
- 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/tenantStores.d.ts +74 -0
- package/dist/accessi/server/tenantStores.js +84 -0
- package/dist/accessi/server/tenantStores.js.map +1 -0
- package/dist/accessi/server/tenants.d.ts +116 -27
- package/dist/accessi/server/tenants.js +186 -104
- package/dist/accessi/server/tenants.js.map +1 -1
- package/dist/accessi-module/Console/console.js +2 -2
- package/dist/accessi-module/Console/wiki.js +1 -1
- package/dist/accessi-module/Console/wiki.js.map +1 -1
- package/dist/emilsoftware-utilities-2.0.0-dev.82.tgz +0 -0
- package/dist/scripts/startAccessiTenantService.d.ts +1 -0
- package/dist/scripts/startAccessiTenantService.js +35 -0
- package/dist/scripts/startAccessiTenantService.js.map +1 -0
- package/docs/adr/0002-multi-tenancy.md +63 -35
- package/docs/multi-tenancy.md +139 -103
- package/package.json +7 -4
- package/dist/emilsoftware-utilities-2.0.0-dev.80.tgz +0 -0
|
@@ -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.
|
|
32
|
-
beta.
|
|
31
|
+
acme.environment.ts
|
|
32
|
+
beta.environment.ts
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Ogni file
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
`
|
|
53
|
-
|
|
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
|
-
- `
|
|
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 `.
|
|
77
|
-
abilitato e passa `PORT`, `
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 `.
|
|
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 `.
|
|
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>.
|
|
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
|
package/docs/multi-tenancy.md
CHANGED
|
@@ -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
|
|
5
|
-
decisione architetturale è in
|
|
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.
|
|
12
|
-
beta.
|
|
12
|
+
acme.environment.ts
|
|
13
|
+
beta.environment.ts
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
`acme.
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
- `
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
- `
|
|
56
|
-
- Ogni tenant deve avere chiave JWT e
|
|
57
|
-
chiavi condivise: un token di un tenant non deve essere
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
- `
|
|
87
|
-
- passa `PORT`, `
|
|
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
|
|
109
|
-
Environment=
|
|
110
|
-
|
|
111
|
-
ExecStart=/usr/bin/node dist/scripts/
|
|
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
|
-
|
|
126
|
-
|
|
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,25 +166,28 @@ 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
|
-
|
|
172
|
+
env_file: ./secrets/acme.env
|
|
139
173
|
volumes:
|
|
140
|
-
- ./
|
|
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
|
|
|
179
|
+
## Template pronti
|
|
180
|
+
|
|
181
|
+
In `deploy/` ci sono esempi copiabili:
|
|
182
|
+
|
|
183
|
+
- `deploy/systemd/accessi-tenant@.service` e `tenant.secrets.example`;
|
|
184
|
+
- `deploy/docker/docker-compose.yml`;
|
|
185
|
+
- `deploy/nginx/accessi-tenants.conf`;
|
|
186
|
+
- `deploy/traefik/dynamic.yml`.
|
|
187
|
+
|
|
188
|
+
La CI esegue `npm run check:deploy` per verificare presenza, coerenza e assenza
|
|
189
|
+
di segreti nei template.
|
|
190
|
+
|
|
154
191
|
## Reverse proxy
|
|
155
192
|
|
|
156
193
|
Il proxy instrada per host o path e **sovrascrive** l'header tenant se lo usa.
|
|
@@ -162,7 +199,7 @@ Non fidarsi di un header inviato dal client.
|
|
|
162
199
|
map $host $accessi_upstream {
|
|
163
200
|
accessi.acme.example.com http://127.0.0.1:3201;
|
|
164
201
|
accessi.beta.example.com http://127.0.0.1:3202;
|
|
165
|
-
default http://127.0.0.1:3201;
|
|
202
|
+
default http://127.0.0.1:3201;
|
|
166
203
|
}
|
|
167
204
|
|
|
168
205
|
server {
|
|
@@ -201,9 +238,9 @@ http:
|
|
|
201
238
|
|
|
202
239
|
### Path
|
|
203
240
|
|
|
204
|
-
Con `
|
|
241
|
+
Con `tenant.path = '/acme'`, il proxy deve rimuovere il prefisso prima di
|
|
205
242
|
inoltrare (Accessi non lo conosce) oppure il tenant deve essere servito alla
|
|
206
|
-
radice
|
|
243
|
+
radice:
|
|
207
244
|
|
|
208
245
|
```nginx
|
|
209
246
|
location /acme/ {
|
|
@@ -218,41 +255,38 @@ Se si usa la risoluzione via header, impostarlo solo nel proxy:
|
|
|
218
255
|
proxy_set_header X-Accessi-Tenant acme;
|
|
219
256
|
```
|
|
220
257
|
|
|
221
|
-
e avviare
|
|
258
|
+
e avviare il router con `allowHeader: true` solo nel servizio che si fida del
|
|
259
|
+
proxy.
|
|
222
260
|
|
|
223
261
|
## Store Redis condivisi
|
|
224
262
|
|
|
225
|
-
Con Redis condiviso
|
|
226
|
-
|
|
263
|
+
Con Redis condiviso usare l'helper: crea tutti gli store con prefissi
|
|
264
|
+
`accessi:<tenantId>:` e li collega alle opzioni.
|
|
227
265
|
|
|
228
266
|
```ts
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
};
|
|
267
|
+
import { createAccessiTenantStores } from 'emilsoftware-utilities/accessi/server';
|
|
268
|
+
|
|
269
|
+
const stores = createAccessiTenantStores({
|
|
270
|
+
tenantId: tenant.id,
|
|
271
|
+
client: redisClient,
|
|
272
|
+
encryptionKey: tenant.options.encryptionKey,
|
|
273
|
+
});
|
|
274
|
+
const options: AccessiOptions = stores.applyTo(tenant.options);
|
|
246
275
|
```
|
|
247
276
|
|
|
277
|
+
`applyTo` collega `publicAuthRateLimit`, `serviceTokens`,
|
|
278
|
+
`sessionRevocation`, `accessTokenRevocation`, `refreshTokenRotation` e
|
|
279
|
+
`consoleEntry`, preservando le altre opzioni. I prefissi sono disponibili in
|
|
280
|
+
`stores.prefixes`; `getAccessiTenantStorePrefixes(tenantId)` li espone anche
|
|
281
|
+
senza creare gli store.
|
|
282
|
+
|
|
248
283
|
Senza prefisso due tenant condividerebbero rate limit, revoche e refresh: è un
|
|
249
|
-
leakage.
|
|
250
|
-
va testato.
|
|
284
|
+
leakage. I test di integrazione Redis verificano l'isolamento.
|
|
251
285
|
|
|
252
286
|
## Metriche e audit
|
|
253
287
|
|
|
254
288
|
- Ogni processo espone `/health/metrics` sulla propria porta: Prometheus può
|
|
255
|
-
scrapare per tenant
|
|
289
|
+
scrapare per tenant.
|
|
256
290
|
- `countAccessiMetric` aggiunge `tenant` quando `options.tenantId` è presente.
|
|
257
291
|
- `audit.tenantId` aggiunge `tenant` a ogni record; configurare un sink per
|
|
258
292
|
tenant o un sink condiviso che conserva il label.
|
|
@@ -262,19 +296,19 @@ va testato.
|
|
|
262
296
|
|
|
263
297
|
**Aggiungere un tenant**
|
|
264
298
|
|
|
265
|
-
1. Creare `tenants/<id>.
|
|
299
|
+
1. Creare `tenants/<id>.environment.ts` con DB e chiavi propri.
|
|
266
300
|
2. `npm run accessi:tenants -- --dir ./tenants --check`.
|
|
267
301
|
3. Creare il database, eseguire le migrazioni (`db:update:accessi`,
|
|
268
|
-
`db:catalog:accessi`) con
|
|
302
|
+
`db:catalog:accessi`) con la configurazione del tenant.
|
|
269
303
|
4. Avviare/riavviare il supervisore o l'unità systemd del tenant.
|
|
270
304
|
5. Aggiungere la rotta al proxy e verificare `/health/ready`.
|
|
271
305
|
|
|
272
306
|
**Rimuovere un tenant**
|
|
273
307
|
|
|
274
308
|
1. Rimuovere la rotta dal proxy (il traffico si ferma).
|
|
275
|
-
2.
|
|
309
|
+
2. Impostare `tenant.enabled: false` e riavviare.
|
|
276
310
|
3. Conservare database e backup secondo la retention; solo dopo eliminare il
|
|
277
|
-
file `.
|
|
311
|
+
file `.environment.ts` e i segreti.
|
|
278
312
|
|
|
279
313
|
**Ruotare le chiavi**
|
|
280
314
|
|
|
@@ -283,8 +317,8 @@ chiavi tra tenant; la validazione blocca chiavi JWT condivise.
|
|
|
283
317
|
|
|
284
318
|
## Migrazione da single-tenant
|
|
285
319
|
|
|
286
|
-
1.
|
|
287
|
-
`
|
|
320
|
+
1. Creare `tenants/<id>.environment.ts` partendo dal `.env` attuale, usando
|
|
321
|
+
`process.env` per i segreti.
|
|
288
322
|
2. Validare con `--check`.
|
|
289
323
|
3. Avviare il tenant sulla nuova porta mantenendo il servizio vecchio.
|
|
290
324
|
4. Verificare login, refresh, JWKS, console, health e metriche sul nuovo.
|
|
@@ -303,3 +337,5 @@ schema.
|
|
|
303
337
|
- La risoluzione via header è ammessa solo dietro proxy fidato; il client non
|
|
304
338
|
deve poter scegliere il tenant.
|
|
305
339
|
- Un tenant con path `/` senza host non può convivere con altri (validazione).
|
|
340
|
+
- I file `.environment.ts` sono eseguiti: trattarli come codice fidato e non
|
|
341
|
+
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.
|
|
3
|
+
"version": "2.0.0-dev.82",
|
|
4
4
|
"description": "Utilities for EmilSoftware",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -139,13 +139,14 @@
|
|
|
139
139
|
"prepublishOnly": "npm run build",
|
|
140
140
|
"typecheck": "tsc --noEmit",
|
|
141
141
|
"lint": "node scripts/lint.cjs",
|
|
142
|
+
"check:deploy": "node scripts/check-deploy-templates.cjs",
|
|
142
143
|
"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",
|
|
144
|
+
"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 test/deploy-templates.cjs",
|
|
145
|
+
"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 test/deploy-templates.cjs",
|
|
145
146
|
"test:accessi": "npm run test:unit",
|
|
146
147
|
"test:all": "npm test && npm run test:integration && npm run test:integration:redis",
|
|
147
148
|
"test:integration": "node test/integration/run-firebird.cjs 5",
|
|
148
|
-
"test:integration:redis": "node --test test/integration/redis-stores.integration.cjs",
|
|
149
|
+
"test:integration:redis": "node --test --test-concurrency=1 test/integration/redis-stores.integration.cjs test/integration/redis-tenant-stores.integration.cjs",
|
|
149
150
|
"test:load:redis": "node --test --test-concurrency=1 test/integration/redis-load.integration.cjs",
|
|
150
151
|
"test:integration:fb5": "node test/integration/run-firebird.cjs 5",
|
|
151
152
|
"test:integration:fb3": "node test/integration/run-firebird.cjs 3",
|
|
@@ -187,6 +188,7 @@
|
|
|
187
188
|
"class-transformer": "^0.5.1",
|
|
188
189
|
"class-validator": "^0.14.1",
|
|
189
190
|
"colorette": "^2.0.20",
|
|
191
|
+
"jiti": "^2.7.0",
|
|
190
192
|
"jsonwebtoken": "^9.0.2",
|
|
191
193
|
"jspdf": "^3.0.0",
|
|
192
194
|
"jspdf-autotable": "^5.0.2",
|
|
@@ -226,6 +228,7 @@
|
|
|
226
228
|
},
|
|
227
229
|
"files": [
|
|
228
230
|
"dist",
|
|
231
|
+
"deploy",
|
|
229
232
|
"integration-prompt.md",
|
|
230
233
|
"docs"
|
|
231
234
|
],
|
|
Binary file
|