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

145 lines
4.5 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";
import { ALL_PERMISSION_CODES, IAM_SENSITIVE_PREFIXES } from "./iam_catalog.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000;
export function invalidatePermissionCache(roleId?: number) {
if (roleId != null) {
for (const key of [...permissionCache.keys()]) {
if (key.startsWith(`${roleId}:`)) permissionCache.delete(key);
}
return;
}
permissionCache.clear();
}
export async function userPermissionsByRoleId(
tenantId: number | null,
roleId: number | null,
isOwner: boolean,
): Promise<Set<string>> {
if (isOwner) return new Set(ALL_PERMISSION_CODES);
if (roleId == null) return new Set();
const key = `${roleId}:${tenantId ?? 0}`;
const hit = permissionCache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms;
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
"SELECT permission_code FROM role_permissions WHERE role_id = ?",
).all(roleId) as { permission_code: string }[],
);
const perms = new Set(rows.map((r) => r.permission_code));
permissionCache.set(key, { perms, at: Date.now() });
return perms;
}
/** @deprecated use userPermissionsByRoleId */
export async function userPermissions(
tenantId: number | null,
roleCode: string,
): Promise<Set<string>> {
if (roleCode === "tenant_admin") return new Set(ALL_PERMISSION_CODES);
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
`SELECT rp.permission_code FROM role_permissions rp
JOIN roles r ON r.id = rp.role_id
WHERE r.code = ? AND (r.tenant_id = ? OR (r.is_system AND r.tenant_id IS NULL))`,
).all(roleCode, tenantId) as { permission_code: string }[],
);
return new Set(rows.map((r) => r.permission_code));
}
export async function hasPermission(user: AuthUser, code: string): Promise<boolean> {
if (user.realm === "platform") return true;
if (user.is_owner) return true;
const perms = await userPermissionsByRoleId(user.tenant_id, user.role_id, false);
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_code },
);
}
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 function filterSensitivePermissions(
permissions: string[],
actorIsOwner: boolean,
): string[] {
if (actorIsOwner) return permissions;
return permissions.filter(
(p) => !IAM_SENSITIVE_PREFIXES.some((prefix) => p.startsWith(prefix)),
);
}
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;
}
export async function usersWithRole(tenantId: number | null, roleId: number): Promise<number[]> {
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare("SELECT id FROM users WHERE role_id = ?").all(roleId) as { id: number }[],
);
return rows.map((r) => Number(r.id));
}
export async function revokeSessionsForRoleUsers(
tenantId: number | null,
roleId: number,
): Promise<void> {
const { revokeAllSessionsForUser } = await import("./sessions.ts");
const ids = await usersWithRole(tenantId, roleId);
await Promise.all(ids.map((id) => revokeAllSessionsForUser(id, "app")));
}