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

@@ -2,8 +2,9 @@ import { Router } from "express";
import fs from 'fs/promises';
import multer from 'multer';
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { isS3Enabled, uploadToS3FromFile } from "../utils/s3";
import { isFormAllowedByScopes } from "../utils/api-key";
import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE,
getPendingKey, getActivePendingUploads, addPendingUpload,
} from "./shared";
@@ -11,8 +12,8 @@ import { db } from "../db";
import { fileUploads } from "@shared/schema";
const router = Router();
// File upload endpoint
router.post('/api/upload', authenticateToken, async (req: AuthenticatedRequest, res) => {
// File upload endpoint (JWT пользователя или API-ключ организации)
router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => {
try {
await new Promise<void>((resolve, reject) => {
upload.single('file')(req, res, (err: unknown) => {
@@ -32,6 +33,22 @@ const router = Router();
return res.status(400).json({ error: 'Файл не передан' });
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
if (req.apiKey) {
if (req.apiKey.scopes.mode === 'read') {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' });
}
const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN;
if (!isNaN(taskIdForScope)) {
const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!);
if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
}
}
}
// deleteNewFile — удаляем временный файл с диска если валидация не прошла
// Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки)
const deleteNewFile = async () => {
@@ -164,7 +181,9 @@ const router = Router();
}
// Записываем файл в таблицу трекинга для последующей проверки доступа
if (req.user?.id && req.organizationId) {
// Автор: пользователь (JWT) или владелец API-ключа; botId — атрибуция бота
const uploaderId = req.user?.id ?? req.apiKey?.createdBy;
if (uploaderId && req.organizationId) {
const fileKey = isS3Enabled
? url.replace('/api/files/', '')
: (req.file.filename ?? '');
@@ -174,12 +193,13 @@ const router = Router();
try {
await db.insert(fileUploads).values({
organizationId: req.organizationId,
uploadedBy: req.user.id,
uploadedBy: uploaderId,
fileKey,
originalName: name,
sizeBytes: req.file.size,
taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null,
fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null,
botId: req.apiKey?.botId ?? null,
}).onConflictDoNothing();
} catch (trackErr) {
console.warn('[Upload] Failed to track file upload:', trackErr);