Права доступа API-ключей: скоупы read/write/full и выборочный доступ к формам и справочникам

- organization_api_keys.scopes (jsonb, NULL = полный доступ для legacy-ключей)
- MCP: фильтрация инструментов по режиму и проверка formIds в handler'ах
- PATCH /api/mcp-keys/:id, валидация скоупов в server/utils/api-key.ts
- UI Settings: диалог создания/редактирования ключа с режимом и мультивыбором форм/справочников
This commit is contained in:
2026-07-21 16:12:55 +03:00
parent 08b0979ca2
commit a7733b4a03
8 changed files with 660 additions and 103 deletions

View File

@@ -2,6 +2,7 @@ import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { type ReminderRecipient } from "@shared/schema";
import { normalizeApiKeyScopes, parseApiKeyScopesInput } from "../utils/api-key";
import { handleMcpRequest, handleMcpSse, handleMcpMessages } from "../mcp";
import { setupSwagger } from "../swagger";
import express, { type Request, type Response, type NextFunction } from 'express';
@@ -45,6 +46,8 @@ async function requireMcpApiKey(req: Request, res: Response, next: NextFunction)
const apiKey = await storage.getApiKeyByHash(trimmed);
if (apiKey && apiKey.isActive) {
storage.touchApiKey(apiKey.id).catch(() => {});
// Прикрепляем нормализованные скоупы ключа к запросу для downstream-обработчиков
(req as any).apiKeyScopes = normalizeApiKeyScopes(apiKey.scopes);
next();
return;
}
@@ -82,7 +85,11 @@ export function registerMcpRagRoutes(app: import("express").Express): void {
app.get('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const keys = await storage.listApiKeys(req.organizationId!);
const safeKeys = keys.map(({ keyHash: _h, ...rest }) => rest);
// Возвращаем scopes каждого ключа в нормализованном виде (null в БД = полный доступ)
const safeKeys = keys.map(({ keyHash: _h, scopes, ...rest }) => ({
...rest,
scopes: normalizeApiKeyScopes(scopes),
}));
res.json({ success: true, keys: safeKeys });
} catch (error) {
console.error('List MCP keys error:', error);
@@ -93,7 +100,14 @@ export function registerMcpRagRoutes(app: import("express").Express): void {
app.post('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const label = (req.body?.label as string | undefined)?.trim() || 'Default';
const { key, record } = await storage.createApiKey(req.organizationId!, req.user!.id, label);
// Скоупы необязательны: без них ключ создаётся с полным доступом (scopes = NULL)
let scopes: import('@shared/schema').ApiKeyScopes | null = null;
if (req.body?.scopes !== undefined && req.body?.scopes !== null) {
const parsed = parseApiKeyScopesInput(req.body.scopes);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
scopes = parsed.scopes;
}
const { key, record } = await storage.createApiKey(req.organizationId!, req.user!.id, label, scopes);
const { keyHash: _h, ...safeRecord } = record;
res.json({ success: true, key, record: safeRecord });
} catch (error) {
@@ -102,6 +116,46 @@ export function registerMcpRagRoutes(app: import("express").Express): void {
}
});
// Обновление label и/или scopes существующего ключа.
// scopes: null в body — сброс к полному доступу (NULL в БД).
app.patch('/api/mcp-keys/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const updates: { label?: string; scopes?: import('@shared/schema').ApiKeyScopes | null } = {};
if (req.body?.label !== undefined) {
if (typeof req.body.label !== 'string' || !req.body.label.trim()) {
return res.status(400).json({ error: 'Поле label должно быть непустой строкой' });
}
updates.label = req.body.label.trim();
}
if (req.body?.scopes !== undefined) {
if (req.body.scopes === null) {
updates.scopes = null; // сброс к полному доступу
} else {
const parsed = parseApiKeyScopesInput(req.body.scopes);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
updates.scopes = parsed.scopes;
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Нечего обновлять: передайте label и/или scopes' });
}
const updated = await storage.updateApiKey(id, req.organizationId!, updates);
if (!updated) return res.status(404).json({ error: 'Ключ не найден' });
const { keyHash: _h, ...safeRecord } = updated;
res.json({ success: true, record: safeRecord });
} catch (error) {
console.error('Update MCP key error:', error);
res.status(500).json({ error: 'Ошибка обновления ключа' });
}
});
app.delete('/api/mcp-keys/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);