panels-origin/api/auth.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

257 lines
9.2 KiB
TypeScript

import type { Context, Next } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { withIamTenant, findUserByUsernameAnyTenant } from "./iam_db.ts";
import { getPlatformDb } from "./platform_db.ts";
import { config } from "./config.ts";
import { createSession, getSession, revokeSession, revokeAllSessionsForUser } from "./sessions.ts";
import { hashPassword, verifyPassword } from "./crypto.ts";
export type AuthRealm = "app" | "platform";
export type AuthRole = "platform_admin" | "tenant_admin" | "user";
export type AuthUser = {
id: number;
username: string;
display_name: string;
company_id: number | null;
company_code: string | null;
company_name: string | null;
company_kind: string | null;
tenant_id: number | null;
role: AuthRole;
realm: AuthRealm;
must_change_password: boolean;
};
const COOKIE = "po_session";
const TTL_SECONDS = 60 * 60 * 24 * 7;
export async function createSessionCookie(
c: Context,
userId: number,
realm: AuthRealm = "app",
tenantId: number | null = null,
) {
const id = await createSession(userId, realm, tenantId);
setCookie(c, COOKIE, id, {
httpOnly: true,
path: "/",
sameSite: "Lax",
secure: config.cookieSecure,
maxAge: TTL_SECONDS,
});
}
export async function clearSession(c: Context) {
const id = getCookie(c, COOKIE);
if (id) await revokeSession(id);
deleteCookie(c, COOKIE, { path: "/" });
}
function asAppUser(row: Record<string, unknown>): AuthUser {
const role = (row.role_code as string) || "user";
return {
id: Number(row.id),
username: String(row.username),
display_name: String(row.display_name),
company_id: row.company_id == null ? null : Number(row.company_id),
company_code: (row.company_code as string | null) ?? null,
company_name: (row.company_name as string | null) ?? null,
company_kind: (row.company_kind as string | null) ?? null,
tenant_id: row.tenant_id == null ? null : Number(row.tenant_id),
role: role === "tenant_admin" ? "tenant_admin" : "user",
realm: "app",
must_change_password: Boolean(row.must_change_password),
};
}
function asPlatformUser(row: { id: number; username: string; display_name: string }): AuthUser {
return {
id: row.id,
username: row.username,
display_name: row.display_name,
company_id: null,
company_code: null,
company_name: null,
company_kind: null,
tenant_id: null,
role: "platform_admin",
realm: "platform",
must_change_password: false,
};
}
async function userFromCookie(c: Context): Promise<AuthUser | null> {
const id = getCookie(c, COOKIE);
if (!id) return null;
const session = await getSession(id);
if (!session) return null;
if (session.realm === "platform") {
const pdb = await getPlatformDb();
const row = await pdb.prepare(
`SELECT id, username, display_name FROM platform_users
WHERE id = ? AND status = 'activo'`,
).get(session.userId) as { id: number; username: string; display_name: string } | undefined;
return row ? asPlatformUser(row) : null;
}
return await getFreshAppUser(session.userId, session.tenantId);
}
/** Relee un usuario app (iam) fresco de la base y lo enriquece con su
* empresa (core) -- usado tras login y tras cambiar contraseña. */
export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> {
const row = await withIamTenant(tenantId, async (db) => {
return await db.prepare(
`SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email FROM users WHERE id = ?`,
).get(userId);
});
if (!row) return null;
const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null);
return asAppUser({ ...row, ...company });
}
function apiKeyOk(c: Context): boolean {
if (!config.apiKey) return false;
const header = c.req.header("x-api-key") ?? "";
if (header.length !== config.apiKey.length) return false;
let diff = 0;
for (let i = 0; i < header.length; i++) diff |= header.charCodeAt(i) ^ config.apiKey.charCodeAt(i);
return diff === 0;
}
export async function requireAuth(c: Context, next: Next) {
if (apiKeyOk(c)) {
// La API key ya NO otorga visibilidad cruzada de todos los tenants
// (era un hallazgo crítico de la revisión de seguridad): ahora exige un
// tenant explícito por header, y las políticas de RLS son fail-closed
// si no se fija -- sin X-Tenant-Id válido, la API key no ve nada.
const tenantHeader = c.req.header("x-tenant-id") ?? "";
const tenantId = Number(tenantHeader);
if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) {
return c.json({ error: "X-API-Key requiere X-Tenant-Id" }, 400);
}
c.set("user", {
id: 0,
username: "api",
display_name: "API Key",
company_id: null,
company_code: null,
company_name: null,
company_kind: null,
tenant_id: tenantId,
role: "tenant_admin",
realm: "app",
must_change_password: false,
} satisfies AuthUser);
await next();
return;
}
const user = await userFromCookie(c);
if (!user) return c.json({ error: "No autenticado" }, 401);
c.set("user", user);
await next();
}
export async function requirePlatformAdmin(c: Context, next: Next) {
const user = await userFromCookie(c);
if (!user) return c.json({ error: "No autenticado" }, 401);
if (user.realm !== "platform" || user.role !== "platform_admin") {
return c.json({ error: "Solo administradores SaaS" }, 403);
}
c.set("user", user);
await next();
}
export function tenantScope(user: AuthUser): number | null {
if (user.realm === "platform") return null;
return user.tenant_id;
}
export async function login(username: string, password: string): Promise<AuthUser | null> {
const user = username.trim();
const pass = password.trim();
if (!user || !pass) return null;
// username es único globalmente (no por tenant) -- se resuelve con el
// pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién
// después de esto se conoce el tenant_id para todo lo demás.
const appRow = await findUserByUsernameAnyTenant(user);
if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
const authUser = await withIamTenant(
appRow.tenant_id == null ? null : Number(appRow.tenant_id),
async (db) => {
const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
return asAppUser({ ...appRow, ...company });
},
);
const pdb = await getPlatformDb();
const { tenantAccessBlocked } = await import("./saas.ts");
const blocked = await tenantAccessBlocked(pdb, authUser.tenant_id);
if (blocked) {
throw Object.assign(new Error(blocked), { code: "TENANT_BLOCKED" });
}
return authUser;
}
const pdb = await getPlatformDb();
const plat = await pdb.prepare(
`SELECT id, username, display_name, password_hash FROM platform_users
WHERE username = ? AND status = 'activo'`,
).get(user) as
| { id: number; username: string; display_name: string; password_hash: string }
| undefined;
if (plat && await verifyPassword(pass, plat.password_hash)) {
return asPlatformUser(plat);
}
return null;
}
/** company_id de iam.users es una referencia lógica a core.companies(id)
* (sin FK -- esquemas aislados). Esta función vive en auth.ts para no
* crear un import cruzado iam<->core; usa la conexión core con el rol de
* runtime normal (companies no está sujeta a RLS por-fila salvo por
* tenant_id, así que basta con conocer el tenant ya resuelto). */
async function enrichWithCompanyViaCore(companyId: number | null) {
if (companyId == null) return { company_code: null, company_name: null, company_kind: null };
const { getCoreDb } = await import("./db.ts");
const db = await getCoreDb();
const row = await db.prepare("SELECT code, name, kind FROM companies WHERE id = ?").get(
companyId,
);
return {
company_code: (row?.code as string) ?? null,
company_name: (row?.name as string) ?? null,
company_kind: (row?.kind as string) ?? null,
};
}
export async function changePassword(
userId: number,
tenantId: number | null,
currentPassword: string,
newPassword: string,
): Promise<{ error?: string }> {
const next = (newPassword ?? "").trim();
if (next.length < 8) return { error: "La nueva contraseña debe tener al menos 8 caracteres" };
return await withIamTenant(tenantId, async (db) => {
const row = await db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as
| { password_hash: string }
| undefined;
if (!row) return { error: "Usuario no encontrado" };
if (!await verifyPassword(currentPassword, row.password_hash)) {
return { error: "Contraseña actual incorrecta" };
}
const hash = await hashPassword(next);
await db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?",
).run(hash, userId);
// Cambiar password revoca TODAS las demás sesiones activas de este
// usuario -- antes (cookie HMAC stateless) esto era imposible; ahora
// sí, porque el estado real vive en Redis (ver sessions.ts).
await revokeAllSessionsForUser(userId, "app");
return {};
});
}