panels-origin/api/permissions.ts
Alberto Martinez 41273dfb04 Gastos, almacén y control presupuestal
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Validación de endpoints y SPs

Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados.

### Bugs corregidos en migraciones/SQL

1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`.
2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`.

### Tests añadidos

- `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos
- `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos

### Resultados

- `deno test cost_modules_test.ts` — 5/5 OK
- `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?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-eeec3b5c-f789-43e8-a353-0755e4706377&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-04 04:29:02 +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;
}