From 8cfd49fd9fec2569bc501292a9bc82665ec60e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Jul 2026 15:18:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=82=D1=80=D0=B8=D0=B1=D1=83=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D1=82=20=D0=B1=D0=BE=D1=82=D0=B0=20=D0=B2?= =?UTF-8?q?=20MCP/REST=20+=20REST=20API=20=D0=BF=D0=BE=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D1=83=20(=D1=8D=D1=82=D0=B0=D0=BF=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 12 + server/mcp.ts | 494 ++++++++++++++++-------- server/middleware/auth.middleware.ts | 142 +++++++ server/routes/chat.messages.routes.ts | 39 +- server/routes/files.routes.ts | 30 +- server/routes/task-crud-write.routes.ts | 35 +- server/routes/task-fields.routes.ts | 44 ++- server/routes/task.routes.ts | 8 +- server/services/file-upload.service.ts | 4 +- server/services/task-message.service.ts | 93 +++-- server/utils/api-key-actor.ts | 44 +++ server/utils/api-key.ts | 10 + server/utils/upload.ts | 11 +- 13 files changed, 720 insertions(+), 246 deletions(-) create mode 100644 server/utils/api-key-actor.ts diff --git a/.env.example b/.env.example index 7d99dfe..8b804cf 100644 --- a/.env.example +++ b/.env.example @@ -130,3 +130,15 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require # DOC_WORKER_URL=http://document-worker:3000 # For local development without Docker, you can point to an external service: # DOC_WORKER_URL=http://localhost:3000 + +# ============================================= +# File upload limits (optional, in megabytes) +# ============================================= +# Per-type limits (extension-based, enforced after upload): +# UPLOAD_IMAGE_MAX_MB=25 +# UPLOAD_DOC_MAX_MB=100 +# Hard cap for the multipart parser (multer fileSize): +# UPLOAD_MAX_MB=100 +# Bot login token TTL (access / refresh) — см. этап bot API keys: +# JWT_BOT_LOGIN_EXPIRES=30d +# JWT_BOT_LOGIN_REFRESH_EXPIRES=90d diff --git a/server/mcp.ts b/server/mcp.ts index cda971c..19bb867 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -33,8 +33,8 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri } } import type { Request, Response } from "express"; -import type { Task, ApiKeyScopes } from "@shared/schema"; -import { normalizeApiKeyScopes } from "./utils/api-key"; +import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema"; +import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key"; import beautify from "js-beautify"; import { semanticSearch, @@ -147,6 +147,7 @@ const READ_TOOLS: readonly string[] = [ 'get_task_tree', 'get_user_field_values', 'dadata_suggest', + 'get_api_guide', ]; // WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных. @@ -171,10 +172,12 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [ // Все остальные инструменты (изменение/удаление форм, задач, пользователей, // автоматизаций, таб-модулей, страниц, переиндексация) — только режим full. -// Результат разрешения API-ключа: организация + нормализованные скоупы доступа. +// Результат разрешения API-ключа: организация + нормализованные скоупы доступа +// + полная запись ключа (botId, createdBy, label) для атрибуции изменений. export interface ResolvedApiKey { organizationId: number; scopes: ApiKeyScopes; + key: OrganizationApiKey; } // Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы @@ -199,7 +202,7 @@ async function resolveApiKey(req: Request): Promise { } if (!apiKey.isActive) return null; storage.touchApiKey(apiKey.id).catch(() => {}); - return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes) }; + return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes), key: apiKey }; } function taskToJson(t: Task) { @@ -216,7 +219,7 @@ function taskToJson(t: Task) { }; } -function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer { +function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyRecord: OrganizationApiKey | null): McpServer { const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" }); // ── Фильтрация инструментов по режиму ключа (scopes.mode) ───────────────── @@ -239,12 +242,107 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer }) as typeof server.registerTool; // ── Объектный доступ по scopes.formIds ──────────────────────────────────── - const isFormAllowed = (formId: number) => scopes.formIds === null || scopes.formIds.includes(formId); + const isFormAllowed = (formId: number) => isFormAllowedByScopes(scopes, formId); const formDenied = (formId: number) => ({ content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }], isError: true, }); + // ── Актор изменений по API-ключу ─────────────────────────────────────────── + // user — владелец ключа (fallback: первый админ) для notNull FK-колонок; + // bot — бот ключа (если привязан); displayName — имя для аудита + // (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек). + interface McpActor { + user: User; + bot: Bot | null; + displayName: string; + } + let actorPromise: Promise | null = null; + const getActor = (): Promise => { + if (!actorPromise) { + actorPromise = (async () => { + let user: User | null | undefined = apiKeyRecord?.createdBy + ? await storage.getUser(apiKeyRecord.createdBy).catch(() => null) + : null; + if (!user || user.organizationId !== organizationId) { + const orgUsers = await storage.getUsersByOrganization(organizationId); + user = orgUsers.find((u) => u.appRole === 'admin') ?? orgUsers[0] ?? null; + } + if (!user) throw new Error('В организации нет пользователей'); + const bot = apiKeyRecord?.botId + ? await storage.getBot(apiKeyRecord.botId, organizationId).catch(() => null) ?? null + : null; + const displayName = bot?.name + ?? (apiKeyRecord ? `Ключ «${apiKeyRecord.label}»` : null) + ?? (`${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || 'MCP'); + return { user, bot, displayName }; + })(); + } + return actorPromise; + }; + + // Поля аудит-записи от актора: для бота changedBy=null и botId выставлен, + // канал фиксируется в metadata.source='mcp'. + const actorAudit = (actor: McpActor) => ({ + changedBy: actor.bot ? null : actor.user.id, + changedByName: actor.displayName, + botId: actor.bot?.id ?? null, + }); + + // Источник файла для upload-инструментов: base64 (загрузка в хранилище) + // или fileUrl (уже загруженный файл — только привязка, без повторной загрузки). + // Ровно один источник обязателен. + const resolveUploadSource = async (args: { + fileName?: string; + contentBase64?: string; + fileUrl?: string; + fileSize?: number; + mimeType?: string; + taskId?: number | null; + fieldId?: number | null; + }): Promise< + | { error: string } + | { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } } + > => { + const hasBase64 = !!args.contentBase64; + const hasUrl = !!args.fileUrl; + if (hasBase64 === hasUrl) { + return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' }; + } + const actor = await getActor(); + if (hasUrl) { + const url = args.fileUrl!; + if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) { + return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' }; + } + const name = args.fileName || url.split('/').pop() || 'file'; + return { + actor, + file: { + key: url.replace(/^\/api\/files\/|^\/uploads\//, ''), + url, + name, + size: args.fileSize ?? 0, + mimeType: args.mimeType ?? 'application/octet-stream', + }, + }; + } + if (!args.fileName) { + return { error: 'fileName обязателен при загрузке через contentBase64' }; + } + const file = await uploadFileFromBase64({ + organizationId, + userId: actor.user.id, + fileName: args.fileName, + contentBase64: args.contentBase64!, + mimeType: args.mimeType, + taskId: args.taskId, + fieldId: args.fieldId, + botId: actor.bot?.id ?? null, + }); + return { actor, file }; + }; + // ── Объектный доступ по scopes.tableIds (справочники) ───────────────────── const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId); const tableDenied = (tableId: number) => ({ @@ -494,11 +592,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer } } - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) { - return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true }; - } + const actor = await getActor(); let parsedDueDate: Date | null = null; if (due_date) { @@ -513,7 +607,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer title, formId: form_id, organizationId, - createdBy: adminUser.id, + createdBy: actor.user.id, assignedTo: assigned_to ?? null, currentStatusId: resolvedStatusId, description: description ?? null, @@ -523,14 +617,12 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer parentTaskId: null, }); - const creatorName = `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP"; storage.addTaskAuditLog({ taskId: task.id, organizationId, action: "task.created", - changedBy: adminUser.id, - changedByName: creatorName, - metadata: { title: task.title }, + ...actorAudit(actor), + metadata: { title: task.title, source: 'mcp' }, }).catch((e: unknown) => { console.error("Audit log error (MCP create_task):", e); }); if (field_values && typeof field_values === "object") { @@ -593,6 +685,23 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer isCompleted: isFinalStatus, completedAt: isFinalStatus ? (task.completedAt ?? new Date()) : null, }); + + // Аудит смены статуса с актором ключа (как REST: action 'status.changed') + const oldStatus = statuses.find((s) => s.id === task.currentStatusId); + if (task.currentStatusId !== status_id) { + const actor = await getActor(); + storage.addTaskAuditLog({ + taskId: task_id, + organizationId, + action: 'status.changed', + fieldName: 'Статус', + oldValue: oldStatus?.name ?? String(task.currentStatusId), + newValue: validStatus.name, + ...actorAudit(actor), + metadata: { source: 'mcp' }, + }).catch((e: unknown) => { console.error("Audit log error (MCP update_task_status):", e); }); + } + return { content: [ { @@ -655,6 +764,49 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer } const updated = await storage.updateTask(task_id, organizationId, updates); + + // Аудит изменённых полей с актором ключа (как REST PUT /api/tasks/:id) + { + const actor = await getActor(); + const resolveUserName = async (userId: number | null | undefined): Promise => { + if (!userId) return null; + const u = await storage.getUser(userId).catch(() => null); + return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(userId); + }; + const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [ + { key: 'title', label: 'Заголовок' }, + { key: 'description', label: 'Описание' }, + { key: 'assignedTo', label: 'Исполнитель' }, + { key: 'dueDate', label: 'Срок' }, + ]; + for (const { key, label } of trackedFields) { + if (!(key in updates)) continue; + const oldVal = task[key]; + const newVal = updates[key]; + const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); + const newStr = newVal === null || newVal === undefined ? '' : String(newVal); + if (oldStr === newStr) continue; + let auditOldValue: string | null = oldVal === null || oldVal === undefined ? null : String(oldVal); + let auditNewValue: string | null = newVal === null || newVal === undefined ? null : String(newVal); + if (key === 'assignedTo') { + [auditOldValue, auditNewValue] = await Promise.all([ + resolveUserName(oldVal as number | null), + resolveUserName(newVal as number | null), + ]); + } + storage.addTaskAuditLog({ + taskId: task_id, + organizationId, + action: 'task.updated', + fieldName: label, + oldValue: auditOldValue, + newValue: auditNewValue, + ...actorAudit(actor), + metadata: { source: 'mcp' }, + }).catch((e: unknown) => { console.error("Audit log error (MCP update_task):", e); }); + } + } + return { content: [ { @@ -823,17 +975,13 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer }, }, async ({ name, description }) => { - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) { - return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true }; - } + const actor = await getActor(); const form = await storage.createForm({ organizationId, name, description: description ?? null, - createdBy: adminUser.id, + createdBy: actor.user.id, isActive: true, chatEnabled: true, chatLayout: "default", @@ -1103,11 +1251,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer if (existing) { return { content: [{ type: "text" as const, text: `A module with type "${type}" already exists (id=${existing.id})` }], isError: true }; } - const adminUsers = await storage.getUsersByOrganization(organizationId); - const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id; - if (!createdBy) { - return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true }; - } + const createdBy = (await getActor()).user.id; const mod = await storage.createCustomTabModule({ type, label, @@ -1179,11 +1323,7 @@ RULES for tab component code: isError: true, }; } - const adminUsers = await storage.getUsersByOrganization(organizationId); - const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id; - if (!createdBy) { - return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true }; - } + const createdBy = (await getActor()).user.id; const mod = await storage.createCustomTabModule({ type, label, @@ -1560,11 +1700,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: if (existing) { return { content: [{ type: "text" as const, text: `A page with slug "${slug}" already exists` }], isError: true }; } - const adminUsers = await storage.getUsersByOrganization(organizationId); - const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id; - if (!createdBy) { - return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true }; - } + const createdBy = (await getActor()).user.id; const createWarnings = validatePageCode(code); const formattedCode = formatPageCode(code); @@ -2112,10 +2248,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, }, async ({ name, code, trigger, description, trigger_config, is_active, run_offline, client_compatible }) => { - const users = await storage.getUsersByOrganization(organizationId); - const adminUser = users.find(u => u.appRole === 'admin'); - const createdBy = adminUser?.id; - if (!createdBy) return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true }; + const createdBy = (await getActor()).user.id; const item = await storage.createAutomation({ organizationId, name, @@ -2596,11 +2729,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }; } - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) { - return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true }; - } + const actor = await getActor(); const field = await storage.createFieldTemplate({ code, @@ -2613,7 +2742,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false defaultValue: default_value ?? null, validationRules: null, organizationId, - createdBy: adminUser.id, + createdBy: actor.user.id, }); return { @@ -2826,24 +2955,16 @@ To block task creation from task.before_create, set: ctx.result = { allow: false resolvedTabId = found.id; } - const adminUsers = await storage.getUsersByOrganization(organizationId); - const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id; - if (!createdBy) { - return { content: [{ type: "text" as const, text: "No user found to assign createdBy" }], isError: true }; - } + const actor = await getActor(); const row = await storage.createRegularTableRow({ taskId: task_id, tabId: resolvedTabId, data: data ?? {}, - createdBy, + createdBy: actor.user.id, }); const tab = await storage.getFormTab(resolvedTabId, task.formId, organizationId); - const adminUser = adminUsers.find((u) => u.id === createdBy); - const editorName = adminUser - ? `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP" - : "MCP"; const persistedData = row.data && typeof row.data === "object" && Object.keys(row.data).length > 0 ? row.data : null; storage.addTaskAuditLog({ taskId: task_id, @@ -2852,8 +2973,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false fieldName: `Таблица «${tab?.name || resolvedTabId}»: добавлена строка`, oldValue: null, newValue: persistedData, - changedBy: createdBy, - changedByName: editorName, + ...actorAudit(actor), + metadata: { source: 'mcp' }, }).catch((e: unknown) => { console.error("Audit log error (MCP append_table_row):", e); }); return { @@ -3320,18 +3441,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, }, async ({ name, description, columns }) => { - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) { - return directoryError("В организации нет пользователей для назначения createdBy"); - } + const actor = await getActor(); const table = await storage.createDataTable({ name, description: description ?? null, columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })), organizationId, - createdBy: adminUser.id, + createdBy: actor.user.id, }); return { content: [{ @@ -3640,14 +3757,13 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!isFormAllowed(task.formId)) return formDenied(task.formId); if (!content.trim()) return mcpError("Текст сообщения обязателен"); - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей для авторства сообщения"); + const actor = await getActor(); try { const created = await sendTaskMessage({ task, - user: adminUser, + user: actor.bot ? undefined : actor.user, + botId: actor.bot?.id ?? null, organizationId, message: content, }); @@ -3737,7 +3853,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`); } - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + const actor = await getActor(); // Полная замена списка: удаляем лишних, добавляем недостающих const current = await storage.getTaskAssignees(taskId, organizationId); @@ -3755,11 +3871,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false const newAssignedTo = uniqueIds[0] ?? null; await storage.updateTask(taskId, organizationId, { assignedTo: newAssignedTo }); tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); - const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser?.id ?? null }); + const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id }); - const editorName = adminUser - ? (`${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP') - : 'MCP'; const names = uniqueIds .map((id) => { const u = orgUsers.find((x) => x.id === id); @@ -3773,15 +3886,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false fieldName: 'Ответственные обновлены', oldValue: null, newValue: names || '(пусто)', - changedBy: adminUser?.id ?? null, - changedByName: editorName, + ...actorAudit(actor), + metadata: { source: 'mcp' }, }).catch((e: unknown) => { console.error('Audit log error (MCP set_task_assignees):', e); }); const refreshedTask = await storage.getTask(taskId, organizationId); // Уведомление новому основному ответственному (если сменился) if (refreshedTask && newAssignedTo && newAssignedTo !== task.assignedTo) { - notifyTaskAssigned(refreshedTask, newAssignedTo, adminUser?.id ?? null, organizationId) + notifyTaskAssigned(refreshedTask, newAssignedTo, actor.user.id, organizationId) .catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err)); } @@ -3868,9 +3981,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей для генерации документа"); + const actor = await getActor(); // Сервис генерации собирается так же, как в server/documents/routes.ts const templateService = new DocumentTemplateService(); @@ -3883,7 +3994,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false templateId, taskId, organizationId, - userId: adminUser.id, + userId: actor.user.id, outputFormat: format, }); return { @@ -4094,13 +4205,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!recipient) { return mcpError(`Пользователь ${userId} не принадлежит организации`); } - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); + const actor = await getActor(); const reminder = await storage.createTaskReminder({ taskId, organizationId, - createdByUserId: adminUser.id, + createdByUserId: actor.user.id, remindAt: remindAtDate, note: message ?? null, recipients: [{ type: "user", userId }], @@ -4740,16 +4850,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, }, async ({ fileName, contentBase64, mimeType }) => { - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); + const actor = await getActor(); try { const file = await uploadFileFromBase64({ organizationId, - userId: adminUser.id, + userId: actor.user.id, fileName, contentBase64, mimeType, + botId: actor.bot?.id ?? null, }); return { content: [{ @@ -4771,18 +4880,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Task Field File", description: - "Upload a file (base64) and APPEND it to a file-type form field of a task. " + + "Upload a file and APPEND it to a file-type form field of a task. " + + "Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " + "File fields are multiple: the value is an array of {url, name, size}. " + "Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.", inputSchema: { taskId: z.number().int().describe("The numeric ID of the task"), fieldId: z.number().int().describe("The numeric ID of the file-type form field"), - fileName: z.string().min(1).describe("Original file name with extension"), - contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64; for fileUrl defaults to the URL basename)"), + contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), + fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => { + async ({ taskId, fieldId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); @@ -4794,20 +4906,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`); } - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); - try { - const file = await uploadFileFromBase64({ - organizationId, - userId: adminUser.id, - fileName, - contentBase64, - mimeType, - taskId, - fieldId, - }); + const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId, fieldId }); + if ('error' in source) return mcpError(source.error); + const { actor, file } = source; // File-поля множественные: значение = массив {url, name, size} — добавляем файл const existingValues = await storage.getTaskFieldValues(taskId, organizationId); @@ -4832,7 +4934,6 @@ To block task creation from task.before_create, set: ctx.result = { allow: false await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles }); } - const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP'; storage.addTaskAuditLog({ taskId, organizationId, @@ -4841,12 +4942,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false fieldName: field.name, oldValue: existingValue?.value ?? null, newValue: newFiles, - changedBy: adminUser.id, - changedByName: editorName, + ...actorAudit(actor), + metadata: { source: 'mcp' }, }).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); }); tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); - const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id }); + const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id }); indexTaskAsync(taskId, organizationId).catch(() => {}); const freshTask = await storage.getTask(taskId, organizationId); eventBus.publishEvent({ @@ -4879,39 +4980,34 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Message Attachment", description: - "Upload a file (base64) and post it as a task comment attachment. " + + "Upload a file and post it as a task comment attachment. " + + "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "If content is omitted, the message text is generated as '📎 '. " + "Triggers the same side effects as send_task_message (notifications, SSE, webhooks).", inputSchema: { taskId: z.number().int().describe("The numeric ID of the task"), - fileName: z.string().min(1).describe("Original file name with extension"), - contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), + contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), + fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), content: z.string().optional().describe("Comment text (optional; default '📎 ')"), }, }, - async ({ taskId, fileName, contentBase64, mimeType, content }) => { + async ({ taskId, fileName, contentBase64, fileUrl, fileSize, mimeType, content }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); - try { - const file = await uploadFileFromBase64({ - organizationId, - userId: adminUser.id, - fileName, - contentBase64, - mimeType, - taskId, - }); + const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId }); + if ('error' in source) return mcpError(source.error); + const { actor, file } = source; const created = await sendTaskMessage({ task, - user: adminUser, + user: actor.bot ? undefined : actor.user, + botId: actor.bot?.id ?? null, organizationId, message: content?.trim() || `📎 ${file.name}`, attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }], @@ -4942,18 +5038,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Directory Cell File", description: - "Upload a file (base64) and write a link into a directory row cell. " + + "Upload a file and write a link into a directory row cell. " + + "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "Directory columns have no file type, so the cell gets a markdown link: [file name](url).", inputSchema: { tableId: z.number().int().describe("The numeric ID of the directory (data table)"), rowId: z.number().int().describe("The numeric ID of the row"), columnIndex: z.number().int().min(0).describe("Column index (0-based)"), - fileName: z.string().min(1).describe("Original file name with extension"), - contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), + contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), + fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => { + async ({ tableId, rowId, columnIndex, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { if (!isTableAllowed(tableId)) return tableDenied(tableId); const table = await storage.getDataTable(tableId, organizationId); if (!table) return mcpError(`Справочник ${tableId} не найден`); @@ -4964,18 +5063,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false const row = await storage.getDataTableRow(rowId, tableId, organizationId); if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`); - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); - try { - const file = await uploadFileFromBase64({ - organizationId, - userId: adminUser.id, - fileName, - contentBase64, - mimeType, - }); + const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType }); + if ('error' in source) return mcpError(source.error); + const { file } = source; // У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url) const values = Array.isArray(row.values) ? [...row.values] : []; @@ -5007,7 +5098,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false { title: "Upload Table Tab Cell File", description: - "Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " + + "Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " + + "Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " + "The cell gets a markdown link: [file name](url). " + "If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.", inputSchema: { @@ -5015,12 +5107,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false tabId: z.number().int().describe("The numeric ID of the table tab"), columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"), rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."), - fileName: z.string().min(1).describe("Original file name with extension"), - contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"), + fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"), + contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."), + fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."), + fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"), mimeType: z.string().optional().describe("MIME type (optional)"), }, }, - async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => { + async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => { const task = await storage.getTask(taskId, organizationId); if (!task) return mcpError(`Задача ${taskId} не найдена`); if (!isFormAllowed(task.formId)) return formDenied(task.formId); @@ -5034,19 +5128,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`); } - const orgUsers = await storage.getUsersByOrganization(organizationId); - const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; - if (!adminUser) return mcpError("В организации нет пользователей"); - try { - const file = await uploadFileFromBase64({ - organizationId, - userId: adminUser.id, - fileName, - contentBase64, - mimeType, - taskId, - }); + const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId }); + if ('error' in source) return mcpError(source.error); + const { actor, file } = source; // Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст): // пишем markdown-ссылку [имя](url), как и в справочниках @@ -5063,7 +5148,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false taskId, tabId, data: { [columnId]: cellValue }, - createdBy: adminUser.id, + createdBy: actor.user.id, }); } @@ -5085,6 +5170,91 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } ); + // get_api_guide + register( + "get_api_guide", + { + title: "Get API Guide", + description: "Returns a compact Russian-language guide for AI agents: how to upload files and create tasks/comments via REST with the same API key, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.", + inputSchema: {}, + }, + async () => { + const guide = ` +# Работа с API iistwin по ключу (гайд для ИИ-агента) + +Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`). +Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST. + +## 1. Загрузка файла (multipart, НЕ base64) + +\`\`\`bash +curl -F "file=@/path/report.pdf" \\ + -H "X-Api-Key: $KEY" \\ + "https://iistwin.ru/api/upload?taskId=&fieldId=" +# → { "url": "/api/files/", "name": "report.pdf", "size": 123456 } +\`\`\` + +- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи. +- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ, + документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ. +- Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar. + Для jpg/png/pdf проверяются magic bytes. +- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 — + грузи через REST /api/upload, а привязывай через fileUrl (п.2). + +## 2. Привязка файла к задаче/справочнику + +Проще всего — MCP-инструменты с fileUrl (без повторной загрузки): +- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи. +- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением. +- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника. +- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы. + +Либо напрямую REST (file-поле — массив объектов {url, name, size}): +\`\`\`bash +# Прочитать текущее значение, ДОБАВИТЬ объект, записать назад: +curl -X PATCH -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\ + -d '{"value":[{"url":"/api/files/","name":"report.pdf","size":123456}]}' \\ + "https://iistwin.ru/api/tasks//field-values/" +\`\`\` +ВНИМАНИЕ: PATCH полностью заменяет значение поля — сначала прочитай задачу +(\`get_task\` в MCP или GET /api/tasks/ в REST) и добавь файл к существующему массиву. + +## 3. Создание задачи и комментария через REST + +\`\`\`bash +# Задача (customFields: { "customField_": значение }) +curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\ + -d '{"title":"Новая задача","customFields":{"customField_12":"Текст"}}' \\ + "https://iistwin.ru/api/forms//tasks" + +# Комментарий (с вложением — attachments: [{url, name, size, mimeType?}]) +curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\ + -d '{"message":"Готово","attachments":[{"url":"/api/files/","name":"report.pdf","size":123456}]}' \\ + "https://iistwin.ru/api/tasks//messages" +\`\`\` + +## 4. Права ключа + +- mode=read: только чтение (запись → 403). mode=write: чтение+создание. mode=full: всё. +- formIds/tableIds ограничивают список доступных форм/справочников (null = все). +- REST по ключу открыт только для: POST /api/upload, POST /api/tasks/:id/messages, + PATCH /api/tasks/:id/field-values/:fieldId, POST /api/tasks/:id/field-values, + POST /api/forms/:id/tasks. Остальное — через MCP-инструменты. +- Атрибуция: действия по ключу бота записываются в историю от имени бота (bot_id), + по обычному ключу — «Ключ "label"» (metadata.source = 'mcp' | 'api'). + +## 5. Форматы значений + +- file-поле задачи: массив \`[{url, name, size}]\` (множественное — добавляй, не заменяй). +- Вложение сообщения: \`{url, name, size, mimeType?}\`. +- Ячейка справочника/таб-таблицы: markdown-ссылка \`[имя](url)\`. +- url файла: \`/api/files/\` (S3) или \`/uploads/\` (локальный режим). +`.trim(); + return { content: [{ type: "text" as const, text: guide }] }; + } + ); + return server; } @@ -5114,7 +5284,7 @@ export async function handleMcpRequest(req: Request, res: Response) { } return; } - const { organizationId, scopes } = resolved; + const { organizationId, scopes, key } = resolved; const sessionId = req.headers["mcp-session-id"] as string | undefined; @@ -5126,7 +5296,7 @@ export async function handleMcpRequest(req: Request, res: Response) { mcpTransports.set(sid, { transport, server, organizationId, scopes }); }, }); - const server = buildMcpServer(organizationId, scopes); + const server = buildMcpServer(organizationId, scopes, key); await server.connect(transport); @@ -5184,7 +5354,7 @@ export async function handleMcpSse(req: Request, res: Response) { res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." }); return; } - const { organizationId, scopes } = resolved; + const { organizationId, scopes, key } = resolved; const transport = new SSEServerTransport("/mcp/messages", res); const sessionId = transport.sessionId; @@ -5195,7 +5365,7 @@ export async function handleMcpSse(req: Request, res: Response) { sseSessions.delete(sessionId); }; - const server = buildMcpServer(organizationId, scopes); + const server = buildMcpServer(organizationId, scopes, key); await server.connect(transport); } diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 0c33732..bb2867b 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -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 ). +// При авторизации ключом: 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. diff --git a/server/routes/chat.messages.routes.ts b/server/routes/chat.messages.routes.ts index 743f59a..84eb8b1 100644 --- a/server/routes/chat.messages.routes.ts +++ b/server/routes/chat.messages.routes.ts @@ -1,12 +1,13 @@ import { Router } from "express"; import { z } from 'zod'; import { storage } from "../storage"; -import { authenticateToken, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; +import { authenticateToken, authenticateTokenOrApiKey, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { createTaskMessageSchema } from "@shared/schema"; import { eventBus } from "./shared"; import { sendTaskMessage, SendTaskMessageError } from "../services/task-message.service"; +import { isFormAllowedByScopes } from "../utils/api-key"; export function registerChatMessageRoutes(router: Router): void { // Get task messages @@ -40,10 +41,10 @@ export function registerChatMessageRoutes(router: Router): void { } ); - // Create task message + // Create task message (JWT пользователя или API-ключ организации) router.post('/api/tasks/:id/messages', tryBotServiceToken, - authenticateToken, + authenticateTokenOrApiKey, tenantIsolation, validateRequest(createTaskMessageSchema.omit({ taskId: true })), async (req: AuthenticatedRequest, res) => { @@ -65,9 +66,9 @@ export function registerChatMessageRoutes(router: Router): void { { const taskRolesForGuard = await storage.getTaskRoles(taskId, req.organizationId!); - if (taskRolesForGuard.length > 0) { + if (req.user && taskRolesForGuard.length > 0) { 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: 'Нет доступа к этой задаче' }); @@ -75,12 +76,38 @@ export function registerChatMessageRoutes(router: Router): void { } } + // Проверки для авторизации по API-ключу (JWT-путь не меняется) + if (req.apiKey) { + if (req.apiKey.scopes.mode === 'read') { + return res.status(403).json({ success: false, error: 'API-ключ в режиме read не может отправлять сообщения' }); + } + if (!isFormAllowedByScopes(req.apiKey.scopes, task.formId)) { + return res.status(403).json({ success: false, error: 'Доступ к форме этой задачи запрещён правами API-ключа' }); + } + } + + // Автор сообщения: пользователь (JWT); по ключу бота — сам бот; + // по ключу без бота — владелец ключа + let messageUser = req.user ?? null; + let messageBotId: number | null = null; + if (!messageUser && req.apiKey) { + if (req.apiKey.botId) { + messageBotId = req.apiKey.botId; + } else { + messageUser = await storage.getUser(req.apiKey.createdBy); + } + } + if (!messageUser && !messageBotId) { + return res.status(401).json({ success: false, error: 'Не удалось определить автора сообщения' }); + } + // Основная логика (сообщение + side-эффекты) вынесена в сервис — // переиспользуется также MCP-сервером (инструмент send_task_message). try { const createdMessage = await sendTaskMessage({ task, - user: req.user!, + user: messageUser ?? undefined, + botId: messageBotId, organizationId: req.organizationId!, message: req.body.message || '', messageType: req.body.messageType, diff --git a/server/routes/files.routes.ts b/server/routes/files.routes.ts index 45c8c09..a690068 100644 --- a/server/routes/files.routes.ts +++ b/server/routes/files.routes.ts @@ -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((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); diff --git a/server/routes/task-crud-write.routes.ts b/server/routes/task-crud-write.routes.ts index 23f2f36..90360d0 100644 --- a/server/routes/task-crud-write.routes.ts +++ b/server/routes/task-crud-write.routes.ts @@ -16,6 +16,7 @@ import { validateRequiredFields } from "../utils/validate-required-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; +import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor"; import { db, withTenant } from "../db"; import { eq, and, sql } from "drizzle-orm"; @@ -51,9 +52,16 @@ export function registerTaskCrudWriteRoutes(router: ReturnType { console.error('Audit log error:', auditErr); }); if (customFields && typeof customFields === 'object') { @@ -307,8 +322,8 @@ export function registerTaskCrudWriteRoutes(router: ReturnType console.error('[TaskCreate] notifyTaskAssigned error:', err)); } diff --git a/server/routes/task-fields.routes.ts b/server/routes/task-fields.routes.ts index 5b294a7..33eb3ba 100644 --- a/server/routes/task-fields.routes.ts +++ b/server/routes/task-fields.routes.ts @@ -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 { try { @@ -77,14 +78,19 @@ export function registerTaskFieldRoutes(router: ReturnType { console.error('Audit log error:', auditErr); }); @@ -365,7 +374,7 @@ export function registerTaskFieldRoutes(router: ReturnType { try { @@ -380,14 +389,19 @@ export function registerTaskFieldRoutes(router: ReturnType { console.error('Audit log error (inline edit):', auditErr); }); } diff --git a/server/routes/task.routes.ts b/server/routes/task.routes.ts index ce7147b..0c6688f 100644 --- a/server/routes/task.routes.ts +++ b/server/routes/task.routes.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { authenticateToken } from "../middleware/auth.middleware"; +import { authenticateToken, authenticateTokenOrApiKey } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { registerUserRoutes } from "./user.routes"; import { registerFormRoutes } from "./form.routes"; @@ -11,11 +11,13 @@ import { registerTaskRoleRoutes } from "./task-roles.routes"; const router = Router(); // Global middleware for all protected route prefixes +// Для /api/tasks и /api/forms — authenticateTokenOrApiKey: JWT-путь идентичен, +// API-ключ допускается только на endpoint'ы из белого списка (см. auth.middleware). router.use('/api/users', authenticateToken, tenantIsolation); router.use('/api/user-statuses', authenticateToken, tenantIsolation); router.use('/api/organizations', authenticateToken, tenantIsolation); -router.use('/api/forms', authenticateToken, tenantIsolation); -router.use('/api/tasks', authenticateToken, tenantIsolation); +router.use('/api/forms', authenticateTokenOrApiKey, tenantIsolation); +router.use('/api/tasks', authenticateTokenOrApiKey, tenantIsolation); // Register domain route handlers registerUserRoutes(router); diff --git a/server/services/file-upload.service.ts b/server/services/file-upload.service.ts index 63ba31a..1294d75 100644 --- a/server/services/file-upload.service.ts +++ b/server/services/file-upload.service.ts @@ -31,6 +31,7 @@ export interface UploadFileFromBase64Params { mimeType?: string; taskId?: number | null; // опциональная привязка к задаче fieldId?: number | null; // опциональная привязка к полю формы + botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id) } export interface UploadedFileInfo { @@ -50,7 +51,7 @@ const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024; // whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее). // Создаёт запись трекинга в file_uploads. export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise { - const { organizationId, userId, taskId = null, fieldId = null } = params; + const { organizationId, userId, taskId = null, fieldId = null, botId = null } = params; // 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter) const name = path.basename(params.fileName || ''); @@ -123,6 +124,7 @@ export async function uploadFileFromBase64(params: UploadFileFromBase64Params): sizeBytes: buffer.length, taskId, fieldId, + botId, }).returning({ id: fileUploads.id }); fileUploadId = row?.id ?? null; } catch (trackErr) { diff --git a/server/services/task-message.service.ts b/server/services/task-message.service.ts index 4457cc5..4afa06e 100644 --- a/server/services/task-message.service.ts +++ b/server/services/task-message.service.ts @@ -23,7 +23,8 @@ type MessageAttachment = { url: string; name: string; size: number; mimeType?: s export interface SendTaskMessageParams { task: Task; // задача (уже загружена и проверена вызывающим кодом) - user: User; // автор сообщения + user?: User; // автор сообщения; необязателен, если передан botId + botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot' organizationId: number; message: string; messageType?: string; // 'comment' (default), 'bot', 'system', ... @@ -41,8 +42,14 @@ export interface SendTaskMessageParams { // постановка embedding в очередь, запись взаимодействия с задачей. // Логика вынесена из POST /api/tasks/:id/messages (routes/chat.messages.routes.ts) // и переиспользуется MCP-сервером — поведение не менять. +// При botId (сообщение от бота) авторство и пользовательские side-эффекты пропускаются, +// как в POST /api/bot/message (bot-api.routes.ts). export async function sendTaskMessage(params: SendTaskMessageParams): Promise { const { task, user, organizationId } = params; + const botId = params.botId ?? null; + if (!user && !botId) { + throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500); + } const taskId = task.id; let replyToMessage = null; @@ -59,7 +66,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise u.id); mentionedUserIds = params.mentionedUserIds.filter(id => - orgUserIds.includes(id) && id !== user.id + orgUserIds.includes(id) && id !== user?.id ); } @@ -67,15 +74,16 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise(); - if (!isBotOrSystemMessage && params.mentionedBotIds && params.mentionedBotIds.length > 0) { + if (!isBotOrSystemMessage && user && params.mentionedBotIds && params.mentionedBotIds.length > 0) { const orgBots = await storage.getBotsByOrganization(organizationId); const activeBots = orgBots.filter(b => b.isActive); activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot)); @@ -86,10 +94,11 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise 0 ? mentionedUserIds : null, attachments: params.attachments?.length ? params.attachments : null, }; @@ -127,7 +136,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise 0) { + if (mentionedBotIds.length > 0 && user) { const taskFieldValues = await storage.getTaskFieldValues(taskId, organizationId); const form = await storage.getForm(task.formId, organizationId); const taskWithFields = { ...task, fieldValues: taskFieldValues }; @@ -226,40 +235,44 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise 100 - ? createdMessage.message.substring(0, 100) + '...' - : createdMessage.message, - taskTitle: task.title, - authorName: `${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim(), - originalAuthorId: replyToMessage?.authorId, - }, - mentionedUserIds: mentionedUserIds, - timestamp: new Date(), - }; + // Уведомления и исходящие вебхуки — только для сообщений от пользователя + // (для bot-сообщений — как в POST /api/bot/message: без notificationService). + if (user) { + const notificationEvent: NotificationEvent = { + type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED, + organizationId, + triggeredBy: user.id, + taskId: taskId, + formId: task.formId, + messageId: createdMessage.id, + payload: { + message: createdMessage.message.length > 100 + ? createdMessage.message.substring(0, 100) + '...' + : createdMessage.message, + taskTitle: task.title, + authorName: `${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim(), + originalAuthorId: replyToMessage?.authorId, + }, + mentionedUserIds: mentionedUserIds, + timestamp: new Date(), + }; - notificationService.processEvent(notificationEvent).then(notifiedUserIds => { - notifiedUserIds.forEach(userId => { - eventBus.publishEvent({ - type: 'notification', - data: { type: notificationEvent.type, taskId, messageId: createdMessage.id }, - organizationId, - userId: userId + notificationService.processEvent(notificationEvent).then(notifiedUserIds => { + notifiedUserIds.forEach(userId => { + eventBus.publishEvent({ + type: 'notification', + data: { type: notificationEvent.type, taskId, messageId: createdMessage.id }, + organizationId, + userId: userId + }); }); - }); - }).catch(err => console.error('Notification processing error:', err)); + }).catch(err => console.error('Notification processing error:', err)); - if (messageData.messageType === 'comment') { - webhookService.dispatchComment( - organizationId, taskId, task.formId, createdMessage, user.id - ).catch(err => console.error('Webhook dispatch error:', err)); + if (messageData.messageType === 'comment') { + webhookService.dispatchComment( + organizationId, taskId, task.formId, createdMessage, user.id + ).catch(err => console.error('Webhook dispatch error:', err)); + } } if (messageData.messageType === 'comment') { @@ -267,7 +280,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise console.error('[RAG] enqueue message embedding error:', err)); } - if (user.id) { + if (user?.id) { storage.recordTaskInteractionAuto(taskId, user.id, organizationId) .catch(err => console.error('recordTaskInteraction error:', err)); } diff --git a/server/utils/api-key-actor.ts b/server/utils/api-key-actor.ts new file mode 100644 index 0000000..4f2a48a --- /dev/null +++ b/server/utils/api-key-actor.ts @@ -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 { + 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; +} diff --git a/server/utils/api-key.ts b/server/utils/api-key.ts index 4075446..6f1ad45 100644 --- a/server/utils/api-key.ts +++ b/server/utils/api-key.ts @@ -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 { diff --git a/server/utils/upload.ts b/server/utils/upload.ts index 56a421d..59160eb 100644 --- a/server/utils/upload.ts +++ b/server/utils/upload.ts @@ -53,10 +53,13 @@ export const EXT_MAGIC: Record = { 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);