Files
iistwin/server/utils/api-key.ts
Ильяс Султанов a7733b4a03 Права доступа 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: диалог создания/редактирования ключа с режимом и мультивыбором форм/справочников
2026-07-21 16:12:55 +03:00

89 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import crypto from 'crypto';
import type { ApiKeyScopes } from '@shared/schema';
function getHmacSecret(): string {
const secret = process.env.API_KEY_HMAC_SECRET;
if (!secret) {
throw new Error(
'API_KEY_HMAC_SECRET environment variable is required for API key hashing. ' +
'Generate with: openssl rand -hex 32'
);
}
return secret;
}
export function hashApiKey(raw: string): string {
return crypto
.createHmac('sha256', getHmacSecret())
.update(raw)
.digest('hex');
}
export function verifyApiKey(raw: string, hash: string): boolean {
const expected = hashApiKey(raw);
if (expected.length !== hash.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(hash, 'hex')
);
}
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 } };
}