mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
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>
257 lines
9.2 KiB
TypeScript
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 {};
|
|
});
|
|
}
|