feat(connection): 新增生产连接只读保护

This commit is contained in:
Syngnat
2026-06-23 15:33:11 +08:00
parent 3205d131c9
commit b0a9a995fb
30 changed files with 776 additions and 19 deletions

View File

@@ -0,0 +1,250 @@
import type { ConnectionConfig } from "../types";
import { convertMongoShellToJsonCommand } from "./mongodb";
import { resolveSqlDialect } from "./sqlDialect";
import { findSqlStatementRanges } from "./sqlStatementSelection";
type ConnectionReadOnlyLike = Pick<
ConnectionConfig,
"type" | "driver" | "oceanBaseProtocol" | "readOnly"
> | null | undefined;
const CONNECTION_READ_ONLY_TYPES = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"sqlserver",
"iris",
"sqlite",
"duckdb",
"oracle",
"dameng",
"tdengine",
"clickhouse",
"trino",
"mongodb",
]);
const SQL_READ_ONLY_KEYWORDS = new Set([
"select",
"with",
"show",
"describe",
"desc",
"explain",
"pragma",
"values",
"consume",
]);
const SQL_MUTATING_WITH_KEYWORDS = /\b(insert|update|delete|replace|merge|upsert)\b/i;
const SQL_SELECT_INTO_PATTERN = /^\s*select\b[\s\S]*\binto\b/i;
const MONGO_READ_ONLY_COMMANDS = new Set([
"aggregate",
"buildinfo",
"collstats",
"connectionstatus",
"count",
"countdocuments",
"dbstats",
"distinct",
"explain",
"find",
"findone",
"getparameter",
"hello",
"hostinfo",
"ismaster",
"listcollections",
"listdatabases",
"listindexes",
"ping",
"serverstatus",
]);
const MONGO_WRITE_COMMANDS = new Set([
"bulkwrite",
"collmod",
"create",
"createindexes",
"delete",
"drop",
"dropdatabase",
"dropindexes",
"findandmodify",
"insert",
"mapreduce",
"renamecollection",
"update",
]);
const MONGO_META_KEYS = new Set([
"$db",
"$readpreference",
"api",
"apideprecationerrors",
"apistrict",
"comment",
"let",
"lsid",
"maxtimems",
"ordered",
"readconcern",
"writeconcern",
]);
const stripLeadingSqlComments = (statement: string): string => {
let text = String(statement || "").trim();
while (text) {
if (text.startsWith("--")) {
const next = text.indexOf("\n");
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
continue;
}
if (text.startsWith("#")) {
const next = text.indexOf("\n");
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
continue;
}
if (text.startsWith("/*")) {
const next = text.indexOf("*/");
text = next >= 0 ? text.slice(next + 2).trimStart() : "";
continue;
}
break;
}
return text;
};
const extractLeadingSqlKeyword = (statement: string): string => {
const text = stripLeadingSqlComments(statement);
const match = text.match(/^[A-Za-z_][A-Za-z0-9_]*/);
return match ? match[0].toLowerCase() : "";
};
const resolveConnectionReadOnlyType = (
config: ConnectionReadOnlyLike,
): string => {
if (!config) return "";
return String(
resolveSqlDialect(String(config.type || ""), String(config.driver || ""), {
oceanBaseProtocol: config.oceanBaseProtocol,
}),
)
.trim()
.toLowerCase();
};
const isReadOnlySqlStatement = (statement: string): boolean => {
const text = stripLeadingSqlComments(statement);
if (!text) return true;
const keyword = extractLeadingSqlKeyword(text);
if (!keyword || !SQL_READ_ONLY_KEYWORDS.has(keyword)) {
return false;
}
if (keyword === "select") {
return !SQL_SELECT_INTO_PATTERN.test(text);
}
if (keyword === "with") {
return !SQL_SELECT_INTO_PATTERN.test(text) &&
!SQL_MUTATING_WITH_KEYWORDS.test(text);
}
return true;
};
const normalizeMongoCommandText = (statement: string): string => {
const trimmed = String(statement || "").trim();
if (!trimmed) return "";
if (trimmed.startsWith("{")) {
return trimmed;
}
const converted = convertMongoShellToJsonCommand(trimmed);
if (converted.recognized && converted.command) {
return converted.command;
}
return "";
};
const resolveMongoCommandKey = (command: Record<string, unknown>): string => {
const keys = Object.keys(command).map((key) => key.trim());
const effectiveKeys = keys.filter((key) => {
const normalized = key.toLowerCase();
return normalized !== "" && !MONGO_META_KEYS.has(normalized);
});
for (const key of effectiveKeys) {
if (MONGO_WRITE_COMMANDS.has(key.toLowerCase())) {
return key;
}
}
for (const key of effectiveKeys) {
if (MONGO_READ_ONLY_COMMANDS.has(key.toLowerCase())) {
return key;
}
}
return effectiveKeys[0] || "";
};
const isReadOnlyMongoStatement = (statement: string): boolean => {
const commandText = normalizeMongoCommandText(statement);
if (!commandText) return false;
try {
const parsed = JSON.parse(commandText) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return false;
}
const commandKey = resolveMongoCommandKey(parsed).toLowerCase();
if (!commandKey) return false;
if (MONGO_WRITE_COMMANDS.has(commandKey)) {
return false;
}
return MONGO_READ_ONLY_COMMANDS.has(commandKey);
} catch {
return false;
}
};
const isConnectionReadOnlyStatement = (
config: ConnectionReadOnlyLike,
statement: string,
): boolean => {
const dialect = resolveConnectionReadOnlyType(config);
if (dialect === "mongodb") {
return isReadOnlyMongoStatement(statement);
}
return isReadOnlySqlStatement(statement);
};
export const supportsConnectionReadOnlyMode = (
config: ConnectionReadOnlyLike,
): boolean => {
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
};
export const isConnectionForcedReadOnly = (
config: ConnectionReadOnlyLike,
): boolean => {
return supportsConnectionReadOnlyMode(config) && config?.readOnly === true;
};
export const findConnectionMutatingStatements = (
config: ConnectionReadOnlyLike,
sql: string,
): string[] => {
if (!isConnectionForcedReadOnly(config)) {
return [];
}
return findSqlStatementRanges(String(sql || ""))
.map((range) => range.text.trim())
.filter((statement) => statement.length > 0)
.filter((statement) => !isConnectionReadOnlyStatement(config, statement));
};

View File

@@ -169,6 +169,19 @@ describe('buildRpcConnectionConfig', () => {
expect(result.sslKeyPath).toBe('C:/certs/client-key.pem');
});
it('preserves the connection-level readOnly guard flag for RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-prod',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
readOnly: true,
} as any);
expect(result.readOnly).toBe(true);
});
it('fills default nested config blocks needed by RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis',

View File

@@ -129,6 +129,7 @@ export function buildRpcConnectionConfig(
user: toStringValue(rpcMerged.user),
password: toStringValue(rpcMerged.password),
database: toStringValue(rpcMerged.database),
readOnly: rpcMerged.readOnly === true,
useSSH: rpcMerged.useSSH === true,
ssh: normalizeSSHConfig(rpcMerged.ssh),
useProxy: rpcMerged.useProxy === true,

View File

@@ -240,6 +240,27 @@ describe('dataSourceCapabilities', () => {
});
});
it('forces supported SQL connections marked read-only into query-only mode', () => {
expect(getDataSourceCapabilities({ type: 'postgres', readOnly: true })).toMatchObject({
type: 'postgres',
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: false,
forceReadOnlyQueryResult: true,
});
});
it('ignores readOnly for datasource types that do not support connection-level production guard', () => {
expect(getDataSourceCapabilities({ type: 'redis', readOnly: true })).toMatchObject({
type: 'redis',
supportsQueryEditor: false,
supportsCreateDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
});
it('treats RabbitMQ as a queryable messaging datasource with publish support', () => {
expect(getDataSourceCapabilities({ type: 'rabbitmq' })).toMatchObject({
type: 'rabbitmq',

View File

@@ -1,7 +1,8 @@
import type { ConnectionConfig } from '../types';
import { isConnectionForcedReadOnly } from './connectionReadOnly';
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol'> | null | undefined;
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
@@ -208,16 +209,17 @@ const DROP_DATABASE_TYPES = new Set([
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
const type = resolveDataSourceType(config);
const forcedReadOnly = isConnectionForcedReadOnly(config);
return {
type,
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
supportsCreateDatabase: CREATE_DATABASE_TYPES.has(type),
supportsRenameDatabase: RENAME_DATABASE_TYPES.has(type),
supportsDropDatabase: DROP_DATABASE_TYPES.has(type),
supportsMessagePublish: MESSAGE_PUBLISH_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
supportsCreateDatabase: !forcedReadOnly && CREATE_DATABASE_TYPES.has(type),
supportsRenameDatabase: !forcedReadOnly && RENAME_DATABASE_TYPES.has(type),
supportsDropDatabase: !forcedReadOnly && DROP_DATABASE_TYPES.has(type),
supportsMessagePublish: !forcedReadOnly && MESSAGE_PUBLISH_TYPES.has(type),
forceReadOnlyQueryResult: forcedReadOnly || FORCE_READ_ONLY_QUERY_TYPES.has(type),
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),