Права доступа 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

@@ -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<ApiKeyScopes['mode']>(apiKey?.scopes?.mode ?? 'full');
const [allForms, setAllForms] = useState(apiKey?.scopes?.formIds == null);
const [allTables, setAllTables] = useState(apiKey?.scopes?.tableIds == null);
const [selectedFormIds, setSelectedFormIds] = useState<number[]>(apiKey?.scopes?.formIds ?? []);
const [selectedTableIds, setSelectedTableIds] = useState<number[]>(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,
) => (
<div className="space-y-1.5">
<Label className="text-xs font-medium">{title}</Label>
<RadioGroup
value={all ? 'all' : 'selected'}
onValueChange={(v) => setAll(v === 'all')}
className="flex gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="all" id={`${testIdPrefix}-all`} />
<Label htmlFor={`${testIdPrefix}-all`} className="text-xs font-normal cursor-pointer">{allLabel}</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="selected" id={`${testIdPrefix}-selected`} />
<Label htmlFor={`${testIdPrefix}-selected`} className="text-xs font-normal cursor-pointer">Выбранные</Label>
</div>
</RadioGroup>
{!all && (
<div className="max-h-32 overflow-y-auto border border-border rounded p-1.5 space-y-1">
{items.length === 0 ? (
<div className="text-[11px] text-muted-foreground py-1 text-center">Список пуст</div>
) : (
items.map((item) => (
<div key={item.id} className="flex items-center gap-1.5">
<Checkbox
id={`${testIdPrefix}-${item.id}`}
checked={selectedIds.includes(item.id)}
onCheckedChange={() => setSelectedIds(toggleId(selectedIds, item.id))}
data-testid={`checkbox-${testIdPrefix}-${item.id}`}
/>
<Label htmlFor={`${testIdPrefix}-${item.id}`} className="text-xs font-normal cursor-pointer">
{item.name}
</Label>
</div>
))
)}
</div>
)}
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">{apiKey ? 'Редактировать API-ключ' : 'Создать API-ключ'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Название</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Название ключа (например: Claude Desktop)"
className="h-7 text-xs"
data-testid="input-key-label"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium">Режим доступа</Label>
<RadioGroup value={mode} onValueChange={(v) => setMode(v as ApiKeyScopes['mode'])} className="space-y-1">
<div className="flex items-center gap-1.5">
<RadioGroupItem value="read" id="key-mode-read" data-testid="radio-mode-read" />
<Label htmlFor="key-mode-read" className="text-xs font-normal cursor-pointer">Только чтение</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="write" id="key-mode-write" data-testid="radio-mode-write" />
<Label htmlFor="key-mode-write" className="text-xs font-normal cursor-pointer">Чтение и создание</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="full" id="key-mode-full" data-testid="radio-mode-full" />
<Label htmlFor="key-mode-full" className="text-xs font-normal cursor-pointer">Полный доступ</Label>
</div>
</RadioGroup>
</div>
{mode !== 'full' && (
<>
{renderScopePicker('Доступ к формам', 'Все формы', allForms, setAllForms, forms, selectedFormIds, setSelectedFormIds, 'scope-form')}
{renderScopePicker('Доступ к справочникам', 'Все справочники', allTables, setAllTables, directories, selectedTableIds, setSelectedTableIds, 'scope-table')}
{scopesInvalid && (
<p className="text-[11px] text-destructive">
Выберите хотя бы одну форму и один справочник или переключитесь на «Все»
</p>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Отмена</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={isPending || !label.trim() || scopesInvalid}
data-testid="button-save-key"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : null}
{apiKey ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── Main Settings ──────────────────────────────────────────────────────────────
const Settings = () => {
const { toast } = useToast();
const { user, isLoading: isAuthLoading } = useAuth();
const [location, setLocation] = useLocation();
const [newKeyLabel, setNewKeyLabel] = useState('');
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Диалог создания/редактирования ключа: editingKey === null — создание нового
const [keyDialogOpen, setKeyDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKey | null>(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 <Badge className="text-[10px] h-4 bg-blue-100 text-blue-700 border-blue-200">Чтение</Badge>;
if (mode === 'write') return <Badge className="text-[10px] h-4 bg-yellow-100 text-yellow-700 border-yellow-200">Создание</Badge>;
return <Badge className="text-[10px] h-4 bg-green-100 text-green-700 border-green-200">Полный</Badge>;
};
// Мелкое описание ограничений: «Формы: 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 = () => {
<span className="text-sm font-medium">Создать API-ключ</span>
</div>
<div className="p-3 space-y-2">
<div className="flex gap-2">
<Input
value={newKeyLabel}
onChange={(e) => 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"
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">Задайте название и права доступа для нового ключа.</p>
<Button
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => newKeyLabel.trim() && createApiKeyMutation.mutate(newKeyLabel.trim())}
disabled={!newKeyLabel.trim() || createApiKeyMutation.isPending}
className="h-7 px-2.5 text-xs gap-1.5 shrink-0"
onClick={() => { setEditingKey(null); setKeyDialogOpen(true); }}
data-testid="button-create-key"
>
{createApiKeyMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Key className="w-3.5 h-3.5" />}
<Plus className="w-3.5 h-3.5" />
Новый ключ
</Button>
</div>
@@ -1832,23 +2049,38 @@ const Settings = () => {
{apiKeys.map((key) => (
<div key={key.id} className="flex items-center justify-between p-2 rounded border border-border" data-testid={`api-key-row-${key.id}`}>
<div className="space-y-0.5">
<div className="text-xs font-medium">{key.label}</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">{key.label}</span>
{scopeModeBadge(key.scopes)}
</div>
<div className="text-[10px] text-muted-foreground font-mono">{key.keyPrefix}…</div>
<div className="text-[10px] text-muted-foreground">{scopeDetailsText(key.scopes)}</div>
<div className="text-[10px] text-muted-foreground">
Создан: {key.createdAt ? new Date(key.createdAt).toLocaleDateString('ru-RU') : '—'}
{key.lastUsedAt && ` · Использован: ${new Date(key.lastUsedAt).toLocaleDateString('ru-RU')}`}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteApiKeyMutation.mutate(key.id)}
disabled={deleteApiKeyMutation.isPending}
data-testid={`button-delete-key-${key.id}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => { setEditingKey(key); setKeyDialogOpen(true); }}
data-testid={`button-edit-key-${key.id}`}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteApiKeyMutation.mutate(key.id)}
disabled={deleteApiKeyMutation.isPending}
data-testid={`button-delete-key-${key.id}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
@@ -1856,6 +2088,17 @@ const Settings = () => {
</div>
</div>
<ApiKeyFormDialog
key={editingKey ? `edit-${editingKey.id}` : 'new'}
open={keyDialogOpen}
onOpenChange={setKeyDialogOpen}
apiKey={editingKey}
forms={formsList}
directories={directoriesList}
isPending={createApiKeyMutation.isPending || updateApiKeyMutation.isPending}
onSubmit={handleKeyDialogSubmit}
/>
<div className="border border-border rounded">
<div className="px-3 py-2 border-b border-border">
<span className="text-sm font-medium">Доступные MCP-инструменты</span>

View File

@@ -0,0 +1,5 @@
-- Скоупы прав доступа для API-ключей (MCP и REST /api/rag/*).
-- NULL = полный доступ ко всем формам и справочникам (поведение старых ключей не меняется).
ALTER TABLE organization_api_keys
ADD COLUMN IF NOT EXISTS scopes jsonb;

View File

@@ -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<number | null> {
// ── Классификация 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<ResolvedApiKey | null> {
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<number | null> {
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<number | null> {
}
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<string, unknown> | 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<string, unknown> | 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<string, { transport: SSEServerTransport; organizationId: number }> = new Map();
// In-memory SSE sessions: sessionId -> { transport, organizationId, scopes } (legacy)
const sseSessions: Map<string, { transport: SSEServerTransport; organizationId: number; scopes: ApiKeyScopes }> = new Map();
// Stateful Streamable HTTP transports: sessionId -> { transport, server, organizationId }
const mcpTransports = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer; organizationId: number }>();
// Stateful Streamable HTTP transports: sessionId -> { transport, server, organizationId, scopes }
const mcpTransports = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer; organizationId: number; scopes: ApiKeyScopes }>();
// Сравнение скоупов: при изменении прав ключа старая сессия недействительна,
// т.к. набор инструментов фиксируется при создании 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);
}

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);

View File

@@ -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<string, any>): Promise<TaskTabValue>;
// 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<OrganizationApiKey | undefined>;
listApiKeys(organizationId: number): Promise<OrganizationApiKey[]>;
deleteApiKey(id: number, organizationId: number): Promise<void>;
getApiKeyByHash(rawKey: string): Promise<OrganizationApiKey | undefined>;

View File

@@ -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<OrganizationApiKey | undefined> {
const updates: Partial<Pick<OrganizationApiKey, 'label' | 'scopes'>> = {};
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<OrganizationApiKey[]> {
return await db.select().from(organizationApiKeys)
.where(eq(organizationApiKeys.organizationId, organizationId))

View File

@@ -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<ApiKeyScopes>;
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<string, unknown>;
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 } };
}

View File

@@ -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<ApiKeyScopes>(),
}, (table) => ({
orgIndex: index("api_keys_org_idx").on(table.organizationId),
hashIndex: index("api_keys_hash_idx").on(table.keyHash),