SuiteCoffee/packages/core/middlewares/tenantContext.mjs
2025-10-16 19:49:50 +00:00

156 lines
5.3 KiB
JavaScript

// packages/core/middlewares/src/tenantContext.mjs
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; // schema seguro
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function redact(obj) {
// Evita loggear datos sensibles; muestra sólo lo útil para diagnóstico
if (!obj || typeof obj !== 'object') return obj;
const out = {};
for (const k of Object.keys(obj)) {
if (['token', 'access_token', 'id_token', 'refresh_token'].includes(k)) {
out[k] = '[redacted]';
} else if (k === 'sub' || k === 'email' || k === 'name') {
out[k] = obj[k];
} else if (k === 'tenant') {
const t = obj[k] || {};
out[k] = { id: t.id ?? null, schema: t.schema ?? null };
} else if (k === 'user') {
const u = obj[k] || {};
out[k] = {
sub: u.sub ?? null,
email: u.email ?? null,
default_tenant: u.default_tenant ?? u.defaultTenant ?? null,
memberships: Array.isArray(u.memberships) ? `[${u.memberships.length}]` : null,
};
} else {
// no inundar el log; deja constancia de que existe
out[k] = '[present]';
}
}
return out;
}
export function tenantContext(opts = {}) {
const {
requireUser = true,
debug = false,
log = console, // podés inyectar tu logger
autoDeriveFromDefault = true,
// callback opcional para buscar tenant (p.ej., en CORE) si no está en sesión
// Debe devolver { id: uuid, schema: string } o null
resolveTenant = null,
schemaPrefixes = [
process.env.TENANT_SCHEMA_PREFIX || 'empresa_',
].filter(Boolean),
} = opts;
const diag = (msg, data) => {
if (!debug) return;
try { log.debug?.(`[tenantContext] ${msg}`, data !== undefined ? redact(data) : ''); }
catch { /* noop */ }
};
const setDiagHeader = (res, kv) => {
if (!debug) return;
const cur = res.getHeader('X-Tenant-Diag');
const base = typeof cur === 'string' ? String(cur) + '; ' : '';
res.setHeader('X-Tenant-Diag', base + kv);
};
return async (req, res, next) => {
try {
diag('incoming', { sid: req.sessionID, headers: { accept: req.headers.accept } });
const sess = req.session;
if (!sess) {
setDiagHeader(res, 'no-session');
return res.status(401).json({ error: 'unauthenticated' });
}
diag('session.present', { keys: Object.keys(sess) });
if (requireUser && !sess.user?.sub) {
diag('user.missing', { session: sess });
setDiagHeader(res, 'no-user');
return res.status(401).json({ error: 'unauthenticated' });
}
if (requireUser) diag('user.ok', sess.user);
// 1) Leer tenant desde sesión
let t = sess.tenant ?? null;
diag('session.tenant', t);
// 2) Derivar automáticamente si falta
if ((!t?.id || !t?.schema) && autoDeriveFromDefault) {
const fallbackId =
sess.user?.tenant?.id ||
sess.user?.default_tenant ||
sess.user?.defaultTenant ||
null;
if (fallbackId && UUID_RX.test(String(fallbackId))) {
const prefix = String(schemaPrefixes[0] || 'empresa_');
const schema = `${prefix}${String(fallbackId).replace(/-/g, '').toLowerCase()}`;
t = { id: String(fallbackId), schema };
sess.tenant = t; // persistir para siguientes requests
diag('derived.fromDefault', t);
setDiagHeader(res, 'derived-default');
} else {
diag('derived.fromDefault.skipped', { fallbackId });
}
}
// 3) Resolver con callback si aún falta
if ((!t?.id || !t?.schema) && typeof resolveTenant === 'function') {
try {
t = await resolveTenant(req, sess);
if (t) {
sess.tenant = t;
diag('derived.fromResolver', t);
setDiagHeader(res, 'derived-resolver');
} else {
diag('resolver.returned-null');
}
} catch (e) {
diag('resolver.error', { message: e?.message });
}
}
// 4) Validaciones
if (!t?.id || !t?.schema) {
diag('missing-tenant.final');
setDiagHeader(res, 'missing-tenant');
return res.status(401).json({ error: 'Sesión inválida o tenant no seleccionado' });
}
if (!UUID_RX.test(String(t.id))) {
diag('invalid-tenant-id', t);
setDiagHeader(res, 'bad-tenant-id');
return res.status(400).json({ error: 'TenantID inválido' });
}
if (!VALID_IDENT.test(t.schema)) {
diag('invalid-schema', t);
setDiagHeader(res, 'bad-schema');
return res.status(400).json({ error: 'Schema inválido' });
}
const okPrefix = schemaPrefixes.some(p =>
t.schema.toLowerCase().startsWith(String(p).toLowerCase()),
);
if (!okPrefix) {
diag('schema-prefix.rejected', { schema: t.schema, schemaPrefixes });
setDiagHeader(res, 'schema-prefix-rejected');
return res.status(400).json({ error: 'Schema no permitido' });
}
// 5) OK
req.tenant = { id: String(t.id), schema: String(t.schema) };
res.locals.tenant = req.tenant;
setDiagHeader(res, `ok schema=${req.tenant.schema}`);
diag('attach.req.tenant', req.tenant);
return next();
} catch (err) {
diag('exception', { message: err?.message });
return next(err);
}
};
}