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