Files
Termix/src/backend/utils/permission-manager.ts
T

401 lines
9.6 KiB
TypeScript

import type { Request, Response, NextFunction } from "express";
import {
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentUserRepository,
createCurrentHostResolutionRepository,
} from "../database/repositories/factory.js";
import { databaseLogger } from "./logger.js";
interface AuthenticatedRequest extends Request {
userId?: string;
dataKey?: Buffer;
}
const SHARE_PERMISSION_LEVELS = ["connect", "view", "edit", "manage"] as const;
type SharePermissionLevel = (typeof SHARE_PERMISSION_LEVELS)[number];
type HostAction = SharePermissionLevel | "delete";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
view: 2,
edit: 3,
manage: 4,
};
function normalizeSharePermissionLevel(
level: string | null | undefined,
): SharePermissionLevel {
return SHARE_PERMISSION_LEVELS.includes(level as SharePermissionLevel)
? (level as SharePermissionLevel)
: "connect";
}
interface HostAccessInfo {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: SharePermissionLevel;
expiresAt?: string | null;
}
interface PermissionCheckResult {
allowed: boolean;
reason?: string;
}
class PermissionManager {
private static instance: PermissionManager;
private permissionCache: Map<
string,
{ permissions: string[]; timestamp: number }
>;
private readonly CACHE_TTL = 5 * 60 * 1000;
private constructor() {
this.permissionCache = new Map();
setInterval(() => {
this.cleanupExpiredAccess().catch((error) => {
databaseLogger.error(
"Failed to run periodic host access cleanup",
error,
{
operation: "host_access_cleanup_periodic",
},
);
});
}, 60 * 1000);
setInterval(() => {
this.clearPermissionCache();
}, this.CACHE_TTL);
}
static getInstance(): PermissionManager {
if (!this.instance) {
this.instance = new PermissionManager();
}
return this.instance;
}
private async cleanupExpiredAccess(): Promise<void> {
try {
await createCurrentRbacAccessRepository().deleteExpiredHostAccess();
} catch (error) {
databaseLogger.error("Failed to cleanup expired host access", error, {
operation: "host_access_cleanup_failed",
});
}
}
private clearPermissionCache(): void {
this.permissionCache.clear();
}
invalidateUserPermissionCache(userId: string): void {
this.permissionCache.delete(userId);
}
async getUserPermissions(userId: string): Promise<string[]> {
const cached = this.permissionCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.permissions;
}
try {
const userRoleRecords =
await createCurrentRoleRepository().listUserRolePermissions(userId);
const allPermissions = new Set<string>();
for (const record of userRoleRecords) {
try {
const permissions = JSON.parse(record.permissions) as string[];
for (const perm of permissions) {
allPermissions.add(perm);
}
} catch (parseError) {
databaseLogger.warn("Failed to parse role permissions", {
operation: "get_user_permissions",
userId,
error: parseError,
});
}
}
const permissionsArray = Array.from(allPermissions);
this.permissionCache.set(userId, {
permissions: permissionsArray,
timestamp: Date.now(),
});
return permissionsArray;
} catch (error) {
databaseLogger.error("Failed to get user permissions", error, {
operation: "get_user_permissions",
userId,
});
return [];
}
}
async hasPermission(userId: string, permission: string): Promise<boolean> {
const userPermissions = await this.getUserPermissions(userId);
if (userPermissions.includes("*")) {
return true;
}
if (userPermissions.includes(permission)) {
return true;
}
const parts = permission.split(".");
for (let i = parts.length; i > 0; i--) {
const wildcardPermission = parts.slice(0, i).join(".") + ".*";
if (userPermissions.includes(wildcardPermission)) {
return true;
}
}
return false;
}
async canAccessHost(
userId: string,
hostId: number,
action: HostAction = "connect",
): Promise<HostAccessInfo> {
try {
const hostResolutionRepository = createCurrentHostResolutionRepository();
if (await hostResolutionRepository.isHostOwnedByUser(hostId, userId)) {
return {
hasAccess: true,
isOwner: true,
isShared: false,
};
}
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const access =
await createCurrentRbacAccessRepository().findActiveHostAccess(
hostId,
userId,
roleIds,
);
if (access) {
const ownerId = await hostResolutionRepository.findHostOwnerId(hostId);
if (ownerId === userId) {
return {
hasAccess: true,
isOwner: true,
isShared: false,
};
}
const grantedLevel = normalizeSharePermissionLevel(
access.permissionLevel,
);
if (
action === "delete" ||
LEVEL_RANK[grantedLevel] < LEVEL_RANK[action]
) {
return {
hasAccess: false,
isOwner: false,
isShared: true,
permissionLevel: grantedLevel,
expiresAt: access.expiresAt,
};
}
if (action === "connect") {
try {
await createCurrentRbacAccessRepository().touchHostAccess(
access.id,
);
} catch (error) {
databaseLogger.warn("Failed to update host access timestamp", {
operation: "update_host_access_timestamp",
error,
});
}
}
return {
hasAccess: true,
isOwner: false,
isShared: true,
permissionLevel: grantedLevel,
expiresAt: access.expiresAt,
};
}
return {
hasAccess: false,
isOwner: false,
isShared: false,
};
} catch (error) {
databaseLogger.error("Failed to check host access", error, {
operation: "can_access_host",
userId,
hostId,
action,
});
return {
hasAccess: false,
isOwner: false,
isShared: false,
};
}
}
async isAdmin(userId: string): Promise<boolean> {
try {
const user = await createCurrentUserRepository().findById(userId);
if (user?.isAdmin) {
return true;
}
return createCurrentRoleRepository().userHasAnyRoleName(userId, [
"admin",
"super_admin",
]);
} catch (error) {
databaseLogger.error("Failed to check admin status", error, {
operation: "is_admin",
userId,
});
return false;
}
}
requirePermission(permission: string) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const hasPermission = await this.hasPermission(userId, permission);
if (!hasPermission) {
databaseLogger.warn("Permission denied", {
operation: "permission_check",
userId,
permission,
path: req.path,
});
return res.status(403).json({
error: "Insufficient permissions",
required: permission,
});
}
next();
};
}
requireHostAccess(
hostIdParam: string = "id",
action: HostAction = "connect",
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const hostIdValue = Array.isArray(req.params[hostIdParam])
? req.params[hostIdParam][0]
: req.params[hostIdParam];
const hostId = parseInt(hostIdValue, 10);
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const accessInfo = await this.canAccessHost(userId, hostId, action);
if (!accessInfo.hasAccess) {
databaseLogger.warn("Host access denied", {
operation: "host_access_check",
userId,
hostId,
action,
});
return res.status(403).json({
error: "Access denied to host",
hostId,
action,
});
}
(req as unknown as { hostAccessInfo: HostAccessInfo }).hostAccessInfo =
accessInfo;
next();
};
}
requireAdmin() {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const isAdmin = await this.isAdmin(userId);
if (!isAdmin) {
databaseLogger.warn("Admin access denied", {
operation: "admin_check",
userId,
path: req.path,
});
return res.status(403).json({ error: "Admin access required" });
}
next();
};
}
}
export { PermissionManager, SHARE_PERMISSION_LEVELS, LEVEL_RANK };
export type {
AuthenticatedRequest,
HostAccessInfo,
PermissionCheckResult,
SharePermissionLevel,
HostAction,
};