panels-origin/api/iam_http.ts
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

304 lines
12 KiB
TypeScript

import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { requireAuth, tenantScope } from "./auth.ts";
import type { Db } from "./db.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("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("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", 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/:id/permissions", requireAuth, requirePermission("users.update"), async (c) => {
const user = c.get("user");
const body = await c.req.json<{ permissions?: string[] }>();
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 { 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_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,
});
});
}