panels-origin/api/auth.ts
Cursor Agent aed2f4531b
Implement client IAM v2: roles per tenant, CRUD matrix, scope, and panel UI
- Add IAM v2 auth with role_id, is_owner, status checks, and API key without tenant_admin bypass
- Enforce CRUD permissions and project/warehouse scope across API routes
- Rewrite usuarios.vue with ribbon, tree, user/role management, and permission matrix
- Add dynamic menu, route guards, usePermissions/useScope composables
- Apply role templates on create; filter project docs by category; hide cost tab without permission
- Add iam_test unit tests; update bootstrap and SaaS tenant admin to use role_id

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 18:16:59 +00:00

272 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 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_id: number | null;
role_code: string;
is_owner: boolean;
status: string;
realm: AuthRealm;
must_change_password: boolean;
};
const COOKIE = "po_session";
const TTL_SECONDS = 60 * 60 * 24 * 7;
const USER_SELECT = `
SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
u.status, u.must_change_password, u.email
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = ?`;
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 isOwnerRole(row: Record<string, unknown>): boolean {
return row.role_code === "tenant_admin"
&& Boolean(row.role_is_system)
&& row.role_tenant_id == null;
}
function asAppUser(row: Record<string, unknown>): AuthUser {
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_id: row.role_id == null ? null : Number(row.role_id),
role_code: String(row.role_code ?? "user"),
is_owner: isOwnerRole(row),
status: String(row.status ?? "activo"),
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_id: null,
role_code: "platform_admin",
is_owner: true,
status: "activo",
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(USER_SELECT).get(userId);
});
if (!row) return null;
if (String(row.status ?? "activo") !== "activo") 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)) {
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_id: null,
role_code: "api_key",
is_owner: false,
status: "activo",
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_code !== "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;
const appRow = await findUserByUsernameAnyTenant(user);
if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
if (String(appRow.status ?? "activo") !== "activo") {
throw Object.assign(new Error("Usuario dado de baja"), { code: "USER_INACTIVE" });
}
const authUser = await withIamTenant(
appRow.tenant_id == null ? null : Number(appRow.tenant_id),
async () => {
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);
await revokeAllSessionsForUser(userId, "app");
return {};
});
}