Compare commits

..

3 commits

Author SHA1 Message Date
Alberto Martinez
0d660c4549 IAM clientes: roles por tenant, matriz CRUD y alcance
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Implements full client-side IAM: per-tenant custom roles, CRUD permission matrix, user/project/warehouse scope, and enforcement across API and web-panel.

## Key changes

- Auth with `role_id`, `is_owner`, status checks; API key without tenant_admin bypass
- IAM REST: users/roles CRUD, reset access, `/v1/me/permissions`, `/v1/me/scope`
- CRUD route gates + project/warehouse scope + project doc category filtering
- Rewritten `usuarios.vue` with ribbon, tree, matrix; dynamic menu via `usePermissions`
- Tests: `iam_test.ts` (9 passing); `deno check` and web-panel build green

Apply migrations `005-iam-v2-roles-crud.sql` and `031-project-doc-types-technical.sql` before deploy.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-0da3751a-38ff-40ab-bba2-5df2322c8cda?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-0da3751a-38ff-40ab-bba2-5df2322c8cda&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-08 18:49:53 +00:00
Cursor Agent
aed2f4531b
Implement client IAM v2: roles per tenant, CRUD matrix, scope, and panel UI
- Add IAM v2 auth with role_id, is_owner, status checks, and API key without tenant_admin bypass
- Enforce CRUD permissions and project/warehouse scope across API routes
- Rewrite usuarios.vue with ribbon, tree, user/role management, and permission matrix
- Add dynamic menu, route guards, usePermissions/useScope composables
- Apply role templates on create; filter project docs by category; hide cost tab without permission
- Add iam_test unit tests; update bootstrap and SaaS tenant admin to use role_id

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 18:16:59 +00:00
Cursor Agent
25ceb6a6bd
Add IAM v2 roles/permissions migration and project doc types
Introduce Liquibase changeset 005 for IAM schema v2: CRUD permission codes,
tenant-scoped roles with system tenant_admin, role_permissions by role_id,
user scope fields and user_projects, RLS policies, and full RPC suite for
roles and users. Add core changeset 031 for technical project document types.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 18:10:45 +00:00
31 changed files with 2872 additions and 323 deletions

View file

@ -7,7 +7,6 @@ import { createSession, getSession, revokeSession, revokeAllSessionsForUser } fr
import { hashPassword, verifyPassword } from "./crypto.ts";
export type AuthRealm = "app" | "platform";
export type AuthRole = "platform_admin" | "tenant_admin" | "user";
export type AuthUser = {
id: number;
@ -18,7 +17,10 @@ export type AuthUser = {
company_name: string | null;
company_kind: string | null;
tenant_id: number | null;
role: AuthRole;
role_id: number | null;
role_code: string;
is_owner: boolean;
status: string;
realm: AuthRealm;
must_change_password: boolean;
};
@ -26,6 +28,14 @@ export type AuthUser = {
const COOKIE = "po_session";
const TTL_SECONDS = 60 * 60 * 24 * 7;
const USER_SELECT = `
SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
u.status, u.must_change_password, u.email
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = ?`;
export async function createSessionCookie(
c: Context,
userId: number,
@ -48,8 +58,13 @@ export async function clearSession(c: Context) {
deleteCookie(c, COOKIE, { path: "/" });
}
function isOwnerRole(row: Record<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 {
const role = (row.role_code as string) || "user";
return {
id: Number(row.id),
username: String(row.username),
@ -59,7 +74,10 @@ function asAppUser(row: Record<string, unknown>): AuthUser {
company_name: (row.company_name as string | null) ?? null,
company_kind: (row.company_kind as string | null) ?? null,
tenant_id: row.tenant_id == null ? null : Number(row.tenant_id),
role: role === "tenant_admin" ? "tenant_admin" : "user",
role_id: row.role_id == null ? null : Number(row.role_id),
role_code: String(row.role_code ?? "user"),
is_owner: isOwnerRole(row),
status: String(row.status ?? "activo"),
realm: "app",
must_change_password: Boolean(row.must_change_password),
};
@ -75,7 +93,10 @@ function asPlatformUser(row: { id: number; username: string; display_name: strin
company_name: null,
company_kind: null,
tenant_id: null,
role: "platform_admin",
role_id: null,
role_code: "platform_admin",
is_owner: true,
status: "activo",
realm: "platform",
must_change_password: false,
};
@ -103,12 +124,10 @@ async function userFromCookie(c: Context): Promise<AuthUser | null> {
* empresa (core) -- usado tras login y tras cambiar contraseña. */
export async function getFreshAppUser(userId: number, tenantId: number | null): Promise<AuthUser | null> {
const row = await withIamTenant(tenantId, async (db) => {
return await db.prepare(
`SELECT id, username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email FROM users WHERE id = ?`,
).get(userId);
return await db.prepare(USER_SELECT).get(userId);
});
if (!row) return null;
if (String(row.status ?? "activo") !== "activo") return null;
const company = await enrichWithCompanyViaCore((row.company_id as number) ?? null);
return asAppUser({ ...row, ...company });
}
@ -124,10 +143,6 @@ function apiKeyOk(c: Context): boolean {
export async function requireAuth(c: Context, next: Next) {
if (apiKeyOk(c)) {
// La API key ya NO otorga visibilidad cruzada de todos los tenants
// (era un hallazgo crítico de la revisión de seguridad): ahora exige un
// tenant explícito por header, y las políticas de RLS son fail-closed
// si no se fija -- sin X-Tenant-Id válido, la API key no ve nada.
const tenantHeader = c.req.header("x-tenant-id") ?? "";
const tenantId = Number(tenantHeader);
if (!tenantHeader || !Number.isInteger(tenantId) || tenantId <= 0) {
@ -142,7 +157,10 @@ export async function requireAuth(c: Context, next: Next) {
company_name: null,
company_kind: null,
tenant_id: tenantId,
role: "tenant_admin",
role_id: null,
role_code: "api_key",
is_owner: false,
status: "activo",
realm: "app",
must_change_password: false,
} satisfies AuthUser);
@ -158,7 +176,7 @@ export async function requireAuth(c: Context, next: Next) {
export async function requirePlatformAdmin(c: Context, next: Next) {
const user = await userFromCookie(c);
if (!user) return c.json({ error: "No autenticado" }, 401);
if (user.realm !== "platform" || user.role !== "platform_admin") {
if (user.realm !== "platform" || user.role_code !== "platform_admin") {
return c.json({ error: "Solo administradores SaaS" }, 403);
}
c.set("user", user);
@ -175,14 +193,14 @@ export async function login(username: string, password: string): Promise<AuthUse
const pass = password.trim();
if (!user || !pass) return null;
// username es único globalmente (no por tenant) -- se resuelve con el
// pool que omite RLS (ver iam_db.ts#findUserByUsernameAnyTenant); recién
// después de esto se conoce el tenant_id para todo lo demás.
const appRow = await findUserByUsernameAnyTenant(user);
if (appRow && await verifyPassword(pass, appRow.password_hash as string)) {
if (String(appRow.status ?? "activo") !== "activo") {
throw Object.assign(new Error("Usuario dado de baja"), { code: "USER_INACTIVE" });
}
const authUser = await withIamTenant(
appRow.tenant_id == null ? null : Number(appRow.tenant_id),
async (db) => {
async () => {
const company = await enrichWithCompanyViaCore(Number(appRow.company_id) || null);
return asAppUser({ ...appRow, ...company });
},
@ -248,9 +266,6 @@ export async function changePassword(
await db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = false WHERE id = ?",
).run(hash, userId);
// Cambiar password revoca TODAS las demás sesiones activas de este
// usuario -- antes (cookie HMAC stateless) esto era imposible; ahora
// sí, porque el estado real vive en Redis (ver sessions.ts).
await revokeAllSessionsForUser(userId, "app");
return {};
});

View file

@ -5,7 +5,8 @@ import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission } from "./permissions.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -14,38 +15,48 @@ function tid(c: { get: (k: "user") => AuthUser }): number {
}
export function registerCostControlRoutes(app: App) {
app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const auth = [...requireCoreAuth, requirePermission("cost_control.view")] as const;
app.get("/v1/projects/:id/cost-control/summary", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
project_id: projectId,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/projects/:id/cost-control/items", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
project_id: projectId,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/projects/:id/cost-control/chapters", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
project_id: projectId,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/projects/:id/cost-control/deviations", ...auth, async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
project_id: projectId,
limit: c.req.query("limit"),
}, { route: routeLabel(c) });
return respondRpc(c, env);

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

View file

@ -4,10 +4,11 @@ import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requirePermission } from "./permissions.ts";
import { encryptBytes, decryptBytes } from "./docs_crypto.ts";
import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { randomBytes } from "node:crypto";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -22,11 +23,13 @@ function actor(c: Context): { id: number; name: string } {
}
export function registerExpenseRoutes(app: App) {
app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
const projectId = c.req.query("project_id");
if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_list", {
tenant_id: tid(c),
project_id: c.req.query("project_id"),
project_id: projectId,
company_id: c.req.query("company_id"),
date_from: c.req.query("date_from"),
date_to: c.req.query("date_to"),
@ -36,14 +39,20 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (env.ok && env.data) {
const expense = (env.data as { expense?: { project_id?: number } }).expense ?? env.data;
const projectId = Number((expense as { project_id?: number }).project_id);
if (projectId && !await denyUnlessProjectScope(c, projectId)) return;
}
return respondRpc(c, env);
});
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("expenses.create"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
if (!await denyUnlessProjectScope(c, Number(body.project_id))) return;
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_create", {
@ -55,9 +64,14 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const env = await callCoreFn(db, "core.fn_expense_update", {
...body,
id: Number(c.req.param("id")),
@ -66,18 +80,29 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("expenses.delete"), async (c) => {
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("expenses.update"), async (c) => {
const expenseId = Number(c.req.param("id"));
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
const env = await callCoreFn(c.get("db"), "core.fn_expense_attachment_add", {
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId,
...JSON.parse(String(form.get("meta") ?? "{}")),
}, { route: routeLabel(c) });
@ -89,7 +114,6 @@ export function registerExpenseRoutes(app: App) {
if (s3Configured()) {
await putObject(expenseDocKey(expenseId, storageName), cipher);
}
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId,
@ -105,10 +129,15 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
const expenseId = Number(c.req.param("id"));
const attId = Number(c.req.param("attId"));
const db = c.get("db");
const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId }, { route: routeLabel(c) });
if (got.ok && got.data) {
const expense = (got.data as { expense?: { project_id?: number } }).expense ?? got.data;
if (!await denyUnlessProjectScope(c, Number((expense as { project_id?: number }).project_id))) return;
}
const row = await db.prepare(
"SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?",
).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined;
@ -120,7 +149,7 @@ export function registerExpenseRoutes(app: App) {
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
app.get("/v1/iva/summary", ...requireCoreAuth, requirePermission("expenses.view"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_iva_period_summary", {
tenant_id: tid(c),
@ -131,13 +160,13 @@ export function registerExpenseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => {
app.get("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.view"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("manage_cost_settings"), async (c) => {
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("settings.update"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
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. */
export async function findUserByUsernameAnyTenant(username: string) {
const rows = await getAuthPool()`
SELECT id, username, password_hash, display_name, company_id, tenant_id,
role_code, must_change_password, email, created_at
FROM iam.users WHERE username = ${username}`;
SELECT u.id, u.username, u.password_hash, u.display_name, u.company_id, u.tenant_id,
u.role_id, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id,
u.status, u.must_change_password, u.email, u.created_at
FROM iam.users u
JOIN iam.roles r ON r.id = u.role_id
WHERE u.username = ${username}`;
return rows[0] as Record<string, unknown> | undefined;
}

View file

@ -2,63 +2,302 @@ import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { requireAuth, tenantScope } from "./auth.ts";
import type { Db } from "./db.ts";
import { respondRpc } from "./http_errors.ts";
import { requirePermission, callIamFn } from "./permissions.ts";
import { generateSecurePassword, hashPassword } from "./crypto.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import {
callIamFn,
filterSensitivePermissions,
invalidatePermissionCache,
requirePermission,
revokeSessionsForRoleUsers,
} from "./permissions.ts";
import { RESERVED_ROLE_CODES, ROLE_TEMPLATES } from "./iam_catalog.ts";
import { invalidateUserScopeCache } from "./user_scope.ts";
import { revokeAllSessionsForUser } from "./sessions.ts";
import { getUserScope, isOwnerAccountRow } from "./user_scope.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
async function assertNotOwnerTarget(
c: Parameters<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) {
app.get("/v1/iam/users", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid });
app.get("/v1/iam/users", requireAuth, requirePermission("users.view"), async (c) => {
const t = tid(c);
const row = await callIamFn(t, "iam.fn_user_list", { tenant_id: t });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar usuarios IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_permission_list", {});
app.get("/v1/iam/permissions", requireAuth, requirePermission("users.view"), async (c) => {
const row = await callIamFn(tid(c), "iam.fn_permission_list", {});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") });
app.get("/v1/iam/roles", requireAuth, requirePermission("users.view"), async (c) => {
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_list", { tenant_id: t });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar roles", data: null, errors: null });
}
return respondRpc(c, row as Parameters<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) {
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]);
});
app.put("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
app.put("/v1/iam/roles/:id/permissions", requireAuth, requirePermission("users.update"), async (c) => {
const user = c.get("user");
const body = await c.req.json<{ permissions?: string[] }>();
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_save", {
role_code: c.req.param("role"),
permissions: body.permissions ?? [],
const roleId = Number(c.req.param("id"));
const perms = filterSensitivePermissions(body.permissions ?? [], user.is_owner);
const t = tid(c);
const row = await callIamFn(t, "iam.fn_role_permissions_save", {
role_id: roleId,
permissions: perms,
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null });
}
invalidatePermissionCache(roleId);
await revokeSessionsForRoleUsers(t, roleId);
return respondRpc(c, row as Parameters<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]);
});
app.get("/v1/me/permissions", requireAuth, async (c) => {
const user = c.get("user");
const { userPermissions } = await import("./permissions.ts");
const role = user.role === "tenant_admin" ? "tenant_admin" : "user";
const perms = await userPermissions(user.tenant_id, role);
const { userPermissionsByRoleId } = await import("./permissions.ts");
const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, user.is_owner);
return respondRpc(c, {
ok: true,
code: "OK",
layer: "api",
message: `Permisos del usuario ${user.username}`,
data: { permissions: [...perms], role: user.role },
data: {
permissions: [...perms],
role_code: user.role_code,
role_id: user.role_id,
is_owner: user.is_owner,
},
errors: null,
});
});
app.get("/v1/me/scope", requireAuth, async (c) => {
const user = c.get("user");
const scope = await getUserScope(user);
return respondRpc(c, {
ok: true,
code: "OK",
layer: "api",
message: "Alcance del usuario",
data: {
all_projects: scope.allProjects,
project_ids: scope.projectIds,
warehouse_central: scope.warehouseCentral,
warehouse_projects: scope.warehouseProjects,
},
errors: null,
});
});

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,
} from "./auth.ts";
import { requireCoreAuth, withCoreScope } from "./scope.ts";
import { requirePermission, requireAnyPermission } from "./permissions.ts";
import {
denyUnlessProjectScope,
denyUnlessProjectDocCreate,
denyUnlessProjectDocView,
filterProjectDocumentsForUser,
filterProjectListEnvelope,
} from "./scope_enforcement.ts";
import { getPlatformDb, pingPlatformDb } from "./platform_db.ts";
import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, updateTenant } from "./saas.ts";
import { smtpConfigured, testSmtp } from "./mail.ts";
@ -82,6 +90,8 @@ type StorageDoc = {
iv: string;
original_name: string;
mime?: string;
type_code?: string;
type?: string;
};
async function rpcDocumentForDownload(
@ -275,7 +285,7 @@ app.post("/v1/saas/tenants/:id/send-access", requirePlatformAdmin, async (c) =>
});
});
app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
app.get("/v1/catalogs", ...requireCoreAuth, requirePermission("reports.view"), async (c) => {
const db = c.get("db");
const user = c.get("user");
const tid = tenantScope(user);
@ -294,20 +304,15 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
// Configuración del tenant (Fase 4d): zona horaria usada por nómina y
// vigencia de documentos -- reemplaza el PAYROLL_TZ hardcodeado.
app.get("/v1/configuracion", ...requireCoreAuth, async (c) => {
app.get("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.view"), async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const timezone = await tenantTimezone(db, tid);
return c.json({ timezone });
});
app.put("/v1/configuracion", ...requireCoreAuth, async (c) => {
app.put("/v1/configuracion", ...requireCoreAuth, requirePermission("settings.update"), async (c) => {
const user = c.get("user");
if (user.role !== "tenant_admin") {
return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", {
route: routeLabel(c),
});
}
const db = c.get("db");
const tid = tenantScope(user);
if (tid == null) {
@ -332,7 +337,7 @@ app.put("/v1/configuracion", ...requireCoreAuth, async (c) => {
));
});
app.get("/v1/companies", ...requireCoreAuth, async (c) => {
app.get("/v1/companies", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn(
@ -343,7 +348,7 @@ app.get("/v1/companies", ...requireCoreAuth, async (c) => {
));
});
app.post("/v1/companies", ...requireCoreAuth, async (c) => {
app.post("/v1/companies", ...requireCoreAuth, requirePermission("companies.create"), async (c) => {
const body = await c.req.json();
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -361,7 +366,7 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => {
});
});
app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
app.patch("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.update"), async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const db = c.get("db");
@ -380,7 +385,7 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
});
});
app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
app.get("/v1/companies/:id", ...requireCoreAuth, requirePermission("companies.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -414,7 +419,7 @@ app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
});
});
app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => {
app.post("/v1/companies/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
const id = Number(c.req.param("id"));
const form = await c.req.formData();
const type = String(form.get("type") || "");
@ -431,7 +436,7 @@ app.post("/v1/companies/:id/documents", ...requireCoreAuth, async (c) => {
return c.json({ ok: true, checklist: await companyChecklistFor(db, id) });
});
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const companyId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
@ -454,7 +459,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/projects", ...requireCoreAuth, async (c) => {
app.get("/v1/projects", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
const db = c.get("db");
const status = (c.req.query("status") ?? "").trim();
const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : [];
@ -464,12 +469,13 @@ app.get("/v1/projects", ...requireCoreAuth, async (c) => {
status,
});
}
return respondRpc(c, await callCoreFn(
const env = await callCoreFn(
db,
"core.fn_project_list",
{ status: allowed.length ? allowed.join(",") : undefined },
{ route: routeLabel(c) },
));
);
return respondRpc(c, await filterProjectListEnvelope(c, env as Parameters<typeof filterProjectListEnvelope>[1]));
});
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>();
if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, {
@ -567,8 +573,9 @@ app.post("/v1/projects", ...requireCoreAuth, async (c) => {
));
});
app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
app.patch("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<ProjectInput>();
const db = c.get("db");
const route = routeLabel(c);
@ -617,8 +624,9 @@ app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
));
});
app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
app.get("/v1/projects/:id", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
const route = routeLabel(c);
const projectEnv = await callCoreFn<{ project: Record<string, unknown> }>(db, "core.fn_project_get", { id }, { route });
@ -634,6 +642,12 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
{ route },
);
if (!docsEnv.ok) return respondRpc(c, docsEnv);
const user = c.get("user") as AuthUser;
const documents = await filterProjectDocumentsForUser(
user,
db,
(docsEnv.data?.documents ?? []) as Array<{ type_code?: string; type?: string }>,
);
return respondRpc(c, {
ok: true,
code: "OK",
@ -641,20 +655,22 @@ app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
message: `Proyecto ${project.code} cargado`,
data: {
project,
documents: docsEnv.data?.documents ?? [],
documents,
checklist: await projectChecklistFor(db, id),
},
});
});
app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/documents", ...requireCoreAuth, requireAnyPermission("project_docs.tecnico.create", "project_docs.contrato.create", "project_docs.permisos.create", "project_docs.ambiental.create", "project_docs.imss.create", "project_docs.sst.create", "project_docs.otro.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const type = String(form.get("type") || "");
const file = form.get("file");
if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
if (!await denyUnlessProjectDocCreate(c, db, type)) return;
const bytes = new Uint8Array(await file.arrayBuffer());
try {
await storeProjectDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id);
@ -664,8 +680,9 @@ app.post("/v1/projects/:id/documents", ...requireCoreAuth, async (c) => {
return c.json({ ok: true, checklist: await projectChecklistFor(db, id) });
});
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermission("projects.view"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const docId = Number(c.req.param("docId"));
const db = c.get("db");
const route = routeLabel(c);
@ -676,6 +693,8 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
route,
);
if (!doc) return respondRpc(c, envelope);
const typeCode = String(doc.type_code ?? doc.type ?? "");
if (!await denyUnlessProjectDocView(c, db, typeCode)) return;
const enc = await getObject(projectDocKey(projectId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", "application/octet-stream");
@ -684,22 +703,24 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/projects/:id/budget", ...requireCoreAuth, async (c) => {
app.get("/v1/projects/:id/budget", ...requireCoreAuth, requirePermission("budget.view"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
return c.json(await listBudget(db, id));
});
app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, (c) => {
app.get("/v1/projects/:id/budget/template", ...requireCoreAuth, requirePermission("budget.view"), (c) => {
const bytes = buildBudgetTemplate();
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
c.header("Content-Disposition", 'attachment; filename="plantilla-presupuesto.xlsx"');
return c.body(bytes.slice());
});
app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => {
app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission("budget.view"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const db = c.get("db");
const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
@ -710,7 +731,7 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, async (c) => {
return c.body(bytes.slice());
});
app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => {
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -720,8 +741,9 @@ app.post("/v1/budget/preview", ...requireCoreAuth, async (c) => {
return c.json(preview);
});
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -733,8 +755,9 @@ app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, async (c) => {
return c.json(preview);
});
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
@ -746,8 +769,9 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
return c.json(report);
});
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>();
if (!body.name?.trim()) {
return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, {
@ -769,8 +793,9 @@ app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
));
});
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid"));
const body = await c.req.json<{ name?: string; code?: string }>();
const db = c.get("db");
@ -787,8 +812,9 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c)
));
});
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const cid = Number(c.req.param("cid"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(
@ -808,8 +834,9 @@ type BudgetItemInput = {
unit_price?: number;
};
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const body = await c.req.json<BudgetItemInput>();
if (!body.description?.trim()) {
return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, {
@ -834,8 +861,9 @@ app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
));
});
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid"));
const body = await c.req.json<BudgetItemInput>();
const db = c.get("db");
@ -856,8 +884,9 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) =>
));
});
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, requirePermission("budget.delete"), async (c) => {
const id = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, id)) return;
const iid = Number(c.req.param("iid"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(
@ -868,7 +897,7 @@ app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) =
));
});
app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
app.post("/v1/workers/validate", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
const body = await c.req.json<WorkerInput & { id?: number }>();
const errors = validateWorkerFields(body);
const db = c.get("db");
@ -899,12 +928,13 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
return c.json({ ok: Object.keys(errors).length === 0, errors });
});
app.get("/v1/workers", ...requireCoreAuth, async (c) => {
app.get("/v1/workers", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const q = (c.req.query("q") ?? "").trim();
const status = c.req.query("status");
const projectId = c.req.query("project_id");
if (projectId && !await denyUnlessProjectScope(c, Number(projectId))) return;
return respondRpc(c, await callCoreFn(
db,
"core.fn_worker_list",
@ -918,7 +948,7 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => {
));
});
app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => {
app.get("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -966,7 +996,7 @@ async function findExistingWorker(
return env.data.worker ?? undefined;
}
app.post("/v1/workers", ...requireCoreAuth, async (c) => {
app.post("/v1/workers", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
const body = await c.req.json<WorkerInput & { project_id?: number }>();
const errors = validateWorkerFields(body);
const db = c.get("db");
@ -990,6 +1020,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => {
const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION";
return respondApiError(c, code, blocked.error, { route, project_id: body.project_id });
}
if (!await denyUnlessProjectScope(c, body.project_id)) return;
}
const tid = tenantScope(c.get("user"));
return respondRpc(c, await callCoreFn(
@ -1000,7 +1031,7 @@ app.post("/v1/workers", ...requireCoreAuth, async (c) => {
));
});
app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
@ -1031,7 +1062,7 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
));
});
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
const id = Number(c.req.param("id"));
const { pipeline_status } = await c.req.json<{ pipeline_status: string }>();
const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
@ -1057,7 +1088,7 @@ app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
});
});
app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
app.post("/v1/workers/:id/assign", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
const id = Number(c.req.param("id"));
const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>();
const db = c.get("db");
@ -1093,7 +1124,7 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
));
});
app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
app.get("/v1/workers/import/template", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
const db = c.get("db");
const bytes = buildImportTemplate(await listCompanies(db));
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
@ -1101,7 +1132,7 @@ app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
return c.body(bytes.buffer as ArrayBuffer);
});
app.post("/v1/workers/import", ...requireCoreAuth, async (c) => {
app.post("/v1/workers/import", ...requireCoreAuth, requirePermission("workers.create"), async (c) => {
const form = await c.req.formData();
const file = form.get("file");
const projectId = Number(form.get("project_id") || 0) || null;
@ -1120,7 +1151,7 @@ app.post("/v1/workers/import", ...requireCoreAuth, async (c) => {
return c.json(report);
});
app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
const id = Number(c.req.param("id"));
const form = await c.req.formData();
const type = String(form.get("type") || "");
@ -1198,7 +1229,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
});
});
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const workerId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
@ -1218,7 +1249,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => {
app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const bytes = await loadCurrentPhoto(db, id);
@ -1234,7 +1265,7 @@ app.get("/v1/workers/:id/photo", ...requireCoreAuth, async (c) => {
return c.body(bytes.buffer as ArrayBuffer);
});
app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
app.get("/v1/badge-qr", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const curp = (c.req.query("curp") ?? "").trim().toUpperCase();
if (curp.length < 10) return c.json({ error: "CURP requerida" }, 400);
const png = await badgeQrPng(curp);
@ -1243,8 +1274,9 @@ app.get("/v1/badge-qr", ...requireCoreAuth, async (c) => {
return c.body(png.buffer as ArrayBuffer);
});
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] }));
const db = c.get("db");
const user = c.get("user");
@ -1302,18 +1334,18 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
});
});
app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => {
app.get("/v1/badge-jobs", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => {
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => {
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documents.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const route = routeLabel(c);
@ -1331,7 +1363,7 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => {
return c.body(bytes.buffer as ArrayBuffer);
});
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) => {
app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePermission("documents.create"), async (c) => {
const jobId = Number(c.req.param("id"));
const workerId = Number(c.req.param("workerId"));
const { delivered } = await c.req.json<{ delivered: boolean }>();

View file

@ -2,6 +2,8 @@ import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { projectById, projectMustBe, type Db } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts";
import { fullName } from "./mx.ts";
@ -44,7 +46,7 @@ function pdfBody(c: Context, bytes: Uint8Array, filename: string) {
}
export function registerPayrollRoutes(app: App) {
app.get("/v1/attendance", ...requireCoreAuth, async (c) => {
app.get("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const projectId = Number(c.req.query("project_id"));
const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? "";
@ -58,7 +60,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
app.put("/v1/attendance", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const body = await c.req.json<{
project_id: number;
worker_id: number;
@ -86,13 +88,13 @@ export function registerPayrollRoutes(app: App) {
return c.json({ ok: true });
});
app.get("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const db = c.get("db");
const tenantId = tid(c);
return c.json({ settings: await getSettings(db, tenantId), units: await listUnits(db, tenantId) });
});
app.patch("/v1/payroll/settings", ...requireCoreAuth, async (c) => {
app.patch("/v1/payroll/settings", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const db = c.get("db");
const body = await c.req.json<{
loan_commission_enabled?: number | boolean;
@ -109,7 +111,7 @@ export function registerPayrollRoutes(app: App) {
return c.json({ settings });
});
app.post("/v1/destajo/units", ...requireCoreAuth, async (c) => {
app.post("/v1/destajo/units", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
const body = await c.req.json<{ code: string; label?: string }>();
const db = c.get("db");
try {
@ -120,7 +122,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/loans", ...requireCoreAuth, async (c) => {
app.get("/v1/loans", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const db = c.get("db");
const workerId = c.req.query("worker_id");
const env = await callCoreFn<{ loans: unknown[] }>(
@ -132,7 +134,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.post("/v1/loans", ...requireCoreAuth, async (c) => {
app.post("/v1/loans", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
const body = await c.req.json<{
worker_id: number;
amount?: number;
@ -162,7 +164,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ loan: {
@ -213,7 +215,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`);
});
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ payment: {
@ -248,7 +250,7 @@ export function registerPayrollRoutes(app: App) {
return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`);
});
app.get("/v1/payroll/weeks", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/weeks", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const db = c.get("db");
const tenantId = tid(c);
const start = c.req.query("week_start") || weekContaining(await resolveToday(db, tenantId)).weekStart;
@ -256,12 +258,12 @@ export function registerPayrollRoutes(app: App) {
return c.json(bundle);
});
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/weeks/open", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const db = c.get("db");
return c.json({ weeks: await listOpenWeeks(db, tid(c)) });
});
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, async (c) => {
app.post("/v1/payroll/weeks/:id/assemble", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const db = c.get("db");
try {
const nowIso = await resolveToday(db, tid(c));
@ -273,7 +275,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, async (c) => {
app.post("/v1/payroll/weeks/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const db = c.get("db");
try {
const nowIso = await resolveToday(db, tid(c));
@ -285,7 +287,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/weeks/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const csv = await weekCsv(db, id);
@ -294,7 +296,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv);
});
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, async (c) => {
app.post("/v1/payroll/weeks/:id/jornal", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const body = await c.req.json<{ project_id: number; worker_id: number }>();
const db = c.get("db");
try {
@ -306,7 +308,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, async (c) => {
app.post("/v1/payroll/weeks/:id/admin", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>();
const db = c.get("db");
try {
@ -318,7 +320,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
app.patch("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const body = await c.req.json<{ amount: number }>();
const db = c.get("db");
try {
@ -330,7 +332,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, async (c) => {
app.delete("/v1/payroll/weeks/:id/admin/:lineId", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => {
const db = c.get("db");
try {
await removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")));
@ -341,7 +343,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/destajo/jobs", ...requireCoreAuth, async (c) => {
app.post("/v1/destajo/jobs", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
const body = await c.req.json<{
week_id: number;
project_id: number;
@ -363,7 +365,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, async (c) => {
app.patch("/v1/destajo/cuts/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const body = await c.req.json<{
week_id: number;
qty_planned?: number;
@ -380,7 +382,7 @@ export function registerPayrollRoutes(app: App) {
}
});
app.post("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
app.post("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
const body = await c.req.json<{
project_id: number;
week_start: string;
@ -415,7 +417,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/periods", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const db = c.get("db");
const projectId = c.req.query("project_id");
const env = await callCoreFn<{ periods: unknown[] }>(
@ -427,7 +429,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
@ -439,7 +441,7 @@ export function registerPayrollRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
@ -460,7 +462,7 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv);
});
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>();
const db = c.get("db");

View file

@ -3,44 +3,62 @@ import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts";
import { respondApiError, routeLabel } from "./http_errors.ts";
import { ALL_PERMISSION_CODES, IAM_SENSITIVE_PREFIXES } from "./iam_catalog.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000;
export async function userPermissions(
export function invalidatePermissionCache(roleId?: number) {
if (roleId != null) {
for (const key of [...permissionCache.keys()]) {
if (key.startsWith(`${roleId}:`)) permissionCache.delete(key);
}
return;
}
permissionCache.clear();
}
export async function userPermissionsByRoleId(
tenantId: number | null,
roleCode: string,
roleId: number | null,
isOwner: boolean,
): Promise<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);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms;
if (roleCode === "tenant_admin") {
const all = new Set([
"view_expenses", "manage_expenses", "view_warehouse", "manage_warehouse",
"close_warehouse", "manage_cost_settings", "manage_users", "manage_budget",
"manage_payroll", "manage_workers", "manage_projects", "manage_companies",
"manage_documents", "manage_settings", "view_reports",
]);
permissionCache.set(key, { perms: all, at: Date.now() });
return all;
}
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
"SELECT permission_code FROM role_permissions WHERE role_code = ?",
).all(roleCode) as { permission_code: string }[],
"SELECT permission_code FROM role_permissions WHERE role_id = ?",
).all(roleId) as { permission_code: string }[],
);
const perms = new Set(rows.map((r) => r.permission_code));
permissionCache.set(key, { perms, at: Date.now() });
return perms;
}
export async function hasPermission(
user: AuthUser,
code: string,
): Promise<boolean> {
if (user.role === "tenant_admin") return true;
const role = user.role === "user" ? "user" : user.role;
const perms = await userPermissions(user.tenant_id, role);
/** @deprecated use userPermissionsByRoleId */
export async function userPermissions(
tenantId: number | null,
roleCode: string,
): Promise<Set<string>> {
if (roleCode === "tenant_admin") return new Set(ALL_PERMISSION_CODES);
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
`SELECT rp.permission_code FROM role_permissions rp
JOIN roles r ON r.id = rp.role_id
WHERE r.code = ? AND (r.tenant_id = ? OR (r.is_system AND r.tenant_id IS NULL))`,
).all(roleCode, tenantId) as { permission_code: string }[],
);
return new Set(rows.map((r) => r.permission_code));
}
export async function hasPermission(user: AuthUser, code: string): Promise<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);
}
@ -56,7 +74,7 @@ export function requirePermission(code: string) {
c,
"FORBIDDEN",
`Permiso requerido: ${code}`,
{ route: routeLabel(c), permission: code, role: user.role },
{ route: routeLabel(c), permission: code, role: user.role_code },
);
}
await next();
@ -85,6 +103,16 @@ export function requireAnyPermission(...codes: string[]) {
};
}
export function filterSensitivePermissions(
permissions: string[],
actorIsOwner: boolean,
): string[] {
if (actorIsOwner) return permissions;
return permissions.filter(
(p) => !IAM_SENSITIVE_PREFIXES.some((prefix) => p.startsWith(prefix)),
);
}
export async function callIamFn<T = unknown>(
tenantId: number | null,
fn: string,
@ -99,3 +127,19 @@ export async function callIamFn<T = unknown>(
}
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 rows = await iam.prepare(
`SELECT id, username, display_name, COALESCE(email, '') AS email, role_code AS role,
COALESCE(must_change_password, false) AS must_change_password, created_at
FROM users WHERE tenant_id = ? AND role_code = 'tenant_admin'
ORDER BY id`,
`SELECT u.id, u.username, u.display_name, COALESCE(u.email, '') AS email, r.code AS role,
COALESCE(u.must_change_password, false) AS must_change_password, u.created_at
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.tenant_id = ? AND r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL
ORDER BY u.id`,
).all(tenantId) as Array<Record<string, unknown>>;
return rows.map((a) => ({
id: Number(a.id),
@ -344,16 +346,20 @@ export async function createTenant(
).then(() => core.lastInsertId())
);
// Paso 3: admin del tenant en iam.
// Paso 3: admin del tenant en iam (rol sistema tenant_admin).
const hash = await hashPassword(temporaryPassword);
await withIamOwner((iam) =>
iam.prepare(
await withIamOwner(async (iam) => {
const adminRole = await iam.prepare(
`SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`,
).get() as { id: number } | undefined;
if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado en iam.roles");
await iam.prepare(
`INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role_code,
must_change_password, email
) VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, ?)`,
).run(adminUser, hash, adminName, companyId, tenantId, contactEmail)
);
username, password_hash, display_name, company_id, tenant_id, role_id,
must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects
) VALUES (?, ?, ?, ?, ?, ?, true, ?, 'activo', true, true, true)`,
).run(adminUser, hash, adminName, companyId, tenantId, adminRole.id, contactEmail);
});
} catch (e) {
// Compensación: limpiar en orden inverso lo que sí se alcanzó a crear.
if (companyId != null) {

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);
await withIamOwner(async (db) => {
const adminRole = await db.prepare(
`SELECT id FROM roles WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL`,
).get() as { id: number } | undefined;
if (!adminRole) throw new Error("Rol sistema tenant_admin no encontrado");
const existing = await db.prepare("SELECT id FROM users WHERE username = ?").get(username);
if (existing) {
await db.prepare(
`UPDATE users SET password_hash = ?, display_name = ?, company_id = ?, tenant_id = ?,
role_code = 'tenant_admin', must_change_password = true WHERE username = ?`,
).run(hash, displayName, company.id, tenantId, username);
role_id = ?, must_change_password = true, status = 'activo',
scope_all_projects = true, warehouse_central = true, warehouse_projects = true
WHERE username = ?`,
).run(hash, displayName, company.id, tenantId, adminRole.id, username);
console.log(`[bootstrap] iam.users.${username} actualizado (tenant_id=${tenantId})`);
} else {
await db.prepare(
`INSERT INTO users (username, password_hash, display_name, company_id, tenant_id, role_code, must_change_password, email)
VALUES (?, ?, ?, ?, ?, 'tenant_admin', true, '')`,
).run(username, hash, displayName, company.id, tenantId);
`INSERT INTO users (
username, password_hash, display_name, company_id, tenant_id, role_id,
must_change_password, email, status, scope_all_projects, warehouse_central, warehouse_projects
) VALUES (?, ?, ?, ?, ?, ?, true, '', 'activo', true, true, true)`,
).run(username, hash, displayName, company.id, tenantId, adminRole.id);
console.log(`[bootstrap] iam.users.${username} creado (tenant_id=${tenantId})`);
}
});

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 type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope, denyUnlessWarehouseCentral } from "./scope_enforcement.ts";
import { getUserScope } from "./user_scope.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
@ -18,37 +20,59 @@ function actor(c: Context): { id: number; name: string } {
return { id: u.id, name: u.display_name };
}
async function filterWarehouseList(c: Context, env: Awaited<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) {
app.get("/v1/warehouses", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
app.get("/v1/warehouses", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_list", {
tenant_id: tid(c),
include_closed: c.req.query("include_closed") === "true",
}, { route: routeLabel(c) });
return respondRpc(c, env);
return respondRpc(c, await filterWarehouseList(c, env));
});
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
if (!await denyUnlessWarehouseCentral(c)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
project_id: projectId,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => {
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const projectId = Number(c.req.param("id"));
if (!await denyUnlessProjectScope(c, projectId)) return;
const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string }));
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: Number(c.req.param("id")),
project_id: projectId,
force: body.force,
closed_by_name: a.name,
close_notes: body.close_notes,
@ -56,17 +80,26 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
const db = c.get("db");
const whId = Number(c.req.param("id"));
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
warehouse_id: whId,
global: false,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
if (!await denyUnlessWarehouseCentral(c)) return;
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
@ -75,51 +108,75 @@ export function registerWarehouseRoutes(app: App) {
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => {
const db = c.get("db");
const whId = Number(c.req.param("id"));
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
warehouse_id: Number(c.req.param("id")),
warehouse_id: whId,
material_id: c.req.query("material_id"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
const whId = Number(c.req.param("id"));
const db = c.get("db");
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const body = await c.req.json<Record<string, unknown>>();
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
...body,
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
warehouse_id: whId,
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const whId = Number(c.req.param("id"));
const db = c.get("db");
const wh = await db.prepare("SELECT kind, project_id FROM warehouses WHERE id = ?").get(whId) as
| { kind: string; project_id: number | null }
| undefined;
if (!wh) return respondApiError(c, "NOT_FOUND", "Almacén no encontrado", { route: routeLabel(c), warehouse_id: whId });
if (wh.kind === "central") {
if (!await denyUnlessWarehouseCentral(c)) return;
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
const body = await c.req.json<Record<string, unknown>>();
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
...body,
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
warehouse_id: whId,
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
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 { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { projectById } from "./db.ts";
import { requirePermission } from "./permissions.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import {
createEmptyWorkProgram,
exportWorkProgram,
@ -22,7 +23,11 @@ import {
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const;
const viewAuth = [...requireCoreAuth, requirePermission("work_program.view")] as const;
async function ensureProject(c: Context, id: number): Promise<boolean> {
return await denyUnlessProjectScope(c, id);
}
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
const form = await c.req.formData();
@ -32,70 +37,90 @@ async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Pr
}
export function registerWorkProgramRoutes(app: App) {
app.get("/v1/projects/:id/work-program", ...auth, async (c) => {
app.get("/v1/projects/:id/work-program", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const data = await getWorkProgram(db, Number(c.req.param("id")));
const data = await getWorkProgram(db, id);
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => {
app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const data = await listConcepts(db, Number(c.req.param("id")));
const data = await listConcepts(db, id);
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => {
app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const data = await listPartidas(db, Number(c.req.param("id")));
const data = await listPartidas(db, id);
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => {
app.get("/v1/projects/:id/work-program/vs-cost", ...requireCoreAuth, requirePermission("cost_control.view"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const data = await getVsCost(db, Number(c.req.param("id")));
const data = await getVsCost(db, id);
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => {
app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const bytes = await readUpload(c);
const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db);
const preview = await previewWorkProgramExcel(bytes, id, db);
return c.json({ ok: true, data: preview });
});
app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => {
app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const bytes = await readUpload(c);
const createChapters = c.req.query("create_chapters") !== "false";
const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters });
const data = await importWorkProgramExcel(db, id, bytes, { createChapters });
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program", ...auth, async (c) => {
app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const data = await createEmptyWorkProgram(db, Number(c.req.param("id")));
const data = await createEmptyWorkProgram(db, id);
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => {
app.post("/v1/projects/:id/work-program/generate", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword");
const data = await generateWorkProgram(db, id, body.strategy || "keyword");
return c.json({ ok: true, data });
});
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => {
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const env = await recalculatePartidas(db, Number(c.req.param("id")));
const env = await recalculatePartidas(db, id);
return c.json({ ok: true, data: env });
});
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => {
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const body = await c.req.json() as { start_pct?: number; end_pct?: number };
const data = await updateConceptSlot(
db,
Number(c.req.param("id")),
id,
Number(c.req.param("itemId")),
Number(c.req.param("periodId")),
Number(body.start_pct ?? 0),
@ -104,12 +129,14 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => {
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const body = await c.req.json() as { amount?: number };
const data = await updatePartidaAmount(
db,
Number(c.req.param("id")),
id,
Number(c.req.param("chapterId")),
Number(c.req.param("periodId")),
Number(body.amount ?? 0),
@ -117,12 +144,14 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => {
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => {
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const db = c.get("db");
const body = await c.req.json() as { pct_complete?: number };
const data = await updateProgress(
db,
Number(c.req.param("id")),
id,
Number(c.req.param("itemId")),
Number(c.req.param("periodId")),
Number(body.pct_complete ?? 0),
@ -130,9 +159,10 @@ export function registerWorkProgramRoutes(app: App) {
return c.json({ ok: true, data });
});
app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => {
app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => {
const db = c.get("db");
const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return;
const kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida";
const project = await projectById(db, id);
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);

View file

@ -37,5 +37,6 @@
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,12 @@
--liquibase formatted sql
-- PANELS · core · tipos de documento técnico adicionales
--changeset panel:core-031a-project-doc-types-technical endDelimiter:; splitStatements:true
INSERT INTO core.project_document_types (code, label, required, category) VALUES
('renders', 'Renders', false, 'tecnico'),
('modelo_3d', 'Modelo 3D', false, 'tecnico'),
('sketchup', 'SketchUp', false, 'tecnico')
ON CONFLICT (code) DO NOTHING;
--changeset panel:core-031b-iam-rpc-created-grant endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_iam_app;

View file

@ -10,5 +10,6 @@
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,972 @@
--liquibase formatted sql
-- PANELS · iam · IAM v2: permisos CRUD, roles por tenant, usuarios ampliados y RPC
--changeset panel:iam-005a-permissions-metadata endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='permissions' AND column_name='module'
ALTER TABLE iam.permissions ADD COLUMN module TEXT;
ALTER TABLE iam.permissions ADD COLUMN verb TEXT;
ALTER TABLE iam.permissions ADD COLUMN perm_group TEXT;
--changeset panel:iam-005b-permissions-v2-seed endDelimiter:; splitStatements:true
INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
('workers.view', 'Consultar padrón', 'workers', 'view', NULL),
('workers.create', 'Alta de personal', 'workers', 'create', NULL),
('workers.update', 'Editar personal', 'workers', 'update', NULL),
('workers.delete', 'Baja de personal', 'workers', 'delete', NULL),
('kanban.view', 'Tablero kanban', 'kanban', 'view', NULL),
('projects.view', 'Consultar obras', 'projects', 'view', NULL),
('projects.create', 'Alta de obras', 'projects', 'create', NULL),
('projects.update', 'Editar obras', 'projects', 'update', NULL),
('companies.view', 'Consultar empresas', 'companies', 'view', NULL),
('companies.create', 'Alta de empresas', 'companies', 'create', NULL),
('companies.update', 'Editar empresas', 'companies', 'update', NULL),
('budget.view', 'Ver presupuesto', 'budget', 'view', NULL),
('budget.create', 'Alta partidas presupuesto', 'budget', 'create', NULL),
('budget.update', 'Editar presupuesto', 'budget', 'update', NULL),
('budget.delete', 'Eliminar partidas', 'budget', 'delete', NULL),
('work_program.view', 'Ver programa de obra', 'work_program', 'view', NULL),
('work_program.create', 'Importar/generar programa', 'work_program', 'create', NULL),
('work_program.update', 'Capturar avances', 'work_program', 'update', NULL),
('cost_control.view', 'Control de costos', 'cost_control', 'view', NULL),
('expenses.view', 'Ver gastos', 'expenses', 'view', NULL),
('expenses.create', 'Capturar gastos', 'expenses', 'create', NULL),
('expenses.update', 'Editar gastos', 'expenses', 'update', NULL),
('expenses.delete', 'Eliminar gastos', 'expenses', 'delete', NULL),
('warehouse.view', 'Ver almacén', 'warehouse', 'view', NULL),
('warehouse.create', 'Entradas almacén', 'warehouse', 'create', NULL),
('warehouse.update', 'Salidas/transferencias', 'warehouse', 'update', NULL),
('payroll.view', 'Ver nómina', 'payroll', 'view', NULL),
('payroll.create', 'Alta nómina/préstamos', 'payroll', 'create', NULL),
('payroll.update', 'Armar/pagar nómina', 'payroll', 'update', NULL),
('payroll.delete', 'Eliminar líneas nómina', 'payroll', 'delete', NULL),
('documents.view', 'Ver documentos/gafetes', 'documents', 'view', NULL),
('documents.create', 'Subir documentos/gafetes', 'documents', 'create', NULL),
('project_docs.tecnico.view', 'Planos y docs técnicos', 'project_docs', 'view', 'tecnico'),
('project_docs.tecnico.create', 'Subir docs técnicos', 'project_docs', 'create', 'tecnico'),
('project_docs.contrato.view', 'Docs contrato', 'project_docs', 'view', 'contrato'),
('project_docs.contrato.create', 'Subir docs contrato', 'project_docs', 'create', 'contrato'),
('project_docs.permisos.view', 'Docs permisos', 'project_docs', 'view', 'permisos'),
('project_docs.permisos.create', 'Subir docs permisos', 'project_docs', 'create', 'permisos'),
('project_docs.ambiental.view', 'Docs ambientales', 'project_docs', 'view', 'ambiental'),
('project_docs.ambiental.create', 'Subir docs ambientales', 'project_docs', 'create', 'ambiental'),
('project_docs.imss.view', 'Docs IMSS', 'project_docs', 'view', 'imss'),
('project_docs.imss.create', 'Subir docs IMSS', 'project_docs', 'create', 'imss'),
('project_docs.sst.view', 'Docs SST', 'project_docs', 'view', 'sst'),
('project_docs.sst.create', 'Subir docs SST', 'project_docs', 'create', 'sst'),
('project_docs.otro.view', 'Otros docs obra', 'project_docs', 'view', 'otro'),
('project_docs.otro.create', 'Subir otros docs', 'project_docs', 'create', 'otro'),
('users.view', 'Listar usuarios IAM', 'users', 'view', NULL),
('users.create', 'Alta usuarios IAM', 'users', 'create', NULL),
('users.update', 'Editar usuarios IAM', 'users', 'update', NULL),
('users.delete', 'Baja usuarios IAM', 'users', 'delete', NULL),
('settings.view', 'Ver configuración', 'settings', 'view', NULL),
('settings.update', 'Guardar configuración', 'settings', 'update', NULL),
('reports.view', 'Reportes e inicio', 'reports', 'view', NULL)
ON CONFLICT (code) DO UPDATE SET
label = EXCLUDED.label,
module = EXCLUDED.module,
verb = EXCLUDED.verb,
perm_group = EXCLUDED.perm_group;
--changeset panel:iam-005c-legacy-perm-map endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS iam._legacy_perm_map (
legacy_code TEXT NOT NULL,
v2_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (legacy_code, v2_code)
);
INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
('manage_workers', 'workers.view'),
('manage_workers', 'workers.create'),
('manage_workers', 'workers.update'),
('manage_workers', 'workers.delete'),
('manage_workers', 'kanban.view'),
('manage_projects', 'projects.view'),
('manage_projects', 'projects.create'),
('manage_projects', 'projects.update'),
('manage_companies', 'companies.view'),
('manage_companies', 'companies.create'),
('manage_companies', 'companies.update'),
('manage_budget', 'budget.view'),
('manage_budget', 'budget.create'),
('manage_budget', 'budget.update'),
('manage_budget', 'budget.delete'),
('manage_budget', 'work_program.view'),
('manage_budget', 'work_program.create'),
('manage_budget', 'work_program.update'),
('manage_expenses', 'expenses.view'),
('manage_expenses', 'expenses.create'),
('manage_expenses', 'expenses.update'),
('manage_expenses', 'expenses.delete'),
('manage_expenses', 'cost_control.view'),
('view_expenses', 'expenses.view'),
('view_expenses', 'cost_control.view'),
('manage_payroll', 'payroll.view'),
('manage_payroll', 'payroll.create'),
('manage_payroll', 'payroll.update'),
('manage_payroll', 'payroll.delete'),
('manage_documents', 'documents.view'),
('manage_documents', 'documents.create'),
('manage_documents', 'project_docs.tecnico.view'),
('manage_documents', 'project_docs.tecnico.create'),
('manage_documents', 'project_docs.contrato.view'),
('manage_documents', 'project_docs.contrato.create'),
('manage_documents', 'project_docs.permisos.view'),
('manage_documents', 'project_docs.permisos.create'),
('manage_users', 'users.view'),
('manage_users', 'users.create'),
('manage_users', 'users.update'),
('manage_users', 'users.delete'),
('manage_settings', 'settings.view'),
('manage_settings', 'settings.update'),
('view_reports', 'reports.view'),
('view_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.view'),
('manage_warehouse', 'warehouse.create'),
('manage_warehouse', 'warehouse.update'),
('close_warehouse', 'warehouse.update'),
('manage_cost_settings', 'settings.update'),
('manage_cost_settings', 'cost_control.view')
ON CONFLICT DO NOTHING;
--changeset panel:iam-005d-roles-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
CREATE TABLE iam.roles_v2 (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER,
code TEXT NOT NULL,
label TEXT NOT NULL,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT NULL, code, label, true
FROM iam.roles
WHERE code = 'tenant_admin';
INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system)
SELECT DISTINCT u.tenant_id, 'operativo', 'Operativo', false
FROM iam.users u
WHERE u.role_code = 'user' AND u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM iam.roles_v2 r
WHERE r.tenant_id = u.tenant_id AND r.code = 'operativo'
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_tenant_code
ON iam.roles_v2 (tenant_id, code)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_system_code
ON iam.roles_v2 (code)
WHERE tenant_id IS NULL AND is_system;
--changeset panel:iam-005e-role-permissions-v2 endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='role_permissions' AND column_name='role_id'
CREATE TABLE iam.role_permissions_v2 (
role_id BIGINT NOT NULL REFERENCES iam.roles_v2(id) ON DELETE CASCADE,
permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_code)
);
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT sr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 sr ON sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
WHERE rp.role_code = 'tenant_admin'
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT sr.id, p.code
FROM iam.roles_v2 sr
CROSS JOIN iam.permissions p
WHERE sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND p.module IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO iam.role_permissions_v2 (role_id, permission_code)
SELECT DISTINCT tr.id, lm.v2_code
FROM iam.role_permissions rp
JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code
JOIN iam.roles_v2 tr ON tr.code = 'operativo' AND tr.tenant_id IS NOT NULL
WHERE rp.role_code = 'user'
ON CONFLICT DO NOTHING;
--changeset panel:iam-005f-users-v2-columns endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='users' AND column_name='role_id'
ALTER TABLE iam.users ADD COLUMN role_id BIGINT;
ALTER TABLE iam.users ADD COLUMN status TEXT NOT NULL DEFAULT 'activo';
ALTER TABLE iam.users ADD COLUMN scope_all_projects BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_central BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE iam.users ADD COLUMN warehouse_projects BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE iam.users ADD CONSTRAINT users_status_check
CHECK (status IN ('activo', 'baja'));
--changeset panel:iam-005g-users-backfill-role-id endDelimiter:; splitStatements:true
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'tenant_admin'
AND sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_code = 'user'
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id
AND u.role_id IS NULL;
UPDATE iam.users u
SET role_id = sr.id
FROM iam.roles_v2 sr
WHERE u.role_id IS NULL
AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id;
--changeset panel:iam-005h-swap-roles endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id'
ALTER TABLE iam.users DROP CONSTRAINT IF EXISTS users_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_role_code_fkey;
ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_permission_code_fkey;
DROP TABLE iam.role_permissions;
ALTER TABLE iam.users DROP COLUMN IF EXISTS role_code;
ALTER TABLE iam.role_permissions_v2 RENAME TO role_permissions;
ALTER TABLE iam.roles DROP CONSTRAINT roles_pkey;
DROP TABLE iam.roles;
ALTER TABLE iam.roles_v2 RENAME TO roles;
ALTER TABLE iam.users
ADD CONSTRAINT users_role_id_fkey FOREIGN KEY (role_id) REFERENCES iam.roles(id);
ALTER TABLE iam.users ALTER COLUMN role_id SET NOT NULL;
DO $$
DECLARE
v_admin_role_id bigint;
BEGIN
SELECT id INTO v_admin_role_id
FROM iam.roles
WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL
LIMIT 1;
IF v_admin_role_id IS NOT NULL THEN
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_users_one_tenant_admin_per_tenant ON iam.users (tenant_id) WHERE role_id = %s',
v_admin_role_id
);
END IF;
END $$;
--changeset panel:iam-005i-user-projects endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='user_projects'
CREATE TABLE iam.user_projects (
user_id BIGINT NOT NULL REFERENCES iam.users(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id)
);
CREATE INDEX idx_iam_user_projects_user ON iam.user_projects(user_id);
--changeset panel:iam-005j-rls endDelimiter:; splitStatements:true
ALTER TABLE iam.roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_and_system_roles ON iam.roles;
CREATE POLICY tenant_and_system_roles ON iam.roles
USING (
(tenant_id IS NOT NULL AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (tenant_id IS NULL AND is_system)
)
WITH CHECK (
tenant_id IS NOT NULL
AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT is_system
);
ALTER TABLE iam.role_permissions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_role_permissions ON iam.role_permissions;
CREATE POLICY tenant_role_permissions ON iam.role_permissions
USING (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND (
(r.tenant_id IS NOT NULL AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
OR (r.tenant_id IS NULL AND r.is_system)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.roles r
WHERE r.id = role_permissions.role_id
AND r.tenant_id IS NOT NULL
AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
AND NOT r.is_system
)
);
ALTER TABLE iam.user_projects ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_user_projects ON iam.user_projects;
CREATE POLICY tenant_user_projects ON iam.user_projects
USING (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM iam.users u
WHERE u.id = user_projects.user_id
AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer
)
);
--changeset panel:iam-005k-fn-permission-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'code', p.code,
'label', p.label,
'module', p.module,
'verb', p.verb,
'group', p.perm_group
) ORDER BY p.module, p.code
), '[]'::jsonb) INTO v_rows
FROM permissions p
WHERE p.module IS NOT NULL;
RETURN core.rpc_ok(
jsonb_build_object('permissions', v_rows),
format('Catálogo de permisos v2: %s', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_permission_list')
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005l-fn-role-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', r.id,
'code', r.code,
'label', r.label,
'tenant_id', r.tenant_id,
'is_system', r.is_system,
'is_editable', NOT r.is_system,
'created_at', r.created_at
) ORDER BY r.is_system DESC, r.label
), '[]'::jsonb) INTO v_rows
FROM roles r
WHERE (r.tenant_id = v_tid) OR (r.tenant_id IS NULL AND r.is_system);
RETURN core.rpc_ok(
jsonb_build_object('roles', v_rows),
format('Roles del tenant %s: %s', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005m-fn-role-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_code text := lower(nullif(btrim(payload->>'code'), ''));
v_label text := nullif(btrim(payload->>'label'), '');
v_new_id bigint;
v_role jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_role_create'));
END IF;
IF v_code IS NULL OR v_code !~ '^[a-z][a-z0-9_]{1,63}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: code inválido (minúsculas, números, _, 2-64 chars)',
jsonb_build_object('fn', 'fn_role_create', 'field', 'code'));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_create: label es obligatorio',
jsonb_build_object('fn', 'fn_role_create', 'field', 'label'));
END IF;
IF v_code IN ('tenant_admin', 'platform_admin', 'user', 'operativo') THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_create: el código %s está reservado', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code));
END IF;
INSERT INTO roles (tenant_id, code, label, is_system)
VALUES (v_tid, v_code, v_label, false)
RETURNING id INTO v_new_id;
SELECT to_jsonb(r) INTO v_role FROM roles r WHERE r.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('role', v_role),
format('Rol %s creado (id=%s)', v_code, v_new_id),
jsonb_build_object('fn', 'fn_role_create', 'role_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_role_create: ya existe el rol %s en el tenant', v_code),
jsonb_build_object('fn', 'fn_role_create', 'code', v_code, 'tenant_id', v_tid));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005n-fn-role-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_label text := nullif(btrim(payload->>'label'), '');
v_role record;
v_updated jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
IF v_label IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_update: label es obligatorio',
jsonb_build_object('fn', 'fn_role_update', 'field', 'label'));
END IF;
UPDATE roles SET label = v_label WHERE id = v_role_id;
SELECT to_jsonb(r) INTO v_updated FROM roles r WHERE r.id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role', v_updated),
format('Rol %s actualizado (id=%s)', v_updated->>'code', v_role_id),
jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005o-fn-role-delete splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_delete(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_assigned integer;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_delete', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_delete: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_delete: no se puede eliminar un rol del sistema',
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_role_delete: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'tenant_id', v_tid));
END IF;
SELECT COUNT(*)::integer INTO v_assigned FROM users WHERE role_id = v_role_id;
IF v_assigned > 0 THEN
RETURN core.rpc_err('CONFLICT',
format('fn_role_delete: rol %s tiene %s usuario(s) asignado(s)', v_role.code, v_assigned),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'assigned_users', v_assigned));
END IF;
DELETE FROM roles WHERE id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'code', v_role.code),
format('Rol %s eliminado (id=%s)', v_role.code, v_role_id),
jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_delete', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005p-fn-role-permissions-get splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_role record;
v_rows jsonb;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_get', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_get: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id));
END IF;
SELECT COALESCE(jsonb_agg(rp.permission_code ORDER BY rp.permission_code), '[]'::jsonb) INTO v_rows
FROM role_permissions rp WHERE rp.role_id = v_role_id;
RETURN core.rpc_ok(
jsonb_build_object(
'role_id', v_role_id,
'role_code', v_role.code,
'is_editable', NOT v_role.is_system,
'permissions', v_rows
),
format('Permisos del rol %s: %s', v_role.code, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005q-fn-role-permissions-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb);
v_role record;
v_perm text;
BEGIN
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: role_id es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_save', 'field', 'role_id'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_save: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system AND v_role.code = 'tenant_admin' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
IF v_role.is_system OR v_role.tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar un rol del sistema',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id));
END IF;
DELETE FROM role_permissions WHERE role_id = v_role_id;
FOR v_perm IN SELECT jsonb_array_elements_text(v_perms)
LOOP
INSERT INTO role_permissions (role_id, permission_code)
VALUES (v_role_id, v_perm)
ON CONFLICT DO NOTHING;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('role_id', v_role_id, 'role_code', v_role.code, 'permissions', v_perms),
format('Permisos del rol %s actualizados (%s permiso(s))', v_role.code, jsonb_array_length(v_perms)),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005r-fn-user-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_data ORDER BY row_data->>'display_name'), '[]'::jsonb) INTO v_rows
FROM (
SELECT jsonb_build_object(
'id', u.id,
'username', u.username,
'display_name', u.display_name,
'email', u.email,
'company_id', u.company_id,
'tenant_id', u.tenant_id,
'role_id', u.role_id,
'role_code', r.code,
'role_label', r.label,
'status', u.status,
'scope_all_projects', u.scope_all_projects,
'warehouse_central', u.warehouse_central,
'warehouse_projects', u.warehouse_projects,
'project_ids', COALESCE((
SELECT jsonb_agg(up.project_id ORDER BY up.project_id)
FROM user_projects up WHERE up.user_id = u.id
), '[]'::jsonb),
'is_owner', (r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL),
'must_change_password', u.must_change_password,
'created_at', u.created_at
) AS row_data
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.tenant_id = v_tid
) sub;
RETURN core.rpc_ok(
jsonb_build_object('users', v_rows),
format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005s-fn-user-create splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_username text := lower(nullif(btrim(payload->>'username'), ''));
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_display_name text := nullif(btrim(payload->>'display_name'), '');
v_email text := COALESCE(nullif(btrim(payload->>'email'), ''), '');
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_company_id integer := NULLIF(payload->>'company_id', '')::integer;
v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'activo');
v_scope_all boolean := COALESCE((payload->>'scope_all_projects')::boolean, false);
v_wh_central boolean := COALESCE((payload->>'warehouse_central')::boolean, false);
v_wh_projects boolean := COALESCE((payload->>'warehouse_projects')::boolean, true);
v_role record;
v_new_id bigint;
v_user jsonb;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: tenant_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create'));
END IF;
IF v_username IS NULL OR v_username !~ '^[a-z0-9._-]{3,64}$' THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: username inválido',
jsonb_build_object('fn', 'fn_user_create', 'field', 'username'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'password_hash'));
END IF;
IF v_display_name IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: display_name es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'display_name'));
END IF;
IF v_role_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: role_id es obligatorio',
jsonb_build_object('fn', 'fn_user_create', 'field', 'role_id'));
END IF;
IF v_status NOT IN ('activo', 'baja') THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: status debe ser activo o baja',
jsonb_build_object('fn', 'fn_user_create', 'field', 'status'));
END IF;
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_create: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_create: use bootstrap para crear tenant_admin',
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_create: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id));
END IF;
INSERT INTO users (
username, password_hash, display_name, email, company_id, tenant_id, role_id,
status, scope_all_projects, warehouse_central, warehouse_projects, must_change_password
) VALUES (
v_username, v_password_hash, v_display_name, v_email, v_company_id, v_tid, v_role_id,
v_status, v_scope_all, v_wh_central, v_wh_projects, COALESCE((payload->>'must_change_password')::boolean, true)
)
RETURNING id INTO v_new_id;
SELECT to_jsonb(u) INTO v_user FROM users u WHERE u.id = v_new_id;
RETURN core.rpc_created(
jsonb_build_object('user', v_user),
format('Usuario %s creado (id=%s)', v_username, v_new_id),
jsonb_build_object('fn', 'fn_user_create', 'user_id', v_new_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('fn_user_create: ya existe el username %s', v_username),
jsonb_build_object('fn', 'fn_user_create', 'username', v_username));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005t-fn-user-update splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'id', '')::bigint;
v_user record;
v_role record;
v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint;
v_updated jsonb;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: id es obligatorio',
jsonb_build_object('fn', 'fn_user_update', 'field', 'id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede modificar el propietario del tenant',
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id));
END IF;
IF v_role_id IS NOT NULL THEN
SELECT * INTO v_role FROM roles WHERE id = v_role_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: rol id=%s no encontrado', v_role_id),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.code = 'tenant_admin' AND v_role.is_system THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede asignar rol tenant_admin',
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid),
jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id));
END IF;
END IF;
UPDATE users SET
display_name = COALESCE(nullif(btrim(payload->>'display_name'), ''), display_name),
email = COALESCE(nullif(btrim(payload->>'email'), ''), email),
company_id = COALESCE(NULLIF(payload->>'company_id', '')::integer, company_id),
role_id = COALESCE(v_role_id, role_id),
status = COALESCE(nullif(btrim(payload->>'status'), ''), status),
scope_all_projects = COALESCE((payload->>'scope_all_projects')::boolean, scope_all_projects),
warehouse_central = COALESCE((payload->>'warehouse_central')::boolean, warehouse_central),
warehouse_projects = COALESCE((payload->>'warehouse_projects')::boolean, warehouse_projects)
WHERE id = v_user_id;
SELECT to_jsonb(u) INTO v_updated FROM users u WHERE u.id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user', v_updated),
format('Usuario id=%s actualizado', v_user_id),
jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005u-fn-user-reset-access splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_reset_access(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_password_hash text := nullif(btrim(payload->>'password_hash'), '');
v_user record;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'user_id'));
END IF;
IF v_password_hash IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: password_hash es obligatorio',
jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'password_hash'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_reset_access: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_reset_access: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id));
END IF;
UPDATE users
SET password_hash = v_password_hash,
must_change_password = COALESCE((payload->>'must_change_password')::boolean, true)
WHERE id = v_user_id;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id),
format('Acceso restablecido para usuario id=%s', v_user_id),
jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_reset_access', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005v-fn-user-projects-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_projects_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint;
v_project_ids jsonb := COALESCE(payload->'project_ids', '[]'::jsonb);
v_user record;
v_pid integer;
BEGIN
IF v_user_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: user_id es obligatorio',
jsonb_build_object('fn', 'fn_user_projects_save', 'field', 'user_id'));
END IF;
SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id
INTO v_user
FROM users u
JOIN roles r ON r.id = u.role_id
WHERE u.id = v_user_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_user_projects_save: usuario id=%s no encontrado', v_user_id),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.tenant_id <> v_tid THEN
RETURN core.rpc_err('VALIDATION', format('fn_user_projects_save: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: no se puede modificar proyectos del propietario',
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id));
END IF;
DELETE FROM user_projects WHERE user_id = v_user_id;
FOR v_pid IN
SELECT DISTINCT (elem)::integer
FROM jsonb_array_elements_text(v_project_ids) AS elem
WHERE btrim(elem) ~ '^\d+$'
LOOP
INSERT INTO user_projects (user_id, project_id) VALUES (v_user_id, v_pid);
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('user_id', v_user_id, 'project_ids', v_project_ids),
format('Proyectos del usuario id=%s actualizados (%s)', v_user_id, jsonb_array_length(v_project_ids)),
jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_projects_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-005w-iam-v2-rpc-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_delete(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_create(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_update(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_reset_access(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_projects_save(jsonb) TO panels_iam_app;

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_kind?: string | null;
tenant_id?: number | null;
role?: "platform_admin" | "tenant_admin" | "user";
role_id?: number | null;
role_code?: string;
is_owner?: boolean;
status?: string;
realm?: "app" | "platform";
must_change_password?: boolean;
} | null>("auth-user", () => null);

View file

@ -16,12 +16,14 @@ export type RibbonGroup = {
};
export type DeskDetail = {
kind: "none" | "worker" | "project" | "budget";
kind: "none" | "worker" | "project" | "budget" | "iam-user" | "iam-role";
id?: number;
doc?: string;
tab?: string;
project?: Record<string, unknown>;
budgetItem?: Record<string, unknown>;
iamUser?: Record<string, unknown>;
iamRole?: Record<string, unknown>;
};
export type DeskStatusMessage = {
@ -114,6 +116,16 @@ export function useDesktop() {
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() {
detail.value = { kind: "none" };
}
@ -155,6 +167,8 @@ export function useDesktop() {
openWorker,
openProject,
openBudgetItem,
openIamUser,
openIamRole,
clearDetail,
bumpDetail,
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"
@edit="editFromFicha"
/>
<IamDetail v-else-if="detail.kind === 'iam-user' || detail.kind === 'iam-role'" :detail="detail" />
<div v-else class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
@ -114,8 +115,10 @@
<script setup lang="ts">
import LoanDialog from "~/components/padron/LoanDialog.vue";
import IamDetail from "~/components/IamDetail.vue";
const { user, logout } = useAuth();
const perms = usePermissions();
const { initials } = useUi();
const route = useRoute();
const desk = useDesktop();
@ -161,42 +164,43 @@ function nav(label: string, icon: string, to: string): NavItem {
return { label, icon, to, command: () => navigateTo(to) };
}
const menu: NavItem[] = [
nav("Inicio", "pi pi-home", "/"),
{
label: "Obra",
icon: "pi pi-building",
items: [
nav("Proyectos", "pi pi-building", "/proyectos"),
nav("Presupuesto", "pi pi-calculator", "/presupuesto"),
nav("Programa de obra", "pi pi-calendar", "/programa-obra"),
nav("Control costos", "pi pi-chart-bar", "/control-presupuesto"),
],
},
{
label: "Operación",
icon: "pi pi-briefcase",
items: [
nav("Gastos", "pi pi-money-bill", "/gastos"),
nav("Almacén", "pi pi-box", "/almacen"),
nav("Kanban", "pi pi-th-large", "/kanban"),
],
},
{
label: "Personal",
icon: "pi pi-users",
items: [
nav("Padrón", "pi pi-users", "/padron"),
nav("Gafetes", "pi pi-id-card", "/gafetes"),
nav("Nómina", "pi pi-wallet", "/nomina"),
],
},
{
label: "Administración",
icon: "pi pi-shield",
items: [nav("Usuarios", "pi pi-shield", "/usuarios")],
},
];
function navIf(label: string, icon: string, to: string, permission: string): NavItem | null {
if (!perms.can(permission)) return null;
return nav(label, icon, to);
}
const menu = computed<NavItem[]>(() => {
const items: NavItem[] = [];
const home = navIf("Inicio", "pi pi-home", "/", "reports.view");
if (home) items.push(home);
const obraItems = [
navIf("Proyectos", "pi pi-building", "/proyectos", "projects.view"),
navIf("Presupuesto", "pi pi-calculator", "/presupuesto", "budget.view"),
navIf("Programa de obra", "pi pi-calendar", "/programa-obra", "work_program.view"),
navIf("Control costos", "pi pi-chart-bar", "/control-presupuesto", "cost_control.view"),
].filter(Boolean) as NavItem[];
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
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"),
].filter(Boolean) as NavItem[];
if (opItems.length) items.push({ label: "Operación", icon: "pi pi-briefcase", items: opItems });
const personalItems = [
navIf("Padrón", "pi pi-users", "/padron", "workers.view"),
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 });
const adminItems = [navIf("Usuarios", "pi pi-shield", "/usuarios", "users.view")].filter(Boolean) as NavItem[];
if (adminItems.length) items.push({ label: "Administración", icon: "pi pi-shield", items: adminItems });
return items;
});
function isActive(to: string) {
if (!to) return false;
@ -256,6 +260,7 @@ watch(
if (!workerOk && desk.detail.value.kind === "worker") 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("/usuarios") && (desk.detail.value.kind === "iam-user" || desk.detail.value.kind === "iam-role")) desk.clearDetail();
},
);
</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) => {
if (to.path === "/login") return;
const { user, fetchMe } = useAuth();
@ -14,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
if (!user.value) return navigateTo("/login");
if (user.value.role === "platform_admin" && user.value.realm === "platform") {
if (user.value.role_code === "platform_admin" && user.value.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });
@ -34,4 +51,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (!user.value.must_change_password && to.path === "/cambiar-password") {
return navigateTo("/");
}
const perms = usePermissions();
const userScope = useScope();
if (!perms.loaded.value) await perms.load();
if (!userScope.loaded.value) await userScope.load();
const required = Object.entries(ROUTE_PERMISSIONS).find(([path]) =>
path === "/" ? to.path === "/" : to.path === path || to.path.startsWith(`${path}/`),
)?.[1];
if (required && !perms.can(required)) {
return navigateTo("/");
}
});

View file

@ -281,7 +281,7 @@ async function onSubmit() {
await login(username.value.trim(), password.value.trim());
const { user } = useAuth();
const { api } = useApi();
if (user.value?.role === "platform_admin" && user.value?.realm === "platform") {
if (user.value?.role_code === "platform_admin" && user.value?.realm === "platform") {
user.value = null;
try {
await api("/v1/auth/logout", { method: "POST" });

View file

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

View file

@ -1,78 +1,521 @@
<template>
<div class="usuarios-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar usuario">
<Chip :label="`${users.length} usuarios`" icon="pi pi-users" />
<DeskTableBar v-model="tableQ" :placeholder="treeKey.startsWith('roles') ? 'Buscar rol' : 'Buscar usuario'">
<Chip
:label="treeKey.startsWith('roles') ? `${roles.length} roles` : `${listedUsers.length} usuarios`"
icon="pi pi-users"
/>
</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="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" />
</DataTable>
<Panel header="Permisos del rol operativo (user)" class="mt-4">
<div class="perm-grid">
<div v-for="perm in permissions" :key="perm.code" class="perm-item">
<Checkbox v-model="rolePerms" :input-id="perm.code" :value="perm.code" />
<label :for="perm.code">{{ perm.label }}</label>
</div>
<DataTable
v-else
v-model:selection="selectedRole"
:value="listedRoles"
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>
</FormField>
</div>
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" />
</Panel>
<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>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response";
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string };
type PermRow = { code: string; label: string };
type UserRow = {
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 { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const tableQ = ref("");
const treeKey = ref("users-active");
const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<PermRow[]>([]);
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 q = tableQ.value.toLowerCase();
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].join(" ").toLowerCase().includes(q));
const userForm = reactive({
username: "",
display_name: "",
email: "",
role_id: null as number | null,
scope_all_projects: false,
warehouse_central: false,
warehouse_projects: true,
project_ids: [] as number[],
});
async function load() {
const [u, p, rp] = await Promise.all([
api<{ data?: { users?: UserRow[] } }>("/v1/iam/users"),
api<{ data?: { permissions?: PermRow[] } }>("/v1/iam/permissions"),
api<{ data?: { permissions?: string[] } }>("/v1/iam/roles/user/permissions"),
]);
users.value = u.data?.users ?? [];
permissions.value = p.data?.permissions ?? [];
rolePerms.value = rp.data?.permissions ?? [];
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);
}
async function saveRolePerms() {
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() {
const [u, p, r, proj] = await Promise.all([
api<{ users?: UserRow[] }>("/v1/iam/users"),
api<{ permissions?: PermRow[] }>("/v1/iam/permissions"),
api<{ roles?: RoleRow[] }>("/v1/iam/roles"),
api<{ projects?: ProjectRow[] }>("/v1/projects").catch(() => ({ projects: [] as ProjectRow[] })),
]);
users.value = (u.users ?? []).map((row) => ({
...row,
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 {
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", {
method: "PUT",
json: { permissions: rolePerms.value },
});
toast.add({ severity: "success", summary: r.message || "Permisos guardados", life: 2500 });
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() {
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 {
await api(`/v1/iam/roles/${role.id}/permissions`, {
method: "PUT",
json: { permissions: rolePerms.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 });
} 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 () => {
desk.configure({ tab: "Usuarios IAM", ribbon: [] });
if (!treeKey.value) treeKey.value = "users-active";
await load();
});
</script>
<style scoped>
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; }
.perm-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.form-grid { display: flex; flex-direction: column; gap: 12px; }
</style>