import type { Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { requireAuth, tenantScope } from "./auth.ts"; import type { Db } from "./db.ts"; import { generateSecurePassword, hashPassword } from "./crypto.ts"; import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { callIamFn, filterSensitivePermissions, invalidatePermissionCache, requirePermission, revokeSessionsForRoleUsers, } from "./permissions.ts"; import { RESERVED_ROLE_CODES, ROLE_TEMPLATES } from "./iam_catalog.ts"; import { invalidateUserScopeCache } from "./user_scope.ts"; import { revokeAllSessionsForUser } from "./sessions.ts"; import { getUserScope, isOwnerAccountRow } from "./user_scope.ts"; type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; function tid(c: { get: (k: "user") => AuthUser }): number { return tenantScope(c.get("user")) ?? 0; } async function assertNotOwnerTarget( c: Parameters[0], tenantId: number, userId: number, ): Promise { const list = await callIamFn<{ ok: boolean; data?: { users?: Array<{ id: number; is_owner?: boolean }> } }>( tenantId, "iam.fn_user_list", { tenant_id: tenantId }, ); const users = list?.data?.users ?? []; const target = users.find((u) => u.id === userId); if (target && isOwnerAccountRow(target)) { respondApiError(c, "FORBIDDEN", "No se puede modificar la cuenta propietaria desde IAM", { route: routeLabel(c), user_id: userId, }); return false; } return true; } export function registerIamRoutes(app: App) { app.get("/v1/iam/users", requireAuth, requirePermission("users.view"), async (c) => { const t = tid(c); const row = await callIamFn(t, "iam.fn_user_list", { tenant_id: t }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar usuarios IAM", data: null, errors: null }); } return respondRpc(c, row as Parameters[1]); }); app.get("/v1/iam/permissions", requireAuth, requirePermission("users.view"), async (c) => { const row = await callIamFn(tid(c), "iam.fn_permission_list", {}); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null }); } return respondRpc(c, row as Parameters[1]); }); app.get("/v1/iam/roles", requireAuth, requirePermission("users.view"), async (c) => { const t = tid(c); const row = await callIamFn(t, "iam.fn_role_list", { tenant_id: t }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar roles", data: null, errors: null }); } return respondRpc(c, row as Parameters[1]); }); app.post("/v1/iam/roles", requireAuth, requirePermission("users.create"), async (c) => { const user = c.get("user"); const body = await c.req.json<{ code?: string; label?: string; template?: string }>(); const code = (body.code ?? body.template ?? "").trim().toLowerCase(); if (RESERVED_ROLE_CODES.has(code)) { return respondApiError(c, "VALIDATION", `Código de rol reservado: ${code}`, { route: routeLabel(c) }); } const t = tid(c); const row = await callIamFn(t, "iam.fn_role_create", { tenant_id: t, code, label: body.label ?? code, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo crear rol", data: null, errors: null }); } const env = row as { ok: boolean; data?: { role?: { id: number } } }; if (env.ok && body.template && ROLE_TEMPLATES[body.template]) { const roleId = env.data?.role?.id ?? (env.data as { id?: number })?.id; if (roleId) { const perms = filterSensitivePermissions(ROLE_TEMPLATES[body.template].permissions, user.is_owner); await callIamFn(t, "iam.fn_role_permissions_save", { role_id: roleId, permissions: perms }); } } return respondRpc(c, row as Parameters[1]); }); app.patch("/v1/iam/roles/:id", requireAuth, requirePermission("users.update"), async (c) => { const t = tid(c); const body = await c.req.json<{ label?: string }>(); const row = await callIamFn(t, "iam.fn_role_update", { tenant_id: t, role_id: Number(c.req.param("id")), label: body.label, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo actualizar rol", data: null, errors: null }); } return respondRpc(c, row as Parameters[1]); }); app.delete("/v1/iam/roles/:id", requireAuth, requirePermission("users.delete"), async (c) => { const t = tid(c); const roleId = Number(c.req.param("id")); const row = await callIamFn(t, "iam.fn_role_delete", { tenant_id: t, role_id: roleId }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo eliminar rol", data: null, errors: null }); } const env = row as { ok: boolean; code?: string }; if (!env.ok && env.code === "CONFLICT") { return respondApiError(c, "CONFLICT", "El rol tiene usuarios asignados", { route: routeLabel(c), role_id: roleId }); } return respondRpc(c, row as Parameters[1]); }); app.get("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.view"), async (c) => { const t = tid(c); const row = await callIamFn(t, "iam.fn_role_permissions_get", { role_id: Number(c.req.param("id")) }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos del rol", data: null, errors: null }); } return respondRpc(c, row as Parameters[1]); }); app.put("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.update"), async (c) => { const user = c.get("user"); const body = await c.req.json<{ permissions?: string[] }>(); const roleId = Number(c.req.param("id")); const perms = filterSensitivePermissions(body.permissions ?? [], user.is_owner); const t = tid(c); const row = await callIamFn(t, "iam.fn_role_permissions_save", { role_id: roleId, permissions: perms, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null }); } invalidatePermissionCache(roleId); await revokeSessionsForRoleUsers(t, roleId); return respondRpc(c, row as Parameters[1]); }); app.post("/v1/iam/users", requireAuth, requirePermission("users.create"), async (c) => { const user = c.get("user"); const body = await c.req.json<{ username?: string; display_name?: string; email?: string; role_id?: number; company_id?: number | null; scope_all_projects?: boolean; warehouse_central?: boolean; warehouse_projects?: boolean; project_ids?: number[]; }>(); const tempPassword = generateSecurePassword(); const hash = await hashPassword(tempPassword); const t = tid(c); const row = await callIamFn(t, "iam.fn_user_create", { tenant_id: t, username: body.username, display_name: body.display_name, email: body.email ?? "", role_id: body.role_id, company_id: body.company_id, password_hash: hash, must_change_password: true, scope_all_projects: user.is_owner ? Boolean(body.scope_all_projects) : false, warehouse_central: Boolean(body.warehouse_central), warehouse_projects: body.warehouse_projects !== false, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo crear usuario", data: null, errors: null }); } const env = row as { ok: boolean; data?: { user?: { id: number } } }; if (env.ok && env.data?.user?.id && body.project_ids?.length) { await callIamFn(t, "iam.fn_user_projects_save", { user_id: env.data.user.id, project_ids: body.project_ids, }); } if (env.ok) { (env as { data: Record }).data = { ...(env.data ?? {}), temporary_password: tempPassword, }; } return respondRpc(c, row as Parameters[1]); }); app.patch("/v1/iam/users/:id", requireAuth, requirePermission("users.update"), async (c) => { const user = c.get("user"); const userId = Number(c.req.param("id")); const t = tid(c); if (!await assertNotOwnerTarget(c, t, userId)) return; const body = await c.req.json<{ display_name?: string; email?: string; role_id?: number; status?: string; scope_all_projects?: boolean; warehouse_central?: boolean; warehouse_projects?: boolean; project_ids?: number[]; }>(); const row = await callIamFn(t, "iam.fn_user_update", { tenant_id: t, id: userId, display_name: body.display_name, email: body.email, role_id: body.role_id, status: body.status, scope_all_projects: user.is_owner ? body.scope_all_projects : false, warehouse_central: body.warehouse_central, warehouse_projects: body.warehouse_projects, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo actualizar usuario", data: null, errors: null }); } const env = row as { ok: boolean }; if (env.ok && body.project_ids) { await callIamFn(t, "iam.fn_user_projects_save", { user_id: userId, project_ids: body.project_ids }); } if (env.ok) { invalidateUserScopeCache(userId); if (body.role_id != null || body.status === "baja") { await revokeAllSessionsForUser(userId, "app"); } } return respondRpc(c, row as Parameters[1]); }); app.post("/v1/iam/users/:id/reset-access", requireAuth, requirePermission("users.update"), async (c) => { const userId = Number(c.req.param("id")); const t = tid(c); if (!await assertNotOwnerTarget(c, t, userId)) return; const tempPassword = generateSecurePassword(); const hash = await hashPassword(tempPassword); const row = await callIamFn(t, "iam.fn_user_reset_access", { tenant_id: t, user_id: userId, password_hash: hash, }); if (!row) { return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo resetear acceso", data: null, errors: null }); } const env = row as { ok: boolean; data?: Record }; if (env.ok) { await revokeAllSessionsForUser(userId, "app"); env.data = { ...(env.data ?? {}), temporary_password: tempPassword }; } return respondRpc(c, row as Parameters[1]); }); app.get("/v1/me/permissions", requireAuth, async (c) => { const user = c.get("user"); const { userPermissionsByRoleId } = await import("./permissions.ts"); const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, user.is_owner); return respondRpc(c, { ok: true, code: "OK", layer: "api", message: `Permisos del usuario ${user.username}`, data: { permissions: [...perms], role_code: user.role_code, role_id: user.role_id, is_owner: user.is_owner, }, errors: null, }); }); app.get("/v1/me/scope", requireAuth, async (c) => { const user = c.get("user"); const scope = await getUserScope(user); return respondRpc(c, { ok: true, code: "OK", layer: "api", message: "Alcance del usuario", data: { all_projects: scope.allProjects, project_ids: scope.projectIds, warehouse_central: scope.warehouseCentral, warehouse_projects: scope.warehouseProjects, }, errors: null, }); }); }