Атрибуция от бота в 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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user