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

@@ -1,7 +1,8 @@
import { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { authenticateToken, authenticateTokenOrApiKey, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
import { buildSystemFieldValues, buildTableRowMap, deleteRemovedFiles, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
import { evaluateAutoTransitions } from "../utils/auto-transitions";
import { tasksMinimalCache } from "../utils/cache";
@@ -63,7 +64,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
// Create/Update task field values (bulk)
router.post('/api/tasks/:id/field-values',
authenticateToken,
authenticateTokenOrApiKey,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
@@ -77,14 +78,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
if (req.user) {
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
taskId, req.user.id, req.organizationId!, req.user.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrPost = checkApiKeyWriteAccess(req, existingTask.formId);
if (apiKeyErrPost) {
return res.status(403).json({ success: false, error: apiKeyErrPost });
}
const { fieldValues } = req.body;
if (!Array.isArray(fieldValues)) {
@@ -226,7 +232,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
}
}
const fieldEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
const fieldActor = await resolveRestActor(req, req.organizationId!);
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
for (const fieldValue of fieldValues) {
const field = fieldMap.get(fieldValue.fieldId);
@@ -247,11 +253,14 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: newVal ?? null,
changedBy: req.user?.id ?? null,
changedByName: fieldEditorName,
changedBy: fieldActor.changedBy,
changedByName: fieldActor.changedByName,
botId: fieldActor.botId,
metadata: {
displayOldValue: displayOld || null,
displayNewValue: displayNew || null,
// Атрибуция канала: 'api' при изменении по API-ключу
...(fieldActor.source ? { source: fieldActor.source } : {}),
},
...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
@@ -365,7 +374,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
// Update single field value (inline editing)
router.patch('/api/tasks/:id/field-values/:fieldId',
authenticateToken,
authenticateTokenOrApiKey,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
@@ -380,14 +389,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
if (req.user) {
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
taskId, req.user.id, req.organizationId!, req.user.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrPatch = checkApiKeyWriteAccess(req, existingTask.formId);
if (apiKeyErrPatch) {
return res.status(403).json({ success: false, error: apiKeyErrPatch });
}
const { value } = req.body;
@@ -473,9 +487,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
});
}
const editorName = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
const patchActor = await resolveRestActor(req, req.organizationId!);
const offlineEnqueuedAtPatch = parseOfflineTimestamp(req);
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = normalizedValue === null || normalizedValue === undefined ? '' : String(normalizedValue);
@@ -488,8 +500,10 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: normalizedValue ?? null,
changedBy: req.user?.id ?? null,
changedByName: editorName,
changedBy: patchActor.changedBy,
changedByName: patchActor.changedByName,
botId: patchActor.botId,
...(patchActor.source ? { metadata: { source: patchActor.source } } : {}),
...(offlineEnqueuedAtPatch ? { createdAt: offlineEnqueuedAtPatch } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error (inline edit):', auditErr); });
}