Files
iistwin/server/utils/api-key.ts
Ильяс Султанов 8cfd49fd9f Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)
- 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
2026-07-22 15:18:04 +03:00

99 lines
4.1 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 = все формы)
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 } };
}