import type { Request, Response, NextFunction } from 'express'; import { verifyAccessToken, verifyBotServiceToken, verifySuperAdminToken } from '../utils/jwt'; import { storage } from '../storage'; import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db'; import { eq } from 'drizzle-orm'; import { trackUserActivity } from '../utils/userActivity'; export interface AuthenticatedRequest extends Request { user?: any; organizationId?: number; /** True when the request was authenticated with a bot-service JWT (type: 'bot_service'). * Routes should skip bot-trigger logic when this flag is set to prevent message loops. */ isBotToken?: boolean; } export interface SuperAdminRequest extends Request { superAdmin?: { id: number; email: string; name?: string }; } const BILLING_EXEMPT_PREFIXES = [ '/api/auth/', '/api/superadmin/', '/api/billing/', '/api/health', ]; // Validates Bearer JWT and populates req.user. After successful auth, opens a // per-request pg client with SET LOCAL app.current_org_id so all subsequent // db.* calls in the handler automatically use the tenant-scoped connection. // This covers every route that uses authenticateToken — no per-route wiring needed. export const authenticateToken = async ( req: AuthenticatedRequest, res: Response, next: NextFunction ) => { // Already authenticated as bot-service by tryBotServiceToken — skip user lookup. if (req.isBotToken) { return next(); } const authHeader = req.headers['authorization']; const headerToken = authHeader && authHeader.split(' ')[1]; const cookieToken = (req as any).cookies?.access_token; const token = cookieToken || headerToken; if (!token) { return res.status(401).json({ error: 'Токен доступа отсутствует' }); } try { const decoded = verifyAccessToken(token); // Токены ботов (старые с appRole='bot' или любые с type='bot*') не принимаются // на пользовательских ресурсах — это закрывает коллизию bot.id ↔ user.id. const payloadType = (decoded as { type?: string }).type; if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } // Use JWT organizationId to set tenant context for the users table lookup, // preventing auth failure when FORCE RLS is active on the users table. const user = await withTenant(decoded.organizationId, () => storage.getUserWithOrganization(decoded.userId) ); if (!user || !user.isActive) { return res.status(401).json({ error: 'Пользователь не найден или заблокирован' }); } if (user.organization && !user.organization.isActive) { return res.status(403).json({ error: 'Доступ организации заблокирован' }); } req.user = user; req.organizationId = user.organizationId; trackUserActivity(user.id); const isBillingExempt = BILLING_EXEMPT_PREFIXES.some(p => req.path.startsWith(p)); if (!isBillingExempt && user.organization?.billingBlocked) { return res.status(402).json({ error: 'Доступ приостановлен', blocked: true, reason: 'insufficient_balance', message: 'Баланс организации исчерпан. Обратитесь к администратору для пополнения.', }); } // Open a per-request tenant context if one is not already active. // SSE connections (text/event-stream) skip the long-lived transaction — // their individual DB calls use withTenant point-operations instead. if (_tenantCtx.getStore()) { return next(); } const isSSE = req.headers.accept?.includes('text/event-stream'); if (isSSE) { return next(); } openTenantCtx(user.organizationId) .then((handle) => { handle.run(() => { const guard = setTimeout(() => { console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`); handle.release(); }, 30_000); res.once('finish', () => { clearTimeout(guard); handle.release(); }); res.once('close', () => { clearTimeout(guard); handle.release(); }); next(); }); }) .catch((err) => next(err as Error)); } catch { return res.status(403).json({ error: 'Недействительный токен' }); } }; /** * Optional pre-middleware: detects a bot-service JWT (type: 'bot_service') and sets * req.isBotToken = true when found. authenticateToken then skips its user-lookup step. * * Apply only to routes that must accept both user tokens and bot service tokens, e.g.: * router.post('/…', tryBotServiceToken, authenticateToken, handler) * * Routes that only ever handle human users must NOT use this middleware, preserving * the invariant that req.user is always set after authenticateToken. */ export const tryBotServiceToken = ( req: AuthenticatedRequest, _res: Response, next: NextFunction ): void => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (token) { try { const botDecoded = verifyBotServiceToken(token); req.isBotToken = true; req.organizationId = botDecoded.organizationId; } catch { // Not a bot-service token — authenticateToken will handle it normally. } } next(); }; // Like authenticateToken but also accepts ?token= for file-download routes. export const authenticateFileToken = async ( req: AuthenticatedRequest, res: Response, next: NextFunction ) => { const authHeader = req.headers['authorization']; const headerToken = authHeader && authHeader.split(' ')[1]; const queryToken = typeof req.query.token === 'string' ? req.query.token : null; const cookieToken = (req as any).cookies?.access_token; const token = cookieToken || headerToken || queryToken; if (!token) { return res.status(401).json({ error: 'Токен доступа отсутствует' }); } try { const decoded = verifyAccessToken(token); // Токены ботов не принимаются и на файловых ресурсах (та же коллизия id) const payloadType = (decoded as { type?: string }).type; if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) { return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' }); } const user = await withTenant(decoded.organizationId, () => storage.getUserWithOrganization(decoded.userId) ); if (!user || !user.isActive) { return res.status(401).json({ error: 'Пользователь не найден или заблокирован' }); } if (user.organization && !user.organization.isActive) { return res.status(403).json({ error: 'Доступ организации заблокирован' }); } req.user = user; req.organizationId = user.organizationId; trackUserActivity(user.id); if (_tenantCtx.getStore()) return next(); openTenantCtx(user.organizationId) .then((handle) => { handle.run(() => { const guard = setTimeout(() => { console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`); handle.release(); }, 30_000); res.once('finish', () => { clearTimeout(guard); handle.release(); }); res.once('close', () => { clearTimeout(guard); handle.release(); }); next(); }); }) .catch((err) => next(err as Error)); } catch { return res.status(403).json({ error: 'Недействительный токен' }); } }; /** * @deprecated Use requirePermission(...) instead. * Kept for transitional compatibility during the role refactor. */ export const requireAdmin = ( req: AuthenticatedRequest, res: Response, next: NextFunction ) => { const role = req.user?.appRole; if (!req.user || role !== 'admin') { return res.status(403).json({ error: 'Требуются права администратора' }); } next(); }; // Permission cache (appRole slug -> string[] of permission codes) let _permissionCache: Map | null = null; let _permissionCacheTs = 0; const PERMISSION_CACHE_TTL = 60_000; // 1 minute async function loadPermissionCache(): Promise> { const now = Date.now(); if (_permissionCache && (now - _permissionCacheTs) < PERMISSION_CACHE_TTL) { return _permissionCache; } const { db } = await import('../db'); const { appRoles: arTable, permissions: pTable, appRolePermissions: arpTable } = await import('@shared/schema'); const rows = await db.select({ appRoleSlug: arTable.slug, permissionCode: pTable.code, }).from(arpTable) .innerJoin(arTable, eq(arpTable.appRoleId, arTable.id)) .innerJoin(pTable, eq(arpTable.permissionId, pTable.id)); const map = new Map(); for (const r of rows) { if (!map.has(r.appRoleSlug)) map.set(r.appRoleSlug, []); map.get(r.appRoleSlug)!.push(r.permissionCode); } _permissionCache = map; _permissionCacheTs = now; return map; } export async function hasAppRolePermission(appRole: string, ...codes: string[]): Promise { const cache = await loadPermissionCache(); const perms = cache.get(appRole) || []; return codes.some(c => perms.includes(c)); } export const requirePermission = (...codes: string[]) => { return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { if (!req.user) { return res.status(403).json({ error: 'Нет доступа' }); } const role = req.user.appRole; if (!role) { return res.status(403).json({ error: 'Нет доступа' }); } const has = await hasAppRolePermission(role, ...codes); if (!has) { return res.status(403).json({ error: 'Недостаточно прав' }); } next(); }; }; /** * Requires either a global app-role permission OR admin-level access to the * form identified by `formIdParam` (author or formAccessRules.accessLevel === 'admin'). * Use this for mutating form endpoints so that form admins can manage their own * forms without needing the global `forms.manage` permission. */ export const requireFormAdminOrPermission = (permissionCode: string, formIdParam = 'id') => { return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { if (!req.user) { return res.status(403).json({ error: 'Нет доступа' }); } const role = req.user.appRole; if (!role) { return res.status(403).json({ error: 'Нет доступа' }); } const hasGlobal = await hasAppRolePermission(role, permissionCode); if (hasGlobal) { return next(); } const rawFormId = req.params[formIdParam]; const formId = typeof rawFormId === 'string' ? parseInt(rawFormId, 10) : NaN; if (!isNaN(formId) && req.organizationId) { const isFormAdmin = await storage.canUserAccessForm(req.user.id, formId, req.organizationId, 'admin'); if (isFormAdmin) { return next(); } } return res.status(403).json({ error: 'Недостаточно прав' }); }; }; export const requireActiveUser = ( req: AuthenticatedRequest, res: Response, next: NextFunction ) => { if (!req.user || !req.user.isActive) { return res.status(403).json({ error: 'Аккаунт заблокирован' }); } next(); }; // Validates superadmin JWT and opens a per-request SA-scoped connection // (SET LOCAL app.is_superadmin='true') so all storage queries in any // superadmin route automatically bypass tenant RLS. export const requireSuperAdmin = async ( req: SuperAdminRequest, res: Response, next: NextFunction ): Promise => { const authHeader = req.headers['authorization']; const headerToken = authHeader && authHeader.split(' ')[1]; const cookieToken = (req as any).cookies?.superadmin_token; const token = headerToken || cookieToken; if (!token) { res.status(401).json({ error: 'Токен суперадмина отсутствует' }); return; } try { const payload = verifySuperAdminToken(token); const admin = await storage.getSuperAdminById(payload.superAdminId); if (!admin) { res.status(403).json({ error: 'Суперадмин не найден или был удалён' }); return; } req.superAdmin = { id: admin.id, email: admin.email }; } catch { res.status(403).json({ error: 'Недействительный токен суперадмина' }); return; } if (req.headers.accept?.includes('text/event-stream')) { next(); return; } openSuperAdminCtx() .then((handle) => { handle.run(() => { const guard = setTimeout(() => { console.warn(`[POOL] Force-releasing superadmin connection after 30s timeout (${req.method} ${req.path})`); handle.release(); }, 30_000); res.once('finish', () => { clearTimeout(guard); handle.release(); }); res.once('close', () => { clearTimeout(guard); handle.release(); }); next(); }); }) .catch((err) => next(err as Error)); };