mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 23:03:18 +00:00
<!-- 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> <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> </div>
101 lines
3.1 KiB
TypeScript
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;
|
|
}
|