mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 16:13:17 +00:00
- 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>
304 lines
12 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|