diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 6484a99..aac0adf 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -19,6 +19,16 @@ import { BotsContent } from '@/pages/Bots'; import { AutomationsContent } from '@/pages/Automations'; import { AppearanceSettings } from '@/pages/AppearanceSettings'; import { Badge } from '@/components/ui/badge'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Checkbox } from '@/components/ui/checkbox'; + +// Скоупы прав API-ключа: режим доступа и ограничения по формам/справочникам. +// null в formIds/tableIds означает «все формы» / «все справочники». +type ApiKeyScopes = { + mode: 'read' | 'write' | 'full'; // read = только чтение; write = чтение + создание; full = всё + formIds: number[] | null; + tableIds: number[] | null; +}; type ApiKey = { id: number; @@ -28,6 +38,7 @@ type ApiKey = { createdAt: string | null; lastUsedAt: string | null; isActive: boolean; + scopes: ApiKeyScopes | null; // null = полный доступ (legacy-ключи) }; type OrgInfo = { id: number; displayName: string; name: string } | null; @@ -1571,15 +1582,177 @@ function LlmProvidersContent() { ); } +// ── ApiKeyFormDialog ────────────────────────────────────────────────────────── +// Диалог создания/редактирования API-ключа: название, режим доступа и скоупы +// по формам и справочникам. Компоненту передаётся key={...} со стороны родителя, +// чтобы состояние сбрасывалось при открытии для другого ключа. + +type ApiKeyFormDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + apiKey: ApiKey | null; // null — создание нового ключа + forms: { id: number; name: string }[]; + directories: { id: number; name: string }[]; + isPending: boolean; + onSubmit: (label: string, scopes: ApiKeyScopes) => void; +}; + +function ApiKeyFormDialog({ open, onOpenChange, apiKey, forms, directories, isPending, onSubmit }: ApiKeyFormDialogProps) { + // Для legacy-ключей (scopes === null) показываем полный доступ со всеми формами и справочниками + const [label, setLabel] = useState(apiKey?.label ?? ''); + const [mode, setMode] = useState(apiKey?.scopes?.mode ?? 'full'); + const [allForms, setAllForms] = useState(apiKey?.scopes?.formIds == null); + const [allTables, setAllTables] = useState(apiKey?.scopes?.tableIds == null); + const [selectedFormIds, setSelectedFormIds] = useState(apiKey?.scopes?.formIds ?? []); + const [selectedTableIds, setSelectedTableIds] = useState(apiKey?.scopes?.tableIds ?? []); + + const toggleId = (list: number[], id: number) => + list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; + + // При выборочном доступе нужно выбрать хотя бы одну форму и один справочник + const scopesInvalid = + mode !== 'full' && + ((!allForms && selectedFormIds.length === 0) || (!allTables && selectedTableIds.length === 0)); + + const handleSubmit = () => { + const scopes: ApiKeyScopes = + mode === 'full' + ? { mode, formIds: null, tableIds: null } + : { + mode, + formIds: allForms ? null : selectedFormIds, + tableIds: allTables ? null : selectedTableIds, + }; + onSubmit(label.trim(), scopes); + }; + + // Блок выбора «Все / Выбранные» со скроллируемым списком чекбоксов + const renderScopePicker = ( + title: string, + allLabel: string, + all: boolean, + setAll: (v: boolean) => void, + items: { id: number; name: string }[], + selectedIds: number[], + setSelectedIds: (ids: number[]) => void, + testIdPrefix: string, + ) => ( +
+ + setAll(v === 'all')} + className="flex gap-3" + > +
+ + +
+
+ + +
+
+ {!all && ( +
+ {items.length === 0 ? ( +
Список пуст
+ ) : ( + items.map((item) => ( +
+ setSelectedIds(toggleId(selectedIds, item.id))} + data-testid={`checkbox-${testIdPrefix}-${item.id}`} + /> + +
+ )) + )} +
+ )} +
+ ); + + return ( + + + + {apiKey ? 'Редактировать API-ключ' : 'Создать API-ключ'} + +
+
+ + setLabel(e.target.value)} + placeholder="Название ключа (например: Claude Desktop)" + className="h-7 text-xs" + data-testid="input-key-label" + /> +
+ +
+ + setMode(v as ApiKeyScopes['mode'])} className="space-y-1"> +
+ + +
+
+ + +
+
+ + +
+
+
+ + {mode !== 'full' && ( + <> + {renderScopePicker('Доступ к формам', 'Все формы', allForms, setAllForms, forms, selectedFormIds, setSelectedFormIds, 'scope-form')} + {renderScopePicker('Доступ к справочникам', 'Все справочники', allTables, setAllTables, directories, selectedTableIds, setSelectedTableIds, 'scope-table')} + {scopesInvalid && ( +

+ Выберите хотя бы одну форму и один справочник или переключитесь на «Все» +

+ )} + + )} +
+ + + + +
+
+ ); +} + // ── Main Settings ────────────────────────────────────────────────────────────── const Settings = () => { const { toast } = useToast(); const { user, isLoading: isAuthLoading } = useAuth(); const [location, setLocation] = useLocation(); - const [newKeyLabel, setNewKeyLabel] = useState(''); const [createdKey, setCreatedKey] = useState(null); const [copiedKey, setCopiedKey] = useState(false); + // Диалог создания/редактирования ключа: editingKey === null — создание нового + const [keyDialogOpen, setKeyDialogOpen] = useState(false); + const [editingKey, setEditingKey] = useState(null); useEffect(() => { if (!isAuthLoading && !user) { @@ -1598,21 +1771,72 @@ const Settings = () => { }); const apiKeys = apiKeysData?.keys ?? []; + // Формы и справочники — для выбора скоупов в диалоге ключа + const { data: formsData } = useQuery<{ success: boolean; forms: { id: number; name: string }[] }>({ + queryKey: ['/api/forms'], + enabled: !!user && isAdminUser, + }); + const formsList = formsData?.forms ?? []; + + const { data: directoriesData } = useQuery<{ tables: { id: number; name: string }[] }>({ + queryKey: ['/api/directories'], + enabled: !!user && isAdminUser, + }); + const directoriesList = directoriesData?.tables ?? []; + const createApiKeyMutation = useMutation({ - mutationFn: async (label: string) => { - const res = await apiRequest('POST', '/api/mcp-keys', { label }); + mutationFn: async ({ label, scopes }: { label: string; scopes: ApiKeyScopes }) => { + const res = await apiRequest('POST', '/api/mcp-keys', { label, scopes }); return res.json(); }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['/api/mcp-keys'] }); setCreatedKey(data.key); - setNewKeyLabel(''); + setKeyDialogOpen(false); }, onError: () => { toast({ title: 'Ошибка создания ключа', variant: 'destructive' }); }, }); + const updateApiKeyMutation = useMutation({ + mutationFn: async ({ id, label, scopes }: { id: number; label: string; scopes: ApiKeyScopes }) => { + const res = await apiRequest('PATCH', `/api/mcp-keys/${id}`, { label, scopes }); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/mcp-keys'] }); + setKeyDialogOpen(false); + toast({ title: 'Ключ обновлён' }); + }, + onError: () => { + toast({ title: 'Ошибка обновления ключа', variant: 'destructive' }); + }, + }); + + const handleKeyDialogSubmit = (label: string, scopes: ApiKeyScopes) => { + if (editingKey) { + updateApiKeyMutation.mutate({ id: editingKey.id, label, scopes }); + } else { + createApiKeyMutation.mutate({ label, scopes }); + } + }; + + // Бейдж режима доступа ключа; scopes === null — legacy-ключ с полным доступом + const scopeModeBadge = (scopes: ApiKeyScopes | null) => { + const mode = scopes?.mode ?? 'full'; + if (mode === 'read') return Чтение; + if (mode === 'write') return Создание; + return Полный; + }; + + // Мелкое описание ограничений: «Формы: N из M · Справочники: K из L» или «Все …» + const scopeDetailsText = (scopes: ApiKeyScopes | null) => { + const formsText = scopes?.formIds == null ? 'Все формы' : `Формы: ${scopes.formIds.length} из ${formsList.length}`; + const tablesText = scopes?.tableIds == null ? 'Все справочники' : `Справочники: ${scopes.tableIds.length} из ${directoriesList.length}`; + return `${formsText} · ${tablesText}`; + }; + const deleteApiKeyMutation = useMutation({ mutationFn: async (id: number) => { return apiRequest('DELETE', `/api/mcp-keys/${id}`); @@ -1774,23 +1998,16 @@ const Settings = () => { Создать API-ключ
-
- setNewKeyLabel(e.target.value)} - placeholder="Название ключа (например: Claude Desktop)" - className="h-7 text-xs flex-1" - onKeyDown={(e) => e.key === 'Enter' && newKeyLabel.trim() && createApiKeyMutation.mutate(newKeyLabel.trim())} - data-testid="input-key-label" - /> +
+

Задайте название и права доступа для нового ключа.

@@ -1832,23 +2049,38 @@ const Settings = () => { {apiKeys.map((key) => (
-
{key.label}
+
+ {key.label} + {scopeModeBadge(key.scopes)} +
{key.keyPrefix}…
+
{scopeDetailsText(key.scopes)}
Создан: {key.createdAt ? new Date(key.createdAt).toLocaleDateString('ru-RU') : '—'} {key.lastUsedAt && ` · Использован: ${new Date(key.lastUsedAt).toLocaleDateString('ru-RU')}`}
- +
+ + +
))}
@@ -1856,6 +2088,17 @@ const Settings = () => {
+ +
Доступные MCP-инструменты diff --git a/migrations/0063_api_key_scopes.sql b/migrations/0063_api_key_scopes.sql new file mode 100644 index 0000000..179bbfe --- /dev/null +++ b/migrations/0063_api_key_scopes.sql @@ -0,0 +1,5 @@ +-- Скоупы прав доступа для API-ключей (MCP и REST /api/rag/*). +-- NULL = полный доступ ко всем формам и справочникам (поведение старых ключей не меняется). + +ALTER TABLE organization_api_keys + ADD COLUMN IF NOT EXISTS scopes jsonb; diff --git a/server/mcp.ts b/server/mcp.ts index 2e8c241..193a45c 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -33,7 +33,8 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri } } import type { Request, Response } from "express"; -import type { Task } from "@shared/schema"; +import type { Task, ApiKeyScopes } from "@shared/schema"; +import { normalizeApiKeyScopes } from "./utils/api-key"; import beautify from "js-beautify"; import { semanticSearch, @@ -91,7 +92,48 @@ function validatePageCode(code: string): string[] { return warnings; } -async function resolveOrgFromKey(req: Request): Promise { +// ── Классификация MCP-инструментов по уровню доступа ──────────────────────── +// READ_TOOLS — доступны во всех режимах (read, write, full): только чтение данных. +const READ_TOOLS: readonly string[] = [ + 'list_forms', + 'get_form_fields', + 'list_tasks', + 'get_task', + 'search_tasks', + 'get_related_tasks', + 'get_form_tabs', + 'list_users', + 'list_automations', + 'get_automation', + 'list_field_templates', + 'list_tab_modules', + 'get_tab_module', + 'list_custom_pages', + 'get_custom_page', + 'get_js_coding_reference', + 'semantic_search', + 'get_organization_context', +]; + +// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных. +const WRITE_EXTRA_TOOLS: readonly string[] = [ + 'create_task', + 'append_table_row', + 'link_tasks', +]; + +// Все остальные инструменты (изменение/удаление форм, задач, пользователей, +// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full. + +// Результат разрешения API-ключа: организация + нормализованные скоупы доступа. +export interface ResolvedApiKey { + organizationId: number; + scopes: ApiKeyScopes; +} + +// Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы +// (NULL в БД = полный доступ, legacy). Логика legacy-ключей (SHA-256) и touchApiKey сохранена. +async function resolveApiKey(req: Request): Promise { const rawKey = (req.headers["x-api-key"] as string | undefined) || (req.headers["authorization"] as string | undefined)?.replace(/^Bearer\s+/i, "") || @@ -100,7 +142,7 @@ async function resolveOrgFromKey(req: Request): Promise { if (!rawKey) return null; const trimmed = rawKey.trim(); - let apiKey = await storage.getApiKeyByHash(trimmed); + const apiKey = await storage.getApiKeyByHash(trimmed); if (!apiKey) { const legacyKey = await storage.getApiKeyByLegacyHash(trimmed); if (legacyKey && legacyKey.isActive) { @@ -111,7 +153,7 @@ async function resolveOrgFromKey(req: Request): Promise { } if (!apiKey.isActive) return null; storage.touchApiKey(apiKey.id).catch(() => {}); - return apiKey.organizationId; + return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes) }; } function taskToJson(t: Task) { @@ -128,11 +170,37 @@ function taskToJson(t: Task) { }; } -function buildMcpServer(organizationId: number): McpServer { +function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer { const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" }); + // ── Фильтрация инструментов по режиму ключа (scopes.mode) ───────────────── + // read → только READ_TOOLS + // write → READ_TOOLS + WRITE_EXTRA_TOOLS + // full → все инструменты + const isToolAllowedByMode = (name: string): boolean => { + if (scopes.mode === 'full') return true; + if (scopes.mode === 'write') return READ_TOOLS.includes(name) || WRITE_EXTRA_TOOLS.includes(name); + return READ_TOOLS.includes(name); + }; + + // Обертка над server.registerTool: не регистрирует инструменты, + // недоступные по режиму ключа — клиент их просто не увидит. + // Тип typeof server.registerTool сохраняет вывод типов аргументов handler из inputSchema. + const register = ((name: string, meta: unknown, handler: unknown) => { + if (!isToolAllowedByMode(name)) return undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (server.registerTool as any)(name, meta, handler); + }) as typeof server.registerTool; + + // ── Объектный доступ по scopes.formIds ──────────────────────────────────── + const isFormAllowed = (formId: number) => scopes.formIds === null || scopes.formIds.includes(formId); + const formDenied = (formId: number) => ({ + content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }], + isError: true, + }); + // list_forms - server.registerTool( + register( "list_forms", { title: "List Forms", @@ -141,12 +209,14 @@ function buildMcpServer(organizationId: number): McpServer { }, async () => { const all = await storage.getFormsByOrganization(organizationId); + // Фильтруем выдачу по scopes.formIds (null = все формы) + const allowed = all.filter((f) => isFormAllowed(f.id)); return { content: [ { type: "text" as const, text: JSON.stringify( - all.map((f) => ({ id: f.id, name: f.name, description: f.description, createdAt: f.createdAt })), + allowed.map((f) => ({ id: f.id, name: f.name, description: f.description, createdAt: f.createdAt })), null, 2 ), @@ -157,7 +227,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // get_form_fields - server.registerTool( + register( "get_form_fields", { title: "Get Form Fields", @@ -167,6 +237,7 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id }) => { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; const [fields, statuses] = await Promise.all([ @@ -205,7 +276,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // list_tasks - server.registerTool( + register( "list_tasks", { title: "List Tasks", @@ -218,6 +289,8 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id, status_id, assigned_to, limit }) => { + // form_id передан явно — проверяем доступ к форме + if (form_id && !isFormAllowed(form_id)) return formDenied(form_id); let taskList: Task[]; if (form_id) { taskList = await storage.getTasksByForm(form_id, organizationId); @@ -230,6 +303,8 @@ function buildMcpServer(organizationId: number): McpServer { assignedTo: assigned_to, minimal: false, })) as Task[]; + // Без form_id — фильтруем выдачу по разрешённым формам + taskList = taskList.filter((t) => isFormAllowed(t.formId)); } return { content: [ @@ -243,7 +318,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // get_task - server.registerTool( + register( "get_task", { title: "Get Task", @@ -256,6 +331,8 @@ function buildMcpServer(organizationId: number): McpServer { const detail = await storage.getTaskDetail(task_id, organizationId); if (!detail) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true }; const { task, form, fields, statuses, fieldValues, subtasks } = detail; + // Проверка доступа к форме задачи + if (!isFormAllowed(task.formId)) return formDenied(task.formId); const fieldMap = new Map(fields.map((f) => [f.id, f])); const statusMap = new Map(statuses.map((s) => [s.id, s.name])); return { @@ -293,7 +370,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_task - server.registerTool( + register( "create_task", { title: "Create Task", @@ -312,6 +389,7 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id, title, assigned_to, status_id, description, due_date, field_values }) => { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; @@ -408,7 +486,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // update_task_status - server.registerTool( + register( "update_task_status", { title: "Update Task Status", @@ -421,6 +499,8 @@ function buildMcpServer(organizationId: number): McpServer { async ({ task_id, status_id }) => { const task = await storage.getTask(task_id, organizationId); if (!task) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true }; + // Проверка доступа к форме задачи + if (!isFormAllowed(task.formId)) return formDenied(task.formId); // Validate status belongs to the task's form const statuses = await storage.getFormStatuses(task.formId, organizationId); @@ -454,7 +534,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // update_task - server.registerTool( + register( "update_task", { title: "Update Task", @@ -471,6 +551,8 @@ function buildMcpServer(organizationId: number): McpServer { async ({ task_id, title, description, assigned_to, due_date, is_completed }) => { const task = await storage.getTask(task_id, organizationId); if (!task) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true }; + // Проверка доступа к форме задачи + if (!isFormAllowed(task.formId)) return formDenied(task.formId); // Validate assigned_to belongs to this organization if (assigned_to !== undefined && assigned_to !== null) { @@ -514,7 +596,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // list_users - server.registerTool( + register( "list_users", { title: "List Users", @@ -544,7 +626,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_user - server.registerTool( + register( "create_user", { title: "Create User", @@ -615,7 +697,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // search_tasks - server.registerTool( + register( "search_tasks", { title: "Search Tasks", @@ -627,11 +709,15 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ query, form_id, limit }) => { + // form_id передан явно — проверяем доступ к форме + if (form_id && !isFormAllowed(form_id)) return formDenied(form_id); let allTasks: Task[]; if (form_id) { allTasks = await storage.getTasksByForm(form_id, organizationId); } else { allTasks = (await storage.getTasksByOrganization(organizationId, { limit: 500, minimal: false })) as Task[]; + // Фильтруем выдачу по разрешённым формам + allTasks = allTasks.filter((t) => isFormAllowed(t.formId)); } const lq = query.toLowerCase(); @@ -651,7 +737,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_form - server.registerTool( + register( "create_form", { title: "Create Form", @@ -696,7 +782,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_form_status - server.registerTool( + register( "create_form_status", { title: "Create Form Status", @@ -710,6 +796,7 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id, name, color, is_initial, is_final }) => { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; @@ -743,7 +830,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // add_form_field - server.registerTool( + register( "add_form_field", { title: "Add Form Field", @@ -759,6 +846,7 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id, name, type, is_required, options, placeholder, default_value }) => { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; @@ -827,7 +915,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // delete_form - server.registerTool( + register( "delete_form", { title: "Delete Form", @@ -837,6 +925,7 @@ function buildMcpServer(organizationId: number): McpServer { }, }, async ({ form_id }) => { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; @@ -856,7 +945,7 @@ function buildMcpServer(organizationId: number): McpServer { // ── Tab Modules ───────────────────────────────────────────────────── // list_tab_modules - server.registerTool( + register( "list_tab_modules", { title: "List Tab Modules", @@ -899,7 +988,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // get_tab_module - server.registerTool( + register( "get_tab_module", { title: "Get Tab Module", @@ -919,7 +1008,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_tab_module - server.registerTool( + register( "create_tab_module", { title: "Create Tab Module (Declarative)", @@ -978,7 +1067,7 @@ function buildMcpServer(organizationId: number): McpServer { ); // create_js_tab_module — specialized tool for JS-code tabs - server.registerTool( + register( "create_js_tab_module", { title: "Create JS Tab Module", @@ -1064,7 +1153,7 @@ RULES for tab component code: ); // update_js_tab_module — update JS code of an existing js_component tab - server.registerTool( + register( "update_js_tab_module", { title: "Update JS Tab Module", @@ -1141,7 +1230,7 @@ RULES for tab component code: ); // update_tab_module - server.registerTool( + register( "update_tab_module", { title: "Update Tab Module (Declarative)", @@ -1210,7 +1299,7 @@ RULES for tab component code: ); // assign_tab_module_to_form - server.registerTool( + register( "assign_tab_module_to_form", { title: "Assign Tab Module to Form", @@ -1258,7 +1347,7 @@ RULES for tab component code: ); // remove_tab_module_from_form - server.registerTool( + register( "remove_tab_module_from_form", { title: "Remove Tab Module from Form", @@ -1286,7 +1375,7 @@ RULES for tab component code: ); // delete_tab_module - server.registerTool( + register( "delete_tab_module", { title: "Delete Tab Module", @@ -1308,7 +1397,7 @@ RULES for tab component code: // ── Custom JS Pages ───────────────────────────────────────────────── // list_custom_pages - server.registerTool( + register( "list_custom_pages", { title: "List Custom Pages", @@ -1342,7 +1431,7 @@ RULES for tab component code: ); // get_custom_page - server.registerTool( + register( "get_custom_page", { title: "Get Custom Page", @@ -1371,7 +1460,7 @@ RULES for tab component code: ); // create_custom_page - server.registerTool( + register( "create_custom_page", { title: "Create Custom Page", @@ -1426,7 +1515,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: ); // update_custom_page - server.registerTool( + register( "update_custom_page", { title: "Update Custom Page", @@ -1475,7 +1564,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: ); // delete_custom_page - server.registerTool( + register( "delete_custom_page", { title: "Delete Custom Page", @@ -1497,7 +1586,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: // ── JS Coding Reference ──────────────────────────────────────────────────── // get_js_coding_reference - server.registerTool( + register( "get_js_coding_reference", { title: "Get JS Coding Reference", @@ -1836,7 +1925,7 @@ import React from 'react'; // NO imports allowed // ── Automations ──────────────────────────────────────────────────────────── // list_automations - server.registerTool( + register( "list_automations", { title: "List Automations", @@ -1855,7 +1944,7 @@ import React from 'react'; // NO imports allowed ); // get_automation - server.registerTool( + register( "get_automation", { title: "Get Automation", @@ -1872,7 +1961,7 @@ import React from 'react'; // NO imports allowed ); // create_automation - server.registerTool( + register( "create_automation", { title: "Create Automation", @@ -1969,7 +2058,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // update_automation - server.registerTool( + register( "update_automation", { title: "Update Automation", @@ -2004,7 +2093,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // delete_automation - server.registerTool( + register( "delete_automation", { title: "Delete Automation", @@ -2024,7 +2113,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false // ── Task Relations ────────────────────────────────────────────────────────── // link_tasks - server.registerTool( + register( "link_tasks", { title: "Link Tasks", @@ -2052,6 +2141,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ]); if (!currentTask) return { content: [{ type: "text" as const, text: `Task id=${task_id} not found` }], isError: true }; if (!relatedTask) return { content: [{ type: "text" as const, text: `Task id=${related_task_id} not found` }], isError: true }; + // Проверка доступа к формам обеих задач + if (!isFormAllowed(currentTask.formId)) return formDenied(currentTask.formId); + if (!isFormAllowed(relatedTask.formId)) return formDenied(relatedTask.formId); const relation = await storage.upsertTaskRelation( type === "parent" @@ -2075,7 +2167,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // get_related_tasks - server.registerTool( + register( "get_related_tasks", { title: "Get Related Tasks", @@ -2091,19 +2183,31 @@ To block task creation from task.before_create, set: ctx.result = { allow: false async ({ task_id }) => { const task = await storage.getTask(task_id, organizationId); if (!task) return { content: [{ type: "text" as const, text: `Task id=${task_id} not found` }], isError: true }; + // Проверка доступа к форме задачи + if (!isFormAllowed(task.formId)) return formDenied(task.formId); const related = await storage.getRelatedTasks(task_id, organizationId); + // Отсекаем узлы дерева связей из недоступных форм (рекурсивно по полю nodes) + const filterNodes = (nodes: any[]): any[] => + (Array.isArray(nodes) ? nodes : []) + .filter((n) => n && typeof n.formId === 'number' && isFormAllowed(n.formId)) + .map((n) => ({ ...n, nodes: filterNodes(n.nodes) })); return { content: [{ type: "text" as const, - text: JSON.stringify({ success: true, task_id, ...related }, null, 2), + text: JSON.stringify({ + success: true, + task_id, + parents: filterNodes((related as any).parents), + children: filterNodes((related as any).children), + }, null, 2), }], }; } ); // semantic_search - server.registerTool( + register( "semantic_search", { title: "Semantic Search", @@ -2141,8 +2245,37 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }; } + // Фильтрация результатов по scopes.formIds: + // entity 'form' — по id формы; 'task' — по форме задачи; + // 'task_message' — по форме родительской задачи (metadata.taskId). + // Задел под scopes.tableIds: сущности справочников/data tables в выдаче + // сейчас не встречаются; при их появлении здесь же применять scopes.tableIds. + let filteredResults = results; + if (scopes.formIds !== null) { + const resolved = await Promise.all(results.map(async (r) => { + const metaFormId = (r.metadata as Record | null)?.formId; + if (typeof metaFormId === 'number') return { r, formId: metaFormId }; + if (r.entityType === 'form') return { r, formId: r.entityId as number | null }; + if (r.entityType === 'task') { + const t = await storage.getTask(r.entityId, organizationId).catch(() => null); + return { r, formId: t?.formId ?? null }; + } + if (r.entityType === 'task_message') { + const metaTaskId = (r.metadata as Record | null)?.taskId; + if (typeof metaTaskId !== 'number') return { r, formId: null }; + const t = await storage.getTask(metaTaskId, organizationId).catch(() => null); + return { r, formId: t?.formId ?? null }; + } + return { r, formId: null }; + })); + // Результаты без определяемой формы при ограниченном formIds не показываем + filteredResults = resolved + .filter((x) => x.formId !== null && isFormAllowed(x.formId)) + .map((x) => x.r); + } + // Enrich results with human-readable details - const enriched = await Promise.all(results.map(async (r) => { + const enriched = await Promise.all(filteredResults.map(async (r) => { const base = { entityType: r.entityType, entityId: r.entityId, @@ -2179,7 +2312,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // reindex_organization - server.registerTool( + register( "reindex_organization", { title: "Reindex Organization", @@ -2226,7 +2359,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // get_organization_context - server.registerTool( + register( "get_organization_context", { title: "Get Organization Context", @@ -2243,7 +2376,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, async ({ include_recent_tasks, include_recent_messages, recent_tasks_per_form, recent_messages_limit }) => { try { - const forms = await storage.getFormsByOrganization(organizationId); + const allForms = await storage.getFormsByOrganization(organizationId); + // Фильтруем выдачу по scopes.formIds (null = все формы) + const forms = allForms.filter((f) => isFormAllowed(f.id)); const formContexts = await Promise.all( forms.map(async (form) => { const [fields, statuses, counts] = await Promise.all([ @@ -2282,7 +2417,9 @@ To block task creation from task.before_create, set: ctx.result = { allow: false let recentMessages: any[] = []; if (include_recent_messages !== false) { - const allTasks = (await storage.getTasksByOrganization(organizationId, { limit: 200, minimal: false })) as Task[]; + const allTasks = ((await storage.getTasksByOrganization(organizationId, { limit: 200, minimal: false })) as Task[]) + // Сообщения только из задач разрешённых форм + .filter((t) => isFormAllowed(t.formId)); const taskTitleMap = new Map(allTasks.map(t => [t.id, t.title])); const messageChunks = await Promise.all( allTasks.slice(0, 50).map(t => storage.getTaskMessages(t.id, organizationId).catch(() => [])) @@ -2324,7 +2461,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // list_field_templates - server.registerTool( + register( "list_field_templates", { title: "List Field Templates", @@ -2355,7 +2492,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // create_field_template - server.registerTool( + register( "create_field_template", { title: "Create Field Template", @@ -2418,7 +2555,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // add_field_template_to_form - server.registerTool( + register( "add_field_template_to_form", { title: "Add Field Template to Form", @@ -2436,6 +2573,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, async ({ form_id, field_template_id, position, tab_id }) => { try { + if (!isFormAllowed(form_id)) return formDenied(form_id); const [form, fieldTemplate] = await Promise.all([ storage.getForm(form_id, organizationId), storage.getFieldTemplate(field_template_id, organizationId), @@ -2518,7 +2656,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // get_form_tabs - server.registerTool( + register( "get_form_tabs", { title: "Get Form Tabs", @@ -2531,6 +2669,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, async ({ form_id }) => { try { + if (!isFormAllowed(form_id)) return formDenied(form_id); const form = await storage.getForm(form_id, organizationId); if (!form) { return { content: [{ type: "text" as const, text: "Form not found" }], isError: true }; @@ -2557,7 +2696,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // append_table_row - server.registerTool( + register( "append_table_row", { title: "Append Row to Table Tab", @@ -2583,6 +2722,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false if (!task) { return { content: [{ type: "text" as const, text: "Task not found" }], isError: true }; } + // Таб резолвится через форму задачи — проверяем доступ к ней + if (!isFormAllowed(task.formId)) return formDenied(task.formId); let resolvedTabId: number; if (typeof tab_id === "number") { @@ -2654,7 +2795,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false ); // add_field_templates_to_table_tab - server.registerTool( + register( "add_field_templates_to_table_tab", { title: "Add Field Templates to Table Tab", @@ -2670,6 +2811,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false }, async ({ form_id, tab_name, field_template_ids }) => { try { + if (!isFormAllowed(form_id)) return formDenied(form_id); const [form, existingTabs] = await Promise.all([ storage.getForm(form_id, organizationId), storage.getFormTabs(form_id, organizationId), @@ -2756,11 +2898,17 @@ To block task creation from task.before_create, set: ctx.result = { allow: false return server; } -// In-memory SSE sessions: sessionId -> { transport, organizationId } (legacy) -const sseSessions: Map = new Map(); +// In-memory SSE sessions: sessionId -> { transport, organizationId, scopes } (legacy) +const sseSessions: Map = new Map(); -// Stateful Streamable HTTP transports: sessionId -> { transport, server, organizationId } -const mcpTransports = new Map(); +// Stateful Streamable HTTP transports: sessionId -> { transport, server, organizationId, scopes } +const mcpTransports = new Map(); + +// Сравнение скоупов: при изменении прав ключа старая сессия недействительна, +// т.к. набор инструментов фиксируется при создании MCP-сервера. +function scopesEqual(a: ApiKeyScopes, b: ApiKeyScopes): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} function createJsonRpcErrorResponse(code: number, message: string) { return { jsonrpc: "2.0" as const, error: { code, message }, id: null }; @@ -2769,13 +2917,14 @@ function createJsonRpcErrorResponse(code: number, message: string) { // GET/POST/DELETE /mcp — Streamable HTTP (stateful, modern clients: Cursor, Cline, Kimi CLI, etc.) export async function handleMcpRequest(req: Request, res: Response) { try { - const organizationId = await resolveOrgFromKey(req); - if (!organizationId) { + const resolved = await resolveApiKey(req); + if (!resolved) { if (!res.headersSent) { res.status(401).json(createJsonRpcErrorResponse(-32001, "Invalid or missing API key. Provide X-Api-Key header.")); } return; } + const { organizationId, scopes } = resolved; const sessionId = req.headers["mcp-session-id"] as string | undefined; @@ -2784,10 +2933,10 @@ export async function handleMcpRequest(req: Request, res: Response) { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sid) => { - mcpTransports.set(sid, { transport, server, organizationId }); + mcpTransports.set(sid, { transport, server, organizationId, scopes }); }, }); - const server = buildMcpServer(organizationId); + const server = buildMcpServer(organizationId, scopes); await server.connect(transport); @@ -2811,6 +2960,17 @@ export async function handleMcpRequest(req: Request, res: Response) { } return; } + // Перечитываем скоупы при ревалидации ключа: если права изменились, + // закрываем сессию — клиент должен переподключиться с новым набором инструментов. + if (!scopesEqual(session.scopes, scopes)) { + mcpTransports.delete(sessionId); + session.transport.close().catch(() => {}); + if (!res.headersSent) { + res.status(401).json(createJsonRpcErrorResponse(-32001, "Права API-ключа изменены. Переподключитесь (новая MCP-сессия).")); + } + return; + } + session.scopes = scopes; await session.transport.handleRequest(req, res, req.body); return; } @@ -2829,22 +2989,23 @@ export async function handleMcpRequest(req: Request, res: Response) { // GET /mcp/sse — Legacy SSE transport (Claude Desktop) export async function handleMcpSse(req: Request, res: Response) { - const organizationId = await resolveOrgFromKey(req); - if (!organizationId) { + const resolved = await resolveApiKey(req); + if (!resolved) { res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." }); return; } + const { organizationId, scopes } = resolved; const transport = new SSEServerTransport("/mcp/messages", res); const sessionId = transport.sessionId; - sseSessions.set(sessionId, { transport, organizationId }); + sseSessions.set(sessionId, { transport, organizationId, scopes }); transport.onclose = () => { sseSessions.delete(sessionId); }; - const server = buildMcpServer(organizationId); + const server = buildMcpServer(organizationId, scopes); await server.connect(transport); } @@ -2863,11 +3024,19 @@ export async function handleMcpMessages(req: Request, res: Response) { } // Re-validate the API key on each message to prevent session hijacking - const organizationId = await resolveOrgFromKey(req); - if (!organizationId || organizationId !== session.organizationId) { + const resolved = await resolveApiKey(req); + if (!resolved || resolved.organizationId !== session.organizationId) { res.status(401).json({ error: "Invalid or missing API key" }); return; } + // Перечитываем скоупы при ревалидации: при изменении прав закрываем сессию + if (!scopesEqual(session.scopes, resolved.scopes)) { + sseSessions.delete(sessionId); + session.transport.close().catch(() => {}); + res.status(401).json({ error: "Права API-ключа изменены. Переподключитесь (новая MCP-сессия)." }); + return; + } + session.scopes = resolved.scopes; await session.transport.handlePostMessage(req, res, req.body); } diff --git a/server/routes/mcp-rag.routes.ts b/server/routes/mcp-rag.routes.ts index 3dbd1a4..ce49343 100644 --- a/server/routes/mcp-rag.routes.ts +++ b/server/routes/mcp-rag.routes.ts @@ -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); diff --git a/server/storage.ts b/server/storage.ts index ea24030..1dce3b9 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -2,7 +2,7 @@ // Implementation is split into domain modules under server/storage/ import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, forms, formTabs, formFields, formStatuses, statusTransitions, tasks, taskFieldValues, fieldHistory, taskMessages, userNotifications, messageReads, bookmarkFolders, bookmarks, bots, botSubscriptions, botSessions, dataTables, dataTableRows, dataTablePermissions, externalServices, regularTableRows, fieldTemplates, customTabModules, taskTabValues, deviceTokens, userPresence, webPushSubscriptions, customPages, type User, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type UserProfileTab, type UserProfileField, type UserProfileFieldValue, type UserProfileAuditLog, type InsertUserProfileTab, type InsertUserProfileField, type InsertUserProfileFieldValue, type InsertUserProfileAuditLog, userProfileAuditLog, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type Task, type TaskFieldValue, type FieldHistory, type TaskMessage, type TaskMessageWithAuthor, type UserNotification, type MessageRead, type InsertMessageRead, type BookmarkFolder, type Bookmark, type InsertForm, type InsertFormTab, type InsertFormField, type InsertFormStatus, type InsertStatusTransition, type InsertTask, type InsertTaskFieldValue, type InsertFieldHistory, type InsertTaskMessage, type InsertUserNotification, type InsertBookmarkFolder, type InsertBookmark, type Bot, type BotSubscription, type BotSession, type InsertBot, type InsertBotSubscription, type InsertBotSession, type BotWithSubscriptions, type DataTable, type DataTableRow, type DataTablePermission, type DataTableAccessRule, type InsertDataTableAccessRule, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableWithRows, type DataTableFull, type ExternalService, type InsertExternalService, type RegularTableRow, type InsertRegularTableRow, type FieldTemplate, type InsertFieldTemplate, type CustomTabModule, type InsertCustomTabModule, type TaskTabValue, type InsertTaskTabValue, type DeviceToken, type InsertDeviceToken, type UserPresence, type InsertUserPresence, type WebPushSubscription, type CustomPage, type InsertCustomPage } from "@shared/schema"; -import { organizationApiKeys, type OrganizationApiKey } from "@shared/schema"; +import { organizationApiKeys, type OrganizationApiKey, type ApiKeyScopes } from "@shared/schema"; import { automations, type Automation, type InsertAutomation } from "@shared/schema"; import { taskRelations, type TaskRelation, type InsertTaskRelation } from "@shared/schema"; import { taskReminders, type TaskReminder, type InsertTaskReminder } from "@shared/schema"; @@ -364,7 +364,8 @@ export interface IStorage { upsertTaskTabValues(taskId: number, tabId: number, values: Record): Promise; // Organization API Keys (MCP) - createApiKey(organizationId: number, createdBy: number, label: string): Promise<{ key: string; record: OrganizationApiKey }>; + createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | 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; diff --git a/server/storage/content.storage.ts b/server/storage/content.storage.ts index 5cc889e..cdc1200 100644 --- a/server/storage/content.storage.ts +++ b/server/storage/content.storage.ts @@ -1,5 +1,5 @@ import { forms, formTabs, fieldTemplates, customTabModules, taskTabValues, deviceTokens, userPresence, webPushSubscriptions, customPages, type User, type Organization, type FormTab, type Task, type FieldTemplate, type InsertFieldTemplate, type CustomTabModule, type InsertCustomTabModule, type TaskTabValue, type DeviceToken, type UserPresence, type WebPushSubscription, type CustomPage, type InsertCustomPage } from "@shared/schema"; -import { organizationApiKeys, type OrganizationApiKey } from "@shared/schema"; +import { organizationApiKeys, type OrganizationApiKey, type ApiKeyScopes } from "@shared/schema"; import { automations, type Automation, type InsertAutomation } from "@shared/schema"; import { systemConfig } from "@shared/schema"; import { db } from "../db"; @@ -517,16 +517,36 @@ export class ContentStorage extends DataTablesStorage { } // Organization API Keys (MCP) - async createApiKey(organizationId: number, createdBy: number, label: string): Promise<{ key: string; record: OrganizationApiKey }> { + async createApiKey(organizationId: number, createdBy: number, label: string, scopes?: ApiKeyScopes | null): Promise<{ key: string; record: OrganizationApiKey }> { const rawKey = "wf_" + crypto.randomBytes(32).toString("base64url"); const keyHash = hashApiKey(rawKey); const keyPrefix = rawKey.substring(0, 10); const [record] = await db.insert(organizationApiKeys).values({ organizationId, createdBy, label, keyHash, keyPrefix, isLegacy: false, + // NULL = полный доступ (legacy-поведение) + scopes: scopes ?? null, }).returning(); return { key: rawKey, record }; } + // Обновление label/scopes ключа с проверкой принадлежности организации. + // Возвращает undefined, если ключ не найден в этой организации. + async updateApiKey(id: number, organizationId: number, data: { label?: string; scopes?: ApiKeyScopes | null }): Promise { + const updates: Partial> = {}; + if (data.label !== undefined) updates.label = data.label; + if (data.scopes !== undefined) updates.scopes = data.scopes; + if (Object.keys(updates).length === 0) { + const [existing] = await db.select().from(organizationApiKeys) + .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))); + return existing || undefined; + } + const [updated] = await db.update(organizationApiKeys) + .set(updates) + .where(and(eq(organizationApiKeys.id, id), eq(organizationApiKeys.organizationId, organizationId))) + .returning(); + return updated || undefined; + } + async listApiKeys(organizationId: number): Promise { return await db.select().from(organizationApiKeys) .where(eq(organizationApiKeys.organizationId, organizationId)) diff --git a/server/utils/api-key.ts b/server/utils/api-key.ts index c86f49e..4075446 100644 --- a/server/utils/api-key.ts +++ b/server/utils/api-key.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import type { ApiKeyScopes } from '@shared/schema'; function getHmacSecret(): string { const secret = process.env.API_KEY_HMAC_SECRET; @@ -30,3 +31,58 @@ export function verifyApiKey(raw: string, hash: string): boolean { export function legacySha256Hash(raw: string): string { return crypto.createHash('sha256').update(raw).digest('hex'); } + +// Полный доступ — используется как дефолт для legacy-ключей (scopes = NULL в БД) +export const FULL_API_KEY_SCOPES: ApiKeyScopes = { mode: 'full', formIds: null, tableIds: null }; + +// Нормализация скоупов из БД: NULL/отсутствующий или частично заполненный объект +// приводится к полной структуре ApiKeyScopes (дефолты — полный доступ). +export function normalizeApiKeyScopes(scopes: unknown): ApiKeyScopes { + if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) { + return { ...FULL_API_KEY_SCOPES }; + } + const s = scopes as Partial; + return { + mode: s.mode === 'read' || s.mode === 'write' || s.mode === 'full' ? s.mode : 'full', + formIds: Array.isArray(s.formIds) + ? s.formIds.filter((n): n is number => typeof n === 'number' && Number.isInteger(n)) + : null, + tableIds: Array.isArray(s.tableIds) + ? s.tableIds.filter((n): n is number => typeof n === 'number' && Number.isInteger(n)) + : null, + }; +} + +// Строгая валидация скоупов, присланных клиентом (POST/PATCH /api/mcp-keys). +// Возвращает { ok: true, scopes } либо { ok: false, error } с русским сообщением. +export function parseApiKeyScopesInput( + input: unknown +): { ok: true; scopes: ApiKeyScopes } | { ok: false; error: string } { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { ok: false, error: 'Поле scopes должно быть объектом { mode, formIds, tableIds }' }; + } + const s = input as Record; + + if (s.mode !== 'read' && s.mode !== 'write' && s.mode !== 'full') { + return { ok: false, error: "Поле scopes.mode должно быть одним из: 'read', 'write', 'full'" }; + } + + const parseIds = (value: unknown, field: string): number[] | null | { error: string } => { + if (value === null || value === undefined) return null; + if (!Array.isArray(value) || !value.every((n) => typeof n === 'number' && Number.isInteger(n))) { + return { error: `Поле scopes.${field} должно быть массивом целых чисел или null` }; + } + return value as number[]; + }; + + const formIds = parseIds(s.formIds, 'formIds'); + if (formIds !== null && typeof formIds === 'object' && 'error' in formIds) { + return { ok: false, error: formIds.error }; + } + const tableIds = parseIds(s.tableIds, 'tableIds'); + if (tableIds !== null && typeof tableIds === 'object' && 'error' in tableIds) { + return { ok: false, error: tableIds.error }; + } + + return { ok: true, scopes: { mode: s.mode, formIds, tableIds } }; +} diff --git a/shared/schema.ts b/shared/schema.ts index 6b058fa..e529c8e 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -2557,6 +2557,14 @@ export type WebPushSubscription = typeof webPushSubscriptions.$inferSelect; // Organization API Keys (for MCP server access) // ===================== +// Скоупы прав доступа API-ключа. +// NULL в БД = полный доступ (legacy-ключи, созданные до введения скоупов). +export type ApiKeyScopes = { + mode: 'read' | 'write' | 'full'; // read = только чтение; write = чтение + создание; full = всё + formIds: number[] | null; // null = все формы + tableIds: number[] | null; // null = все справочники +}; + export const organizationApiKeys = pgTable("organization_api_keys", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), @@ -2568,6 +2576,7 @@ export const organizationApiKeys = pgTable("organization_api_keys", { lastUsedAt: timestamp("last_used_at"), isActive: boolean("is_active").notNull().default(true), isLegacy: boolean("is_legacy").notNull().default(false), + scopes: jsonb("scopes").$type(), }, (table) => ({ orgIndex: index("api_keys_org_idx").on(table.organizationId), hashIndex: index("api_keys_hash_idx").on(table.keyHash),