diff --git a/migrations/0064_bot_api_keys.sql b/migrations/0064_bot_api_keys.sql new file mode 100644 index 0000000..a1cc541 --- /dev/null +++ b/migrations/0064_bot_api_keys.sql @@ -0,0 +1,17 @@ +-- Привязка API-ключей к ботам (1:1) и атрибуция действий ботов. +-- organization_api_keys.bot_id — ключ выдан боту; NULL — обычные/legacy-ключи (их может быть много). +-- task_audit_log.bot_id и file_uploads.bot_id — атрибуция действий, выполненных по ключу бота. + +ALTER TABLE organization_api_keys + ADD COLUMN IF NOT EXISTS bot_id int REFERENCES bots(id); + +-- Один ключ на бота (частичный индекс: NULL не ограничивается) +CREATE UNIQUE INDEX IF NOT EXISTS organization_api_keys_bot_id_unique + ON organization_api_keys (bot_id) + WHERE bot_id IS NOT NULL; + +ALTER TABLE task_audit_log + ADD COLUMN IF NOT EXISTS bot_id int; + +ALTER TABLE file_uploads + ADD COLUMN IF NOT EXISTS bot_id int; diff --git a/server/routes/bots-crud.routes.ts b/server/routes/bots-crud.routes.ts index 9333114..53a8e7c 100644 --- a/server/routes/bots-crud.routes.ts +++ b/server/routes/bots-crud.routes.ts @@ -8,11 +8,24 @@ import { validateRequest } from "../middleware/validation.middleware"; import { createBotSchema, updateBotSchema, botLoginSchema, mcpServerSchema, + type ApiKeyScopes, } from "@shared/schema"; import { generateBotLoginTokens } from "../utils/jwt"; import { verifyPassword } from "../utils/password"; import { authLimiter } from "./shared"; import { encrypt, decrypt } from "../crypto"; +import { parseApiKeyScopesInput, normalizeApiKeyScopes } from "../utils/api-key"; + +// Валидация apiAccess из тела запроса → ApiKeyScopes (дефолт mode='read'). +// Возвращает { ok: true, scopes } либо { ok: false, error } с русским сообщением. +function parseBotApiAccess(apiAccess: { enabled: boolean; mode?: 'read' | 'write' | 'full'; formIds?: number[] | null; tableIds?: number[] | null }): + { ok: true; scopes: ApiKeyScopes } | { ok: false; error: string } { + return parseApiKeyScopesInput({ + mode: apiAccess.mode ?? 'read', + formIds: apiAccess.formIds ?? null, + tableIds: apiAccess.tableIds ?? null, + }); +} const CYRILLIC_TO_LATIN: Record = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n', @@ -109,6 +122,11 @@ export function registerBotCrudRoutes(app: import("express").Express): void { async (req: AuthenticatedRequest, res) => { try { const bots = await storage.getBotsByOrganization(req.organizationId!); + // Ключи организации одним запросом; по bot_id находим ключ каждого бота + const apiKeys = await storage.listApiKeys(req.organizationId!); + const keyByBotId = new Map( + apiKeys.filter((k) => k.botId != null).map((k) => [k.botId as number, k]) + ); const safeBots = bots.map(bot => ({ id: bot.id, @@ -125,6 +143,11 @@ export function registerBotCrudRoutes(app: import("express").Express): void { mcpServers: bot.mcpServers ? bot.mcpServers.map(s => ({ url: s.url, name: s.name })) : null, + // Сырой ключ и keyHash никогда не отдаём + apiKey: (() => { + const k = keyByBotId.get(bot.id); + return k ? { id: k.id, keyPrefix: k.keyPrefix, scopes: k.scopes, isActive: k.isActive, lastUsedAt: k.lastUsedAt } : null; + })(), })); res.json({ success: true, bots: safeBots }); @@ -207,6 +230,16 @@ export function registerBotCrudRoutes(app: import("express").Express): void { return res.status(400).json({ success: false, error: 'Бот с таким логином уже существует' }); } + // apiAccess валидируем ДО создания бота, чтобы не оставлять бота без ключа при ошибке + let apiKeyScopes: ApiKeyScopes | null = null; + if (req.body.apiAccess?.enabled) { + const parsed = parseBotApiAccess(req.body.apiAccess); + if (!parsed.ok) { + return res.status(400).json({ success: false, error: parsed.error }); + } + apiKeyScopes = parsed.scopes; + } + if (llmProviderId) { const numProv = Number(llmProviderId); if (!Number.isInteger(numProv) || numProv <= 0) { @@ -255,6 +288,16 @@ export function registerBotCrudRoutes(app: import("express").Express): void { llmModel: llmProviderId != null ? (llmModel || null) : null, }); + // API-ключ бота (1:1): создаём только при apiAccess.enabled. + // СЫРОЙ КЛЮЧ возвращается один раз — только в этом ответе. + let apiKeyResponse: { key: string; keyPrefix: string } | null = null; + if (apiKeyScopes) { + const { key, record } = await storage.createApiKey( + req.organizationId!, req.user!.id, bot.name, apiKeyScopes, bot.id + ); + apiKeyResponse = { key, keyPrefix: record.keyPrefix }; + } + res.status(201).json({ success: true, message: 'Бот создан', @@ -266,7 +309,8 @@ export function registerBotCrudRoutes(app: import("express").Express): void { webhookSecret: finalWebhookSecret, type: bot.type ?? 'webhook', createdAt: bot.createdAt - } + }, + apiKey: apiKeyResponse }); } catch (error) { console.error('Create bot error:', error); @@ -347,6 +391,35 @@ export function registerBotCrudRoutes(app: import("express").Express): void { const updatedBot = await storage.updateBot(botId, req.organizationId!, updates as Parameters[2]); + // Управление API-ключом бота (1:1) через apiAccess: + // enabled без ключа → создать (сырой ключ — только в этом ответе); + // enabled с ключом → обновить scopes (+реактивировать, если был выключен); + // disabled с активным ключом → деактивировать (запись сохраняется). + let apiKeyResponse: { key: string; keyPrefix: string } | null = null; + if (req.body.apiAccess !== undefined) { + const apiAccess = req.body.apiAccess; + const existingKey = await storage.getApiKeyByBotId(botId, req.organizationId!); + if (apiAccess.enabled) { + const parsed = parseBotApiAccess(apiAccess); + if (!parsed.ok) { + return res.status(400).json({ success: false, error: parsed.error }); + } + if (existingKey) { + await storage.updateApiKey(existingKey.id, req.organizationId!, { scopes: parsed.scopes }); + if (!existingKey.isActive) { + await storage.setApiKeyActive(existingKey.id, req.organizationId!, true); + } + } else { + const { key, record } = await storage.createApiKey( + req.organizationId!, req.user!.id, updatedBot.name, parsed.scopes, botId + ); + apiKeyResponse = { key, keyPrefix: record.keyPrefix }; + } + } else if (existingKey?.isActive) { + await storage.setApiKeyActive(existingKey.id, req.organizationId!, false); + } + } + res.json({ success: true, message: 'Бот обновлен', @@ -359,7 +432,8 @@ export function registerBotCrudRoutes(app: import("express").Express): void { webhookUrl: updatedBot.webhookUrl, webhookEnabled: updatedBot.webhookEnabled, isActive: updatedBot.isActive - } + }, + apiKey: apiKeyResponse }); } catch (error) { console.error('Update bot error:', error); @@ -368,6 +442,53 @@ export function registerBotCrudRoutes(app: import("express").Express): void { } ); + // Regenerate bot API key (admin only) + app.post('/api/bots/:id/api-key/regenerate', + authenticateToken, + requirePermission('bots.manage'), + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const botId = parseInt(req.params.id); + if (isNaN(botId)) { + return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); + } + + const bot = await storage.getBot(botId, req.organizationId!); + if (!bot) { + return res.status(404).json({ success: false, error: 'Бот не найден' }); + } + + const existingKey = await storage.getApiKeyByBotId(botId, req.organizationId!); + // Скоупы нового ключа = скоупы старого; без старого ключа — безопасный дефолт 'read' + const scopes: ApiKeyScopes = existingKey + ? normalizeApiKeyScopes(existingKey.scopes) + : { mode: 'read', formIds: null, tableIds: null }; + + if (existingKey) { + // Деактивируем и отвязываем от бота: частичный уникальный индекс по bot_id + // не позволит создать новый ключ, пока старый хранит привязку. + await storage.setApiKeyActive(existingKey.id, req.organizationId!, false); + await storage.detachApiKeyFromBot(existingKey.id, req.organizationId!); + } + + const { key, record } = await storage.createApiKey( + req.organizationId!, req.user!.id, bot.name, scopes, botId + ); + + // СЫРОЙ КЛЮЧ возвращается один раз — только в этом ответе + res.json({ + success: true, + message: 'API-ключ бота перевыпущен', + apiKey: { key, keyPrefix: record.keyPrefix } + }); + } catch (error) { + console.error('Regenerate bot API key error:', error); + res.status(500).json({ success: false, error: 'Ошибка при перевыпуске API-ключа' }); + } + } + ); + // Regenerate bot password (admin only) app.post('/api/bots/:id/regenerate-password', authenticateToken, diff --git a/server/storage.ts b/server/storage.ts index 300537b..4b0cbdf 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -364,13 +364,16 @@ export interface IStorage { upsertTaskTabValues(taskId: number, tabId: number, values: Record): Promise; // Organization API Keys (MCP) - createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | null): Promise<{ key: string; record: OrganizationApiKey }>; + createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | null, botId?: number | null): Promise<{ key: string; record: OrganizationApiKey }>; updateApiKey(id: number, organizationId: number, data: { label?: string; scopes?: ApiKeyScopes | null }): Promise; listApiKeys(organizationId: number): Promise; deleteApiKey(id: number, organizationId: number): Promise; getApiKeyByHash(rawKey: string): Promise; getApiKeyByLegacyHash(rawKey: string): Promise; touchApiKey(id: number): Promise; + getApiKeyByBotId(botId: number, organizationId: number): Promise; + setApiKeyActive(id: number, organizationId: number, isActive: boolean): Promise; + detachApiKeyFromBot(id: number, organizationId: number): Promise; // Task Relations upsertTaskRelation(data: { parentTaskId: number; childTaskId: number; fieldId?: number | null; organizationId: number }): Promise; diff --git a/server/storage/content.storage.ts b/server/storage/content.storage.ts index cdc1200..6356a5a 100644 --- a/server/storage/content.storage.ts +++ b/server/storage/content.storage.ts @@ -517,7 +517,7 @@ export class ContentStorage extends DataTablesStorage { } // Organization API Keys (MCP) - async createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | null): Promise<{ key: string; record: OrganizationApiKey }> { + async createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | null, botId?: number | null): Promise<{ key: string; record: OrganizationApiKey }> { const rawKey = "wf_" + crypto.randomBytes(32).toString("base64url"); const keyHash = hashApiKey(rawKey); const keyPrefix = rawKey.substring(0, 10); @@ -525,10 +525,34 @@ export class ContentStorage extends DataTablesStorage { organizationId, createdBy, label, keyHash, keyPrefix, isLegacy: false, // NULL = полный доступ (legacy-поведение) scopes: scopes ?? null, + botId: botId ?? null, }).returning(); return { key: rawKey, record }; } + // Ключ, привязанный к боту (1:1), с проверкой принадлежности организации. + async getApiKeyByBotId(botId: number, organizationId: number): Promise { + const [record] = await db.select().from(organizationApiKeys) + .where(and(eq(organizationApiKeys.botId, botId), eq(organizationApiKeys.organizationId, organizationId))); + return record || undefined; + } + + // Активация/деактивация ключа (запись сохраняется). + async setApiKeyActive(id: number, organizationId: number, isActive: boolean): Promise { + await db.update(organizationApiKeys) + .set({ isActive }) + .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); + } + + // Отвязка ключа от бота (bot_id = NULL). + // Нужна при regenerate: частичный уникальный индекс по bot_id не даёт + // создать новый ключ бота, пока старый хранит привязку. + async detachApiKeyFromBot(id: number, organizationId: number): Promise { + await db.update(organizationApiKeys) + .set({ botId: null }) + .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); + } + // Обновление label/scopes ключа с проверкой принадлежности организации. // Возвращает undefined, если ключ не найден в этой организации. async updateApiKey(id: number, organizationId: number, data: { label?: string; scopes?: ApiKeyScopes | null }): Promise { diff --git a/shared/schema.ts b/shared/schema.ts index e529c8e..4cc5f7b 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1714,6 +1714,14 @@ const accessPolicySchema = z.object({ userIds: z.array(z.number()).optional(), }); +// Настройки API-доступа бота (ключ organization_api_keys, привязанный к боту 1:1) +export const botApiAccessSchema = z.object({ + enabled: z.boolean(), + mode: z.enum(['read', 'write', 'full']).optional(), + formIds: z.array(z.number().int()).nullable().optional(), + tableIds: z.array(z.number().int()).nullable().optional(), +}); + // Create/Update schemas for bots export const createBotSchema = z.object({ name: z.string().min(1, "Название бота обязательно").max(200), @@ -1734,6 +1742,7 @@ export const createBotSchema = z.object({ sendSystemPrompt: z.boolean().optional(), sendCardInfo: z.boolean().optional(), sendChatHistory: z.boolean().optional(), + apiAccess: botApiAccessSchema.optional(), }); export const updateBotSchema = z.object({ @@ -1755,6 +1764,7 @@ export const updateBotSchema = z.object({ sendSystemPrompt: z.boolean().optional(), sendCardInfo: z.boolean().optional(), sendChatHistory: z.boolean().optional(), + apiAccess: botApiAccessSchema.optional(), }).partial(); export const createBotSubscriptionSchema = z.object({ @@ -2577,9 +2587,13 @@ export const organizationApiKeys = pgTable("organization_api_keys", { isActive: boolean("is_active").notNull().default(true), isLegacy: boolean("is_legacy").notNull().default(false), scopes: jsonb("scopes").$type(), + // Привязка ключа к боту (1:1); NULL — обычный/legacy-ключ без бота + botId: integer("bot_id").references(() => bots.id), }, (table) => ({ orgIndex: index("api_keys_org_idx").on(table.organizationId), hashIndex: index("api_keys_hash_idx").on(table.keyHash), + // Один ключ на бота (частичный индекс — NULL не ограничивается) + botUniqueIdx: uniqueIndex("organization_api_keys_bot_id_unique").on(table.botId).where(sql`${table.botId} IS NOT NULL`), })); export type OrganizationApiKey = typeof organizationApiKeys.$inferSelect; @@ -2712,6 +2726,8 @@ export const taskAuditLog = pgTable("task_audit_log", { newValue: jsonb("new_value"), changedBy: integer("changed_by").references(() => users.id), changedByName: text("changed_by_name"), + // Атрибуция действий бота (действие выполнено по API-ключу бота); NULL — действие пользователя + botId: integer("bot_id"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ @@ -3192,6 +3208,8 @@ export const fileUploads = pgTable("file_uploads", { sizeBytes: integer("size_bytes"), taskId: integer("task_id"), fieldId: integer("field_id"), + // Атрибуция загрузки бота (файл загружен по API-ключу бота); NULL — загрузка пользователя + botId: integer("bot_id"), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ orgIdx: index("file_uploads_org_idx").on(table.organizationId),