API-ключи привязаны к ботам 1:1 (этап 1)

- Миграция 0064: organization_api_keys.bot_id (unique partial), task_audit_log.bot_id, file_uploads.bot_id
- POST/PUT /api/bots: apiAccess {enabled, mode, formIds, tableIds} — ключ создаётся/обновляется/деактивируется вместе с ботом, сырой ключ показывается один раз
- POST /api/bots/:id/api-key/regenerate; GET /api/bots возвращает apiKey summary
This commit is contained in:
2026-07-22 14:42:32 +03:00
parent 1fae441a09
commit 68153caef2
5 changed files with 187 additions and 4 deletions

View File

@@ -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<string, string> = {
а:'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<typeof storage.updateBot>[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,