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]; export type HostAction = SharePermissionLevel | "delete"; const LEVEL_RANK: Record = { 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; isAdminBypass?: 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); // Entries expire on read against their own timestamp, so this sweep only // has to drop ones nobody has come back for. Flushing the whole map on a // timer instead expired every active user at the same instant, so each // sweep was followed by a burst of simultaneous role lookups. setInterval(() => { this.evictExpiredPermissions(); }, this.CACHE_TTL); } static getInstance(): PermissionManager { if (!this.instance) { this.instance = new PermissionManager(); } return this.instance; } private async cleanupExpiredAccess(): Promise { try { await createCurrentRbacAccessRepository().deleteExpiredHostAccess(); } catch (error) { databaseLogger.error("Failed to cleanup expired host access", error, { operation: "host_access_cleanup_failed", }); } } private evictExpiredPermissions(): void { const now = Date.now(); for (const [userId, entry] of this.permissionCache) { if (now - entry.timestamp >= this.CACHE_TTL) { this.permissionCache.delete(userId); } } } invalidateUserPermissionCache(userId: string): void { this.permissionCache.delete(userId); } async getUserPermissions(userId: string): Promise { 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(); for (const record of userRoleRecords) { // A role can legitimately have no permissions column yet, and // JSON.parse(null) returns null rather than throwing, which used to // blow up the loop and leave the user with no permissions at all. if (!record.permissions) continue; try { const parsed = JSON.parse(record.permissions) as unknown; if (!Array.isArray(parsed)) { databaseLogger.warn("Role permissions are not a list", { operation: "get_user_permissions", userId, }); continue; } for (const perm of parsed) { if (typeof perm === "string") 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 { 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; } } // Checked last: it costs two queries and only matters on the deny path. return this.isAdmin(userId); } async canAccessHost( userId: string, hostId: number, action: HostAction = "connect", ): Promise { 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] ) { if (await this.isAdmin(userId)) { return this.adminBypassAccess(); } 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, }; } if (await this.isAdmin(userId)) { return this.adminBypassAccess(); } 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, }; } } /** * The subset of `hostIds` this user may reach, resolved in a fixed number of * queries instead of one call per host. * * canAccessHost costs between one and four queries, so filtering a list with * it is linear in host count, and the status poll does exactly that every * few seconds for the whole fleet. This answers the same question for many * hosts at once using the same three rules, in the same order: owner, then * an unexpired grant, then admin bypass. * * Deliberately limited to read-style checks. It does not touch grant * timestamps the way `canAccessHost(..., "connect")` does, because this is * used for visibility filtering rather than for opening a connection. */ async filterAccessibleHostIds( userId: string, hostIds: number[], ): Promise> { if (hostIds.length === 0) return new Set(); try { if (await this.isAdmin(userId)) { return new Set(hostIds); } const owned = await createCurrentHostResolutionRepository().listOwnedHostIds(userId); const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); const grants = await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( userId, roleIds, ); const granted = new Set(grants.map((grant) => grant.hostId)); return new Set(hostIds.filter((id) => owned.has(id) || granted.has(id))); } catch (error) { databaseLogger.error("Failed to filter accessible hosts", error, { operation: "filter_accessible_hosts", userId, }); // Fail closed: showing nothing is safer than showing another // tenant's hosts. return new Set(); } } // Admins get owner-equivalent access to every host; each connect is // audit-logged in the host resolver. private adminBypassAccess(): HostAccessInfo { return { hasAccess: true, isOwner: false, isShared: false, isAdminBypass: true, permissionLevel: "manage", }; } async isAdmin(userId: string): Promise { 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 }; export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult, SharePermissionLevel, };