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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user