🐛 fix(nacos-connection): 支持受限命名空间与权限保护

- 在连接表单中独立维护 Namespace ID 与 contextPath
- 为无命名空间列表权限的账号回退到已配置作用域
- 细分数据编辑、结构管理与导入保护并阻止陈旧操作
- 补齐侧边栏请求代际和连接 URI 回归测试
This commit is contained in:
Syngnat
2026-07-28 23:51:36 +08:00
parent e5e80cbbe8
commit 12e410aead
18 changed files with 1635 additions and 122 deletions

View File

@@ -9,6 +9,7 @@ import {
isSingleReadOnlyConnectionQuery,
resolveConnectionProtectionConfig,
supportsConnectionKeepAliveSQL,
supportsConnectionReadOnlyMode,
} from './connectionReadOnly';
describe('connectionReadOnly', () => {
@@ -28,6 +29,13 @@ describe('connectionReadOnly', () => {
expect(supportsConnectionKeepAliveSQL({ type: 'redis' } as any)).toBe(false);
});
it('supports Nacos production protection without treating it as a SQL keepalive source', () => {
const config = { type: 'nacos' } as any;
expect(supportsConnectionReadOnlyMode(config)).toBe(true);
expect(supportsConnectionKeepAliveSQL(config)).toBe(false);
});
it('maps legacy readOnly connections to the full production protection set', () => {
expect(resolveConnectionProtectionConfig({
type: 'postgres',

View File

@@ -65,6 +65,11 @@ const CONNECTION_READ_ONLY_TYPES = new Set([
"mongodb",
]);
const CONNECTION_PROTECTION_TYPES = new Set([
...CONNECTION_READ_ONLY_TYPES,
"nacos",
]);
export const MAX_CONNECTION_KEEPALIVE_SQL_LENGTH = 4096;
const SQL_READ_ONLY_KEYWORDS = new Set([
@@ -446,7 +451,7 @@ export const isSingleReadOnlyConnectionQuery = (
export const supportsConnectionReadOnlyMode = (
config: ConnectionReadOnlyLike,
): boolean => {
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
return CONNECTION_PROTECTION_TYPES.has(resolveConnectionReadOnlyType(config));
};
export const normalizeConnectionProtectionConfig = (

View File

@@ -220,6 +220,40 @@ describe('buildRpcConnectionConfig', () => {
});
});
it('preserves Nacos legacy readOnly for backend mutation guards', () => {
const result = buildRpcConnectionConfig({
id: 'conn-nacos-readonly',
type: 'nacos',
host: 'nacos.local',
port: 8848,
readOnly: true,
} as any);
expect(result.readOnly).toBe(true);
});
it('preserves explicit Nacos protection flags for backend mutation guards', () => {
const result = buildRpcConnectionConfig({
id: 'conn-nacos-protected',
type: 'nacos',
host: 'nacos.local',
port: 8848,
protection: {
restrictDataEdit: true,
restrictStructureEdit: true,
restrictDataImport: true,
},
} as any);
expect(result.readOnly).toBe(false);
expect(result.protection).toEqual({
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: false,
restrictDataImport: true,
});
});
it('ignores the legacy readOnly flag for unsupported connection types', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis-readonly',

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
extractNacosConnectionScope,
resolveNacosConnectionScope,
setNacosConnectionScope,
} from "./nacosConnectionScope";
describe("nacosConnectionScope", () => {
it("keeps an explicitly configured public namespace distinct from no scope", () => {
expect(resolveNacosConnectionScope("contextPath=/nacos")).toEqual({
configured: false,
namespaceId: "",
requestNamespaceId: "",
});
expect(
resolveNacosConnectionScope(
"contextPath=%2Fnacos&namespaceId=public",
),
).toEqual({
configured: true,
namespaceId: "public",
requestNamespaceId: "",
});
});
it("sets and clears a namespace without dropping other Nacos parameters", () => {
const scoped = setNacosConnectionScope(
"contextPath=/registry&custom=value",
" team/dev ",
);
expect(new URLSearchParams(scoped).get("contextPath")).toBe("/registry");
expect(new URLSearchParams(scoped).get("custom")).toBe("value");
expect(new URLSearchParams(scoped).get("namespaceId")).toBe("team/dev");
const cleared = setNacosConnectionScope(scoped, "");
expect(new URLSearchParams(cleared).get("contextPath")).toBe("/registry");
expect(new URLSearchParams(cleared).get("custom")).toBe("value");
expect(new URLSearchParams(cleared).has("namespaceId")).toBe(false);
});
it("extracts the dedicated scope from the advanced connection parameters", () => {
expect(
extractNacosConnectionScope(
"contextPath=/nacos;namespaceId=dev-team\ncustom=value",
),
).toEqual({
connectionParams: "contextPath=%2Fnacos&custom=value",
scope: {
configured: true,
namespaceId: "dev-team",
requestNamespaceId: "dev-team",
},
});
});
});

View File

@@ -0,0 +1,73 @@
export const NACOS_NAMESPACE_ID_PARAM = "namespaceId";
export type NacosConnectionScope = {
configured: boolean;
namespaceId: string;
requestNamespaceId: string;
};
const normalizeNacosConnectionParamsText = (raw: unknown): string => {
let text = String(raw ?? "").trim();
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
return text
.replace(/^[?&]+/, "")
.replace(/[;\r\n]+/g, "&")
.trim();
};
const parseNacosConnectionParams = (raw: unknown): URLSearchParams =>
new URLSearchParams(normalizeNacosConnectionParamsText(raw));
export const resolveNacosConnectionScope = (
connectionParams: unknown,
): NacosConnectionScope => {
const params = parseNacosConnectionParams(connectionParams);
const namespaceId = String(
params.get(NACOS_NAMESPACE_ID_PARAM) ?? "",
).trim();
const configured = namespaceId !== "";
return {
configured,
namespaceId,
requestNamespaceId:
configured && namespaceId.toLowerCase() !== "public"
? namespaceId
: "",
};
};
export const setNacosConnectionScope = (
connectionParams: unknown,
namespaceId: unknown,
): string => {
const params = parseNacosConnectionParams(connectionParams);
const normalizedNamespaceId = String(namespaceId ?? "").trim();
if (normalizedNamespaceId) {
params.set(NACOS_NAMESPACE_ID_PARAM, normalizedNamespaceId);
} else {
params.delete(NACOS_NAMESPACE_ID_PARAM);
}
return params.toString();
};
export const extractNacosConnectionScope = (
connectionParams: unknown,
): {
connectionParams: string;
scope: NacosConnectionScope;
} => {
const params = parseNacosConnectionParams(connectionParams);
const scope = resolveNacosConnectionScope(params.toString());
params.delete(NACOS_NAMESPACE_ID_PARAM);
return {
connectionParams: params.toString(),
scope,
};
};