Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)

- 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
This commit is contained in:
2026-07-22 15:18:04 +03:00
parent 68153caef2
commit 8cfd49fd9f
13 changed files with 720 additions and 246 deletions

View File

@@ -4,6 +4,8 @@ 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;
@@ -11,6 +13,19 @@ export interface AuthenticatedRequest extends Request {
/** 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 {
@@ -111,6 +126,133 @@ export const authenticateToken = async (
}
};
// ── 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.