mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 21:43: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>
145 lines
4.5 KiB
TypeScript
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")));
|
|
}
|