Песочница отчётов для ИИ: recharts в custom pages + внешние БД с read-only шлюзом

- JsComponentRenderer: ctx.libs.charts (recharts) — графики в кастомных страницах
  и JS-вкладках без redeploy; справка get_js_coding_reference обновлена
- Внешние БД (миграция 0088): вкладка «Настройки → Внешние БД» — реестр подключений
  (пароль AES-256-GCM, env EXTERNAL_DB_SECRET, по API не отдаётся), тест подключения,
  allowlist таблиц на подключение
- Read-only шлюз POST /api/external-db/:id/query (finance.manage): только SELECT,
  без мульти-выражений, обёртка с LIMIT 5000, statement_timeout 10с, проверка таблиц
  по allowlist, аудит в external_db_query_log (кто/SQL/строки/мс)
- Назначение: ИИ-агенты строят отчёты и виджеты поверх витрин (напр. НДС 1С) без
  новых серверных эндпоинтов и пересборки
This commit is contained in:
2026-09-29 22:50:40 +03:00
parent 9827b5df33
commit 59bd802bf7
7 changed files with 686 additions and 1 deletions

View File

@@ -0,0 +1,324 @@
// Внешние подключения к БД: реестр (admin) + read-only SQL-шлюз (finance.manage).
// Назначение: отчёты и виджеты (в т.ч. построенные ИИ-агентами через custom pages)
// читают внешние витрины данных без новых серверных эндпоинтов и без redeploy.
//
// Безопасность:
// - пароль шифруется AES-256-GCM, по API не отдаётся (только маска);
// - шлюз: только SELECT, без мульти-выражений, allowlist таблиц на подключение,
// принудительный LIMIT (обёртка подзапросом), statement_timeout 10 с;
// - каждый запрос пишется в external_db_query_log (кто/когда/SQL/строки/мс);
// - рекомендуется заводить во внешней БД пользователя с реальным GRANT SELECT
// только на нужные таблицы (defence in depth — шлюз не заменяет права БД).
import type { Express, Response } from "express";
import { Router } from "express";
import { Pool, type PoolConfig } from "pg";
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
import { db } from "../db";
import { sql } from "drizzle-orm";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
const MAX_ROWS = 5000;
const STATEMENT_TIMEOUT_MS = 10000;
function encryptionKey(): Buffer {
const secret = process.env.EXTERNAL_DB_SECRET || process.env.API_KEY_HMAC_SECRET || "";
if (!secret) {
console.warn("[external-db] EXTERNAL_DB_SECRET не задан — используется небезопасный dev-ключ");
}
return createHash("sha256").update(secret || "iistwin-external-db-dev-key").digest();
}
function encryptPassword(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("base64")}.${tag.toString("base64")}.${enc.toString("base64")}`;
}
function decryptPassword(stored: string): string {
const [ivB64, tagB64, dataB64] = stored.split(".");
if (!ivB64 || !tagB64 || !dataB64) throw new Error("Некорректный формат зашифрованного пароля");
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(ivB64, "base64"));
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]).toString("utf8");
}
interface ConnRow {
id: number;
organization_id: number;
name: string;
host: string;
port: number;
database: string;
username: string;
password_encrypted: string;
ssl: boolean;
allowed_tables: string[];
is_active: boolean;
created_at: string;
updated_at: string;
}
const pools = new Map<number, Pool>();
function getPool(conn: ConnRow): Pool {
let pool = pools.get(conn.id);
if (!pool) {
const cfg: PoolConfig = {
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.username,
password: decryptPassword(conn.password_encrypted),
ssl: conn.ssl ? { rejectUnauthorized: false } : false,
max: 3,
connectionTimeoutMillis: 8000,
statement_timeout: STATEMENT_TIMEOUT_MS,
} as PoolConfig;
pool = new Pool(cfg);
pool.on("error", (err) => console.error(`[external-db] pool ${conn.id} error:`, err.message));
pools.set(conn.id, pool);
}
return pool;
}
function dropPool(connId: number): void {
const pool = pools.get(connId);
if (pool) {
pools.delete(connId);
pool.end().catch(() => {});
}
}
// --- Валидация SQL для read-only шлюза ---
// Вырезаем строковые литералы, чтобы ключевые слова внутри них не ломали проверки
function stripStringLiterals(sqlText: string): string {
return sqlText.replace(/'([^']|'')*'/g, "''");
}
function validateReadOnly(rawSql: unknown): string {
if (typeof rawSql !== "string" || !rawSql.trim()) {
throw new Error("SQL запрос не задан");
}
const cleaned = rawSql.trim().replace(/;+\s*$/, "");
if (!/^select\b/i.test(cleaned)) {
throw new Error("Только SELECT-запросы");
}
if (/;\s*\S/.test(cleaned)) {
throw new Error("Не допускается несколько выражений в одном запросе");
}
const noStrings = stripStringLiterals(cleaned);
const forbidden = /\b(insert|update|delete|drop|alter|truncate|grant|revoke|create|copy|call|execute|do|merge|vacuum|cluster|reindex|refresh)\b/i;
if (forbidden.test(noStrings)) {
throw new Error("Запрещённое ключевое слово в запросе");
}
return cleaned;
}
function extractTableNames(sqlText: string): string[] {
const names = new Set<string>();
const re = /\b(?:from|join)\s+([a-zA-Z_][\w$]*(?:\.[a-zA-Z_][\w$]*)?)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(sqlText)) !== null) {
const parts = m[1].split(".");
names.add(parts[parts.length - 1].toLowerCase());
}
return [...names];
}
function toPublicConn(row: ConnRow) {
return {
id: row.id,
name: row.name,
host: row.host,
port: row.port,
database: row.database,
username: row.username,
hasPassword: true,
ssl: row.ssl,
allowedTables: row.allowed_tables ?? [],
isActive: row.is_active,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function registerExternalDbRoutes(app: Express): void {
const r = Router();
app.use("/api/external-db", r);
// Управление подключениями — только admin приложения
r.use("/connections", authenticateToken, requireAppAdmin);
r.get("/connections", async (req: AuthenticatedRequest, res: Response) => {
try {
const orgId = req.user!.organizationId;
const rows = await db.execute(sql`
SELECT * FROM external_db_connections
WHERE organization_id = ${orgId}
ORDER BY id
`);
res.json((rows.rows as unknown as ConnRow[]).map(toPublicConn));
} catch (err) {
console.error(err);
res.status(500).json({ error: "Ошибка базы данных" });
}
});
r.post("/connections", async (req: AuthenticatedRequest, res: Response) => {
try {
const orgId = req.user!.organizationId;
const { name, host, port, database, username, password, ssl, allowedTables } = req.body ?? {};
if (!name || !host || !database || !username || !password) {
return res.status(400).json({ error: "Обязательные поля: name, host, database, username, password" });
}
const tables = sanitizeTables(allowedTables);
const inserted = await db.execute(sql`
INSERT INTO external_db_connections
(organization_id, name, host, port, database, username, password_encrypted, ssl, allowed_tables)
VALUES
(${orgId}, ${String(name)}, ${String(host)}, ${Number(port) || 5432}, ${String(database)},
${String(username)}, ${encryptPassword(String(password))}, ${!!ssl}, ${JSON.stringify(tables)}::jsonb)
RETURNING *
`);
res.status(201).json(toPublicConn(inserted.rows[0] as unknown as ConnRow));
} catch (err) {
console.error(err);
res.status(500).json({ error: "Ошибка базы данных" });
}
});
r.patch("/connections/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const orgId = req.user!.organizationId;
const id = parseInt(req.params.id, 10);
const b = req.body ?? {};
const existing = await findConn(orgId, id);
if (!existing) return res.status(404).json({ error: "Подключение не найдено" });
const sets: ReturnType<typeof sql>[] = [];
if (b.name !== undefined) sets.push(sql`name = ${String(b.name)}`);
if (b.host !== undefined) sets.push(sql`host = ${String(b.host)}`);
if (b.port !== undefined) sets.push(sql`port = ${Number(b.port) || 5432}`);
if (b.database !== undefined) sets.push(sql`database = ${String(b.database)}`);
if (b.username !== undefined) sets.push(sql`username = ${String(b.username)}`);
if (b.password) sets.push(sql`password_encrypted = ${encryptPassword(String(b.password))}`);
if (b.ssl !== undefined) sets.push(sql`ssl = ${!!b.ssl}`);
if (b.allowedTables !== undefined) sets.push(sql`allowed_tables = ${JSON.stringify(sanitizeTables(b.allowedTables))}::jsonb`);
if (b.isActive !== undefined) sets.push(sql`is_active = ${!!b.isActive}`);
if (sets.length === 0) return res.status(400).json({ error: "Нет полей для обновления" });
const updated = await db.execute(sql`
UPDATE external_db_connections
SET ${sql.join(sets, sql`, `)}, updated_at = NOW()
WHERE id = ${id} AND organization_id = ${orgId}
RETURNING *
`);
dropPool(id);
res.json(toPublicConn(updated.rows[0] as unknown as ConnRow));
} catch (err) {
console.error(err);
res.status(500).json({ error: "Ошибка базы данных" });
}
});
r.delete("/connections/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const orgId = req.user!.organizationId;
const id = parseInt(req.params.id, 10);
await db.execute(sql`
DELETE FROM external_db_connections WHERE id = ${id} AND organization_id = ${orgId}
`);
dropPool(id);
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Ошибка базы данных" });
}
});
r.post("/connections/:id/test", async (req: AuthenticatedRequest, res: Response) => {
try {
const orgId = req.user!.organizationId;
const id = parseInt(req.params.id, 10);
const conn = await findConn(orgId, id);
if (!conn) return res.status(404).json({ error: "Подключение не найдено" });
const started = Date.now();
await getPool(conn).query("SELECT 1");
res.json({ ok: true, durationMs: Date.now() - started });
} catch (err: any) {
res.json({ ok: false, error: err?.message || "Ошибка подключения" });
}
});
// --- Read-only шлюз (finance.manage) ---
r.post("/:id/query", authenticateToken, requirePermission("finance.manage"), async (req: AuthenticatedRequest, res: Response) => {
const started = Date.now();
const orgId = req.user!.organizationId;
const id = parseInt(req.params.id, 10);
const sqlText = req.body?.sql;
let rowCount = 0;
let errorText: string | null = null;
let conn: ConnRow | null = null;
try {
conn = await findConn(orgId, id);
if (!conn) return res.status(404).json({ error: "Подключение не найдено" });
if (!conn.is_active) return res.status(403).json({ error: "Подключение отключено" });
const cleaned = validateReadOnly(sqlText);
const allowed = (conn.allowed_tables ?? []).map((t) => String(t).toLowerCase());
if (allowed.length === 0) {
return res.status(403).json({ error: "Для подключения не задан список разрешённых таблиц" });
}
const usedTables = extractTableNames(stripStringLiterals(cleaned));
const denied = usedTables.filter((t) => !allowed.includes(t));
if (denied.length > 0) {
return res.status(403).json({ error: `Таблицы не в allowlist: ${denied.join(", ")}` });
}
const wrapped = `SELECT * FROM (${cleaned}) AS __gateway_q LIMIT ${MAX_ROWS}`;
const result = await getPool(conn).query(wrapped);
rowCount = result.rowCount ?? result.rows.length;
res.json({ columns: result.fields.map((f) => f.name), rows: result.rows, rowCount, truncated: rowCount >= MAX_ROWS });
} catch (err: any) {
errorText = err?.message || String(err);
if (!res.headersSent) {
res.status(400).json({ error: errorText });
}
} finally {
if (conn) {
const botId = (req as any).bot?.id ?? null;
db.execute(sql`
INSERT INTO external_db_query_log
(organization_id, connection_id, user_id, bot_id, sql_text, row_count, duration_ms, error)
VALUES
(${orgId}, ${id}, ${req.user!.id}, ${botId},
${String(sqlText ?? "").slice(0, 4000)}, ${rowCount}, ${Date.now() - started}, ${errorText})
`).catch((e) => console.error("[external-db] audit log error:", e.message));
}
}
});
async function findConn(orgId: number, id: number): Promise<ConnRow | null> {
const rows = await db.execute(sql`
SELECT * FROM external_db_connections WHERE id = ${id} AND organization_id = ${orgId}
`);
return (rows.rows[0] as unknown as ConnRow) ?? null;
}
}
function sanitizeTables(input: unknown): string[] {
if (!Array.isArray(input)) return [];
return [...new Set(input.map((t) => String(t).trim().toLowerCase()).filter(Boolean))];
}
function requireAppAdmin(req: AuthenticatedRequest, res: Response, next: () => void): void {
if (req.user?.appRole !== "admin") {
res.status(403).json({ error: "Требуется роль администратора" });
return;
}
next();
}

View File

@@ -44,6 +44,7 @@ import { registerMedScheduleRoutes } from "../medschedule/routes";
import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes";
import { registerGpsRoutes } from "../gps/routes";
import { registerVpnRoutes } from "../vpn";
import { registerExternalDbRoutes } from "./external-db.routes";
import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types";
export { publishNotificationSSE };
@@ -127,6 +128,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// GPS-модуль: публичный /api/gps/ingest регистрируется внутри до authenticateToken
registerGpsRoutes(app);
registerVpnRoutes(app);
registerExternalDbRoutes(app);
const httpServer = createServer(app);
return httpServer;