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

@@ -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 } };
}