@volcanicminds/backend 2.2.21 → 2.3.2
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/README.md +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -169
- package/dist/index.js.map +1 -1
- package/dist/lib/api/auth/controller/auth.d.ts +0 -9
- package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +32 -75
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/auth/routes.d.ts +0 -45
- package/dist/lib/api/auth/routes.d.ts.map +1 -1
- package/dist/lib/api/auth/routes.js +1 -32
- package/dist/lib/api/auth/routes.js.map +1 -1
- package/dist/lib/api/tool/controller/tool.d.ts.map +1 -1
- package/dist/lib/api/tool/controller/tool.js +4 -0
- package/dist/lib/api/tool/controller/tool.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts +10 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +56 -2
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/api/users/routes.d.ts +67 -0
- package/dist/lib/api/users/routes.d.ts.map +1 -1
- package/dist/lib/api/users/routes.js +55 -2
- package/dist/lib/api/users/routes.js.map +1 -1
- package/dist/lib/config/general.d.ts +7 -0
- package/dist/lib/config/general.d.ts.map +1 -1
- package/dist/lib/config/general.js +8 -1
- package/dist/lib/config/general.js.map +1 -1
- package/dist/lib/defaults/managers.d.ts +8 -0
- package/dist/lib/defaults/managers.d.ts.map +1 -0
- package/dist/lib/defaults/managers.js +71 -0
- package/dist/lib/defaults/managers.js.map +1 -0
- package/dist/lib/hooks/onRequest.d.ts.map +1 -1
- package/dist/lib/hooks/onRequest.js +54 -2
- package/dist/lib/hooks/onRequest.js.map +1 -1
- package/dist/lib/hooks/onResponse.d.ts.map +1 -1
- package/dist/lib/hooks/onResponse.js +5 -0
- package/dist/lib/hooks/onResponse.js.map +1 -1
- package/dist/lib/loader/general.d.ts.map +1 -1
- package/dist/lib/loader/general.js +6 -1
- package/dist/lib/loader/general.js.map +1 -1
- package/dist/lib/loader/tenant.d.ts +3 -0
- package/dist/lib/loader/tenant.d.ts.map +1 -0
- package/dist/lib/loader/tenant.js +67 -0
- package/dist/lib/loader/tenant.js.map +1 -0
- package/lib/api/auth/controller/auth.ts +35 -81
- package/lib/api/auth/routes.ts +4 -33
- package/lib/api/tool/controller/tool.ts +5 -0
- package/lib/api/users/controller/user.ts +69 -2
- package/lib/api/users/routes.ts +58 -2
- package/lib/config/general.ts +8 -1
- package/lib/defaults/managers.ts +88 -0
- package/lib/hooks/onRequest.ts +72 -7
- package/lib/hooks/onResponse.ts +6 -0
- package/lib/loader/general.ts +6 -1
- package/lib/loader/tenant.ts +86 -0
- package/package.json +1 -1
package/lib/hooks/onRequest.ts
CHANGED
|
@@ -23,6 +23,63 @@ const normalizeRoles = (rolesArray: any[] | undefined): string[] => {
|
|
|
23
23
|
export default async (req, reply) => {
|
|
24
24
|
if (log.i) req.startedAt = new Date()
|
|
25
25
|
|
|
26
|
+
const { multi_tenant } = global.config?.options || {}
|
|
27
|
+
|
|
28
|
+
if (multi_tenant?.enabled) {
|
|
29
|
+
let tenantSlug: string | undefined
|
|
30
|
+
|
|
31
|
+
if (multi_tenant.resolver === 'subdomain') {
|
|
32
|
+
const host = req.headers.host || ''
|
|
33
|
+
const parts = host.split('.')
|
|
34
|
+
if (parts.length >= 2) {
|
|
35
|
+
// FIXME: Improve subdomain extraction strategy. Currently assumes [slug].[domain].[tld] or [slug].localhost
|
|
36
|
+
if (parts[0] !== 'www') {
|
|
37
|
+
tenantSlug = parts[0]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else if (multi_tenant.resolver === 'header') {
|
|
41
|
+
tenantSlug = req.headers[multi_tenant?.header_key || 'x-tenant-id'] as string
|
|
42
|
+
} else if (multi_tenant.resolver === 'query') {
|
|
43
|
+
tenantSlug = (req.query as any)[multi_tenant?.query_key || 'tid']
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!tenantSlug) {
|
|
47
|
+
return reply.code(400).send({ statusCode: 400, error: 'Tenant ID missing', message: 'Tenant ID is required' })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!global.connection) {
|
|
51
|
+
log.error('Multi-tenant enabled but global.connection not found')
|
|
52
|
+
return reply.code(500).send({ statusCode: 500, error: 'Internal Server Error' })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tenant = await global.connection.getRepository(global.entity.Tenant).findOneBy({ slug: tenantSlug })
|
|
56
|
+
|
|
57
|
+
if (!tenant) {
|
|
58
|
+
return reply
|
|
59
|
+
.code(404)
|
|
60
|
+
.send({ statusCode: 404, error: 'Tenant Not Found', message: `Tenant '${tenantSlug}' not found` })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (tenant.status !== 'active') {
|
|
64
|
+
return reply.code(403).send({ statusCode: 403, error: 'Tenant Inactive', message: 'Tenant is not active' })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Tenant Context Setup
|
|
68
|
+
const runner = global.connection.createQueryRunner()
|
|
69
|
+
await runner.connect()
|
|
70
|
+
|
|
71
|
+
// Validate schema name safety
|
|
72
|
+
if (!/^[a-z0-9_]+$/i.test(tenant.schema)) {
|
|
73
|
+
await runner.release()
|
|
74
|
+
return reply.code(400).send({ statusCode: 400, error: 'Invalid Schema Name' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await runner.query(`SET search_path TO "${tenant.schema}", "public"`)
|
|
78
|
+
|
|
79
|
+
req.runner = runner
|
|
80
|
+
req.tenant = tenant
|
|
81
|
+
}
|
|
82
|
+
|
|
26
83
|
req.data = () => getData(req)
|
|
27
84
|
req.parameters = () => getParams(req)
|
|
28
85
|
|
|
@@ -55,9 +112,7 @@ export default async (req, reply) => {
|
|
|
55
112
|
let bearerToken: string | undefined
|
|
56
113
|
|
|
57
114
|
if (AUTH_MODE === 'COOKIE') {
|
|
58
|
-
// COOKIE MODE: ONLY Check Cookie
|
|
59
115
|
const cookieToken = req.cookies['auth_token']
|
|
60
|
-
// If cookie is signed:
|
|
61
116
|
if (cookieToken) {
|
|
62
117
|
const unsigned = req.unsignCookie(cookieToken)
|
|
63
118
|
if (unsigned.valid && unsigned.value) {
|
|
@@ -65,7 +120,6 @@ export default async (req, reply) => {
|
|
|
65
120
|
}
|
|
66
121
|
}
|
|
67
122
|
} else {
|
|
68
|
-
// BEARER MODE: ONLY Check Header
|
|
69
123
|
const auth = req.headers?.authorization || ''
|
|
70
124
|
const [prefix, token] = auth.split(' ')
|
|
71
125
|
if (prefix === 'Bearer' && token != null) {
|
|
@@ -77,7 +131,19 @@ export default async (req, reply) => {
|
|
|
77
131
|
try {
|
|
78
132
|
const tokenData = reply.server.jwt.verify(bearerToken)
|
|
79
133
|
|
|
80
|
-
//
|
|
134
|
+
// Validate Tenant Access
|
|
135
|
+
const { multi_tenant } = global.config?.options || {}
|
|
136
|
+
if (multi_tenant?.enabled && req.tenant && tokenData.tid) {
|
|
137
|
+
if (tokenData.tid !== req.tenant.id) {
|
|
138
|
+
return reply.status(403).send({
|
|
139
|
+
statusCode: 403,
|
|
140
|
+
code: 'TENANT_MISMATCH',
|
|
141
|
+
message: 'Token does not belong to this tenant'
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// MFA Gatekeeper Check
|
|
81
147
|
if (tokenData.role === 'pre-auth-mfa') {
|
|
82
148
|
const currentUrl = req.routeOptions.url || req.raw.url
|
|
83
149
|
const isAllowed = MFA_SETUP_WHITELIST.some((url) => currentUrl.endsWith(url))
|
|
@@ -143,9 +209,6 @@ export default async (req, reply) => {
|
|
|
143
209
|
})
|
|
144
210
|
}
|
|
145
211
|
}
|
|
146
|
-
} else {
|
|
147
|
-
// No token found, but maybe route is public?
|
|
148
|
-
// If route requires roles and no token, it will be caught by permissions check below (bearerToken is null)
|
|
149
212
|
}
|
|
150
213
|
|
|
151
214
|
if (cfg.requiredRoles?.length > 0) {
|
|
@@ -160,3 +223,5 @@ export default async (req, reply) => {
|
|
|
160
223
|
}
|
|
161
224
|
}
|
|
162
225
|
}
|
|
226
|
+
|
|
227
|
+
|
package/lib/hooks/onResponse.ts
CHANGED
package/lib/loader/general.ts
CHANGED
|
@@ -8,11 +8,16 @@ export async function load() {
|
|
|
8
8
|
options: {
|
|
9
9
|
allow_multiple_admin: false,
|
|
10
10
|
admin_can_change_passwords: false,
|
|
11
|
+
allow_admin_change_password_users: false,
|
|
11
12
|
reset_external_id_on_login: false,
|
|
12
13
|
scheduler: false,
|
|
13
14
|
embedded_auth: true,
|
|
14
15
|
mfa_admin_forced_reset_email: undefined,
|
|
15
|
-
mfa_admin_forced_reset_until: undefined
|
|
16
|
+
mfa_admin_forced_reset_until: undefined,
|
|
17
|
+
multi_tenant: {
|
|
18
|
+
enabled: false,
|
|
19
|
+
query_key: 'tid'
|
|
20
|
+
}
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
23
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { FastifyInstance } from 'fastify'
|
|
2
|
+
import { TenantManagement } from '../../types/global.js'
|
|
3
|
+
|
|
4
|
+
export async function apply(server: FastifyInstance) {
|
|
5
|
+
const { multi_tenant } = global.config.options || {}
|
|
6
|
+
|
|
7
|
+
// Se multi-tenant non è abilitato, usciamo subito e iniettiamo il contesto single-tenant
|
|
8
|
+
if (!multi_tenant?.enabled) {
|
|
9
|
+
if (log.i) log.info('Multi-Tenant: Disabled — using single-tenant DB context')
|
|
10
|
+
|
|
11
|
+
server.addHook('onRequest', async (req) => {
|
|
12
|
+
const dataSource = global.connection
|
|
13
|
+
if (dataSource) {
|
|
14
|
+
req.db = dataSource.manager
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (log.i) log.info('Multi-Tenant: 🟢 Enabled')
|
|
21
|
+
|
|
22
|
+
// Hook globale per la risoluzione del tenant
|
|
23
|
+
// Deve essere eseguito all'inizio della richiesta
|
|
24
|
+
server.addHook('onRequest', async (req, reply) => {
|
|
25
|
+
// Recuperiamo il gestore tenant iniettato o di default
|
|
26
|
+
const tm = server['tenantManager'] as TenantManagement
|
|
27
|
+
|
|
28
|
+
// Controllo critico: se è abilitato il MT, DEVE esserci un manager implementato
|
|
29
|
+
if (!tm || !tm.isImplemented()) {
|
|
30
|
+
const errorMsg = 'Multi-Tenant enabled but no TenantManager provided/implemented!'
|
|
31
|
+
if (log.f) log.fatal(errorMsg)
|
|
32
|
+
throw new Error(errorMsg)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 1. Risoluzione Tenant
|
|
37
|
+
const tenant = await tm.resolveTenant(req)
|
|
38
|
+
|
|
39
|
+
if (!tenant) {
|
|
40
|
+
if (log.w) log.warn(`Multi-Tenant: Tenant resolution failed for request ${req.id}`)
|
|
41
|
+
return reply.code(404).send({
|
|
42
|
+
statusCode: 404,
|
|
43
|
+
error: 'Not Found',
|
|
44
|
+
message: 'Tenant not found or resolution failed'
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Setup Contesto
|
|
49
|
+
req.tenant = tenant
|
|
50
|
+
if (log.t) log.trace(`Multi-Tenant: Context switched to ${tenant.slug || tenant.id}`)
|
|
51
|
+
|
|
52
|
+
// 3. Creazione QueryRunner (Context-Chain Root)
|
|
53
|
+
// Questo è il cuore della "Golden Solution": ogni richiesta ha il suo QueryRunner isolato
|
|
54
|
+
const dataSource = global.connection
|
|
55
|
+
if (dataSource) {
|
|
56
|
+
const qr = dataSource.createQueryRunner()
|
|
57
|
+
await qr.connect()
|
|
58
|
+
|
|
59
|
+
// Assegnamo il manager del QueryRunner alla richiesta
|
|
60
|
+
req.db = qr.manager
|
|
61
|
+
|
|
62
|
+
// 4. Switch Schema su QUESTO QueryRunner
|
|
63
|
+
// Passiamo il manager affinché il TenantManager possa eseguire "SET search_path" su questa connessione specifica
|
|
64
|
+
await tm.switchContext(tenant, qr.manager)
|
|
65
|
+
|
|
66
|
+
// 5. Cleanup: Rilascio del QueryRunner alla fine della richiesta
|
|
67
|
+
reply.raw.on('finish', async () => {
|
|
68
|
+
if (!qr.isReleased) {
|
|
69
|
+
await qr.release()
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
} else {
|
|
73
|
+
if (log.w) log.warn('Multi-Tenant: Global connection not found! Skipping DB context creation.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
} catch (err: unknown) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
78
|
+
if (log.e) log.error(`Multi-Tenant Error: ${message}`)
|
|
79
|
+
return reply.code(500).send({
|
|
80
|
+
statusCode: 500,
|
|
81
|
+
error: 'Internal Server Error',
|
|
82
|
+
message: 'Tenant Context Switch Failed'
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|