Атрибуция от бота в 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

@@ -0,0 +1,44 @@
import { storage } from '../storage';
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
import { isFormAllowedByScopes } from './api-key';
// Актор изменений для REST-хендлеров, поддерживающих API-ключ:
// JWT — текущий пользователь; ключ бота — бот (changedBy=null); ключ без бота — label ключа.
export interface RestActor {
changedBy: number | null;
changedByName: string;
botId: number | null;
source: 'api' | null; // 'api' при авторизации по ключу (идёт в metadata аудит-записей)
}
export async function resolveRestActor(req: AuthenticatedRequest, organizationId: number): Promise<RestActor> {
if (!req.apiKey) {
const name = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
return { changedBy: req.user?.id ?? null, changedByName: name, botId: null, source: null };
}
const bot = req.apiKey.botId
? await storage.getBot(req.apiKey.botId, organizationId).catch(() => null)
: null;
return {
changedBy: bot ? null : req.apiKey.createdBy,
changedByName: bot?.name ?? `Ключ «${req.apiKey.label}»`,
botId: bot?.id ?? null,
source: 'api',
};
}
// Проверки авторизации по API-ключу для write-endpoint'а (изменение данных формы/задачи).
// Возвращает текст ошибки для 403 или null, если доступ разрешён.
// Для JWT-запросов всегда null (их проверки — в хендлерах, как раньше).
export function checkApiKeyWriteAccess(req: AuthenticatedRequest, formId: number): string | null {
if (!req.apiKey) return null;
if (req.apiKey.scopes.mode === 'read') {
return 'API-ключ в режиме read не может изменять данные';
}
if (!isFormAllowedByScopes(req.apiKey.scopes, formId)) {
return 'Доступ к этой форме запрещён правами API-ключа';
}
return null;
}

View File

@@ -35,6 +35,16 @@ export function legacySha256Hash(raw: string): string {
// Полный доступ — используется как дефолт для legacy-ключей (scopes = NULL в БД)
export const FULL_API_KEY_SCOPES: ApiKeyScopes = { mode: 'full', formIds: null, tableIds: null };
// Проверка доступа к форме по скоупам ключа (null = все формы)
export function isFormAllowedByScopes(scopes: ApiKeyScopes, formId: number): boolean {
return scopes.formIds === null || scopes.formIds.includes(formId);
}
// Проверка доступа к справочнику по скоупам ключа (null = все справочники)
export function isTableAllowedByScopes(scopes: ApiKeyScopes, tableId: number): boolean {
return scopes.tableIds === null || scopes.tableIds.includes(tableId);
}
// Нормализация скоупов из БД: NULL/отсутствующий или частично заполненный объект
// приводится к полной структуре ApiKeyScopes (дефолты — полный доступ).
export function normalizeApiKeyScopes(scopes: unknown): ApiKeyScopes {

View File

@@ -53,10 +53,13 @@ export const EXT_MAGIC: Record<string, Buffer> = {
pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF
};
// Image extensions → 10 MB limit; all others → 50 MB
// Image extensions → лимит изображений; all others → документный лимит
// Лимиты настраиваются через env (в МБ), дефолты: 25 МБ изображения, 100 МБ документы
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
export const IMAGE_MAX_SIZE = 10 * 1024 * 1024; // 10 МБ
export const DOC_MAX_SIZE = 50 * 1024 * 1024; // 50 МБ
export const IMAGE_MAX_SIZE = Number(process.env.UPLOAD_IMAGE_MAX_MB || 25) * 1024 * 1024;
export const DOC_MAX_SIZE = Number(process.env.UPLOAD_DOC_MAX_MB || 100) * 1024 * 1024;
// Жёсткий потолок multer (fileSize) — отдельный env, дефолт 100 МБ
export const UPLOAD_MAX_SIZE = Number(process.env.UPLOAD_MAX_MB || 100) * 1024 * 1024;
// Derives the lowercase extension from the original filename (trusted)
export function getFileExt(originalname: string): string {
@@ -127,7 +130,7 @@ const multerStorage = multer.diskStorage({
export const upload = multer({
storage: multerStorage,
limits: { fileSize: DOC_MAX_SIZE }, // hard cap 50 МБ; per-type check done in handler
limits: { fileSize: UPLOAD_MAX_SIZE }, // жёсткий потолок (UPLOAD_MAX_MB); раздельная проверка по типам — в хендлере
fileFilter: (_req, file, cb) => {
// 1. Validate filename: strict regex + extension whitelist (extension is trusted)
const { valid, reason } = validateFilename(file.originalname);