mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 20:35:14 +08:00
✨ feat(connection): 支持生产连接多项保护策略
- 新增数据编辑、结构编辑、脚本执行和数据导入四类连接级保护配置 - 升级生产连接保护弹窗为多选卡片,并修复选项对齐与勾选态显示 - 按保护类型收口 QueryEditor、DataGrid、表设计、导入与同步目标入口 - 后端统一拦截 SQL 或 Mongo 写操作、结果编辑、结构变更和导入写入 - AI 本地工具与 RPC 执行链路透传连接保护配置并复用后端守卫 - 补充多语言文案、定向测试与需求追踪记录
This commit is contained in:
@@ -16,6 +16,7 @@ const sectionKeyEntries = [
|
||||
['uri', 'uri'],
|
||||
['target', 'target'],
|
||||
['fileTarget', 'fileTarget'],
|
||||
['readOnly', 'readOnly'],
|
||||
['connectionMode', 'connectionMode'],
|
||||
['oceanBaseProtocol', 'oceanBaseProtocol'],
|
||||
['mongoDiscovery', 'mongoDiscovery'],
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ConnectionConfigSectionKey =
|
||||
| 'uri'
|
||||
| 'target'
|
||||
| 'fileTarget'
|
||||
| 'readOnly'
|
||||
| 'connectionMode'
|
||||
| 'oceanBaseProtocol'
|
||||
| 'mongoDiscovery'
|
||||
@@ -86,6 +87,7 @@ const connectionConfigSectionKeyMap: Record<
|
||||
uri: 'uri',
|
||||
target: 'target',
|
||||
fileTarget: 'fileTarget',
|
||||
readOnly: 'readOnly',
|
||||
connectionMode: 'connectionMode',
|
||||
oceanBaseProtocol: 'oceanBaseProtocol',
|
||||
mongoDiscovery: 'mongoDiscovery',
|
||||
|
||||
57
frontend/src/utils/connectionReadOnly.test.ts
Normal file
57
frontend/src/utils/connectionReadOnly.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findConnectionMutatingStatements,
|
||||
isConnectionDataEditRestricted,
|
||||
isConnectionDataImportRestricted,
|
||||
isConnectionScriptExecutionRestricted,
|
||||
isConnectionStructureEditRestricted,
|
||||
resolveConnectionProtectionConfig,
|
||||
} from './connectionReadOnly';
|
||||
|
||||
describe('connectionReadOnly', () => {
|
||||
it('maps legacy readOnly connections to the full production protection set', () => {
|
||||
expect(resolveConnectionProtectionConfig({
|
||||
type: 'postgres',
|
||||
readOnly: true,
|
||||
})).toEqual({
|
||||
restrictDataEdit: true,
|
||||
restrictStructureEdit: true,
|
||||
restrictScriptExecution: true,
|
||||
restrictDataImport: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps partial protection flags isolated from each other', () => {
|
||||
const config = {
|
||||
type: 'postgres',
|
||||
protection: {
|
||||
restrictDataEdit: true,
|
||||
restrictDataImport: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(isConnectionDataEditRestricted(config)).toBe(true);
|
||||
expect(isConnectionDataImportRestricted(config)).toBe(true);
|
||||
expect(isConnectionStructureEditRestricted(config)).toBe(false);
|
||||
expect(isConnectionScriptExecutionRestricted(config)).toBe(false);
|
||||
});
|
||||
|
||||
it('only blocks mutating SQL when script execution protection is enabled', () => {
|
||||
expect(findConnectionMutatingStatements({
|
||||
type: 'postgres',
|
||||
protection: {
|
||||
restrictScriptExecution: true,
|
||||
},
|
||||
}, "SELECT * FROM users; UPDATE users SET name = 'next';")).toEqual([
|
||||
"UPDATE users SET name = 'next'",
|
||||
]);
|
||||
|
||||
expect(findConnectionMutatingStatements({
|
||||
type: 'postgres',
|
||||
protection: {
|
||||
restrictDataEdit: true,
|
||||
},
|
||||
}, "UPDATE users SET name = 'next';")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,42 @@ import { convertMongoShellToJsonCommand } from "./mongodb";
|
||||
import { resolveSqlDialect } from "./sqlDialect";
|
||||
import { findSqlStatementRanges } from "./sqlStatementSelection";
|
||||
|
||||
export type ConnectionProtectionKey =
|
||||
| "restrictDataEdit"
|
||||
| "restrictStructureEdit"
|
||||
| "restrictScriptExecution"
|
||||
| "restrictDataImport";
|
||||
|
||||
export type ConnectionProtectionConfig = NonNullable<
|
||||
ConnectionConfig["protection"]
|
||||
>;
|
||||
|
||||
type ConnectionReadOnlyLike = Pick<
|
||||
ConnectionConfig,
|
||||
"type" | "driver" | "oceanBaseProtocol" | "readOnly"
|
||||
"type" | "driver" | "oceanBaseProtocol" | "readOnly" | "protection"
|
||||
> | null | undefined;
|
||||
|
||||
export const CONNECTION_PROTECTION_KEYS: ConnectionProtectionKey[] = [
|
||||
"restrictDataEdit",
|
||||
"restrictStructureEdit",
|
||||
"restrictScriptExecution",
|
||||
"restrictDataImport",
|
||||
];
|
||||
|
||||
const EMPTY_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
|
||||
restrictDataEdit: false,
|
||||
restrictStructureEdit: false,
|
||||
restrictScriptExecution: false,
|
||||
restrictDataImport: false,
|
||||
};
|
||||
|
||||
const FULL_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
|
||||
restrictDataEdit: true,
|
||||
restrictStructureEdit: true,
|
||||
restrictScriptExecution: true,
|
||||
restrictDataImport: true,
|
||||
};
|
||||
|
||||
const CONNECTION_READ_ONLY_TYPES = new Set([
|
||||
"mysql",
|
||||
"goldendb",
|
||||
@@ -230,17 +261,109 @@ export const supportsConnectionReadOnlyMode = (
|
||||
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
|
||||
};
|
||||
|
||||
export const normalizeConnectionProtectionConfig = (
|
||||
value: unknown,
|
||||
): ConnectionProtectionConfig => {
|
||||
const raw =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
restrictDataEdit: raw.restrictDataEdit === true,
|
||||
restrictStructureEdit: raw.restrictStructureEdit === true,
|
||||
restrictScriptExecution: raw.restrictScriptExecution === true,
|
||||
restrictDataImport: raw.restrictDataImport === true,
|
||||
};
|
||||
};
|
||||
|
||||
export const createEmptyConnectionProtectionConfig =
|
||||
(): ConnectionProtectionConfig => ({ ...EMPTY_CONNECTION_PROTECTION });
|
||||
|
||||
export const deriveLegacyConnectionReadOnlyFlag = (
|
||||
protection: unknown,
|
||||
): boolean => {
|
||||
const normalized = normalizeConnectionProtectionConfig(protection);
|
||||
return CONNECTION_PROTECTION_KEYS.every((key) => normalized[key] === true);
|
||||
};
|
||||
|
||||
export const resolveConnectionProtectionConfig = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): ConnectionProtectionConfig => {
|
||||
if (!supportsConnectionReadOnlyMode(config)) {
|
||||
return createEmptyConnectionProtectionConfig();
|
||||
}
|
||||
const normalized = normalizeConnectionProtectionConfig(config?.protection);
|
||||
const hasExplicitRestriction = CONNECTION_PROTECTION_KEYS.some(
|
||||
(key) => normalized[key] === true,
|
||||
);
|
||||
if (hasExplicitRestriction) {
|
||||
return normalized;
|
||||
}
|
||||
if (config?.readOnly === true) {
|
||||
return { ...FULL_CONNECTION_PROTECTION };
|
||||
}
|
||||
return createEmptyConnectionProtectionConfig();
|
||||
};
|
||||
|
||||
export const isConnectionProtectionEnabled = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
key: ConnectionProtectionKey,
|
||||
): boolean => {
|
||||
return resolveConnectionProtectionConfig(config)[key] === true;
|
||||
};
|
||||
|
||||
export const getConnectionProtectionEnabledCount = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): number => {
|
||||
const protection = resolveConnectionProtectionConfig(config);
|
||||
return CONNECTION_PROTECTION_KEYS.filter(
|
||||
(key) => protection[key] === true,
|
||||
).length;
|
||||
};
|
||||
|
||||
export const hasAnyConnectionProtection = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return getConnectionProtectionEnabledCount(config) > 0;
|
||||
};
|
||||
|
||||
export const isConnectionDataEditRestricted = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return isConnectionProtectionEnabled(config, "restrictDataEdit");
|
||||
};
|
||||
|
||||
export const isConnectionStructureEditRestricted = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return isConnectionProtectionEnabled(config, "restrictStructureEdit");
|
||||
};
|
||||
|
||||
export const isConnectionScriptExecutionRestricted = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return isConnectionProtectionEnabled(config, "restrictScriptExecution");
|
||||
};
|
||||
|
||||
export const isConnectionDataImportRestricted = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return isConnectionProtectionEnabled(config, "restrictDataImport");
|
||||
};
|
||||
|
||||
export const isConnectionForcedReadOnly = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return supportsConnectionReadOnlyMode(config) && config?.readOnly === true;
|
||||
return deriveLegacyConnectionReadOnlyFlag(
|
||||
resolveConnectionProtectionConfig(config),
|
||||
);
|
||||
};
|
||||
|
||||
export const findConnectionMutatingStatements = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
sql: string,
|
||||
): string[] => {
|
||||
if (!isConnectionForcedReadOnly(config)) {
|
||||
if (!isConnectionScriptExecutionRestricted(config)) {
|
||||
return [];
|
||||
}
|
||||
return findSqlStatementRanges(String(sql || ""))
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { connection } from '../../wailsjs/go/models';
|
||||
import {
|
||||
deriveLegacyConnectionReadOnlyFlag,
|
||||
normalizeConnectionProtectionConfig,
|
||||
} from './connectionReadOnly';
|
||||
import {
|
||||
OCEANBASE_PROTOCOL_PARAM_KEYS,
|
||||
resolveOceanBaseProtocolFromConfig,
|
||||
@@ -120,6 +124,7 @@ export function buildRpcConnectionConfig(
|
||||
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
|
||||
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
|
||||
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
|
||||
const protection = normalizeConnectionProtectionConfig(rpcMerged.protection);
|
||||
|
||||
const rpcConfig = new connection.ConnectionConfig({
|
||||
...rpcPayload,
|
||||
@@ -129,7 +134,8 @@ export function buildRpcConnectionConfig(
|
||||
user: toStringValue(rpcMerged.user),
|
||||
password: toStringValue(rpcMerged.password),
|
||||
database: toStringValue(rpcMerged.database),
|
||||
readOnly: rpcMerged.readOnly === true,
|
||||
readOnly: deriveLegacyConnectionReadOnlyFlag(protection),
|
||||
protection: new connection.ConnectionProtectionConfig(protection),
|
||||
useSSH: rpcMerged.useSSH === true,
|
||||
ssh: normalizeSSHConfig(rpcMerged.ssh),
|
||||
useProxy: rpcMerged.useProxy === true,
|
||||
|
||||
@@ -248,6 +248,39 @@ describe('dataSourceCapabilities', () => {
|
||||
supportsDropDatabase: false,
|
||||
supportsMessagePublish: false,
|
||||
forceReadOnlyQueryResult: true,
|
||||
forceReadOnlyStructureDesigner: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows script execution while still disabling result edits when only data-edit protection is enabled', () => {
|
||||
expect(getDataSourceCapabilities({
|
||||
type: 'postgres',
|
||||
protection: {
|
||||
restrictDataEdit: true,
|
||||
},
|
||||
})).toMatchObject({
|
||||
type: 'postgres',
|
||||
supportsCreateDatabase: true,
|
||||
supportsRenameDatabase: true,
|
||||
supportsDropDatabase: true,
|
||||
forceReadOnlyQueryResult: true,
|
||||
forceReadOnlyStructureDesigner: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps query results editable while disabling DDL shortcuts when only structure protection is enabled', () => {
|
||||
expect(getDataSourceCapabilities({
|
||||
type: 'postgres',
|
||||
protection: {
|
||||
restrictStructureEdit: true,
|
||||
},
|
||||
})).toMatchObject({
|
||||
type: 'postgres',
|
||||
supportsCreateDatabase: false,
|
||||
supportsRenameDatabase: false,
|
||||
supportsDropDatabase: false,
|
||||
forceReadOnlyQueryResult: false,
|
||||
forceReadOnlyStructureDesigner: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,6 +291,7 @@ describe('dataSourceCapabilities', () => {
|
||||
supportsCreateDatabase: false,
|
||||
supportsDropDatabase: false,
|
||||
forceReadOnlyQueryResult: false,
|
||||
forceReadOnlyStructureDesigner: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
import { isConnectionForcedReadOnly } from './connectionReadOnly';
|
||||
import {
|
||||
isConnectionDataEditRestricted,
|
||||
isConnectionStructureEditRestricted,
|
||||
} from './connectionReadOnly';
|
||||
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
|
||||
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly'> | null | undefined;
|
||||
type ConnectionLike = Pick<
|
||||
ConnectionConfig,
|
||||
'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly' | 'protection'
|
||||
> | null | undefined;
|
||||
|
||||
const normalizeDataSourceToken = (raw: string): string => {
|
||||
const normalized = String(raw || '').trim().toLowerCase();
|
||||
@@ -142,6 +148,7 @@ const COPY_INSERT_TYPES = new Set([
|
||||
|
||||
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
|
||||
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
|
||||
const FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES = new Set(['elasticsearch', 'mongodb', 'redis', 'iotdb']);
|
||||
const MESSAGE_PUBLISH_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
|
||||
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'rocketmq', 'mqtt']);
|
||||
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
|
||||
@@ -157,6 +164,7 @@ export type DataSourceCapabilities = {
|
||||
supportsDropDatabase: boolean;
|
||||
supportsMessagePublish: boolean;
|
||||
forceReadOnlyQueryResult: boolean;
|
||||
forceReadOnlyStructureDesigner: boolean;
|
||||
preferManualTotalCount: boolean;
|
||||
supportsApproximateTableCount: boolean;
|
||||
supportsApproximateTotalPages: boolean;
|
||||
@@ -209,17 +217,20 @@ const DROP_DATABASE_TYPES = new Set([
|
||||
|
||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||
const type = resolveDataSourceType(config);
|
||||
const forcedReadOnly = isConnectionForcedReadOnly(config);
|
||||
const dataEditRestricted = isConnectionDataEditRestricted(config);
|
||||
const structureEditRestricted = isConnectionStructureEditRestricted(config);
|
||||
return {
|
||||
type,
|
||||
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
|
||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||
supportsCopyInsert: COPY_INSERT_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),
|
||||
supportsCreateDatabase: !structureEditRestricted && CREATE_DATABASE_TYPES.has(type),
|
||||
supportsRenameDatabase: !structureEditRestricted && RENAME_DATABASE_TYPES.has(type),
|
||||
supportsDropDatabase: !structureEditRestricted && DROP_DATABASE_TYPES.has(type),
|
||||
supportsMessagePublish: !dataEditRestricted && MESSAGE_PUBLISH_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: dataEditRestricted || FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
forceReadOnlyStructureDesigner:
|
||||
structureEditRestricted || FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES.has(type),
|
||||
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
|
||||
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
|
||||
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
|
||||
|
||||
Reference in New Issue
Block a user