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.
- 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.81.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 +127 -103
- package/package.json +5 -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,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
|
-
|
|
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
|
|
|
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;
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
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.
|
|
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
|
|
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>.
|
|
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
|
|
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.
|
|
297
|
+
2. Impostare `tenant.enabled: false` e riavviare.
|
|
276
298
|
3. Conservare database e backup secondo la retention; solo dopo eliminare il
|
|
277
|
-
file `.
|
|
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.
|
|
287
|
-
`
|
|
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.
|
|
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",
|
|
Binary file
|