- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status - sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user - authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api' - Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100 - MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
504 lines
20 KiB
TypeScript
504 lines
20 KiB
TypeScript
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';
|
||
import { normalizeApiKeyScopes } from '../utils/api-key';
|
||
import type { ApiKeyScopes } from '@shared/schema';
|
||
|
||
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;
|
||
/** Контекст API-ключа (authenticateTokenOrApiKey), когда запрос авторизован ключом, а не JWT.
|
||
* req.user при этом равен null. */
|
||
apiKey?: RequestApiKeyContext;
|
||
}
|
||
|
||
// Контекст авторизации по API-ключу организации
|
||
export interface RequestApiKeyContext {
|
||
id: number;
|
||
organizationId: number;
|
||
botId: number | null;
|
||
createdBy: number;
|
||
label: string;
|
||
scopes: ApiKeyScopes;
|
||
}
|
||
|
||
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: 'Недействительный токен' });
|
||
}
|
||
};
|
||
|
||
// ── API-ключи: белый список endpoint'ов, доступных по ключу (REST) ───────────
|
||
// Проверяется по originalUrl только когда запрос авторизован ключом (не JWT).
|
||
const API_KEY_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [
|
||
{ method: 'POST', pattern: /^\/api\/upload(\?|$)/ },
|
||
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/messages(\?|$)/ },
|
||
{ method: 'PATCH', pattern: /^\/api\/tasks\/\d+\/field-values\/\d+(\?|$)/ },
|
||
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/field-values(\?|$)/ },
|
||
{ method: 'POST', pattern: /^\/api\/forms\/\d+\/tasks(\?|$)/ },
|
||
];
|
||
|
||
// Разрешает запрос по JWT пользователя (поведение authenticateToken не меняется)
|
||
// либо по API-ключу организации (X-Api-Key или Authorization: Bearer <key>).
|
||
// При авторизации ключом: req.user = null, req.apiKey заполнен,
|
||
// req.organizationId = key.organizationId, tenant-контекст открывается так же,
|
||
// как в authenticateToken. Legacy/неактивные ключи отклоняются.
|
||
export const authenticateTokenOrApiKey = async (
|
||
req: AuthenticatedRequest,
|
||
res: Response,
|
||
next: NextFunction
|
||
) => {
|
||
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 apiKeyHeader = (req.headers['x-api-key'] as string | undefined)?.trim();
|
||
|
||
// 1. JWT пользователя (cookie или Bearer) — как в authenticateToken
|
||
const userJwt = cookieToken || (!apiKeyHeader ? headerToken : null);
|
||
if (userJwt) {
|
||
try {
|
||
const decoded = verifyAccessToken(userJwt);
|
||
// Токены ботов не принимаются (та же проверка, что в authenticateToken)
|
||
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));
|
||
return;
|
||
} catch {
|
||
// JWT невалиден — если Bearer-токен задан, пробуем его как API-ключ
|
||
if (!headerToken) {
|
||
return res.status(403).json({ error: 'Недействительный токен' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. API-ключ организации
|
||
const rawKey = apiKeyHeader || headerToken;
|
||
if (!rawKey) {
|
||
return res.status(401).json({ error: 'Токен доступа отсутствует' });
|
||
}
|
||
|
||
try {
|
||
// getApiKeyByHash сам фильтрует isActive и isLegacy=false
|
||
const key = await storage.getApiKeyByHash(rawKey);
|
||
if (!key || !key.isActive) {
|
||
return res.status(401).json({ error: 'Недействительный API-ключ' });
|
||
}
|
||
|
||
// Endpoint должен быть в белом списке для API-ключей
|
||
const allowed = API_KEY_ALLOWED_ROUTES.some(
|
||
(r) => r.method === req.method && r.pattern.test(req.originalUrl)
|
||
);
|
||
if (!allowed) {
|
||
return res.status(403).json({ error: 'API-ключ не поддерживается на этом ресурсе' });
|
||
}
|
||
|
||
storage.touchApiKey(key.id).catch(() => {});
|
||
|
||
req.apiKey = {
|
||
id: key.id,
|
||
organizationId: key.organizationId,
|
||
botId: key.botId ?? null,
|
||
createdBy: key.createdBy,
|
||
label: key.label,
|
||
scopes: normalizeApiKeyScopes(key.scopes),
|
||
};
|
||
req.user = null;
|
||
req.organizationId = key.organizationId;
|
||
|
||
if (_tenantCtx.getStore()) return next();
|
||
openTenantCtx(key.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(401).json({ error: 'Недействительный API-ключ' });
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 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<string, string[]> | null = null;
|
||
let _permissionCacheTs = 0;
|
||
const PERMISSION_CACHE_TTL = 60_000; // 1 minute
|
||
|
||
async function loadPermissionCache(): Promise<Map<string, string[]>> {
|
||
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<string, string[]>();
|
||
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<boolean> {
|
||
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<void> => {
|
||
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));
|
||
};
|