From aed2f4531b6265089ffcad4e3c7e8109926259f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:16:59 +0000 Subject: [PATCH] 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 --- api/auth.ts | 59 ++- api/cost_control_http.ts | 29 +- api/cost_modules_test.ts | 25 +- api/expenses_http.ts | 59 ++- api/iam_catalog.ts | 135 +++++++ api/iam_db.ts | 9 +- api/iam_http.ts | 279 ++++++++++++- api/iam_test.ts | 72 ++++ api/main.ts | 138 ++++--- api/payroll_http.ts | 52 +-- api/permissions.ts | 90 +++-- api/saas.ts | 30 +- api/scope_enforcement.ts | 116 ++++++ api/scripts/bootstrap-admin.ts | 19 +- api/user_scope.ts | 77 ++++ api/warehouse_http.ts | 101 ++++- api/work_program_http.ts | 86 ++-- web-panel/components/IamDetail.vue | 110 +++++ web-panel/composables/useApi.ts | 5 +- web-panel/composables/useDesktop.ts | 16 +- web-panel/composables/usePermissions.ts | 39 ++ web-panel/composables/useScope.ts | 35 ++ web-panel/layouts/default.vue | 77 ++-- web-panel/middleware/auth.global.ts | 32 +- web-panel/pages/login.vue | 2 +- web-panel/pages/programa-obra.vue | 4 +- web-panel/pages/usuarios.vue | 513 ++++++++++++++++++++++-- 27 files changed, 1886 insertions(+), 323 deletions(-) create mode 100644 api/iam_catalog.ts create mode 100644 api/iam_test.ts create mode 100644 api/scope_enforcement.ts create mode 100644 api/user_scope.ts create mode 100644 web-panel/components/IamDetail.vue create mode 100644 web-panel/composables/usePermissions.ts create mode 100644 web-panel/composables/useScope.ts diff --git a/api/auth.ts b/api/auth.ts index 443c9e1..06cf53d 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -7,7 +7,6 @@ import { createSession, getSession, revokeSession, revokeAllSessionsForUser } fr import { hashPassword, verifyPassword } from "./crypto.ts"; export type AuthRealm = "app" | "platform"; -export type AuthRole = "platform_admin" | "tenant_admin" | "user"; export type AuthUser = { id: number; @@ -18,7 +17,10 @@ export type AuthUser = { company_name: string | null; company_kind: string | null; tenant_id: number | null; - role: AuthRole; + role_id: number | null; + role_code: string; + is_owner: boolean; + status: string; realm: AuthRealm; must_change_password: boolean; }; @@ -26,6 +28,14 @@ export type AuthUser = { 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, @@ -48,8 +58,13 @@ export async function clearSession(c: Context) { deleteCookie(c, COOKIE, { path: "/" }); } +function isOwnerRole(row: Record): boolean { + return row.role_code === "tenant_admin" + && Boolean(row.role_is_system) + && row.role_tenant_id == null; +} + function asAppUser(row: Record): AuthUser { - const role = (row.role_code as string) || "user"; return { id: Number(row.id), username: String(row.username), @@ -59,7 +74,10 @@ function asAppUser(row: Record): AuthUser { 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", + 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), }; @@ -75,7 +93,10 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin company_name: null, company_kind: null, tenant_id: null, - role: "platform_admin", + role_id: null, + role_code: "platform_admin", + is_owner: true, + status: "activo", realm: "platform", must_change_password: false, }; @@ -103,12 +124,10 @@ async function userFromCookie(c: Context): Promise { * empresa (core) -- usado tras login y tras cambiar contraseña. */ export async function getFreshAppUser(userId: number, tenantId: number | null): Promise { 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); + 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 }); } @@ -124,10 +143,6 @@ function apiKeyOk(c: Context): boolean { 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) { @@ -142,7 +157,10 @@ export async function requireAuth(c: Context, next: Next) { company_name: null, company_kind: null, tenant_id: tenantId, - role: "tenant_admin", + role_id: null, + role_code: "api_key", + is_owner: false, + status: "activo", realm: "app", must_change_password: false, } satisfies AuthUser); @@ -158,7 +176,7 @@ export async function requireAuth(c: Context, next: 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") { + if (user.realm !== "platform" || user.role_code !== "platform_admin") { return c.json({ error: "Solo administradores SaaS" }, 403); } c.set("user", user); @@ -175,14 +193,14 @@ export async function login(username: string, password: string): Promise { + async () => { const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null); return asAppUser({ ...appRow, ...company }); }, @@ -248,9 +266,6 @@ export async function changePassword( 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 {}; }); diff --git a/api/cost_control_http.ts b/api/cost_control_http.ts index fee67f4..412e5ff 100644 --- a/api/cost_control_http.ts +++ b/api/cost_control_http.ts @@ -5,7 +5,8 @@ import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; import { respondRpc, routeLabel } from "./http_errors.ts"; -import { requireAnyPermission } from "./permissions.ts"; +import { requirePermission } from "./permissions.ts"; +import { denyUnlessProjectScope } from "./scope_enforcement.ts"; type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; @@ -14,38 +15,48 @@ function tid(c: { get: (k: "user") => AuthUser }): number { } export function registerCostControlRoutes(app: App) { - app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + const auth = [...requireCoreAuth, requirePermission("cost_control.view")] as const; + + app.get("/v1/projects/:id/cost-control/summary", ...auth, async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_control_project_summary", { tenant_id: tid(c), - project_id: Number(c.req.param("id")), + project_id: projectId, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/projects/:id/cost-control/items", ...auth, async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_control_by_item", { tenant_id: tid(c), - project_id: Number(c.req.param("id")), + project_id: projectId, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/projects/:id/cost-control/chapters", ...auth, async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", { tenant_id: tid(c), - project_id: Number(c.req.param("id")), + project_id: projectId, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/projects/:id/cost-control/deviations", ...auth, async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", { tenant_id: tid(c), - project_id: Number(c.req.param("id")), + project_id: projectId, limit: c.req.query("limit"), }, { route: routeLabel(c) }); return respondRpc(c, env); diff --git a/api/cost_modules_test.ts b/api/cost_modules_test.ts index 831b3b5..735b684 100644 --- a/api/cost_modules_test.ts +++ b/api/cost_modules_test.ts @@ -233,7 +233,7 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn }); }); -Deno.test("RPC IAM: usuarios y permisos", async () => { +Deno.test("RPC IAM v2: roles y permisos por role_id", async () => { const url = Deno.env.get("DATABASE_URL_IAM"); if (!url) throw new Error("DATABASE_URL_IAM requerido"); const pool = createPool(url, { max: 2 }); @@ -254,16 +254,21 @@ Deno.test("RPC IAM: usuarios y permisos", async () => { assertEquals(perms.result.ok, true); assert(perms.result.data.permissions.length > 0); - const roleGet = await db.prepare(`SELECT iam.fn_role_permissions_get($1::jsonb) AS result`).get({ - role_code: "user", - }) as { result: { ok: boolean } }; - assertEquals(roleGet.result.ok, true); + const roleRow = await db.prepare( + `SELECT id FROM roles WHERE tenant_id = ? AND code = 'operativo' LIMIT 1`, + ).get(TENANT_ID) as { id: number } | undefined; + if (roleRow) { + const roleGet = await db.prepare(`SELECT iam.fn_role_permissions_get($1::jsonb) AS result`).get({ + role_id: roleRow.id, + }) as { result: { ok: boolean } }; + assertEquals(roleGet.result.ok, true); - const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({ - role_code: "user", - permissions: ["view_expenses", "view_warehouse", "manage_workers", "manage_documents", "view_reports"], - }) as { result: { ok: boolean } }; - assertEquals(roleSave.result.ok, true); + const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({ + role_id: roleRow.id, + permissions: ["workers.view", "warehouse.view", "documents.view", "reports.view"], + }) as { result: { ok: boolean } }; + assertEquals(roleSave.result.ok, true); + } throw ROLLBACK; }); diff --git a/api/expenses_http.ts b/api/expenses_http.ts index 7db15d3..7ad6c2f 100644 --- a/api/expenses_http.ts +++ b/api/expenses_http.ts @@ -4,10 +4,11 @@ import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; -import { respondRpc, routeLabel } from "./http_errors.ts"; -import { requireAnyPermission, requirePermission } from "./permissions.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; +import { requirePermission } from "./permissions.ts"; import { encryptBytes, decryptBytes } from "./docs_crypto.ts"; import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts"; +import { denyUnlessProjectScope } from "./scope_enforcement.ts"; import { randomBytes } from "node:crypto"; type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; @@ -22,11 +23,13 @@ function actor(c: Context): { id: number; name: string } { } export function registerExpenseRoutes(app: App) { - app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { + const projectId = c.req.query("project_id"); + if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_expense_list", { tenant_id: tid(c), - project_id: c.req.query("project_id"), + project_id: projectId, company_id: c.req.query("company_id"), date_from: c.req.query("date_from"), date_to: c.req.query("date_to"), @@ -36,14 +39,20 @@ export function registerExpenseRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); + if (env.ok && env.data) { + const expense = (env.data as { expense?: { project_id?: number } }).expense ?? env.data; + const projectId = Number((expense as { project_id?: number }).project_id); + if (projectId && !await denyUnlessProjectScope(c, projectId)) return; + } return respondRpc(c, env); }); - app.post("/v1/expenses", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => { + app.post("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.create"), async (c) => { const body = await c.req.json>(); + if (!await denyUnlessProjectScope(c, Number(body.project_id))) return; const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_expense_create", { @@ -55,9 +64,14 @@ export function registerExpenseRoutes(app: App) { return respondRpc(c, env); }); - app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => { + app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); + const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); + if (got.ok && got.data) { + const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; + if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; + } const env = await callCoreFn(db, "core.fn_expense_update", { ...body, id: Number(c.req.param("id")), @@ -66,18 +80,29 @@ export function registerExpenseRoutes(app: App) { return respondRpc(c, env); }); - app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => { + app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.delete"), async (c) => { const db = c.get("db"); + const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); + if (got.ok && got.data) { + const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; + if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; + } const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => { + app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => { const expenseId = Number(c.req.param("id")); + const db = c.get("db"); + const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) }); + if (got.ok && got.data) { + const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; + if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; + } const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) { - const env = await callCoreFn(c.get("db"), "core.fn_expense_attachment_add", { + const env = await callCoreFn(db, "core.fn_expense_attachment_add", { expense_id: expenseId, ...JSON.parse(String(form.get("meta") ?? "{}")), }, { route: routeLabel(c) }); @@ -89,7 +114,6 @@ export function registerExpenseRoutes(app: App) { if (s3Configured()) { await putObject(expenseDocKey(expenseId, storageName), cipher); } - const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_expense_attachment_add", { expense_id: expenseId, @@ -105,10 +129,15 @@ export function registerExpenseRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const expenseId = Number(c.req.param("id")); const attId = Number(c.req.param("attId")); const db = c.get("db"); + const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) }); + if (got.ok && got.data) { + const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data; + if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return; + } const row = await db.prepare( "SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?", ).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined; @@ -120,7 +149,7 @@ export function registerExpenseRoutes(app: App) { return c.body(plain.buffer as ArrayBuffer); }); - app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => { + app.get("/v1/iva/summary", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_iva_period_summary", { tenant_id: tid(c), @@ -131,13 +160,13 @@ export function registerExpenseRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => { + app.get("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("manage_cost_settings"), async (c) => { + app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.update"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }); diff --git a/api/iam_catalog.ts b/api/iam_catalog.ts new file mode 100644 index 0000000..4c50f97 --- /dev/null +++ b/api/iam_catalog.ts @@ -0,0 +1,135 @@ +/** Catálogo de permisos CRUD IAM v2 ({modulo}.{verbo}). */ + +export type PermissionDef = { + code: string; + label: string; + module: string; + verb: "view" | "create" | "update" | "delete"; + group?: string; +}; + +export const IAM_SENSITIVE_PREFIXES = ["users.", "settings."] as const; + +export const RESERVED_ROLE_CODES = new Set([ + "tenant_admin", + "platform_admin", + "user", +]); + +export const PERMISSION_CATALOG: PermissionDef[] = [ + { code: "workers.view", label: "Consultar padrón", module: "workers", verb: "view" }, + { code: "workers.create", label: "Alta de personal", module: "workers", verb: "create" }, + { code: "workers.update", label: "Editar personal", module: "workers", verb: "update" }, + { code: "workers.delete", label: "Baja de personal", module: "workers", verb: "delete" }, + { code: "kanban.view", label: "Tablero kanban", module: "kanban", verb: "view" }, + { code: "projects.view", label: "Consultar obras", module: "projects", verb: "view" }, + { code: "projects.create", label: "Alta de obras", module: "projects", verb: "create" }, + { code: "projects.update", label: "Editar obras", module: "projects", verb: "update" }, + { code: "companies.view", label: "Consultar empresas", module: "companies", verb: "view" }, + { code: "companies.create", label: "Alta de empresas", module: "companies", verb: "create" }, + { code: "companies.update", label: "Editar empresas", module: "companies", verb: "update" }, + { code: "budget.view", label: "Ver presupuesto", module: "budget", verb: "view" }, + { code: "budget.create", label: "Alta partidas presupuesto", module: "budget", verb: "create" }, + { code: "budget.update", label: "Editar presupuesto", module: "budget", verb: "update" }, + { code: "budget.delete", label: "Eliminar partidas", module: "budget", verb: "delete" }, + { code: "work_program.view", label: "Ver programa de obra", module: "work_program", verb: "view" }, + { code: "work_program.create", label: "Importar/generar programa", module: "work_program", verb: "create" }, + { code: "work_program.update", label: "Capturar avances", module: "work_program", verb: "update" }, + { code: "cost_control.view", label: "Control de costos", module: "cost_control", verb: "view" }, + { code: "expenses.view", label: "Ver gastos", module: "expenses", verb: "view" }, + { code: "expenses.create", label: "Capturar gastos", module: "expenses", verb: "create" }, + { code: "expenses.update", label: "Editar gastos", module: "expenses", verb: "update" }, + { code: "expenses.delete", label: "Eliminar gastos", module: "expenses", verb: "delete" }, + { code: "warehouse.view", label: "Ver almacén", module: "warehouse", verb: "view" }, + { code: "warehouse.create", label: "Entradas almacén", module: "warehouse", verb: "create" }, + { code: "warehouse.update", label: "Salidas/transferencias", module: "warehouse", verb: "update" }, + { code: "payroll.view", label: "Ver nómina", module: "payroll", verb: "view" }, + { code: "payroll.create", label: "Alta nómina/préstamos", module: "payroll", verb: "create" }, + { code: "payroll.update", label: "Armar/pagar nómina", module: "payroll", verb: "update" }, + { code: "payroll.delete", label: "Eliminar líneas nómina", module: "payroll", verb: "delete" }, + { code: "documents.view", label: "Ver documentos/gafetes", module: "documents", verb: "view" }, + { code: "documents.create", label: "Subir documentos/gafetes", module: "documents", verb: "create" }, + { code: "project_docs.tecnico.view", label: "Planos y docs técnicos", module: "project_docs", verb: "view", group: "tecnico" }, + { code: "project_docs.tecnico.create", label: "Subir docs técnicos", module: "project_docs", verb: "create", group: "tecnico" }, + { code: "project_docs.contrato.view", label: "Docs contrato", module: "project_docs", verb: "view", group: "contrato" }, + { code: "project_docs.contrato.create", label: "Subir docs contrato", module: "project_docs", verb: "create", group: "contrato" }, + { code: "project_docs.permisos.view", label: "Docs permisos", module: "project_docs", verb: "view", group: "permisos" }, + { code: "project_docs.permisos.create", label: "Subir docs permisos", module: "project_docs", verb: "create", group: "permisos" }, + { code: "project_docs.ambiental.view", label: "Docs ambientales", module: "project_docs", verb: "view", group: "ambiental" }, + { code: "project_docs.ambiental.create", label: "Subir docs ambientales", module: "project_docs", verb: "create", group: "ambiental" }, + { code: "project_docs.imss.view", label: "Docs IMSS", module: "project_docs", verb: "view", group: "imss" }, + { code: "project_docs.imss.create", label: "Subir docs IMSS", module: "project_docs", verb: "create", group: "imss" }, + { code: "project_docs.sst.view", label: "Docs SST", module: "project_docs", verb: "view", group: "sst" }, + { code: "project_docs.sst.create", label: "Subir docs SST", module: "project_docs", verb: "create", group: "sst" }, + { code: "project_docs.otro.view", label: "Otros docs obra", module: "project_docs", verb: "view", group: "otro" }, + { code: "project_docs.otro.create", label: "Subir otros docs", module: "project_docs", verb: "create", group: "otro" }, + { code: "users.view", label: "Listar usuarios IAM", module: "users", verb: "view" }, + { code: "users.create", label: "Alta usuarios IAM", module: "users", verb: "create" }, + { code: "users.update", label: "Editar usuarios IAM", module: "users", verb: "update" }, + { code: "users.delete", label: "Baja usuarios IAM", module: "users", verb: "delete" }, + { code: "settings.view", label: "Ver configuración", module: "settings", verb: "view" }, + { code: "settings.update", label: "Guardar configuración", module: "settings", verb: "update" }, + { code: "reports.view", label: "Reportes e inicio", module: "reports", verb: "view" }, +]; + +export const ALL_PERMISSION_CODES = PERMISSION_CATALOG.map((p) => p.code); + +/** Mapeo legacy → v2 para backfill y transición. */ +export const LEGACY_TO_V2: Record = { + manage_workers: ["workers.view", "workers.create", "workers.update", "workers.delete", "kanban.view"], + manage_projects: ["projects.view", "projects.create", "projects.update"], + manage_companies: ["companies.view", "companies.create", "companies.update"], + manage_budget: ["budget.view", "budget.create", "budget.update", "budget.delete", "work_program.view", "work_program.create", "work_program.update"], + manage_expenses: ["expenses.view", "expenses.create", "expenses.update", "expenses.delete", "cost_control.view"], + view_expenses: ["expenses.view", "cost_control.view"], + manage_payroll: ["payroll.view", "payroll.create", "payroll.update", "payroll.delete"], + manage_documents: ["documents.view", "documents.create", "project_docs.tecnico.view", "project_docs.tecnico.create", "project_docs.contrato.view", "project_docs.contrato.create", "project_docs.permisos.view", "project_docs.permisos.create"], + manage_users: ["users.view", "users.create", "users.update", "users.delete"], + manage_settings: ["settings.view", "settings.update"], + view_reports: ["reports.view"], + view_warehouse: ["warehouse.view"], + manage_warehouse: ["warehouse.view", "warehouse.create", "warehouse.update"], + close_warehouse: ["warehouse.update"], + manage_cost_settings: ["settings.update", "cost_control.view"], +}; + +export const PROJECT_DOC_CATEGORIES = [ + "tecnico", "contrato", "permisos", "ambiental", "imss", "sst", "otro", +] as const; + +export type ProjectDocCategory = typeof PROJECT_DOC_CATEGORIES[number]; + +export function projectDocPerm(category: string, verb: "view" | "create"): string { + return `project_docs.${category}.${verb}`; +} + +export const ROLE_TEMPLATES: Record = { + admin_operativo: { + label: "Administrador operativo", + permissions: ALL_PERMISSION_CODES.filter( + (c) => !IAM_SENSITIVE_PREFIXES.some((p) => c.startsWith(p)), + ), + }, + supervisor_obra: { + label: "Supervisor de obra", + permissions: [ + "projects.view", "work_program.view", "work_program.update", + "project_docs.tecnico.view", + "reports.view", + ], + }, + contador: { + label: "Contador", + permissions: [ + "expenses.view", "expenses.create", "expenses.update", "expenses.delete", + "cost_control.view", "reports.view", "payroll.view", + ], + }, + operativo: { + label: "Operativo", + permissions: [ + "workers.view", "workers.create", "workers.update", "documents.view", "documents.create", + "reports.view", "kanban.view", + ], + }, +}; diff --git a/api/iam_db.ts b/api/iam_db.ts index 1ed16d4..2fa602c 100644 --- a/api/iam_db.ts +++ b/api/iam_db.ts @@ -41,9 +41,12 @@ export async function pingIamDb(): Promise { * qué hacer con tenant_id/role_code. */ export async function findUserByUsernameAnyTenant(username: string) { const rows = await getAuthPool()` - SELECT id, username, password_hash, display_name, company_id, tenant_id, - role_code, must_change_password, email, created_at - FROM iam.users WHERE username = ${username}`; + 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, u.created_at + FROM iam.users u + JOIN iam.roles r ON r.id = u.role_id + WHERE u.username = ${username}`; return rows[0] as Record | undefined; } diff --git a/api/iam_http.ts b/api/iam_http.ts index 372a886..d86cffc 100644 --- a/api/iam_http.ts +++ b/api/iam_http.ts @@ -2,63 +2,302 @@ import type { Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { requireAuth, tenantScope } from "./auth.ts"; import type { Db } from "./db.ts"; -import { respondRpc } from "./http_errors.ts"; -import { requirePermission, callIamFn } from "./permissions.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("manage_users"), async (c) => { - const tid = tenantScope(c.get("user")); - const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid }); + 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("manage_users"), async (c) => { - const tid = tenantScope(c.get("user")); - const row = await callIamFn(tid, "iam.fn_permission_list", {}); + 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/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => { - const tid = tenantScope(c.get("user")); - const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") }); + 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/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => { + 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 tid = tenantScope(c.get("user")); - const row = await callIamFn(tid, "iam.fn_role_permissions_save", { - role_code: c.req.param("role"), - permissions: body.permissions ?? [], + 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 { userPermissions } = await import("./permissions.ts"); - const role = user.role === "tenant_admin" ? "tenant_admin" : "user"; - const perms = await userPermissions(user.tenant_id, role); + 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: user.role }, + 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, }); }); diff --git a/api/iam_test.ts b/api/iam_test.ts new file mode 100644 index 0000000..426a243 --- /dev/null +++ b/api/iam_test.ts @@ -0,0 +1,72 @@ +import { assert, assertEquals } from "jsr:@std/assert@1"; +import { + ALL_PERMISSION_CODES, + LEGACY_TO_V2, + PROJECT_DOC_CATEGORIES, + projectDocPerm, + RESERVED_ROLE_CODES, + ROLE_TEMPLATES, +} from "./iam_catalog.ts"; +import { filterSensitivePermissions } from "./permissions.ts"; +import { assertProjectAccess, isOwnerAccountRow } from "./user_scope.ts"; + +Deno.test("IAM catálogo: permisos CRUD únicos", () => { + const codes = new Set(ALL_PERMISSION_CODES); + assertEquals(codes.size, ALL_PERMISSION_CODES.length); + assert(codes.has("workers.view")); + assert(codes.has("project_docs.tecnico.view")); + assert(codes.has("users.view")); +}); + +Deno.test("IAM: códigos de rol reservados", () => { + assert(RESERVED_ROLE_CODES.has("tenant_admin")); + assert(RESERVED_ROLE_CODES.has("user")); + assert(!RESERVED_ROLE_CODES.has("supervisor_obra")); +}); + +Deno.test("IAM: plantilla supervisor sin gastos ni IAM", () => { + const sup = ROLE_TEMPLATES.supervisor_obra.permissions; + assert(sup.includes("projects.view")); + assert(sup.includes("project_docs.tecnico.view")); + assert(!sup.some((p) => p.startsWith("expenses."))); + assert(!sup.some((p) => p.startsWith("users."))); + assert(!sup.some((p) => p.startsWith("settings."))); +}); + +Deno.test("IAM: plantilla contador con gastos y costos", () => { + const cont = ROLE_TEMPLATES.contador.permissions; + assert(cont.includes("expenses.view")); + assert(cont.includes("cost_control.view")); + assert(!cont.some((p) => p.startsWith("project_docs."))); +}); + +Deno.test("IAM: permisos sensibles filtrados para no propietario", () => { + const input = ["workers.view", "users.create", "settings.update", "reports.view"]; + const filtered = filterSensitivePermissions(input, false); + assertEquals(filtered, ["workers.view", "reports.view"]); + assertEquals(filterSensitivePermissions(input, true), input); +}); + +Deno.test("IAM: alcance por obra", () => { + const scope = { allProjects: false, projectIds: [10, 20], warehouseCentral: false, warehouseProjects: true }; + assert(assertProjectAccess(scope, 10)); + assert(!assertProjectAccess(scope, 99)); + assert(assertProjectAccess({ ...scope, allProjects: true }, 99)); +}); + +Deno.test("IAM: cuenta propietaria", () => { + assert(isOwnerAccountRow({ is_owner: true })); + assert(isOwnerAccountRow({ role_code: "tenant_admin" })); + assert(!isOwnerAccountRow({ role_code: "supervisor" })); +}); + +Deno.test("IAM: categorías documento obra", () => { + for (const cat of PROJECT_DOC_CATEGORIES) { + assertEquals(projectDocPerm(cat, "view"), `project_docs.${cat}.view`); + } +}); + +Deno.test("IAM: mapeo legacy a v2", () => { + assert(LEGACY_TO_V2.manage_workers.includes("workers.view")); + assert(LEGACY_TO_V2.manage_expenses.includes("cost_control.view")); +}); diff --git a/api/main.ts b/api/main.ts index 70c3bc9..b400b4f 100644 --- a/api/main.ts +++ b/api/main.ts @@ -36,6 +36,14 @@ import { type AuthUser, } from "./auth.ts"; import { requireCoreAuth, withCoreScope } from "./scope.ts"; +import { requirePermission, requireAnyPermission } from "./permissions.ts"; +import { + denyUnlessProjectScope, + denyUnlessProjectDocCreate, + denyUnlessProjectDocView, + filterProjectDocumentsForUser, + filterProjectListEnvelope, +} from "./scope_enforcement.ts"; import { getPlatformDb, pingPlatformDb } from "./platform_db.ts"; import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; import { smtpConfigured, testSmtp } from "./mail.ts"; @@ -82,6 +90,8 @@ type StorageDoc = { iv: string; original_name: string; mime?: string; + type_code?: string; + type?: string; }; async function rpcDocumentForDownload( @@ -275,7 +285,7 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) => }); }); -app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { +app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), async (c) => { const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); @@ -294,20 +304,15 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { // Configuración del tenant (Fase 4d): zona horaria usada por nómina y // vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado. -app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { +app.get("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const timezone = await tenantTimezone(db, tid); return c.json({ timezone }); }); -app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { +app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.update"), async (c) => { const user = c.get("user"); - if (user.role !== "tenant_admin") { - return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", { - route: routeLabel(c), - }); - } const db = c.get("db"); const tid = tenantScope(user); if (tid == null) { @@ -332,7 +337,7 @@ app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { )); }); -app.get("/v1/companies", ...requireCoreAuth, async (c) => { +app.get("/v1/companies", ...requireCoreAuth, requirePermission("companies.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); return respondRpc(c, await callCoreFn( @@ -343,7 +348,7 @@ app.get("/v1/companies", ...requireCoreAuth, async (c) => { )); }); -app.post("/v1/companies", ...requireCoreAuth, async (c) => { +app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.create"), async (c) => { const body = await c.req.json(); const db = c.get("db"); const tid = tenantScope(c.get("user")); @@ -361,7 +366,7 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => { }); }); -app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { +app.patch("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.update"), async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = c.get("db"); @@ -380,7 +385,7 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { }); }); -app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { +app.get("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); @@ -414,7 +419,7 @@ app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { }); }); -app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => { +app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); @@ -431,7 +436,7 @@ app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => { return c.json({ ok: true, checklist: await companyChecklistFor(db, id) }); }); -app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { +app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); @@ -454,7 +459,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects", ...requireCoreAuth, async (c) => { +app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"), async (c) => { const db = c.get("db"); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; @@ -464,12 +469,13 @@ app.get("/v1/projects", ...requireCoreAuth, async (c) => { status, }); } - return respondRpc(c, await callCoreFn( + const env = await callCoreFn( db, "core.fn_project_list", { status: allowed.length ? allowed.join(",") : undefined }, { route: routeLabel(c) }, - )); + ); + return respondRpc(c, await filterProjectListEnvelope(c, env as Parameters[1])); }); type ProjectInput = { @@ -519,7 +525,7 @@ function projectFields(body: ProjectInput, cur?: Record) { }; } -app.post("/v1/projects", ...requireCoreAuth, async (c) => { +app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"), async (c) => { const body = await c.req.json(); if (!body.name?.trim()) { return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, { @@ -567,8 +573,9 @@ app.post("/v1/projects", ...requireCoreAuth, async (c) => { )); }); -app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { +app.patch("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.update"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json(); const db = c.get("db"); const route = routeLabel(c); @@ -617,8 +624,9 @@ app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { )); }); -app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { +app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); const route = routeLabel(c); const projectEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); @@ -634,6 +642,12 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { { route }, ); if (!docsEnv.ok) return respondRpc(c, docsEnv); + const user = c.get("user") as AuthUser; + const documents = await filterProjectDocumentsForUser( + user, + db, + (docsEnv.data?.documents ?? []) as Array<{ type_code?: string; type?: string }>, + ); return respondRpc(c, { ok: true, code: "OK", @@ -641,20 +655,22 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { message: `Proyecto ${project.code} cargado`, data: { project, - documents: docsEnv.data?.documents ?? [], + documents, checklist: await projectChecklistFor(db, id), }, }); }); -app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/documents", ...requireCoreAuth, requireAnyPermission("project_docs.tecnico.create", "project_docs.contrato.create", "project_docs.permisos.create", "project_docs.ambiental.create", "project_docs.imss.create", "project_docs.sst.create", "project_docs.otro.create"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); + if (!await denyUnlessProjectDocCreate(c, db, type)) return; const bytes = new Uint8Array(await file.arrayBuffer()); try { await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); @@ -664,8 +680,9 @@ app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => { return c.json({ ok: true, checklist: await projectChecklistFor(db, id) }); }); -app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { +app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermission("projects.view"), async (c) => { const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const docId = Number(c.req.param("docId")); const db = c.get("db"); const route = routeLabel(c); @@ -676,6 +693,8 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { route, ); if (!doc) return respondRpc(c, envelope); + const typeCode = String(doc.type_code ?? doc.type ?? ""); + if (!await denyUnlessProjectDocView(c, db, typeCode)) return; const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -684,22 +703,24 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => { +app.get("/v1/projects/:id/budget", ...requireCoreAuth, requirePermission("budget.view"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); return c.json(await listBudget(db, id)); }); -app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, (c) => { +app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, requirePermission("budget.view"), (c) => { const bytes = buildBudgetTemplate(); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); return c.body(bytes.slice()); }); -app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => { +app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission("budget.view"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const db = c.get("db"); const project = await projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); @@ -710,7 +731,7 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => { return c.body(bytes.slice()); }); -app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => { +app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); @@ -720,8 +741,9 @@ app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => { return c.json(preview); }); -app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); @@ -733,8 +755,9 @@ app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => { return c.json(preview); }); -app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); @@ -746,8 +769,9 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { return c.json(report); }); -app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); if (!body.name?.trim()) { return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, { @@ -769,8 +793,9 @@ app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { )); }); -app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { +app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = c.get("db"); @@ -787,8 +812,9 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) )); }); -app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { +app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const cid = Number(c.req.param("cid")); const db = c.get("db"); return respondRpc(c, await callCoreFn( @@ -808,8 +834,9 @@ type BudgetItemInput = { unit_price?: number; }; -app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const body = await c.req.json(); if (!body.description?.trim()) { return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, { @@ -834,8 +861,9 @@ app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { )); }); -app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { +app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = c.get("db"); @@ -856,8 +884,9 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => )); }); -app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { +app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => { const id = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, id)) return; const iid = Number(c.req.param("iid")); const db = c.get("db"); return respondRpc(c, await callCoreFn( @@ -868,7 +897,7 @@ app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) = )); }); -app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { +app.post("/v1/workers/validate", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); @@ -899,12 +928,13 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { return c.json({ ok: Object.keys(errors).length === 0, errors }); }); -app.get("/v1/workers", ...requireCoreAuth, async (c) => { +app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); const projectId = c.req.query("project_id"); + if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return; return respondRpc(c, await callCoreFn( db, "core.fn_worker_list", @@ -918,7 +948,7 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => { )); }); -app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { +app.get("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); @@ -966,7 +996,7 @@ async function findExistingWorker( return env.data.worker ?? undefined; } -app.post("/v1/workers", ...requireCoreAuth, async (c) => { +app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); @@ -990,6 +1020,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => { const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); } + if (!await denyUnlessProjectScope(c, body.project_id)) return; } const tid = tenantScope(c.get("user")); return respondRpc(c, await callCoreFn( @@ -1000,7 +1031,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => { )); }); -app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { +app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.update"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); @@ -1031,7 +1062,7 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { )); }); -app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { +app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("workers.update"), async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; @@ -1057,7 +1088,7 @@ app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { }); }); -app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { +app.post("/v1/workers/:id/assign", ...requireCoreAuth, requirePermission("workers.update"), async (c) => { const id = Number(c.req.param("id")); const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const db = c.get("db"); @@ -1093,7 +1124,7 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { )); }); -app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { +app.get("/v1/workers/import/template", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const db = c.get("db"); const bytes = buildImportTemplate(await listCompanies(db)); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); @@ -1101,7 +1132,7 @@ app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { return c.body(bytes.buffer as ArrayBuffer); }); -app.post("/v1/workers/import", ...requireCoreAuth, async (c) => { +app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.create"), async (c) => { const form = await c.req.formData(); const file = form.get("file"); const projectId = Number(form.get("project_id") || 0) || null; @@ -1120,7 +1151,7 @@ app.post("/v1/workers/import", ...requireCoreAuth, async (c) => { return c.json(report); }); -app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { +app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const id = Number(c.req.param("id")); const form = await c.req.formData(); const type = String(form.get("type") || ""); @@ -1198,7 +1229,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { }); }); -app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { +app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); @@ -1218,7 +1249,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { return c.body(plain.buffer as ArrayBuffer); }); -app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => { +app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const bytes = await loadCurrentPhoto(db, id); @@ -1234,7 +1265,7 @@ app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => { return c.body(bytes.buffer as ArrayBuffer); }); -app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => { +app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const curp = (c.req.query("curp") ?? "").trim().toUpperCase(); if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); const png = await badgeQrPng(curp); @@ -1243,8 +1274,9 @@ app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => { return c.body(png.buffer as ArrayBuffer); }); -app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { +app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); const db = c.get("db"); const user = c.get("user"); @@ -1302,18 +1334,18 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { }); }); -app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { +app.get("/v1/badge-jobs", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const db = c.get("db"); return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); }); -app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { +app.get("/v1/badge-jobs/:id", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) })); }); -app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { +app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documents.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const route = routeLabel(c); @@ -1331,7 +1363,7 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { return c.body(bytes.buffer as ArrayBuffer); }); -app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => { +app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePermission("documents.create"), async (c) => { const jobId = Number(c.req.param("id")); const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); diff --git a/api/payroll_http.ts b/api/payroll_http.ts index 5280453..3330ae6 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -2,6 +2,8 @@ import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; +import { requirePermission } from "./permissions.ts"; +import { denyUnlessProjectScope } from "./scope_enforcement.ts"; import { projectById, projectMustBe, type Db } from "./db.ts"; import { generateLoanReceiptPdf } from "./pdf.ts"; import { fullName } from "./mx.ts"; @@ -44,7 +46,7 @@ function pdfBody(c: Context, bytes: Uint8Array, filename: string) { } export function registerPayrollRoutes(app: App) { - app.get("/v1/attendance", ...requireCoreAuth, async (c) => { + app.get("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const projectId = Number(c.req.query("project_id")); const from = c.req.query("from") ?? ""; const to = c.req.query("to") ?? ""; @@ -58,7 +60,7 @@ export function registerPayrollRoutes(app: App) { return respondRpc(c, env); }); - app.put("/v1/attendance", ...requireCoreAuth, async (c) => { + app.put("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number; @@ -86,13 +88,13 @@ export function registerPayrollRoutes(app: App) { return c.json({ ok: true }); }); - app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const db = c.get("db"); const tenantId = tid(c); return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) }); }); - app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => { + app.patch("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const db = c.get("db"); const body = await c.req.json<{ loan_commission_enabled?: number | boolean; @@ -109,7 +111,7 @@ export function registerPayrollRoutes(app: App) { return c.json({ settings }); }); - app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => { + app.post("/v1/destajo/units", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => { const body = await c.req.json<{ code: string; label?: string }>(); const db = c.get("db"); try { @@ -120,7 +122,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/loans", ...requireCoreAuth, async (c) => { + app.get("/v1/loans", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const db = c.get("db"); const workerId = c.req.query("worker_id"); const env = await callCoreFn<{ loans: unknown[] }>( @@ -132,7 +134,7 @@ export function registerPayrollRoutes(app: App) { return respondRpc(c, env); }); - app.post("/v1/loans", ...requireCoreAuth, async (c) => { + app.post("/v1/loans", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => { const body = await c.req.json<{ worker_id: number; amount?: number; @@ -162,7 +164,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { + app.get("/v1/loans/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const env = await callCoreFn<{ loan: { @@ -213,7 +215,7 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); }); - app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { + app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const env = await callCoreFn<{ payment: { @@ -248,7 +250,7 @@ export function registerPayrollRoutes(app: App) { return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`); }); - app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/weeks", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const db = c.get("db"); const tenantId = tid(c); const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart; @@ -256,12 +258,12 @@ export function registerPayrollRoutes(app: App) { return c.json(bundle); }); - app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/weeks/open", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const db = c.get("db"); return c.json({ weeks: await listOpenWeeks(db, tid(c)) }); }); - app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const db = c.get("db"); try { const nowIso = await resolveToday(db, tid(c)); @@ -273,7 +275,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const db = c.get("db"); try { const nowIso = await resolveToday(db, tid(c)); @@ -285,7 +287,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const csv = await weekCsv(db, id); @@ -294,7 +296,7 @@ export function registerPayrollRoutes(app: App) { return c.body(csv); }); - app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const body = await c.req.json<{ project_id: number; worker_id: number }>(); const db = c.get("db"); try { @@ -306,7 +308,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => { + app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>(); const db = c.get("db"); try { @@ -318,7 +320,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { + app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const body = await c.req.json<{ amount: number }>(); const db = c.get("db"); try { @@ -330,7 +332,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => { + app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => { const db = c.get("db"); try { await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); @@ -341,7 +343,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => { + app.post("/v1/destajo/jobs", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => { const body = await c.req.json<{ week_id: number; project_id: number; @@ -363,7 +365,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => { + app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const body = await c.req.json<{ week_id: number; qty_planned?: number; @@ -380,7 +382,7 @@ export function registerPayrollRoutes(app: App) { } }); - app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => { + app.post("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => { const body = await c.req.json<{ project_id: number; week_start: string; @@ -415,7 +417,7 @@ export function registerPayrollRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const db = c.get("db"); const projectId = c.req.query("project_id"); const env = await callCoreFn<{ periods: unknown[] }>( @@ -427,7 +429,7 @@ export function registerPayrollRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const env = await callCoreFn<{ period: unknown; lines: unknown[] }>( @@ -439,7 +441,7 @@ export function registerPayrollRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { + app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const env = await callCoreFn<{ period: unknown; lines: Array> }>( @@ -460,7 +462,7 @@ export function registerPayrollRoutes(app: App) { return c.body(csv); }); - app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { + app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: string }>(); const db = c.get("db"); diff --git a/api/permissions.ts b/api/permissions.ts index deb43f0..89b891a 100644 --- a/api/permissions.ts +++ b/api/permissions.ts @@ -3,44 +3,62 @@ import type { AuthUser } from "./auth.ts"; import { tenantScope } from "./auth.ts"; import { withIamTenant } from "./iam_db.ts"; import { respondApiError, routeLabel } from "./http_errors.ts"; +import { ALL_PERMISSION_CODES, IAM_SENSITIVE_PREFIXES } from "./iam_catalog.ts"; const permissionCache = new Map; at: number }>(); const CACHE_TTL_MS = 60_000; -export async function userPermissions( +export function invalidatePermissionCache(roleId?: number) { + if (roleId != null) { + for (const key of [...permissionCache.keys()]) { + if (key.startsWith(`${roleId}:`)) permissionCache.delete(key); + } + return; + } + permissionCache.clear(); +} + +export async function userPermissionsByRoleId( tenantId: number | null, - roleCode: string, + roleId: number | null, + isOwner: boolean, ): Promise> { - const key = `${tenantId ?? 0}:${roleCode}`; + if (isOwner) return new Set(ALL_PERMISSION_CODES); + if (roleId == null) return new Set(); + const key = `${roleId}:${tenantId ?? 0}`; const hit = permissionCache.get(key); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms; - if (roleCode === "tenant_admin") { - const all = new Set([ - "view_expenses", "manage_expenses", "view_warehouse", "manage_warehouse", - "close_warehouse", "manage_cost_settings", "manage_users", "manage_budget", - "manage_payroll", "manage_workers", "manage_projects", "manage_companies", - "manage_documents", "manage_settings", "view_reports", - ]); - permissionCache.set(key, { perms: all, at: Date.now() }); - return all; - } + const rows = await withIamTenant(tenantId, async (db) => await db.prepare( - "SELECT permission_code FROM role_permissions WHERE role_code = ?", - ).all(roleCode) as { permission_code: string }[], + "SELECT permission_code FROM role_permissions WHERE role_id = ?", + ).all(roleId) as { permission_code: string }[], ); const perms = new Set(rows.map((r) => r.permission_code)); permissionCache.set(key, { perms, at: Date.now() }); return perms; } -export async function hasPermission( - user: AuthUser, - code: string, -): Promise { - if (user.role === "tenant_admin") return true; - const role = user.role === "user" ? "user" : user.role; - const perms = await userPermissions(user.tenant_id, role); +/** @deprecated use userPermissionsByRoleId */ +export async function userPermissions( + tenantId: number | null, + roleCode: string, +): Promise> { + if (roleCode === "tenant_admin") return new Set(ALL_PERMISSION_CODES); + const rows = await withIamTenant(tenantId, async (db) => + await db.prepare( + `SELECT rp.permission_code FROM role_permissions rp + JOIN roles r ON r.id = rp.role_id + WHERE r.code = ? AND (r.tenant_id = ? OR (r.is_system AND r.tenant_id IS NULL))`, + ).all(roleCode, tenantId) as { permission_code: string }[], + ); + return new Set(rows.map((r) => r.permission_code)); +} + +export async function hasPermission(user: AuthUser, code: string): Promise { + if (user.realm === "platform") return true; + if (user.is_owner) return true; + const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, false); return perms.has(code); } @@ -56,7 +74,7 @@ export function requirePermission(code: string) { c, "FORBIDDEN", `Permiso requerido: ${code}`, - { route: routeLabel(c), permission: code, role: user.role }, + { route: routeLabel(c), permission: code, role: user.role_code }, ); } await next(); @@ -85,6 +103,16 @@ export function requireAnyPermission(...codes: string[]) { }; } +export function filterSensitivePermissions( + permissions: string[], + actorIsOwner: boolean, +): string[] { + if (actorIsOwner) return permissions; + return permissions.filter( + (p) => !IAM_SENSITIVE_PREFIXES.some((prefix) => p.startsWith(prefix)), + ); +} + export async function callIamFn( tenantId: number | null, fn: string, @@ -99,3 +127,19 @@ export async function callIamFn( } return null; } + +export async function usersWithRole(tenantId: number | null, roleId: number): Promise { + const rows = await withIamTenant(tenantId, async (db) => + await db.prepare("SELECT id FROM users WHERE role_id = ?").all(roleId) as { id: number }[], + ); + return rows.map((r) => Number(r.id)); +} + +export async function revokeSessionsForRoleUsers( + tenantId: number | null, + roleId: number, +): Promise { + const { revokeAllSessionsForUser } = await import("./sessions.ts"); + const ids = await usersWithRole(tenantId, roleId); + await Promise.all(ids.map((id) => revokeAllSessionsForUser(id, "app"))); +} diff --git a/api/saas.ts b/api/saas.ts index 10b7647..f5c6640 100644 --- a/api/saas.ts +++ b/api/saas.ts @@ -236,10 +236,12 @@ export async function getTenantDetail( const admins = await withIamOwner(async (iam) => { const rows = await iam.prepare( - `SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role, - COALESCE(must_change_password, false) AS must_change_password, created_at - FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin' - ORDER BY id`, + `SELECT u.id, u.username, u.display_name, COALESCE(u.email, '') AS email, r.code AS role, + COALESCE(u.must_change_password, false) AS must_change_password, u.created_at + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.tenant_id = ? AND r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL + ORDER BY u.id`, ).all(tenantId) as Array>; return rows.map((a) => ({ id: Number(a.id), @@ -344,16 +346,20 @@ export async function createTenant( ).then(() => core.lastInsertId()) ); - // Paso 3: admin del tenant en iam. + // Paso 3: admin del tenant en iam (rol sistema tenant_admin). const hash = await hashPassword(temporaryPassword); - await withIamOwner((iam) => - iam.prepare( + await withIamOwner(async (iam) => { + const adminRole = await iam.prepare( + `SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`, + ).get() as { id: number } | undefined; + if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado en iam.roles"); + await iam.prepare( `INSERT INTO users ( - username, password_hash, display_name, company_id, tenant_id, role_code, - must_change_password, email - ) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`, - ).run(adminUser, hash, adminName, companyId, tenantId, contactEmail) - ); + username, password_hash, display_name, company_id, tenant_id, role_id, + must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects + ) VALUES (?, ?, ?, ?, ?, ?, true, ?, 'activo', true, true, true)`, + ).run(adminUser, hash, adminName, companyId, tenantId, adminRole.id, contactEmail); + }); } catch (e) { // Compensación: limpiar en orden inverso lo que sí se alcanzó a crear. if (companyId != null) { diff --git a/api/scope_enforcement.ts b/api/scope_enforcement.ts new file mode 100644 index 0000000..9253b5c --- /dev/null +++ b/api/scope_enforcement.ts @@ -0,0 +1,116 @@ +import type { Context } from "hono"; +import type { AuthUser } from "./auth.ts"; +import type { Db } from "./db.ts"; +import { respondApiError, routeLabel, type RpcEnvelope } from "./http_errors.ts"; +import { hasPermission } from "./permissions.ts"; +import { projectDocPerm } from "./iam_catalog.ts"; +import { assertProjectAccess, getUserScope, type UserScope } from "./user_scope.ts"; + +export async function denyUnlessProjectScope( + c: Context, + projectId: number | null | undefined, +): Promise { + if (projectId == null || !Number.isFinite(projectId)) return true; + const user = c.get("user") as AuthUser; + if (user.realm === "platform" || user.is_owner) return true; + const scope = await getUserScope(user); + if (assertProjectAccess(scope, projectId)) return true; + respondApiError(c, "FORBIDDEN", "Obra fuera de su alcance", { + route: routeLabel(c), + project_id: projectId, + }); + return false; +} + +export function filterProjectsByScope( + items: T[], + scope: UserScope, +): T[] { + if (scope.allProjects) return items; + const allowed = new Set(scope.projectIds); + return items.filter((p) => allowed.has(Number(p.id))); +} + +export async function filterProjectListEnvelope( + c: Context, + env: RpcEnvelope<{ projects?: Array<{ id: number }> }>, +): Promise }>> { + const user = c.get("user") as AuthUser; + if (!env.ok || !env.data?.projects || user.realm === "platform" || user.is_owner) return env; + const scope = await getUserScope(user); + return { + ...env, + data: { + ...env.data, + projects: filterProjectsByScope(env.data.projects, scope), + }, + }; +} + +export async function projectDocCategory( + db: Db, + typeCode: string, +): Promise { + const row = await db.prepare( + "SELECT category FROM project_document_types WHERE code = ?", + ).get(typeCode) as { category: string } | undefined; + return row?.category ?? "otro"; +} + +export async function denyUnlessProjectDocView( + c: Context, + db: Db, + typeCode: string, +): Promise { + const user = c.get("user") as AuthUser; + if (user.realm === "platform" || user.is_owner) return true; + const category = await projectDocCategory(db, typeCode); + if (await hasPermission(user, projectDocPerm(category, "view"))) return true; + respondApiError(c, "FORBIDDEN", "Sin permiso para este tipo de documento de obra", { + route: routeLabel(c), + type_code: typeCode, + category, + }); + return false; +} + +export async function denyUnlessProjectDocCreate( + c: Context, + db: Db, + typeCode: string, +): Promise { + const user = c.get("user") as AuthUser; + if (user.realm === "platform" || user.is_owner) return true; + const category = await projectDocCategory(db, typeCode); + if (await hasPermission(user, projectDocPerm(category, "create"))) return true; + respondApiError(c, "FORBIDDEN", "Sin permiso para subir este tipo de documento", { + route: routeLabel(c), + type_code: typeCode, + category, + }); + return false; +} + +export async function filterProjectDocumentsForUser( + user: AuthUser, + db: Db, + documents: Array<{ type_code?: string; type?: string }>, +): Promise { + if (user.realm === "platform" || user.is_owner) return documents; + const out: typeof documents = []; + for (const doc of documents) { + const typeCode = String(doc.type_code ?? doc.type ?? ""); + const category = await projectDocCategory(db, typeCode); + if (await hasPermission(user, projectDocPerm(category, "view"))) out.push(doc); + } + return out; +} + +export async function denyUnlessWarehouseCentral(c: Context): Promise { + const user = c.get("user") as AuthUser; + if (user.realm === "platform" || user.is_owner) return true; + const scope = await getUserScope(user); + if (scope.warehouseCentral) return true; + respondApiError(c, "FORBIDDEN", "Sin acceso al almacén central", { route: routeLabel(c) }); + return false; +} diff --git a/api/scripts/bootstrap-admin.ts b/api/scripts/bootstrap-admin.ts index 4a44496..01ae780 100644 --- a/api/scripts/bootstrap-admin.ts +++ b/api/scripts/bootstrap-admin.ts @@ -74,18 +74,27 @@ async function bootstrapTenantAdmin() { const hash = await hashPassword(config.seedPassword); await withIamOwner(async (db) => { + const adminRole = await db.prepare( + `SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`, + ).get() as { id: number } | undefined; + if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado"); + const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username); if (existing) { await db.prepare( `UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?, - role_code = 'tenant_admin', must_change_password = true WHERE username = ?`, - ).run(hash, displayName, company.id, tenantId, username); + role_id = ?, must_change_password = true, status = 'activo', + scope_all_projects = true, warehouse_central = true, warehouse_projects = true + WHERE username = ?`, + ).run(hash, displayName, company.id, tenantId, adminRole.id, username); console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`); } else { await db.prepare( - `INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email) - VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`, - ).run(username, hash, displayName, company.id, tenantId); + `INSERT INTO users ( + username, password_hash, display_name, company_id, tenant_id, role_id, + must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects + ) VALUES (?, ?, ?, ?, ?, ?, true, '', 'activo', true, true, true)`, + ).run(username, hash, displayName, company.id, tenantId, adminRole.id); console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`); } }); diff --git a/api/user_scope.ts b/api/user_scope.ts new file mode 100644 index 0000000..8242c56 --- /dev/null +++ b/api/user_scope.ts @@ -0,0 +1,77 @@ +import type { AuthUser } from "./auth.ts"; +import { withIamTenant } from "./iam_db.ts"; + +export type UserScope = { + allProjects: boolean; + projectIds: number[]; + warehouseCentral: boolean; + warehouseProjects: boolean; +}; + +const scopeCache = new Map(); +const SCOPE_TTL_MS = 60_000; + +export function invalidateUserScopeCache(userId?: number) { + if (userId != null) scopeCache.delete(userId); + else scopeCache.clear(); +} + +export async function loadUserScope(user: AuthUser): Promise { + if (user.is_owner || user.realm === "platform") { + return { + allProjects: true, + projectIds: [], + warehouseCentral: true, + warehouseProjects: true, + }; + } + const hit = scopeCache.get(user.id); + if (hit && Date.now() - hit.at < SCOPE_TTL_MS) return hit.scope; + + const row = await withIamTenant(user.tenant_id, async (db) => { + const u = await db.prepare( + `SELECT scope_all_projects, warehouse_central, warehouse_projects FROM users WHERE id = ?`, + ).get(user.id) as { + scope_all_projects: boolean; + warehouse_central: boolean; + warehouse_projects: boolean; + } | undefined; + if (!u) return null; + const projects = await db.prepare( + `SELECT project_id FROM user_projects WHERE user_id = ? ORDER BY project_id`, + ).all(user.id) as { project_id: number }[]; + return { + allProjects: Boolean(u.scope_all_projects), + projectIds: projects.map((p) => Number(p.project_id)), + warehouseCentral: Boolean(u.warehouse_central), + warehouseProjects: Boolean(u.warehouse_projects), + }; + }); + const scope = row ?? { + allProjects: false, + projectIds: [], + warehouseCentral: false, + warehouseProjects: false, + }; + scopeCache.set(user.id, { scope, at: Date.now() }); + return scope; +} + +export function allowedProjectIds(scope: UserScope): number[] | null { + if (scope.allProjects) return null; + return scope.projectIds; +} + +export function assertProjectAccess(scope: UserScope, projectId: number | null | undefined): boolean { + if (projectId == null || !Number.isFinite(projectId)) return false; + if (scope.allProjects) return true; + return scope.projectIds.includes(Number(projectId)); +} + +export async function getUserScope(user: AuthUser): Promise { + return await loadUserScope(user); +} + +export function isOwnerAccountRow(row: { is_owner?: boolean; role_code?: string }): boolean { + return Boolean(row.is_owner) || row.role_code === "tenant_admin"; +} diff --git a/api/warehouse_http.ts b/api/warehouse_http.ts index d9b9b00..471e78c 100644 --- a/api/warehouse_http.ts +++ b/api/warehouse_http.ts @@ -4,8 +4,10 @@ import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; -import { respondRpc, routeLabel } from "./http_errors.ts"; -import { requireAnyPermission, requirePermission } from "./permissions.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; +import { requirePermission } from "./permissions.ts"; +import { denyUnlessProjectScope, denyUnlessWarehouseCentral } from "./scope_enforcement.ts"; +import { getUserScope } from "./user_scope.ts"; type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; @@ -18,37 +20,59 @@ function actor(c: Context): { id: number; name: string } { return { id: u.id, name: u.display_name }; } +async function filterWarehouseList(c: Context, env: Awaited>) { + const user = c.get("user") as AuthUser; + if (!env.ok || !env.data || user.realm === "platform" || user.is_owner) return env; + const scope = await getUserScope(user); + const warehouses = (env.data as { warehouses?: Array<{ id: number; kind?: string; project_id?: number | null }> }).warehouses ?? []; + const filtered = warehouses.filter((wh) => { + if (wh.kind === "central") return scope.warehouseCentral; + if (wh.kind === "project") { + if (!scope.warehouseProjects) return false; + if (scope.allProjects) return true; + return scope.projectIds.includes(Number(wh.project_id)); + } + return true; + }); + return { ...env, data: { ...(env.data as object), warehouses: filtered } }; +} + export function registerWarehouseRoutes(app: App) { - app.get("/v1/warehouses", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => { + app.get("/v1/warehouses", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { const db = c.get("db"); const env = await callCoreFn(db, "core.fn_warehouse_list", { tenant_id: tid(c), include_closed: c.req.query("include_closed") === "true", }, { route: routeLabel(c) }); - return respondRpc(c, env); + return respondRpc(c, await filterWarehouseList(c, env)); }); - app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { + app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + if (!await denyUnlessWarehouseCentral(c)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { + app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_warehouse_open_project", { tenant_id: tid(c), - project_id: Number(c.req.param("id")), + project_id: projectId, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => { + app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + const projectId = Number(c.req.param("id")); + if (!await denyUnlessProjectScope(c, projectId)) return; const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string })); const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_warehouse_close_project", { - project_id: Number(c.req.param("id")), + project_id: projectId, force: body.force, closed_by_name: a.name, close_notes: body.close_notes, @@ -56,17 +80,26 @@ export function registerWarehouseRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => { + app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { const db = c.get("db"); + const whId = Number(c.req.param("id")); + const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as + | { kind: string; project_id: number | null } + | undefined; + if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId }); + if (wh.kind === "central") { + if (!await denyUnlessWarehouseCentral(c)) return; + } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; const env = await callCoreFn(db, "core.fn_warehouse_stock_list", { tenant_id: tid(c), - warehouse_id: Number(c.req.param("id")), + warehouse_id: whId, global: false, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => { + app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { + if (!await denyUnlessWarehouseCentral(c)) return; const db = c.get("db"); const env = await callCoreFn(db, "core.fn_warehouse_stock_list", { tenant_id: tid(c), @@ -75,51 +108,75 @@ export function registerWarehouseRoutes(app: App) { return respondRpc(c, env); }); - app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => { + app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { const db = c.get("db"); + const whId = Number(c.req.param("id")); + const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as + | { kind: string; project_id: number | null } + | undefined; + if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId }); + if (wh.kind === "central") { + if (!await denyUnlessWarehouseCentral(c)) return; + } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; const env = await callCoreFn(db, "core.fn_warehouse_movement_list", { - warehouse_id: Number(c.req.param("id")), + warehouse_id: whId, material_id: c.req.query("material_id"), }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { + app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { - const body = await c.req.json>(); + app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { + const whId = Number(c.req.param("id")); const db = c.get("db"); + const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as + | { kind: string; project_id: number | null } + | undefined; + if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId }); + if (wh.kind === "central") { + if (!await denyUnlessWarehouseCentral(c)) return; + } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; + const body = await c.req.json>(); const a = actor(c); const env = await callCoreFn(db, "core.fn_warehouse_entry_create", { ...body, tenant_id: tid(c), - warehouse_id: Number(c.req.param("id")), + warehouse_id: whId, created_by_id: a.id, created_by_name: a.name, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { - const body = await c.req.json>(); + app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + const whId = Number(c.req.param("id")); const db = c.get("db"); + const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as + | { kind: string; project_id: number | null } + | undefined; + if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId }); + if (wh.kind === "central") { + if (!await denyUnlessWarehouseCentral(c)) return; + } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; + const body = await c.req.json>(); const a = actor(c); const env = await callCoreFn(db, "core.fn_warehouse_exit_create", { ...body, tenant_id: tid(c), - warehouse_id: Number(c.req.param("id")), + warehouse_id: whId, created_by_id: a.id, created_by_name: a.name, }, { route: routeLabel(c) }); return respondRpc(c, env); }); - app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { + app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); const a = actor(c); diff --git a/api/work_program_http.ts b/api/work_program_http.ts index ed77a45..9e45b4c 100644 --- a/api/work_program_http.ts +++ b/api/work_program_http.ts @@ -1,9 +1,10 @@ -import type { Hono } from "hono"; +import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { projectById } from "./db.ts"; -import { requirePermission } from "./permissions.ts"; +import { requireAnyPermission, requirePermission } from "./permissions.ts"; +import { denyUnlessProjectScope } from "./scope_enforcement.ts"; import { createEmptyWorkProgram, exportWorkProgram, @@ -22,7 +23,11 @@ import { type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; -const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const; +const viewAuth = [...requireCoreAuth, requirePermission("work_program.view")] as const; + +async function ensureProject(c: Context, id: number): Promise { + return await denyUnlessProjectScope(c, id); +} async function readUpload(c: { req: { formData: () => Promise } }): Promise { const form = await c.req.formData(); @@ -32,70 +37,90 @@ async function readUpload(c: { req: { formData: () => Promise } }): Pr } export function registerWorkProgramRoutes(app: App) { - app.get("/v1/projects/:id/work-program", ...auth, async (c) => { + app.get("/v1/projects/:id/work-program", ...viewAuth, async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const data = await getWorkProgram(db, Number(c.req.param("id"))); + const data = await getWorkProgram(db, id); return c.json({ ok: true, data }); }); - app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => { + app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const data = await listConcepts(db, Number(c.req.param("id"))); + const data = await listConcepts(db, id); return c.json({ ok: true, data }); }); - app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => { + app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const data = await listPartidas(db, Number(c.req.param("id"))); + const data = await listPartidas(db, id); return c.json({ ok: true, data }); }); - app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => { + app.get("/v1/projects/:id/work-program/vs-cost", ...requireCoreAuth, requirePermission("cost_control.view"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const data = await getVsCost(db, Number(c.req.param("id"))); + const data = await getVsCost(db, id); return c.json({ ok: true, data }); }); - app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => { + app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const bytes = await readUpload(c); - const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db); + const preview = await previewWorkProgramExcel(bytes, id, db); return c.json({ ok: true, data: preview }); }); - app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => { + app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const bytes = await readUpload(c); const createChapters = c.req.query("create_chapters") !== "false"; - const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters }); + const data = await importWorkProgramExcel(db, id, bytes, { createChapters }); return c.json({ ok: true, data }); }); - app.post("/v1/projects/:id/work-program", ...auth, async (c) => { + app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const data = await createEmptyWorkProgram(db, Number(c.req.param("id"))); + const data = await createEmptyWorkProgram(db, id); return c.json({ ok: true, data }); }); - app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => { + app.post("/v1/projects/:id/work-program/generate", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const body = await c.req.json().catch(() => ({})) as { strategy?: string }; - const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword"); + const data = await generateWorkProgram(db, id, body.strategy || "keyword"); return c.json({ ok: true, data }); }); - app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => { + app.post("/v1/projects/:id/work-program/recalculate-partidas", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); - const env = await recalculatePartidas(db, Number(c.req.param("id"))); + const env = await recalculatePartidas(db, id); return c.json({ ok: true, data: env }); }); - app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => { + app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const body = await c.req.json() as { start_pct?: number; end_pct?: number }; const data = await updateConceptSlot( db, - Number(c.req.param("id")), + id, Number(c.req.param("itemId")), Number(c.req.param("periodId")), Number(body.start_pct ?? 0), @@ -104,12 +129,14 @@ export function registerWorkProgramRoutes(app: App) { return c.json({ ok: true, data }); }); - app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => { + app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const body = await c.req.json() as { amount?: number }; const data = await updatePartidaAmount( db, - Number(c.req.param("id")), + id, Number(c.req.param("chapterId")), Number(c.req.param("periodId")), Number(body.amount ?? 0), @@ -117,12 +144,14 @@ export function registerWorkProgramRoutes(app: App) { return c.json({ ok: true, data }); }); - app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => { + app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { + const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const db = c.get("db"); const body = await c.req.json() as { pct_complete?: number }; const data = await updateProgress( db, - Number(c.req.param("id")), + id, Number(c.req.param("itemId")), Number(c.req.param("periodId")), Number(body.pct_complete ?? 0), @@ -130,9 +159,10 @@ export function registerWorkProgramRoutes(app: App) { return c.json({ ok: true, data }); }); - app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => { + app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => { const db = c.get("db"); const id = Number(c.req.param("id")); + if (!await ensureProject(c, id)) return; const kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida"; const project = await projectById(db, id); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); diff --git a/web-panel/components/IamDetail.vue b/web-panel/components/IamDetail.vue new file mode 100644 index 0000000..4a38178 --- /dev/null +++ b/web-panel/components/IamDetail.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/web-panel/composables/useApi.ts b/web-panel/composables/useApi.ts index 0b46375..b147d78 100644 --- a/web-panel/composables/useApi.ts +++ b/web-panel/composables/useApi.ts @@ -48,7 +48,10 @@ export const useAuth = () => { company_name?: string | null; company_kind?: string | null; tenant_id?: number | null; - role?: "platform_admin" | "tenant_admin" | "user"; + role_id?: number | null; + role_code?: string; + is_owner?: boolean; + status?: string; realm?: "app" | "platform"; must_change_password?: boolean; } | null>("auth-user", () => null); diff --git a/web-panel/composables/useDesktop.ts b/web-panel/composables/useDesktop.ts index 3390eac..d7db6ce 100644 --- a/web-panel/composables/useDesktop.ts +++ b/web-panel/composables/useDesktop.ts @@ -16,12 +16,14 @@ export type RibbonGroup = { }; export type DeskDetail = { - kind: "none" | "worker" | "project" | "budget"; + kind: "none" | "worker" | "project" | "budget" | "iam-user" | "iam-role"; id?: number; doc?: string; tab?: string; project?: Record; budgetItem?: Record; + iamUser?: Record; + iamRole?: Record; }; export type DeskStatusMessage = { @@ -114,6 +116,16 @@ export function useDesktop() { showDetail.value = true; } + function openIamUser(row: Record & { id: number }) { + detail.value = { kind: "iam-user", id: row.id, iamUser: row }; + showDetail.value = true; + } + + function openIamRole(row: Record & { id: number }) { + detail.value = { kind: "iam-role", id: row.id, iamRole: row }; + showDetail.value = true; + } + function clearDetail() { detail.value = { kind: "none" }; } @@ -155,6 +167,8 @@ export function useDesktop() { openWorker, openProject, openBudgetItem, + openIamUser, + openIamRole, clearDetail, bumpDetail, loanPrompt, diff --git a/web-panel/composables/usePermissions.ts b/web-panel/composables/usePermissions.ts new file mode 100644 index 0000000..d639407 --- /dev/null +++ b/web-panel/composables/usePermissions.ts @@ -0,0 +1,39 @@ +export function usePermissions() { + const permissions = useState("user-permissions", () => []); + const isOwner = useState("user-permissions-owner", () => false); + const loaded = useState("user-permissions-loaded", () => false); + const { api } = useApi(); + + async function load() { + try { + const r = await api<{ + permissions?: string[]; + is_owner?: boolean; + }>("/v1/me/permissions"); + permissions.value = r.permissions ?? []; + isOwner.value = Boolean(r.is_owner); + loaded.value = true; + } catch { + permissions.value = []; + isOwner.value = false; + loaded.value = true; + } + } + + function can(code: string): boolean { + if (isOwner.value) return true; + return permissions.value.includes(code); + } + + function canAny(...codes: string[]): boolean { + return codes.some((c) => can(c)); + } + + function canModuleView(module: string): boolean { + return can(`${module}.view`) || canAny( + ...permissions.value.filter((p) => p.startsWith(`${module}.`) && p.endsWith(".view")), + ); + } + + return { permissions, isOwner, loaded, load, can, canAny, canModuleView }; +} diff --git a/web-panel/composables/useScope.ts b/web-panel/composables/useScope.ts new file mode 100644 index 0000000..137c334 --- /dev/null +++ b/web-panel/composables/useScope.ts @@ -0,0 +1,35 @@ +export type UserScopePayload = { + all_projects: boolean; + project_ids: number[]; + warehouse_central: boolean; + warehouse_projects: boolean; +}; + +export function useScope() { + const scope = useState("user-scope", () => null); + const loaded = useState("user-scope-loaded", () => false); + const { api } = useApi(); + + async function load() { + try { + scope.value = await api("/v1/me/scope"); + loaded.value = true; + } catch { + scope.value = { + all_projects: false, + project_ids: [], + warehouse_central: false, + warehouse_projects: false, + }; + loaded.value = true; + } + } + + function canAccessProject(projectId: number): boolean { + if (!scope.value) return true; + if (scope.value.all_projects) return true; + return scope.value.project_ids.includes(projectId); + } + + return { scope, loaded, load, canAccessProject }; +} diff --git a/web-panel/layouts/default.vue b/web-panel/layouts/default.vue index 0caf896..8d564ec 100644 --- a/web-panel/layouts/default.vue +++ b/web-panel/layouts/default.vue @@ -90,6 +90,7 @@ :item="detail.budgetItem" @edit="editFromFicha" /> +
Seleccione un registro @@ -114,8 +115,10 @@ diff --git a/web-panel/middleware/auth.global.ts b/web-panel/middleware/auth.global.ts index 4b8ca43..ae613ba 100644 --- a/web-panel/middleware/auth.global.ts +++ b/web-panel/middleware/auth.global.ts @@ -1,3 +1,20 @@ +const ROUTE_PERMISSIONS: Record = { + "/": "reports.view", + "/proyectos": "projects.view", + "/obras": "projects.view", + "/presupuesto": "budget.view", + "/programa-obra": "work_program.view", + "/control-presupuesto": "cost_control.view", + "/gastos": "expenses.view", + "/almacen": "warehouse.view", + "/kanban": "kanban.view", + "/padron": "workers.view", + "/gafetes": "documents.view", + "/nomina": "payroll.view", + "/configuracion": "settings.view", + "/usuarios": "users.view", +}; + export default defineNuxtRouteMiddleware(async (to) => { if (to.path === "/login") return; const { user, fetchMe } = useAuth(); @@ -14,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => { } if (!user.value) return navigateTo("/login"); - if (user.value.role === "platform_admin" && user.value.realm === "platform") { + if (user.value.role_code === "platform_admin" && user.value.realm === "platform") { user.value = null; try { await api("/v1/auth/logout", { method: "POST" }); @@ -34,4 +51,17 @@ export default defineNuxtRouteMiddleware(async (to) => { if (!user.value.must_change_password && to.path === "/cambiar-password") { return navigateTo("/"); } + + const perms = usePermissions(); + const userScope = useScope(); + if (!perms.loaded.value) await perms.load(); + if (!userScope.loaded.value) await userScope.load(); + + const required = Object.entries(ROUTE_PERMISSIONS).find(([path]) => + path === "/" ? to.path === "/" : to.path === path || to.path.startsWith(`${path}/`), + )?.[1]; + + if (required && !perms.can(required)) { + return navigateTo("/"); + } }); diff --git a/web-panel/pages/login.vue b/web-panel/pages/login.vue index 6c3ab91..478e246 100644 --- a/web-panel/pages/login.vue +++ b/web-panel/pages/login.vue @@ -281,7 +281,7 @@ async function onSubmit() { await login(username.value.trim(), password.value.trim()); const { user } = useAuth(); const { api } = useApi(); - if (user.value?.role === "platform_admin" && user.value?.realm === "platform") { + if (user.value?.role_code === "platform_admin" && user.value?.realm === "platform") { user.value = null; try { await api("/v1/auth/logout", { method: "POST" }); diff --git a/web-panel/pages/programa-obra.vue b/web-panel/pages/programa-obra.vue index 457ab81..8cb960e 100644 --- a/web-panel/pages/programa-obra.vue +++ b/web-panel/pages/programa-obra.vue @@ -73,7 +73,7 @@ :drill-concepts="ganttConcepts" /> - + @@ -115,6 +115,8 @@ const desk = useDesktop(); const route = useRoute(); const { api, download } = useApi(); const toast = useToast(); +const perms = usePermissions(); +const showCostTab = computed(() => perms.can("cost_control.view")); const projects = ref([]); const loadedProjectId = ref(0); diff --git a/web-panel/pages/usuarios.vue b/web-panel/pages/usuarios.vue index 1bccb3a..4228c8f 100644 --- a/web-panel/pages/usuarios.vue +++ b/web-panel/pages/usuarios.vue @@ -1,78 +1,521 @@