panels-origin/api/permissions.ts
Cursor Agent ab89727522
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado:

- Esquema Liquibase: expense_entries, almacén (central + obra), IVA,
  tenant_cost_settings, permisos IAM y RLS
- Funciones RPC core: gastos, almacén, control presupuestal, sync nómina,
  cierre de almacén al concluir proyecto
- API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http
- Middleware requirePermission con matriz IAM
- UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios
- Componentes BudgetItemPicker, CostSemaphore y semáforos CSS
- Smoke tests extendidos para gastos/almacén/control

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:04:17 +00:00

101 lines
3.1 KiB
TypeScript

import type { Context, Next } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts";
import { respondApiError, routeLabel } from "./http_errors.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000;
export async function userPermissions(
tenantId: number | null,
roleCode: string,
): Promise<Set<string>> {
const key = `${tenantId ?? 0}:${roleCode}`;
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 }[],
);
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);
return perms.has(code);
}
export function requirePermission(code: string) {
return async (c: Context, next: Next) => {
const user = c.get("user") as AuthUser;
if (user.realm === "platform") {
await next();
return;
}
if (!await hasPermission(user, code)) {
return respondApiError(
c,
"FORBIDDEN",
`Permiso requerido: ${code}`,
{ route: routeLabel(c), permission: code, role: user.role },
);
}
await next();
};
}
export function requireAnyPermission(...codes: string[]) {
return async (c: Context, next: Next) => {
const user = c.get("user") as AuthUser;
if (user.realm === "platform") {
await next();
return;
}
for (const code of codes) {
if (await hasPermission(user, code)) {
await next();
return;
}
}
return respondApiError(
c,
"FORBIDDEN",
`Se requiere alguno de: ${codes.join(", ")}`,
{ route: routeLabel(c), permissions: codes },
);
};
}
export async function callIamFn<T = unknown>(
tenantId: number | null,
fn: string,
payload: Record<string, unknown> = {},
): Promise<T | null> {
const row = await withIamTenant(tenantId, async (db) =>
await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(payload) as { result: unknown },
);
const raw = row?.result;
if (raw && typeof raw === "object" && "ok" in (raw as object)) {
return raw as T;
}
return null;
}