- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status - sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user - authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api' - Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100 - MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
99 lines
4.1 KiB
TypeScript
99 lines
4.1 KiB
TypeScript
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 = все формы)
|
||
export function isFormAllowedByScopes(scopes: ApiKeyScopes, formId: number): boolean {
|
||
return scopes.formIds === null || scopes.formIds.includes(formId);
|
||
}
|
||
|
||
// Проверка доступа к справочнику по скоупам ключа (null = все справочники)
|
||
export function isTableAllowedByScopes(scopes: ApiKeyScopes, tableId: number): boolean {
|
||
return scopes.tableIds === null || scopes.tableIds.includes(tableId);
|
||
}
|
||
|
||
// Нормализация скоупов из БД: 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 } };
|
||
}
|