Implement client IAM v2: roles per tenant, CRUD matrix, scope, and panel UI

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

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 18:16:59 +00:00
parent 25ceb6a6bd
commit aed2f4531b
No known key found for this signature in database
27 changed files with 1886 additions and 323 deletions

View file

@ -7,7 +7,6 @@ import { createSession, getSession, revokeSession, revokeAllSessionsForUser } fr
import { hashPassword, verifyPassword } from "./crypto.ts"; import { hashPassword, verifyPassword } from "./crypto.ts";
export type AuthRealm = "app" | "platform"; export type AuthRealm = "app" | "platform";
export type AuthRole = "platform_admin" | "tenant_admin" | "user";
export type AuthUser = { export type AuthUser = {
id: number; id: number;
@ -18,7 +17,10 @@ export type AuthUser = {
company_name: string | null; company_name: string | null;
company_kind: string | null; company_kind: string | null;
tenant_id: number | null; tenant_id: number | null;
role: AuthRole; role_id: number | null;
role_code: string;
is_owner: boolean;
status: string;
realm: AuthRealm; realm: AuthRealm;
must_change_password: boolean; must_change_password: boolean;
}; };
@ -26,6 +28,14 @@ export type AuthUser = {
const COOKIE = "po_session"; const COOKIE = "po_session";
const TTL_SECONDS = 60 * 60 * 24 * 7; 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( export async function createSessionCookie(
c: Context, c: Context,
userId: number, userId: number,
@ -48,8 +58,13 @@ export async function clearSession(c: Context) {
deleteCookie(c, COOKIE, { path: "/" }); deleteCookie(c, COOKIE, { path: "/" });
} }
function isOwnerRole(row: Record<string, unknown>): boolean {
return row.role_code === "tenant_admin"
&& Boolean(row.role_is_system)
&& row.role_tenant_id == null;
}
function asAppUser(row: Record<string, unknown>): AuthUser { function asAppUser(row: Record<string, unknown>): AuthUser {
const role = (row.role_code as string) || "user";
return { return {
id: Number(row.id), id: Number(row.id),
username: String(row.username), username: String(row.username),
@ -59,7 +74,10 @@ function asAppUser(row: Record<string, unknown>): AuthUser {
company_name: (row.company_name as string | null) ?? null, company_name: (row.company_name as string | null) ?? null,
company_kind: (row.company_kind as string | null) ?? null, company_kind: (row.company_kind as string | null) ?? null,
tenant_id: row.tenant_id == null ? null : Number(row.tenant_id), 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", realm: "app",
must_change_password: Boolean(row.must_change_password), 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_name: null,
company_kind: null, company_kind: null,
tenant_id: null, tenant_id: null,
role: "platform_admin", role_id: null,
role_code: "platform_admin",
is_owner: true,
status: "activo",
realm: "platform", realm: "platform",
must_change_password: false, must_change_password: false,
}; };
@ -103,12 +124,10 @@ async function userFromCookie(c: Context): Promise<AuthUser | null> {
* empresa (core) -- usado tras login y tras cambiar contraseña. */ * empresa (core) -- usado tras login y tras cambiar contraseña. */
export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> { export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> {
const row = await withIamTenant(tenantId, async (db) => { const row = await withIamTenant(tenantId, async (db) => {
return await db.prepare( return await db.prepare(USER_SELECT).get(userId);
`SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email FROM users WHERE id = ?`,
).get(userId);
}); });
if (!row) return null; if (!row) return null;
if (String(row.status ?? "activo") !== "activo") return null;
const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null); const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null);
return asAppUser({ ...row, ...company }); return asAppUser({ ...row, ...company });
} }
@ -124,10 +143,6 @@ function apiKeyOk(c: Context): boolean {
export async function requireAuth(c: Context, next: Next) { export async function requireAuth(c: Context, next: Next) {
if (apiKeyOk(c)) { 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 tenantHeader = c.req.header("x-tenant-id") ?? "";
const tenantId = Number(tenantHeader); const tenantId = Number(tenantHeader);
if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) { if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) {
@ -142,7 +157,10 @@ export async function requireAuth(c: Context, next: Next) {
company_name: null, company_name: null,
company_kind: null, company_kind: null,
tenant_id: tenantId, tenant_id: tenantId,
role: "tenant_admin", role_id: null,
role_code: "api_key",
is_owner: false,
status: "activo",
realm: "app", realm: "app",
must_change_password: false, must_change_password: false,
} satisfies AuthUser); } satisfies AuthUser);
@ -158,7 +176,7 @@ export async function requireAuth(c: Context, next: Next) {
export async function requirePlatformAdmin(c: Context, next: Next) { export async function requirePlatformAdmin(c: Context, next: Next) {
const user = await userFromCookie(c); const user = await userFromCookie(c);
if (!user) return c.json({ error: "No autenticado" }, 401); 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); return c.json({ error: "Solo administradores SaaS" }, 403);
} }
c.set("user", user); c.set("user", user);
@ -175,14 +193,14 @@ export async function login(username: string, password: string): Promise<AuthUse
const pass = password.trim(); const pass = password.trim();
if (!user || !pass) return null; if (!user || !pass) return null;
// username es único globalmente (no por tenant) -- se resuelve con el
// pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién
// después de esto se conoce el tenant_id para todo lo demás.
const appRow = await findUserByUsernameAnyTenant(user); const appRow = await findUserByUsernameAnyTenant(user);
if (appRow && await verifyPassword(pass, appRow.password_hash as string)) { if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
if (String(appRow.status ?? "activo") !== "activo") {
throw Object.assign(new Error("Usuario dado de baja"), { code: "USER_INACTIVE" });
}
const authUser = await withIamTenant( const authUser = await withIamTenant(
appRow.tenant_id == null ? null : Number(appRow.tenant_id), appRow.tenant_id == null ? null : Number(appRow.tenant_id),
async (db) => { async () => {
const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null); const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
return asAppUser({ ...appRow, ...company }); return asAppUser({ ...appRow, ...company });
}, },
@ -248,9 +266,6 @@ export async function changePassword(
await db.prepare( await db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?", "UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?",
).run(hash, userId); ).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"); await revokeAllSessionsForUser(userId, "app");
return {}; return {};
}); });

View file

@ -5,7 +5,8 @@ import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts"; import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.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 } }>; 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) { 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", { const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: Number(c.req.param("id")), project_id: projectId,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_item", { const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: Number(c.req.param("id")), project_id: projectId,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", { const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: Number(c.req.param("id")), project_id: projectId,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", { const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: Number(c.req.param("id")), project_id: projectId,
limit: c.req.query("limit"), limit: c.req.query("limit"),
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); return respondRpc(c, env);

View file

@ -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"); const url = Deno.env.get("DATABASE_URL_IAM");
if (!url) throw new Error("DATABASE_URL_IAM requerido"); if (!url) throw new Error("DATABASE_URL_IAM requerido");
const pool = createPool(url, { max: 2 }); const pool = createPool(url, { max: 2 });
@ -254,16 +254,21 @@ Deno.test("RPC IAM: usuarios y permisos", async () => {
assertEquals(perms.result.ok, true); assertEquals(perms.result.ok, true);
assert(perms.result.data.permissions.length > 0); assert(perms.result.data.permissions.length > 0);
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({ const roleGet = await db.prepare(`SELECT iam.fn_role_permissions_get($1::jsonb) AS result`).get({
role_code: "user", role_id: roleRow.id,
}) as { result: { ok: boolean } }; }) as { result: { ok: boolean } };
assertEquals(roleGet.result.ok, true); assertEquals(roleGet.result.ok, true);
const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({ const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({
role_code: "user", role_id: roleRow.id,
permissions: ["view_expenses", "view_warehouse", "manage_workers", "manage_documents", "view_reports"], permissions: ["workers.view", "warehouse.view", "documents.view", "reports.view"],
}) as { result: { ok: boolean } }; }) as { result: { ok: boolean } };
assertEquals(roleSave.result.ok, true); assertEquals(roleSave.result.ok, true);
}
throw ROLLBACK; throw ROLLBACK;
}); });

View file

@ -4,10 +4,11 @@ import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts"; import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts"; import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts"; import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts"; import { requirePermission } from "./permissions.ts";
import { encryptBytes, decryptBytes } from "./docs_crypto.ts"; import { encryptBytes, decryptBytes } from "./docs_crypto.ts";
import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts"; import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; 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) { 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_list", { const env = await callCoreFn(db, "core.fn_expense_list", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: c.req.query("project_id"), project_id: projectId,
company_id: c.req.query("company_id"), company_id: c.req.query("company_id"),
date_from: c.req.query("date_from"), date_from: c.req.query("date_from"),
date_to: c.req.query("date_to"), date_to: c.req.query("date_to"),
@ -36,14 +39,20 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }); 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); 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<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
if (!await denyUnlessProjectScope(c, Number(body.project_id))) return;
const db = c.get("db"); const db = c.get("db");
const a = actor(c); const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_create", { const env = await callCoreFn(db, "core.fn_expense_create", {
@ -55,9 +64,14 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env); 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<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db"); 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", { const env = await callCoreFn(db, "core.fn_expense_update", {
...body, ...body,
id: Number(c.req.param("id")), id: Number(c.req.param("id")),
@ -66,18 +80,29 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env); 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 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) }); const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
return respondRpc(c, env); 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 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 form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof 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, expense_id: expenseId,
...JSON.parse(String(form.get("meta") ?? "{}")), ...JSON.parse(String(form.get("meta") ?? "{}")),
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
@ -89,7 +114,6 @@ export function registerExpenseRoutes(app: App) {
if (s3Configured()) { if (s3Configured()) {
await putObject(expenseDocKey(expenseId, storageName), cipher); await putObject(expenseDocKey(expenseId, storageName), cipher);
} }
const db = c.get("db");
const a = actor(c); const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_attachment_add", { const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId, expense_id: expenseId,
@ -105,10 +129,15 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env); 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 expenseId = Number(c.req.param("id"));
const attId = Number(c.req.param("attId")); const attId = Number(c.req.param("attId"));
const db = c.get("db"); 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( const row = await db.prepare(
"SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?", "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; ).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); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_iva_period_summary", { const env = await callCoreFn(db, "core.fn_iva_period_summary", {
tenant_id: tid(c), tenant_id: tid(c),
@ -131,13 +160,13 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) }); const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env); 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<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }); const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });

135
api/iam_catalog.ts Normal file
View file

@ -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<string, string[]> = {
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<string, { label: string; permissions: string[] }> = {
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",
],
},
};

View file

@ -41,9 +41,12 @@ export async function pingIamDb(): Promise<void> {
* qué hacer con tenant_id/role_code. */ * qué hacer con tenant_id/role_code. */
export async function findUserByUsernameAnyTenant(username: string) { export async function findUserByUsernameAnyTenant(username: string) {
const rows = await getAuthPool()` const rows = await getAuthPool()`
SELECT id, username, password_hash, display_name, company_id, tenant_id, SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
role_code, must_change_password, email, created_at u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
FROM iam.users WHERE username = ${username}`; 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<string, unknown> | undefined; return rows[0] as Record<string, unknown> | undefined;
} }

View file

@ -2,63 +2,302 @@ import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts"; import type { AuthUser } from "./auth.ts";
import { requireAuth, tenantScope } from "./auth.ts"; import { requireAuth, tenantScope } from "./auth.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { respondRpc } from "./http_errors.ts"; import { generateSecurePassword, hashPassword } from "./crypto.ts";
import { requirePermission, callIamFn } from "./permissions.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 } }>; 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<typeof respondRpc>[0],
tenantId: number,
userId: number,
): Promise<boolean> {
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) { export function registerIamRoutes(app: App) {
app.get("/v1/iam/users", requireAuth, requirePermission("manage_users"), async (c) => { app.get("/v1/iam/users", requireAuth, requirePermission("users.view"), async (c) => {
const tid = tenantScope(c.get("user")); const t = tid(c);
const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid }); const row = await callIamFn(t, "iam.fn_user_list", { tenant_id: t });
if (!row) { 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, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar usuarios IAM", data: null, errors: null });
} }
return respondRpc(c, row as Parameters<typeof respondRpc>[1]); return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
}); });
app.get("/v1/iam/permissions", requireAuth, requirePermission("manage_users"), async (c) => { app.get("/v1/iam/permissions", requireAuth, requirePermission("users.view"), async (c) => {
const tid = tenantScope(c.get("user")); const row = await callIamFn(tid(c), "iam.fn_permission_list", {});
const row = await callIamFn(tid, "iam.fn_permission_list", {});
if (!row) { 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, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null });
} }
return respondRpc(c, row as Parameters<typeof respondRpc>[1]); return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
}); });
app.get("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => { app.get("/v1/iam/roles", requireAuth, requirePermission("users.view"), async (c) => {
const tid = tenantScope(c.get("user")); const t = tid(c);
const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") }); 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<typeof respondRpc>[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<typeof respondRpc>[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<typeof respondRpc>[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<typeof respondRpc>[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) { 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, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos del rol", data: null, errors: null });
} }
return respondRpc(c, row as Parameters<typeof respondRpc>[1]); return respondRpc(c, row as Parameters<typeof respondRpc>[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 body = await c.req.json<{ permissions?: string[] }>();
const tid = tenantScope(c.get("user")); const roleId = Number(c.req.param("id"));
const row = await callIamFn(tid, "iam.fn_role_permissions_save", { const perms = filterSensitivePermissions(body.permissions ?? [], user.is_owner);
role_code: c.req.param("role"), const t = tid(c);
permissions: body.permissions ?? [], const row = await callIamFn(t, "iam.fn_role_permissions_save", {
role_id: roleId,
permissions: perms,
}); });
if (!row) { if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null }); 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<typeof respondRpc>[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<string, unknown> }).data = {
...(env.data ?? {}),
temporary_password: tempPassword,
};
}
return respondRpc(c, row as Parameters<typeof respondRpc>[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<typeof respondRpc>[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<string, unknown> };
if (env.ok) {
await revokeAllSessionsForUser(userId, "app");
env.data = { ...(env.data ?? {}), temporary_password: tempPassword };
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]); return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
}); });
app.get("/v1/me/permissions", requireAuth, async (c) => { app.get("/v1/me/permissions", requireAuth, async (c) => {
const user = c.get("user"); const user = c.get("user");
const { userPermissions } = await import("./permissions.ts"); const { userPermissionsByRoleId } = await import("./permissions.ts");
const role = user.role === "tenant_admin" ? "tenant_admin" : "user"; const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, user.is_owner);
const perms = await userPermissions(user.tenant_id, role);
return respondRpc(c, { return respondRpc(c, {
ok: true, ok: true,
code: "OK", code: "OK",
layer: "api", layer: "api",
message: `Permisos del usuario ${user.username}`, 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, errors: null,
}); });
}); });

72
api/iam_test.ts Normal file
View file

@ -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"));
});

View file

@ -36,6 +36,14 @@ import {
type AuthUser, type AuthUser,
} from "./auth.ts"; } from "./auth.ts";
import { requireCoreAuth, withCoreScope } from "./scope.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 { getPlatformDb, pingPlatformDb } from "./platform_db.ts";
import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts"; import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts";
import { smtpConfigured, testSmtp } from "./mail.ts"; import { smtpConfigured, testSmtp } from "./mail.ts";
@ -82,6 +90,8 @@ type StorageDoc = {
iv: string; iv: string;
original_name: string; original_name: string;
mime?: string; mime?: string;
type_code?: string;
type?: string;
}; };
async function rpcDocumentForDownload( 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 db = c.get("db");
const user = c.get("user"); const user = c.get("user");
const tid = tenantScope(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 // Configuración del tenant (Fase 4d): zona horaria usada por nómina y
// vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado. // 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 db = c.get("db");
const tid = tenantScope(c.get("user")); const tid = tenantScope(c.get("user"));
const timezone = await tenantTimezone(db, tid); const timezone = await tenantTimezone(db, tid);
return c.json({ timezone }); 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"); 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 db = c.get("db");
const tid = tenantScope(user); const tid = tenantScope(user);
if (tid == null) { 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 db = c.get("db");
const tid = tenantScope(c.get("user")); const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn( 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 body = await c.req.json();
const db = c.get("db"); const db = c.get("db");
const tid = tenantScope(c.get("user")); 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 id = Number(c.req.param("id"));
const body = await c.req.json(); const body = await c.req.json();
const db = c.get("db"); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const tid = tenantScope(c.get("user")); 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 id = Number(c.req.param("id"));
const form = await c.req.formData(); const form = await c.req.formData();
const type = String(form.get("type") || ""); 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) }); 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 companyId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId")); const docId = Number(c.req.param("docId"));
const db = c.get("db"); 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); 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 db = c.get("db");
const status = (c.req.query("status") ?? "").trim(); const status = (c.req.query("status") ?? "").trim();
const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : [];
@ -464,12 +469,13 @@ app.get("/v1/projects", ...requireCoreAuth, async (c) => {
status, status,
}); });
} }
return respondRpc(c, await callCoreFn( const env = await callCoreFn(
db, db,
"core.fn_project_list", "core.fn_project_list",
{ status: allowed.length ? allowed.join(",") : undefined }, { status: allowed.length ? allowed.join(",") : undefined },
{ route: routeLabel(c) }, { route: routeLabel(c) },
)); );
return respondRpc(c, await filterProjectListEnvelope(c, env as Parameters<typeof filterProjectListEnvelope>[1]));
}); });
type ProjectInput = { type ProjectInput = {
@ -519,7 +525,7 @@ function projectFields(body: ProjectInput, cur?: Record<string, unknown>) {
}; };
} }
app.post("/v1/projects", ...requireCoreAuth, async (c) => { app.post("/v1/projects", ...requireCoreAuth, requirePermission("projects.create"), async (c) => {
const body = await c.req.json<ProjectInput>(); const body = await c.req.json<ProjectInput>();
if (!body.name?.trim()) { if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, { 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<ProjectInput>(); const body = await c.req.json<ProjectInput>();
const db = c.get("db"); const db = c.get("db");
const route = routeLabel(c); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const route = routeLabel(c); const route = routeLabel(c);
const projectEnv = await callCoreFn<{ project: Record<string, unknown> }>(db, "core.fn_project_get", { id }, { route }); const projectEnv = await callCoreFn<{ project: Record<string, unknown> }>(db, "core.fn_project_get", { id }, { route });
@ -634,6 +642,12 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
{ route }, { route },
); );
if (!docsEnv.ok) return respondRpc(c, docsEnv); 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, { return respondRpc(c, {
ok: true, ok: true,
code: "OK", code: "OK",
@ -641,20 +655,22 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
message: `Proyecto ${project.code} cargado`, message: `Proyecto ${project.code} cargado`,
data: { data: {
project, project,
documents: docsEnv.data?.documents ?? [], documents,
checklist: await projectChecklistFor(db, id), 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData(); const form = await c.req.formData();
const type = String(form.get("type") || ""); const type = String(form.get("type") || "");
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
const db = c.get("db"); const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); 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()); const bytes = new Uint8Array(await file.arrayBuffer());
try { try {
await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); 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) }); 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")); const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const docId = Number(c.req.param("docId")); const docId = Number(c.req.param("docId"));
const db = c.get("db"); const db = c.get("db");
const route = routeLabel(c); const route = routeLabel(c);
@ -676,6 +693,8 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
route, route,
); );
if (!doc) return respondRpc(c, envelope); 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 enc = await getObject(projectDocKey(projectId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc); const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", "application/octet-stream"); 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); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db"); const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
return c.json(await listBudget(db, id)); 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(); const bytes = buildBudgetTemplate();
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"'); c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"');
return c.body(bytes.slice()); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const project = await projectById(db, id); const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); 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()); 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 form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); 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); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData(); const form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); 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); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData(); const form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); 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); 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")); 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 }>(); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>();
if (!body.name?.trim()) { if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, { 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid")); const cid = Number(c.req.param("cid"));
const body = await c.req.json<{ name?: string; code?: string }>(); const body = await c.req.json<{ name?: string; code?: string }>();
const db = c.get("db"); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid")); const cid = Number(c.req.param("cid"));
const db = c.get("db"); const db = c.get("db");
return respondRpc(c, await callCoreFn( return respondRpc(c, await callCoreFn(
@ -808,8 +834,9 @@ type BudgetItemInput = {
unit_price?: number; 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<BudgetItemInput>(); const body = await c.req.json<BudgetItemInput>();
if (!body.description?.trim()) { if (!body.description?.trim()) {
return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, { 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid")); const iid = Number(c.req.param("iid"));
const body = await c.req.json<BudgetItemInput>(); const body = await c.req.json<BudgetItemInput>();
const db = c.get("db"); 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")); const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid")); const iid = Number(c.req.param("iid"));
const db = c.get("db"); const db = c.get("db");
return respondRpc(c, await callCoreFn( 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<WorkerInput & { id?: number }>(); const body = await c.req.json<WorkerInput & { id?: number }>();
const errors = validateWorkerFields(body); const errors = validateWorkerFields(body);
const db = c.get("db"); 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 }); 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 db = c.get("db");
const tid = tenantScope(c.get("user")); const tid = tenantScope(c.get("user"));
const q = (c.req.query("q") ?? "").trim(); const q = (c.req.query("q") ?? "").trim();
const status = c.req.query("status"); const status = c.req.query("status");
const projectId = c.req.query("project_id"); const projectId = c.req.query("project_id");
if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return;
return respondRpc(c, await callCoreFn( return respondRpc(c, await callCoreFn(
db, db,
"core.fn_worker_list", "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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const tid = tenantScope(c.get("user")); const tid = tenantScope(c.get("user"));
@ -966,7 +996,7 @@ async function findExistingWorker(
return env.data.worker ?? undefined; 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<WorkerInput & { project_id?: number }>(); const body = await c.req.json<WorkerInput & { project_id?: number }>();
const errors = validateWorkerFields(body); const errors = validateWorkerFields(body);
const db = c.get("db"); const db = c.get("db");
@ -990,6 +1020,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => {
const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION";
return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); 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")); const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn( 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const tid = tenantScope(c.get("user")); 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 id = Number(c.req.param("id"));
const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>();
const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; 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 id = Number(c.req.param("id"));
const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>();
const db = c.get("db"); 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 db = c.get("db");
const bytes = buildImportTemplate(await listCompanies(db)); const bytes = buildImportTemplate(await listCompanies(db));
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); 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); 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 form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
const projectId = Number(form.get("project_id") || 0) || null; 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); 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 id = Number(c.req.param("id"));
const form = await c.req.formData(); const form = await c.req.formData();
const type = String(form.get("type") || ""); 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 workerId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId")); const docId = Number(c.req.param("docId"));
const db = c.get("db"); 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); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const bytes = await loadCurrentPhoto(db, id); 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); 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(); const curp = (c.req.query("curp") ?? "").trim().toUpperCase();
if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400); if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400);
const png = await badgeQrPng(curp); const png = await badgeQrPng(curp);
@ -1243,8 +1274,9 @@ app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
return c.body(png.buffer as ArrayBuffer); 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")); 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 body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] }));
const db = c.get("db"); const db = c.get("db");
const user = c.get("user"); 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"); const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) })); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const route = routeLabel(c); 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); 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 jobId = Number(c.req.param("id"));
const workerId = Number(c.req.param("workerId")); const workerId = Number(c.req.param("workerId"));
const { delivered } = await c.req.json<{ delivered: boolean }>(); const { delivered } = await c.req.json<{ delivered: boolean }>();

View file

@ -2,6 +2,8 @@ import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts"; import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts"; import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.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 { projectById, projectMustBe, type Db } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts"; import { generateLoanReceiptPdf } from "./pdf.ts";
import { fullName } from "./mx.ts"; import { fullName } from "./mx.ts";
@ -44,7 +46,7 @@ function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
} }
export function registerPayrollRoutes(app: App) { 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 projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? ""; const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? ""; const to = c.req.query("to") ?? "";
@ -58,7 +60,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env); 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<{ const body = await c.req.json<{
project_id: number; project_id: number;
worker_id: number; worker_id: number;
@ -86,13 +88,13 @@ export function registerPayrollRoutes(app: App) {
return c.json({ ok: true }); 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 db = c.get("db");
const tenantId = tid(c); const tenantId = tid(c);
return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) }); 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 db = c.get("db");
const body = await c.req.json<{ const body = await c.req.json<{
loan_commission_enabled?: number | boolean; loan_commission_enabled?: number | boolean;
@ -109,7 +111,7 @@ export function registerPayrollRoutes(app: App) {
return c.json({ settings }); 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 body = await c.req.json<{ code: string; label?: string }>();
const db = c.get("db"); const db = c.get("db");
try { 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 db = c.get("db");
const workerId = c.req.query("worker_id"); const workerId = c.req.query("worker_id");
const env = await callCoreFn<{ loans: unknown[] }>( const env = await callCoreFn<{ loans: unknown[] }>(
@ -132,7 +134,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env); 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<{ const body = await c.req.json<{
worker_id: number; worker_id: number;
amount?: 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn<{ loan: { const env = await callCoreFn<{ loan: {
@ -213,7 +215,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn<{ payment: { const env = await callCoreFn<{ payment: {
@ -248,7 +250,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`); 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 db = c.get("db");
const tenantId = tid(c); const tenantId = tid(c);
const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart; 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); 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"); const db = c.get("db");
return c.json({ weeks: await listOpenWeeks(db, tid(c)) }); 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"); const db = c.get("db");
try { try {
const nowIso = await resolveToday(db, tid(c)); 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"); const db = c.get("db");
try { try {
const nowIso = await resolveToday(db, tid(c)); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const csv = await weekCsv(db, id); const csv = await weekCsv(db, id);
@ -294,7 +296,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv); 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 body = await c.req.json<{ project_id: number; worker_id: number }>();
const db = c.get("db"); const db = c.get("db");
try { 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 body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
const db = c.get("db"); const db = c.get("db");
try { 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 body = await c.req.json<{ amount: number }>();
const db = c.get("db"); const db = c.get("db");
try { 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"); const db = c.get("db");
try { try {
await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); 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<{ const body = await c.req.json<{
week_id: number; week_id: number;
project_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<{ const body = await c.req.json<{
week_id: number; week_id: number;
qty_planned?: 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<{ const body = await c.req.json<{
project_id: number; project_id: number;
week_start: string; week_start: string;
@ -415,7 +417,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env); 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 db = c.get("db");
const projectId = c.req.query("project_id"); const projectId = c.req.query("project_id");
const env = await callCoreFn<{ periods: unknown[] }>( const env = await callCoreFn<{ periods: unknown[] }>(
@ -427,7 +429,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>( const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
@ -439,7 +441,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env); 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 id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>( const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
@ -460,7 +462,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv); 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 id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>(); const { status } = await c.req.json<{ status: string }>();
const db = c.get("db"); const db = c.get("db");

View file

@ -3,44 +3,62 @@ import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts"; import { tenantScope } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts"; import { withIamTenant } from "./iam_db.ts";
import { respondApiError, routeLabel } from "./http_errors.ts"; import { respondApiError, routeLabel } from "./http_errors.ts";
import { ALL_PERMISSION_CODES, IAM_SENSITIVE_PREFIXES } from "./iam_catalog.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>(); const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000; 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, tenantId: number | null,
roleCode: string, roleId: number | null,
isOwner: boolean,
): Promise<Set<string>> { ): Promise<Set<string>> {
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); const hit = permissionCache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms; 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) => const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare( await db.prepare(
"SELECT permission_code FROM role_permissions WHERE role_code = ?", "SELECT permission_code FROM role_permissions WHERE role_id = ?",
).all(roleCode) as { permission_code: string }[], ).all(roleId) as { permission_code: string }[],
); );
const perms = new Set(rows.map((r) => r.permission_code)); const perms = new Set(rows.map((r) => r.permission_code));
permissionCache.set(key, { perms, at: Date.now() }); permissionCache.set(key, { perms, at: Date.now() });
return perms; return perms;
} }
export async function hasPermission( /** @deprecated use userPermissionsByRoleId */
user: AuthUser, export async function userPermissions(
code: string, tenantId: number | null,
): Promise<boolean> { roleCode: string,
if (user.role === "tenant_admin") return true; ): Promise<Set<string>> {
const role = user.role === "user" ? "user" : user.role; if (roleCode === "tenant_admin") return new Set(ALL_PERMISSION_CODES);
const perms = await userPermissions(user.tenant_id, role); 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<boolean> {
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); return perms.has(code);
} }
@ -56,7 +74,7 @@ export function requirePermission(code: string) {
c, c,
"FORBIDDEN", "FORBIDDEN",
`Permiso requerido: ${code}`, `Permiso requerido: ${code}`,
{ route: routeLabel(c), permission: code, role: user.role }, { route: routeLabel(c), permission: code, role: user.role_code },
); );
} }
await next(); 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<T = unknown>( export async function callIamFn<T = unknown>(
tenantId: number | null, tenantId: number | null,
fn: string, fn: string,
@ -99,3 +127,19 @@ export async function callIamFn<T = unknown>(
} }
return null; return null;
} }
export async function usersWithRole(tenantId: number | null, roleId: number): Promise<number[]> {
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<void> {
const { revokeAllSessionsForUser } = await import("./sessions.ts");
const ids = await usersWithRole(tenantId, roleId);
await Promise.all(ids.map((id) => revokeAllSessionsForUser(id, "app")));
}

View file

@ -236,10 +236,12 @@ export async function getTenantDetail(
const admins = await withIamOwner(async (iam) => { const admins = await withIamOwner(async (iam) => {
const rows = await iam.prepare( const rows = await iam.prepare(
`SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role, `SELECT u.id, u.username, u.display_name, COALESCE(u.email, '') AS email, r.code AS role,
COALESCE(must_change_password, false) AS must_change_password, created_at COALESCE(u.must_change_password, false) AS must_change_password, u.created_at
FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin' FROM users u
ORDER BY id`, 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<Record<string, unknown>>; ).all(tenantId) as Array<Record<string, unknown>>;
return rows.map((a) => ({ return rows.map((a) => ({
id: Number(a.id), id: Number(a.id),
@ -344,16 +346,20 @@ export async function createTenant(
).then(() => core.lastInsertId()) ).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); const hash = await hashPassword(temporaryPassword);
await withIamOwner((iam) => await withIamOwner(async (iam) => {
iam.prepare( 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 ( `INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role_code, username, password_hash, display_name, company_id, tenant_id, role_id,
must_change_password, email must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, true, ?, 'activo', true, true, true)`,
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail) ).run(adminUser, hash, adminName, companyId, tenantId, adminRole.id, contactEmail);
); });
} catch (e) { } catch (e) {
// Compensación: limpiar en orden inverso lo que sí se alcanzó a crear. // Compensación: limpiar en orden inverso lo que sí se alcanzó a crear.
if (companyId != null) { if (companyId != null) {

116
api/scope_enforcement.ts Normal file
View file

@ -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<boolean> {
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<T extends { id: number }>(
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<RpcEnvelope<{ projects?: Array<{ id: number }> }>> {
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<string> {
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<boolean> {
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<boolean> {
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<typeof documents> {
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<boolean> {
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;
}

View file

@ -74,18 +74,27 @@ async function bootstrapTenantAdmin() {
const hash = await hashPassword(config.seedPassword); const hash = await hashPassword(config.seedPassword);
await withIamOwner(async (db) => { 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); const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username);
if (existing) { if (existing) {
await db.prepare( await db.prepare(
`UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?, `UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?,
role_code = 'tenant_admin', must_change_password = true WHERE username = ?`, role_id = ?, must_change_password = true, status = 'activo',
).run(hash, displayName, company.id, tenantId, username); 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})`); console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`);
} else { } else {
await db.prepare( await db.prepare(
`INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email) `INSERT INTO users (
VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`, username, password_hash, display_name, company_id, tenant_id, role_id,
).run(username, hash, displayName, company.id, tenantId); 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})`); console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`);
} }
}); });

77
api/user_scope.ts Normal file
View file

@ -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<number, { scope: UserScope; at: number }>();
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<UserScope> {
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<UserScope> {
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";
}

View file

@ -4,8 +4,10 @@ import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts"; import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts"; import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts"; import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.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 } }>; 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 }; return { id: u.id, name: u.display_name };
} }
async function filterWarehouseList(c: Context, env: Awaited<ReturnType<typeof callCoreFn>>) {
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) { 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_list", { const env = await callCoreFn(db, "core.fn_warehouse_list", {
tenant_id: tid(c), tenant_id: tid(c),
include_closed: c.req.query("include_closed") === "true", include_closed: c.req.query("include_closed") === "true",
}, { route: routeLabel(c) }); }, { 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) }); const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_open_project", { const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
tenant_id: tid(c), tenant_id: tid(c),
project_id: Number(c.req.param("id")), project_id: projectId,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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 body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string }));
const db = c.get("db"); const db = c.get("db");
const a = actor(c); const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_close_project", { const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: Number(c.req.param("id")), project_id: projectId,
force: body.force, force: body.force,
closed_by_name: a.name, closed_by_name: a.name,
close_notes: body.close_notes, close_notes: body.close_notes,
@ -56,17 +80,26 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env); 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 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", { const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c), tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")), warehouse_id: whId,
global: false, global: false,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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 db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", { const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c), tenant_id: tid(c),
@ -75,51 +108,75 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env); 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 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", { 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"), material_id: c.req.query("material_id"),
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db"); const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }); const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env); return respondRpc(c, env);
}); });
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const body = await c.req.json<Record<string, unknown>>(); const whId = Number(c.req.param("id"));
const db = c.get("db"); 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<Record<string, unknown>>();
const a = actor(c); const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", { const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
...body, ...body,
tenant_id: tid(c), tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")), warehouse_id: whId,
created_by_id: a.id, created_by_id: a.id,
created_by_name: a.name, created_by_name: a.name,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); return respondRpc(c, env);
}); });
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => { app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const body = await c.req.json<Record<string, unknown>>(); const whId = Number(c.req.param("id"));
const db = c.get("db"); 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<Record<string, unknown>>();
const a = actor(c); const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", { const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
...body, ...body,
tenant_id: tid(c), tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")), warehouse_id: whId,
created_by_id: a.id, created_by_id: a.id,
created_by_name: a.name, created_by_name: a.name,
}, { route: routeLabel(c) }); }, { route: routeLabel(c) });
return respondRpc(c, env); 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<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db"); const db = c.get("db");
const a = actor(c); const a = actor(c);

View file

@ -1,9 +1,10 @@
import type { Hono } from "hono"; import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts"; import type { AuthUser } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts"; import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { projectById } 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 { import {
createEmptyWorkProgram, createEmptyWorkProgram,
exportWorkProgram, exportWorkProgram,
@ -22,7 +23,11 @@ import {
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; 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<boolean> {
return await denyUnlessProjectScope(c, id);
}
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> { async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
const form = await c.req.formData(); const form = await c.req.formData();
@ -32,70 +37,90 @@ async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Pr
} }
export function registerWorkProgramRoutes(app: App) { 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 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 }); 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 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 }); 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 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 }); 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 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 }); 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 db = c.get("db");
const bytes = await readUpload(c); 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 }); 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 db = c.get("db");
const bytes = await readUpload(c); const bytes = await readUpload(c);
const createChapters = c.req.query("create_chapters") !== "false"; 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 }); 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 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 }); 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 db = c.get("db");
const body = await c.req.json().catch(() => ({})) as { strategy?: string }; 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 }); 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 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 }); 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 db = c.get("db");
const body = await c.req.json() as { start_pct?: number; end_pct?: number }; const body = await c.req.json() as { start_pct?: number; end_pct?: number };
const data = await updateConceptSlot( const data = await updateConceptSlot(
db, db,
Number(c.req.param("id")), id,
Number(c.req.param("itemId")), Number(c.req.param("itemId")),
Number(c.req.param("periodId")), Number(c.req.param("periodId")),
Number(body.start_pct ?? 0), Number(body.start_pct ?? 0),
@ -104,12 +129,14 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data }); 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 db = c.get("db");
const body = await c.req.json() as { amount?: number }; const body = await c.req.json() as { amount?: number };
const data = await updatePartidaAmount( const data = await updatePartidaAmount(
db, db,
Number(c.req.param("id")), id,
Number(c.req.param("chapterId")), Number(c.req.param("chapterId")),
Number(c.req.param("periodId")), Number(c.req.param("periodId")),
Number(body.amount ?? 0), Number(body.amount ?? 0),
@ -117,12 +144,14 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data }); 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 db = c.get("db");
const body = await c.req.json() as { pct_complete?: number }; const body = await c.req.json() as { pct_complete?: number };
const data = await updateProgress( const data = await updateProgress(
db, db,
Number(c.req.param("id")), id,
Number(c.req.param("itemId")), Number(c.req.param("itemId")),
Number(c.req.param("periodId")), Number(c.req.param("periodId")),
Number(body.pct_complete ?? 0), Number(body.pct_complete ?? 0),
@ -130,9 +159,10 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data }); 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 db = c.get("db");
const id = Number(c.req.param("id")); 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 kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida";
const project = await projectById(db, id); const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);

View file

@ -0,0 +1,110 @@
<template>
<div v-if="detail.kind === 'iam-user' && detail.iamUser" class="iam-detail">
<div class="section-title">Usuario</div>
<p><strong>{{ detail.iamUser.display_name }}</strong></p>
<p class="muted">{{ detail.iamUser.username }}</p>
<p>Rol: {{ detail.iamUser.role_label || detail.iamUser.role_code }}</p>
<p>Estado: {{ detail.iamUser.status }}</p>
<p v-if="detail.iamUser.is_owner" class="owner-badge">Cuenta propietaria (solo SaaS)</p>
<template v-if="!detail.iamUser.is_owner">
<div class="section-title mt-3">Alcance</div>
<p v-if="detail.iamUser.scope_all_projects">Todas las obras</p>
<p v-else>Obras asignadas: {{ (detail.iamUser.project_ids ?? []).length }}</p>
<p>Central: {{ detail.iamUser.warehouse_central ? "Sí" : "No" }}</p>
<p>Obras: {{ detail.iamUser.warehouse_projects !== false ? "Sí" : "No" }}</p>
</template>
</div>
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
<div class="section-title">Matriz del rol</div>
<p><strong>{{ detail.iamRole.label }}</strong> ({{ detail.iamRole.code }})</p>
<Message v-if="detail.iamRole.is_system" severity="info" :closable="false">Rol de sistema: no editable</Message>
<div v-else class="perm-grid mt-3">
<div v-for="perm in groupedPermissions" :key="perm.code" class="perm-item">
<Checkbox
v-model="localPerms"
:input-id="perm.code"
:value="perm.code"
:disabled="!canEdit"
/>
<label :for="perm.code">{{ perm.label }}</label>
</div>
</div>
<Button
v-if="canEdit"
label="Guardar permisos"
icon="pi pi-save"
class="mt-3 w-full"
size="small"
@click="savePerms"
/>
</div>
</template>
<script setup lang="ts">
import { displayErrorMessage } from "~/composables/api-response";
type PermRow = { code: string; label: string; module?: string; group?: string };
const props = defineProps<{
detail: {
kind: string;
iamUser?: Record<string, unknown>;
iamRole?: Record<string, unknown> & { id?: number; is_system?: boolean };
};
}>();
const { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const catalog = ref<PermRow[]>([]);
const localPerms = ref<string[]>([]);
const canEdit = computed(() =>
perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system,
);
const groupedPermissions = computed(() => catalog.value);
async function loadCatalog() {
const r = await api<{ permissions?: PermRow[] }>("/v1/iam/permissions");
catalog.value = r.permissions ?? [];
}
async function loadRolePerms(roleId: number) {
const r = await api<{ permissions?: string[] }>(`/v1/iam/roles/${roleId}/permissions`);
localPerms.value = r.permissions ?? [];
}
async function savePerms() {
const roleId = props.detail.iamRole?.id;
if (!roleId) return;
try {
await api(`/v1/iam/roles/${roleId}/permissions`, {
method: "PUT",
json: { permissions: localPerms.value },
});
toast.add({ severity: "success", summary: "Permisos guardados", life: 2500 });
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
watch(
() => props.detail,
async (d) => {
if (!catalog.value.length) await loadCatalog();
if (d.kind === "iam-role" && d.iamRole?.id) await loadRolePerms(Number(d.iamRole.id));
},
{ immediate: true, deep: true },
);
</script>
<style scoped>
.iam-detail { font-size: 13px; }
.section-title { font-weight: 600; margin-bottom: 4px; }
.muted { color: var(--text-color-secondary); }
.owner-badge { color: var(--orange-500); font-weight: 600; }
.perm-grid { display: grid; grid-template-columns: 1fr; gap: 6px; max-height: 50vh; overflow: auto; }
.perm-item { display: flex; align-items: flex-start; gap: 8px; }
</style>

View file

@ -48,7 +48,10 @@ export const useAuth = () => {
company_name?: string | null; company_name?: string | null;
company_kind?: string | null; company_kind?: string | null;
tenant_id?: number | 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"; realm?: "app" | "platform";
must_change_password?: boolean; must_change_password?: boolean;
} | null>("auth-user", () => null); } | null>("auth-user", () => null);

View file

@ -16,12 +16,14 @@ export type RibbonGroup = {
}; };
export type DeskDetail = { export type DeskDetail = {
kind: "none" | "worker" | "project" | "budget"; kind: "none" | "worker" | "project" | "budget" | "iam-user" | "iam-role";
id?: number; id?: number;
doc?: string; doc?: string;
tab?: string; tab?: string;
project?: Record<string, unknown>; project?: Record<string, unknown>;
budgetItem?: Record<string, unknown>; budgetItem?: Record<string, unknown>;
iamUser?: Record<string, unknown>;
iamRole?: Record<string, unknown>;
}; };
export type DeskStatusMessage = { export type DeskStatusMessage = {
@ -114,6 +116,16 @@ export function useDesktop() {
showDetail.value = true; showDetail.value = true;
} }
function openIamUser(row: Record<string, unknown> & { id: number }) {
detail.value = { kind: "iam-user", id: row.id, iamUser: row };
showDetail.value = true;
}
function openIamRole(row: Record<string, unknown> & { id: number }) {
detail.value = { kind: "iam-role", id: row.id, iamRole: row };
showDetail.value = true;
}
function clearDetail() { function clearDetail() {
detail.value = { kind: "none" }; detail.value = { kind: "none" };
} }
@ -155,6 +167,8 @@ export function useDesktop() {
openWorker, openWorker,
openProject, openProject,
openBudgetItem, openBudgetItem,
openIamUser,
openIamRole,
clearDetail, clearDetail,
bumpDetail, bumpDetail,
loanPrompt, loanPrompt,

View file

@ -0,0 +1,39 @@
export function usePermissions() {
const permissions = useState<string[]>("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 };
}

View file

@ -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<UserScopePayload | null>("user-scope", () => null);
const loaded = useState("user-scope-loaded", () => false);
const { api } = useApi();
async function load() {
try {
scope.value = await api<UserScopePayload>("/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 };
}

View file

@ -90,6 +90,7 @@
:item="detail.budgetItem" :item="detail.budgetItem"
@edit="editFromFicha" @edit="editFromFicha"
/> />
<IamDetail v-else-if="detail.kind === 'iam-user' || detail.kind === 'iam-role'" :detail="detail" />
<div v-else class="detail-empty"> <div v-else class="detail-empty">
<i class="pi pi-file detail-empty-icon" /> <i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong> <strong>Seleccione un registro</strong>
@ -114,8 +115,10 @@
<script setup lang="ts"> <script setup lang="ts">
import LoanDialog from "~/components/padron/LoanDialog.vue"; import LoanDialog from "~/components/padron/LoanDialog.vue";
import IamDetail from "~/components/IamDetail.vue";
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const perms = usePermissions();
const { initials } = useUi(); const { initials } = useUi();
const route = useRoute(); const route = useRoute();
const desk = useDesktop(); const desk = useDesktop();
@ -161,42 +164,43 @@ function nav(label: string, icon: string, to: string): NavItem {
return { label, icon, to, command: () => navigateTo(to) }; return { label, icon, to, command: () => navigateTo(to) };
} }
const menu: NavItem[] = [ function navIf(label: string, icon: string, to: string, permission: string): NavItem | null {
nav("Inicio", "pi pi-home", "/"), if (!perms.can(permission)) return null;
{ return nav(label, icon, to);
label: "Obra", }
icon: "pi pi-building",
items: [ const menu = computed<NavItem[]>(() => {
nav("Proyectos", "pi pi-building", "/proyectos"), const items: NavItem[] = [];
nav("Presupuesto", "pi pi-calculator", "/presupuesto"), const home = navIf("Inicio", "pi pi-home", "/", "reports.view");
nav("Programa de obra", "pi pi-calendar", "/programa-obra"), if (home) items.push(home);
nav("Control costos", "pi pi-chart-bar", "/control-presupuesto"),
], const obraItems = [
}, navIf("Proyectos", "pi pi-building", "/proyectos", "projects.view"),
{ navIf("Presupuesto", "pi pi-calculator", "/presupuesto", "budget.view"),
label: "Operación", navIf("Programa de obra", "pi pi-calendar", "/programa-obra", "work_program.view"),
icon: "pi pi-briefcase", navIf("Control costos", "pi pi-chart-bar", "/control-presupuesto", "cost_control.view"),
items: [ ].filter(Boolean) as NavItem[];
nav("Gastos", "pi pi-money-bill", "/gastos"), if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
nav("Almacén", "pi pi-box", "/almacen"),
nav("Kanban", "pi pi-th-large", "/kanban"), const opItems = [
], navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
}, navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
{ navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
label: "Personal", ].filter(Boolean) as NavItem[];
icon: "pi pi-users", if (opItems.length) items.push({ label: "Operación", icon: "pi pi-briefcase", items: opItems });
items: [
nav("Padrón", "pi pi-users", "/padron"), const personalItems = [
nav("Gafetes", "pi pi-id-card", "/gafetes"), navIf("Padrón", "pi pi-users", "/padron", "workers.view"),
nav("Nómina", "pi pi-wallet", "/nomina"), navIf("Gafetes", "pi pi-id-card", "/gafetes", "documents.view"),
], navIf("Nómina", "pi pi-wallet", "/nomina", "payroll.view"),
}, ].filter(Boolean) as NavItem[];
{ if (personalItems.length) items.push({ label: "Personal", icon: "pi pi-users", items: personalItems });
label: "Administración",
icon: "pi pi-shield", const adminItems = [navIf("Usuarios", "pi pi-shield", "/usuarios", "users.view")].filter(Boolean) as NavItem[];
items: [nav("Usuarios", "pi pi-shield", "/usuarios")], if (adminItems.length) items.push({ label: "Administración", icon: "pi pi-shield", items: adminItems });
},
]; return items;
});
function isActive(to: string) { function isActive(to: string) {
if (!to) return false; if (!to) return false;
@ -256,6 +260,7 @@ watch(
if (!workerOk && desk.detail.value.kind === "worker") desk.clearDetail(); if (!workerOk && desk.detail.value.kind === "worker") desk.clearDetail();
if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail(); if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail(); if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
if (!path.startsWith("/usuarios") && (desk.detail.value.kind === "iam-user" || desk.detail.value.kind === "iam-role")) desk.clearDetail();
}, },
); );
</script> </script>

View file

@ -1,3 +1,20 @@
const ROUTE_PERMISSIONS: Record<string, string> = {
"/": "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) => { export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return; if (to.path === "/login") return;
const { user, fetchMe } = useAuth(); const { user, fetchMe } = useAuth();
@ -14,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
if (!user.value) return navigateTo("/login"); 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; user.value = null;
try { try {
await api("/v1/auth/logout", { method: "POST" }); 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") { if (!user.value.must_change_password && to.path === "/cambiar-password") {
return navigateTo("/"); 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("/");
}
}); });

View file

@ -281,7 +281,7 @@ async function onSubmit() {
await login(username.value.trim(), password.value.trim()); await login(username.value.trim(), password.value.trim());
const { user } = useAuth(); const { user } = useAuth();
const { api } = useApi(); 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; user.value = null;
try { try {
await api("/v1/auth/logout", { method: "POST" }); await api("/v1/auth/logout", { method: "POST" });

View file

@ -73,7 +73,7 @@
:drill-concepts="ganttConcepts" :drill-concepts="ganttConcepts"
/> />
</TabPanel> </TabPanel>
<TabPanel header="Programado vs costos"> <TabPanel v-if="showCostTab" header="Programado vs costos">
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" /> <WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
</TabPanel> </TabPanel>
</TabView> </TabView>
@ -115,6 +115,8 @@ const desk = useDesktop();
const route = useRoute(); const route = useRoute();
const { api, download } = useApi(); const { api, download } = useApi();
const toast = useToast(); const toast = useToast();
const perms = usePermissions();
const showCostTab = computed(() => perms.can("cost_control.view"));
const projects = ref<Project[]>([]); const projects = ref<Project[]>([]);
const loadedProjectId = ref(0); const loadedProjectId = ref(0);

View file

@ -1,78 +1,521 @@
<template> <template>
<div class="usuarios-page"> <div class="usuarios-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar usuario"> <DeskTableBar v-model="tableQ" :placeholder="treeKey.startsWith('roles') ? 'Buscar rol' : 'Buscar usuario'">
<Chip :label="`${users.length} usuarios`" icon="pi pi-users" /> <Chip
:label="treeKey.startsWith('roles') ? `${roles.length} roles` : `${listedUsers.length} usuarios`"
icon="pi pi-users"
/>
</DeskTableBar> </DeskTableBar>
<DataTable :value="listed" data-key="id" paginator :rows="25" scrollable scroll-height="flex"> <DataTable
v-if="!treeKey.startsWith('roles')"
v-model:selection="selectedUser"
:value="listedUsers"
selection-mode="single"
data-key="id"
paginator
:rows="25"
scrollable
scroll-height="flex"
@row-select="onUserSelect"
>
<Column selection-mode="single" style="width: 3rem" />
<Column field="username" header="Usuario" style="width: 10rem" /> <Column field="username" header="Usuario" style="width: 10rem" />
<Column field="display_name" header="Nombre" /> <Column field="display_name" header="Nombre" />
<Column field="role_code" header="Rol" style="width: 9rem" /> <Column field="role_label" header="Rol" style="width: 11rem" />
<Column field="status" header="Estado" style="width: 7rem" />
<Column field="email" header="Correo" style="width: 14rem" /> <Column field="email" header="Correo" style="width: 14rem" />
</DataTable> </DataTable>
<Panel header="Permisos del rol operativo (user)" class="mt-4"> <DataTable
<div class="perm-grid"> v-else
<div v-for="perm in permissions" :key="perm.code" class="perm-item"> v-model:selection="selectedRole"
<Checkbox v-model="rolePerms" :input-id="perm.code" :value="perm.code" /> :value="listedRoles"
<label :for="perm.code">{{ perm.label }}</label> selection-mode="single"
data-key="id"
paginator
:rows="25"
scrollable
scroll-height="flex"
@row-select="onRoleSelect"
>
<Column selection-mode="single" style="width: 3rem" />
<Column field="code" header="Código" style="width: 10rem" />
<Column field="label" header="Etiqueta" />
<Column field="is_system" header="Sistema" style="width: 7rem">
<template #body="{ data }">
<Tag :value="data.is_system ? 'Sí' : 'No'" :severity="data.is_system ? 'info' : 'secondary'" />
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showUserDialog" :header="userDialogTitle" modal class="iam-dialog" style="width: 34rem">
<div class="form-grid">
<FormField v-if="!editingUser" label="Usuario">
<InputText v-model="userForm.username" class="w-full" />
</FormField>
<FormField label="Nombre">
<InputText v-model="userForm.display_name" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="userForm.email" class="w-full" />
</FormField>
<FormField label="Rol">
<Select
v-model="userForm.role_id"
:options="assignableRoles"
option-label="label"
option-value="id"
class="w-full"
/>
</FormField>
<FormField v-if="perms.isOwner.value" label="Alcance obras">
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
<label for="scope-all">Todas las obras</label>
</div>
<MultiSelect
v-if="!userForm.scope_all_projects"
v-model="userForm.project_ids"
:options="projects"
option-label="name"
option-value="id"
placeholder="Obras asignadas"
class="w-full"
/>
</div>
</FormField>
<FormField label="Almacén">
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
<label for="wh-central">Almacén central</label>
</div>
<div class="flex align-items-center gap-2">
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
<label for="wh-projects">Almacenes de obra</label>
</div> </div>
</div> </div>
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" /> </FormField>
</Panel> </div>
<Message v-if="tempPassword" severity="success" :closable="false" class="mt-3">
Contraseña temporal (cópiela ahora): <strong>{{ tempPassword }}</strong>
</Message>
<template #footer>
<Button label="Cancelar" text @click="showUserDialog = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="saveUser" />
</template>
</Dialog>
<Dialog v-model:visible="showRoleDialog" header="Nuevo rol" modal style="width: 28rem">
<div class="form-grid">
<FormField label="Plantilla">
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField label="Código">
<InputText v-model="roleForm.code" class="w-full" />
</FormField>
<FormField label="Etiqueta">
<InputText v-model="roleForm.label" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showRoleDialog = false" />
<Button label="Crear" icon="pi pi-check" :loading="saving" @click="saveRole" />
</template>
</Dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response"; import { displayErrorMessage } from "~/composables/api-response";
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string }; type UserRow = {
type PermRow = { code: string; label: string }; id: number;
username: string;
display_name: string;
role_id: number;
role_code: string;
role_label?: string;
email: string;
status: string;
is_owner?: boolean;
scope_all_projects?: boolean;
warehouse_central?: boolean;
warehouse_projects?: boolean;
project_ids?: number[];
};
type RoleRow = { id: number; code: string; label: string; is_system?: boolean };
type PermRow = { code: string; label: string; module: string; verb: string; group?: string };
type ProjectRow = { id: number; name: string; code?: string };
const desk = useDesktop(); const desk = useDesktop();
const { api } = useApi(); const { api } = useApi();
const toast = useToast(); const toast = useToast();
const perms = usePermissions();
const tableQ = ref(""); const tableQ = ref("");
const treeKey = ref("users-active");
const users = ref<UserRow[]>([]); const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<PermRow[]>([]); const permissions = ref<PermRow[]>([]);
const rolePerms = ref<string[]>([]); const rolePerms = ref<string[]>([]);
const projects = ref<ProjectRow[]>([]);
const selectedUser = ref<UserRow | null>(null);
const selectedRole = ref<RoleRow | null>(null);
const showUserDialog = ref(false);
const showRoleDialog = ref(false);
const editingUser = ref(false);
const saving = ref(false);
const tempPassword = ref("");
const listed = computed(() => { const userForm = reactive({
const q = tableQ.value.toLowerCase(); username: "",
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].join(" ").toLowerCase().includes(q)); display_name: "",
email: "",
role_id: null as number | null,
scope_all_projects: false,
warehouse_central: false,
warehouse_projects: true,
project_ids: [] as number[],
}); });
const roleForm = reactive({
template: "operativo",
code: "",
label: "",
});
const templateOptions = [
{ label: "Administrador operativo", value: "admin_operativo" },
{ label: "Supervisor de obra", value: "supervisor_obra" },
{ label: "Contador", value: "contador" },
{ label: "Operativo", value: "operativo" },
];
const assignableRoles = computed(() => roles.value.filter((r) => !r.is_system || r.code !== "tenant_admin"));
const listedUsers = computed(() => {
const q = tableQ.value.toLowerCase();
let rows = users.value;
if (treeKey.value === "users-active") rows = rows.filter((u) => u.status === "activo" && !u.is_owner);
else if (treeKey.value === "users-baja") rows = rows.filter((u) => u.status === "baja");
else if (treeKey.value === "users-owner") rows = rows.filter((u) => u.is_owner);
if (!q) return rows;
return rows.filter((u) => [u.username, u.display_name, u.email, u.role_code].join(" ").toLowerCase().includes(q));
});
const listedRoles = computed(() => {
const q = tableQ.value.toLowerCase();
const rows = roles.value.filter((r) => !r.is_system);
if (!q) return rows;
return rows.filter((r) => [r.code, r.label].join(" ").toLowerCase().includes(q));
});
const userDialogTitle = computed(() => (editingUser.value ? "Editar usuario" : "Nuevo usuario"));
const canCreateUser = computed(() => perms.can("users.create"));
const canUpdateUser = computed(() => perms.can("users.update"));
const canDeleteUser = computed(() => perms.can("users.delete"));
function onUserSelect(e: { data: UserRow }) {
selectedUser.value = e.data;
desk.openIamUser(e.data);
if (!treeKey.value.startsWith("roles")) loadRoleMatrixForUser(e.data);
}
function onRoleSelect(e: { data: RoleRow }) {
selectedRole.value = e.data;
desk.openIamRole(e.data);
loadRolePermissions(e.data.id);
}
async function loadRolePermissions(roleId: number) {
const rp = await api<{ permissions?: string[] }>(`/v1/iam/roles/${roleId}/permissions`);
rolePerms.value = rp.permissions ?? [];
}
async function loadRoleMatrixForUser(user: UserRow) {
if (user.role_id) await loadRolePermissions(user.role_id);
}
async function load() { async function load() {
const [u, p, rp] = await Promise.all([ const [u, p, r, proj] = await Promise.all([
api<{ data?: { users?: UserRow[] } }>("/v1/iam/users"), api<{ users?: UserRow[] }>("/v1/iam/users"),
api<{ data?: { permissions?: PermRow[] } }>("/v1/iam/permissions"), api<{ permissions?: PermRow[] }>("/v1/iam/permissions"),
api<{ data?: { permissions?: string[] } }>("/v1/iam/roles/user/permissions"), api<{ roles?: RoleRow[] }>("/v1/iam/roles"),
api<{ projects?: ProjectRow[] }>("/v1/projects").catch(() => ({ projects: [] as ProjectRow[] })),
]); ]);
users.value = u.data?.users ?? []; users.value = (u.users ?? []).map((row) => ({
permissions.value = p.data?.permissions ?? []; ...row,
rolePerms.value = rp.data?.permissions ?? []; role_label: r.roles?.find((role) => role.id === row.role_id)?.label ?? row.role_code,
}));
permissions.value = p.permissions ?? [];
roles.value = r.roles ?? [];
projects.value = proj.projects ?? [];
syncChrome();
}
function resetUserForm() {
userForm.username = "";
userForm.display_name = "";
userForm.email = "";
userForm.role_id = assignableRoles.value[0]?.id ?? null;
userForm.scope_all_projects = false;
userForm.warehouse_central = false;
userForm.warehouse_projects = true;
userForm.project_ids = [];
tempPassword.value = "";
}
function openNewUser() {
editingUser.value = false;
resetUserForm();
showUserDialog.value = true;
}
function openEditUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
editingUser.value = true;
tempPassword.value = "";
userForm.display_name = selectedUser.value.display_name;
userForm.email = selectedUser.value.email ?? "";
userForm.role_id = selectedUser.value.role_id;
userForm.scope_all_projects = Boolean(selectedUser.value.scope_all_projects);
userForm.warehouse_central = Boolean(selectedUser.value.warehouse_central);
userForm.warehouse_projects = selectedUser.value.warehouse_projects !== false;
userForm.project_ids = [...(selectedUser.value.project_ids ?? [])];
showUserDialog.value = true;
}
async function saveUser() {
saving.value = true;
try {
if (editingUser.value && selectedUser.value) {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: {
display_name: userForm.display_name,
email: userForm.email,
role_id: userForm.role_id,
scope_all_projects: userForm.scope_all_projects,
warehouse_central: userForm.warehouse_central,
warehouse_projects: userForm.warehouse_projects,
project_ids: userForm.project_ids,
},
});
toast.add({ severity: "success", summary: "Usuario actualizado", life: 2500 });
} else {
const r = await api<{ temporary_password?: string }>("/v1/iam/users", {
method: "POST",
json: {
username: userForm.username,
display_name: userForm.display_name,
email: userForm.email,
role_id: userForm.role_id,
scope_all_projects: userForm.scope_all_projects,
warehouse_central: userForm.warehouse_central,
warehouse_projects: userForm.warehouse_projects,
project_ids: userForm.project_ids,
},
});
tempPassword.value = r.temporary_password ?? "";
toast.add({ severity: "success", summary: "Usuario creado", life: 2500 });
if (!tempPassword.value) showUserDialog.value = false;
}
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function deactivateUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: { status: "baja" },
});
toast.add({ severity: "success", summary: "Usuario dado de baja", life: 2500 });
selectedUser.value = null;
desk.clearDetail();
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function reactivateUser() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
await api(`/v1/iam/users/${selectedUser.value.id}`, {
method: "PATCH",
json: { status: "activo" },
});
toast.add({ severity: "success", summary: "Usuario reactivado", life: 2500 });
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
async function resetAccess() {
if (!selectedUser.value || selectedUser.value.is_owner) return;
saving.value = true;
try {
const r = await api<{ temporary_password?: string }>(`/v1/iam/users/${selectedUser.value.id}/reset-access`, {
method: "POST",
});
tempPassword.value = r.temporary_password ?? "";
toast.add({ severity: "success", summary: "Acceso reseteado", detail: tempPassword.value || undefined, life: 6000 });
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
function openNewRole() {
roleForm.template = "operativo";
roleForm.code = "";
roleForm.label = "";
showRoleDialog.value = true;
}
async function saveRole() {
saving.value = true;
try {
const code = (roleForm.code || roleForm.template).trim().toLowerCase();
await api("/v1/iam/roles", {
method: "POST",
json: { code, label: roleForm.label || code, template: roleForm.template },
});
toast.add({ severity: "success", summary: "Rol creado", life: 2500 });
showRoleDialog.value = false;
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
} }
async function saveRolePerms() { async function saveRolePerms() {
const role = selectedRole.value ?? (selectedUser.value ? roles.value.find((r) => r.id === selectedUser.value!.role_id) : null);
if (!role || role.is_system) return;
saving.value = true;
try { try {
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", { await api(`/v1/iam/roles/${role.id}/permissions`, {
method: "PUT", method: "PUT",
json: { permissions: rolePerms.value }, json: { permissions: rolePerms.value },
}); });
toast.add({ severity: "success", summary: r.message || "Permisos guardados", life: 2500 }); toast.add({ severity: "success", summary: "Permisos guardados", life: 2500 });
} catch (e) { } catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 }); toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
} }
} }
async function deleteRole() {
if (!selectedRole.value || selectedRole.value.is_system) return;
saving.value = true;
try {
await api(`/v1/iam/roles/${selectedRole.value.id}`, { method: "DELETE" });
toast.add({ severity: "success", summary: "Rol eliminado", life: 2500 });
selectedRole.value = null;
desk.clearDetail();
await load();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
} finally {
saving.value = false;
}
}
function onTreeSelect(key: string) {
treeKey.value = key;
selectedUser.value = null;
selectedRole.value = null;
desk.clearDetail();
syncChrome();
}
function syncChrome() {
desk.configure({
tab: "Usuarios IAM",
treeTitle: "IAM",
showTree: true,
showDetail: true,
treeKey: treeKey.value,
onTreeSelect,
tree: [
{
key: "users",
label: "Usuarios",
icon: "pi pi-users",
selectable: false,
children: [
{ key: "users-active", label: "Activos", icon: "pi pi-user" },
{ key: "users-baja", label: "Bajas", icon: "pi pi-user-minus" },
{ key: "users-owner", label: "Propietario", icon: "pi pi-shield" },
],
},
{
key: "roles",
label: "Roles",
icon: "pi pi-key",
selectable: false,
children: [{ key: "roles-list", label: "Roles del tenant", icon: "pi pi-list" }],
},
],
ribbon: [
{
key: "usuarios",
label: "Usuarios",
items: [
{ key: "new-user", label: "Nuevo", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewUser },
{ key: "edit-user", label: "Editar", icon: "pi pi-pencil", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: openEditUser },
{ key: "baja-user", label: "Baja", icon: "pi pi-user-minus", disabled: !canDeleteUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status === "baja", command: deactivateUser },
{ key: "reactivate-user", label: "Reactivar", icon: "pi pi-user-plus", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner || selectedUser.value.status !== "baja", command: reactivateUser },
{ key: "reset-user", label: "Reset acceso", icon: "pi pi-key", disabled: !canUpdateUser.value || !selectedUser.value || selectedUser.value.is_owner, command: resetAccess },
],
},
{
key: "roles",
label: "Roles",
items: [
{ key: "new-role", label: "Nuevo rol", icon: "pi pi-plus", disabled: !canCreateUser.value, command: openNewRole },
{ key: "delete-role", label: "Borrar rol", icon: "pi pi-trash", disabled: !canDeleteUser.value || !selectedRole.value || selectedRole.value.is_system, command: deleteRole },
{ key: "save-perms", label: "Guardar matriz", icon: "pi pi-save", disabled: !canUpdateUser.value, command: saveRolePerms },
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => load() }],
},
],
});
}
watch([selectedUser, selectedRole, treeKey], syncChrome);
onMounted(async () => { onMounted(async () => {
desk.configure({ tab: "Usuarios IAM", ribbon: [] }); if (!treeKey.value) treeKey.value = "users-active";
await load(); await load();
}); });
</script> </script>
<style scoped> <style scoped>
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; } .form-grid { display: flex; flex-direction: column; gap: 12px; }
.perm-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
</style> </style>