feat(nacos): add access control and namespace administration

This commit is contained in:
二丫讲梵
2026-08-21 01:15:46 +08:00
committed by GitHub
parent 30f75d8388
commit 7fa94152a8
59 changed files with 4563 additions and 479 deletions
Generated
+52
View File
@@ -1568,10 +1568,29 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -2440,6 +2459,15 @@ dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -4603,6 +4631,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -6344,6 +6378,12 @@ dependencies = [
"regex",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
version = "0.1.31"
@@ -6374,6 +6414,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -6740,6 +6790,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
+69 -7
View File
@@ -875,6 +875,35 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1468,6 +1497,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "duckdb"
version = "1.10505.0"
@@ -1621,7 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2807,6 +2845,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -3231,7 +3275,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3817,6 +3861,12 @@ dependencies = [
"regex",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
version = "0.1.31"
@@ -3847,6 +3897,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3910,7 +3970,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4149,6 +4209,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"futures-util",
"http",
@@ -4467,7 +4529,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5269,10 +5331,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6105,7 +6167,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -19,7 +19,7 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
@@ -298,6 +298,11 @@ const visibleNacosNamespaces = ref<NacosNamespaceInfo[]>([]);
const visibleNacosNamespaceSelection = ref<Set<string>>(new Set());
const visibleNacosNamespaceSearchText = ref("");
const visibleNacosNamespaceError = ref("");
const visibleNacosNamespaceListingPermissionDenied = ref(false);
const isResolvingManualNacosNamespaces = ref(false);
const visibleNacosNamespaceAccessMode = ref<"automatic" | "manual">("automatic");
const nacosDynamicAllNamespaces = ref(false);
const visibleNacosNamespaceDynamicAllSupported = ref(false);
const showProductionDatabasesDialog = ref(false);
const isLoadingProductionDatabases = ref(false);
const productionDatabaseNames = ref<string[]>([]);
@@ -883,8 +888,9 @@ const nacosImplementation = ref<NacosImplementation>("nacos");
// Nacos 2 and 3 expose different API planes. New connections must therefore
// choose an explicit version instead of relying on endpoint-shape guessing.
const nacosVersionMode = ref<NacosVersionMode>("v2");
const nacosApiPlane = ref<NacosApiPlane>("admin");
const nacosServerAddr = ref("");
const nacosOrdinaryAccount = ref(false);
const nacosContextPath = ref("");
const nacosManagedNamespacesText = ref("");
const nacosRNacosConsoleAddr = ref("");
const nacosHistoryEnabled = ref(false);
@@ -898,6 +904,11 @@ const nacosTlsSkipVerify = ref(false);
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
const nacosMetricsUrl = ref("");
const nacosPageSize = ref(20);
let nacosScopeFingerprintBaseline = "";
function currentNacosScopeFingerprint(): string {
return JSON.stringify([nacosImplementation.value, nacosVersionMode.value, nacosApiPlane.value, nacosServerAddr.value.trim().replace(/\/+$/, ""), nacosContextPath.value.trim(), nacosAuthKind.value, nacosUsername.value.trim()]);
}
type ConsulConsistency = "default" | "stale" | "consistent";
const consulServerAddr = ref("http://127.0.0.1:8500");
const consulDatacenter = ref("");
@@ -935,15 +946,25 @@ const mqttConnectTimeoutSecs = ref(30);
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
const mqttSavedTopics = ref<MqttConnectionConfig["savedTopics"]>([]);
const nacosPrimaryAddressPlaceholder = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return "http://127.0.0.1:8080";
}
return "http://127.0.0.1:8848/nacos";
});
const nacosServiceAddressHint = computed(() => {
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
return t("nacos.nacosConsoleServiceAddressHint");
}
return t("nacos.nacosServiceAddressHint");
});
const nacosV3AdminEndpointWarning = computed(() => {
if (nacosImplementation.value !== "nacos" || nacosVersionMode.value !== "v3" || !nacosServerAddr.value.trim()) return "";
try {
const url = new URL(nacosServerAddr.value.trim());
if (url.port === "8080") {
if (nacosApiPlane.value === "admin" && url.port === "8080") {
return t("nacos.nacosV3ConsolePortWarning");
}
if (nacosApiPlane.value === "console" && url.port === "8848") return t("nacos.nacosV3AdminPortWarning");
} catch {
// The normal form validation will report an invalid URL on save.
}
@@ -971,6 +992,7 @@ function selectNacosConnectionProfile(profile: NacosConnectionProfile) {
}
nacosImplementation.value = "nacos";
nacosVersionMode.value = profile;
if (profile !== "v3") nacosApiPlane.value = "admin";
}
watch(nacosImplementation, (implementation) => {
@@ -1459,11 +1481,13 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
// Saved `auto` profiles are legacy records. The form always saves an
// explicit selection and no longer relies on a separate Console address.
nacosVersionMode.value = config?.versionMode === "v3" ? "v3" : "v2";
nacosApiPlane.value = config?.versionMode === "v3" ? config?.apiPlane || "admin" : "admin";
const serverAddr = config?.serverAddr?.trim() || "";
const contextPath = config?.contextPath?.trim() || "";
nacosServerAddr.value = serverAddr && contextPath && contextPath !== "/" && !serverAddr.endsWith(contextPath) ? `${serverAddr.replace(/\/+$/, "")}/${contextPath.replace(/^\/+/, "")}` : serverAddr;
nacosOrdinaryAccount.value = !!config?.managedNamespaces?.length;
nacosServerAddr.value = serverAddr;
nacosContextPath.value = contextPath;
nacosManagedNamespacesText.value = (config?.managedNamespaces || []).join("\n");
nacosDynamicAllNamespaces.value = !!config && !config.managedNamespaces?.length && !Array.isArray(form.value.visible_databases);
nacosRNacosConsoleAddr.value = config?.rnacosConsoleAddr?.trim() || "";
nacosHistoryEnabled.value = config?.rnacosHistoryEnabled ?? !!config?.rnacosConsoleAddr;
const consoleAuth = config?.rnacosConsoleAuth || { kind: "inherit" };
@@ -1478,6 +1502,7 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
nacosAuthKind.value = auth.kind || "none";
nacosUsername.value = auth.username || "nacos";
nacosPassword.value = auth.password || "";
nacosScopeFingerprintBaseline = currentNacosScopeFingerprint();
}
function hydrateNacosFields(value: unknown) {
@@ -1805,7 +1830,8 @@ function buildNacosAdminConfig(): NacosAdminConfig {
const normalized = normalizeNacosEndpoint(primaryAddress, {
implementation: nacosImplementation.value,
versionMode: nacosVersionMode.value,
contextPath: undefined,
apiPlane: nacosApiPlane.value,
contextPath: nacosContextPath.value,
});
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
@@ -1815,11 +1841,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
}
let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined;
const usesManagedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" && nacosOrdinaryAccount.value;
const managedNamespaces = usesManagedNamespaces ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
if (usesManagedNamespaces && managedNamespaces.length === 0) {
throw new Error(t("nacos.nacosOrdinaryNamespacesRequired"));
}
const managedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
let metricsUrl: string | undefined;
if (nacosMetricsMode.value === "custom") {
try {
@@ -1843,6 +1865,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
return {
implementation: nacosImplementation.value,
versionMode: nacosImplementation.value === "nacos" ? nacosVersionMode.value : undefined,
apiPlane: nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" ? nacosApiPlane.value : undefined,
serverAddr: normalized.serverAddr,
contextPath: normalized.contextPath || undefined,
managedNamespaces: managedNamespaces.length ? managedNamespaces : undefined,
@@ -3461,6 +3484,24 @@ const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHi
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos");
const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin");
const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console");
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword");
const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"));
const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"));
const nacosManualNamespaceHintKey = computed(() => {
if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint";
if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint";
return "nacos.nacosManagedNamespacesHint";
});
const nacosNamespacePickerTitleKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespaces" : "nacos.nacosVisibleNamespacesTitle"));
const nacosNamespacePickerDescriptionKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespacesHint" : "nacos.nacosVisibleNamespacesDescription"));
function hasNacosNamespaceScopeForSave(): boolean {
if (!canDetectNacosNamespaceAccess.value) return true;
if (nacosDynamicAllNamespaces.value) return true;
if (parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0) return true;
return Array.isArray(form.value.visible_databases) && form.value.visible_databases.length > 0;
}
const canChooseVisibleDatabases = computed(() => !canChooseVisibleNacosNamespaces.value && connectionCanChooseVisibleDatabases(form.value));
const visibleFilterUsesSchemas = computed(() => connectionUsesVisibleSchemaFilter(form.value));
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
@@ -3494,7 +3535,7 @@ const filteredVisibleNacosNamespaces = computed(() => {
});
});
const visibleNacosNamespaceSelectedCount = computed(() => visibleNacosNamespaceSelection.value.size);
const visibleNacosNamespaceCanSave = computed(() => visibleNacosNamespaceSelection.value.size > 0);
const visibleNacosNamespaceCanSave = computed(() => (visibleNacosNamespaceAccessMode.value === "manual" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0 : visibleNacosNamespaceSelection.value.size > 0));
const filteredProductionDatabaseNames = computed(() => {
const query = productionDatabaseSearchText.value.trim().toLowerCase();
if (!query) return productionDatabaseNames.value;
@@ -4632,6 +4673,10 @@ function resetVisibleNacosNamespaceDraftState() {
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
isResolvingManualNacosNamespaces.value = false;
visibleNacosNamespaceAccessMode.value = "automatic";
visibleNacosNamespaceDynamicAllSupported.value = false;
}
function resetProductionDatabaseDraftState() {
@@ -4715,24 +4760,99 @@ function normalizeVisibleNacosNamespaceSelection(selected: Iterable<string>, nam
return normalizeNacosNamespaceSelection(selected, namespaces);
}
function normalizeManualNacosNamespaceNames(namespaces: string[], availableNamespaces: NacosNamespaceInfo[]): string[] {
const namespaceIds = new Map(availableNamespaces.map((namespace) => [nacosNamespaceIdentity(namespace.namespace), namespace.namespace]));
const namespaceNames = new Map<string, string[]>();
for (const namespace of availableNamespaces) {
const name = nacosNamespaceLabel(namespace).trim();
if (!name) continue;
namespaceNames.set(name, [...(namespaceNames.get(name) || []), namespace.namespace]);
}
const normalized: string[] = [];
const seen = new Set<string>();
for (const namespace of namespaces) {
const namespaceId = namespaceIds.get(nacosNamespaceIdentity(namespace));
const matchingNames = namespaceNames.get(namespace.trim()) || [];
// Display names are only safe to convert when the server returned one
// exact match. Keep unknown or ambiguous values intact so the regular
// permission check can report them instead of targeting a wrong namespace.
const value = namespaceId ?? (matchingNames.length === 1 ? matchingNames[0] : namespace);
const identity = nacosNamespaceIdentity(value);
if (!seen.has(identity)) {
seen.add(identity);
normalized.push(value);
}
}
return normalized;
}
async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<string[]> {
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...submittedConfig,
// Resolve display names against the authenticated directory rather than
// passing them as namespace IDs to the configuration and naming APIs.
external_config: submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
return normalizeManualNacosNamespaceNames(namespaces, await loadReadableNacosNamespaces(draftId, api));
} catch {
// Some Nacos deployments do not expose a readable namespace directory to
// ordinary users. Preserve manually entered IDs for the normal test path.
return namespaces;
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
}
}
function isNacosNamespaceListingPermissionError(error: unknown): boolean {
const message = errorMessage(error);
if (/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]/.test(message)) return true;
return /\/v3\/(?:admin|console)\/core\/namespace\/list/.test(message) && /\b403\b/.test(message) && /authorization failed/i.test(message);
}
async function openVisibleNacosNamespacesPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleNacosNamespaces.value || isLoadingVisibleNacosNamespaces.value) return;
isLoadingVisibleNacosNamespaces.value = true;
visibleNacosNamespaceError.value = "";
visibleNacosNamespaceListingPermissionDenied.value = false;
visibleNacosNamespaceDynamicAllSupported.value = false;
visibleNacosNamespaceSearchText.value = "";
visibleNacosNamespaceAccessMode.value = "automatic";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
try {
const submittedConfig = connectionConfigForSubmit(draftId);
const draftConfig = {
...connectionConfigForSubmit(draftId),
...submittedConfig,
// Automatic detection must not inherit a previously saved manual scope.
// Otherwise it would only rediscover that old subset instead of checking
// what the currently configured account can really access.
external_config: canDetectNacosNamespaceAccess.value && submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
visible_databases: undefined,
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
const namespaces = await loadReadableNacosNamespaces(draftId, api);
visibleNacosNamespaces.value = [...namespaces].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
try {
const sidebarSnapshot = await api.nacosSidebarSnapshot(draftId);
const readableIds = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const sidebarIds = normalizeVisibleNacosNamespaceSelection(sidebarSnapshot.namespaces.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const readableSet = new Set(readableIds.map(nacosNamespaceIdentity));
const sidebarSet = new Set(sidebarIds.map(nacosNamespaceIdentity));
visibleNacosNamespaceDynamicAllSupported.value = readableSet.size === sidebarSet.size && [...readableSet].every((namespace) => sidebarSet.has(namespace));
} catch {
visibleNacosNamespaceDynamicAllSupported.value = false;
}
const configured = form.value.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleNacosNamespaceSelection(configured, visibleNacosNamespaces.value) : visibleNacosNamespaces.value.map(nacosNamespaceValue);
visibleNacosNamespaceSelection.value = new Set(initialSelection);
@@ -4740,7 +4860,8 @@ async function openVisibleNacosNamespacesPicker() {
} catch (e: any) {
visibleNacosNamespaces.value = [];
visibleNacosNamespaceSelection.value = new Set();
visibleNacosNamespaceError.value = errorMessage(e);
visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e);
visibleNacosNamespaceError.value = visibleNacosNamespaceListingPermissionDenied.value ? t("nacos.nacosManagedNamespacesRequired") : errorMessage(e);
testResult.value = { ok: false, message: visibleNacosNamespaceError.value };
showVisibleNacosNamespacesDialog.value = true;
} finally {
@@ -4765,13 +4886,31 @@ function clearVisibleNacosNamespaceSelection() {
}
function showAllVisibleNacosNamespaces() {
nacosDynamicAllNamespaces.value = true;
nacosManagedNamespacesText.value = "";
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
}
function saveVisibleNacosNamespaceSelection() {
async function saveVisibleNacosNamespaceSelection() {
if (!visibleNacosNamespaceCanSave.value) return;
form.value.visible_databases = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
if (visibleNacosNamespaceAccessMode.value === "manual") {
const manualNamespaces = parseNacosManagedNamespaces(nacosManagedNamespacesText.value);
isResolvingManualNacosNamespaces.value = true;
const resolvedNamespaces = isNacosV3AdminPlane.value ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
isResolvingManualNacosNamespaces.value = false;
nacosManagedNamespacesText.value = resolvedNamespaces.join("\n");
form.value.visible_databases = resolvedNamespaces;
nacosDynamicAllNamespaces.value = false;
} else {
nacosManagedNamespacesText.value = "";
const selected = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
const all = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
const selectsEntireReadableList = selected.length === all.length && selected.every((namespace, index) => namespace === all[index]);
const useDynamicAll = selectsEntireReadableList && visibleNacosNamespaceDynamicAllSupported.value;
form.value.visible_databases = useDynamicAll ? undefined : selected;
nacosDynamicAllNamespaces.value = useDynamicAll;
}
showVisibleNacosNamespacesDialog.value = false;
}
@@ -5214,6 +5353,26 @@ watch(
},
);
watch(
() => currentNacosScopeFingerprint(),
(current) => {
if (form.value.db_type !== "nacos") {
nacosScopeFingerprintBaseline = current;
return;
}
if (!nacosScopeFingerprintBaseline || current === nacosScopeFingerprintBaseline) {
nacosScopeFingerprintBaseline = current;
return;
}
nacosScopeFingerprintBaseline = current;
nacosManagedNamespacesText.value = "";
nacosDynamicAllNamespaces.value = false;
form.value.visible_databases = undefined;
resetVisibleNacosNamespaceDraftState();
resetTestState();
},
);
watch(visibleDatabaseShowSystem, (show) => {
if (show) return;
const connection = connectionConfigSnapshotForVisibleDatabases();
@@ -5404,6 +5563,11 @@ async function persistConnectionNoteVisibilityDraft() {
async function save() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
if (!hasNacosNamespaceScopeForSave()) {
testResult.value = null;
await openVisibleNacosNamespacesPicker();
return;
}
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
let connectionSaved = false;
@@ -6619,11 +6783,28 @@ function openExternalUrl(url: string) {
<section data-nacos-endpoint-section class="rounded-lg border p-4">
<div class="grid gap-4">
<div v-if="nacosImplementation === 'nacos' && nacosVersionMode === 'v3'" class="grid gap-1.5">
<Label>{{ t("nacos.nacosApiPlane") }}</Label>
<div class="grid grid-cols-2 gap-1 rounded-md border bg-muted/20 p-1">
<button
v-for="plane in ['admin', 'console'] as NacosApiPlane[]"
:key="plane"
type="button"
class="min-w-0 rounded px-3 py-2 text-left transition-colors"
:class="nacosApiPlane === plane ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="nacosApiPlane === plane"
@click="nacosApiPlane = plane"
>
<span class="block text-sm font-medium">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}`) }}</span>
<span class="mt-0.5 block text-xs leading-4">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}Hint`) }}</span>
</button>
</div>
</div>
<div class="grid gap-1.5">
<Label>{{ t("nacos.nacosServiceAddress") }}</Label>
<Input v-model="nacosServerAddr" :placeholder="nacosPrimaryAddressPlaceholder" />
<p class="text-xs leading-5 text-muted-foreground">
<template>{{ t("nacos.nacosServiceAddressHint") }}</template>
<template>{{ nacosServiceAddressHint }}</template>
</p>
</div>
<p v-if="nacosV3AdminEndpointWarning" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-400">
@@ -6655,29 +6836,12 @@ function openExternalUrl(url: string) {
<PasswordInput v-model="nacosPassword" />
</div>
</div>
<div v-if="nacosImplementation === 'nacos' && nacosAuthKind === 'usernamePassword'" data-nacos-ordinary-user-toggle class="mt-4 border-t pt-4">
<div class="flex items-start justify-between gap-4">
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t("nacos.nacosOrdinaryAccount") }}</span>
<span class="mt-0.5 block text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosOrdinaryAccountHint") }}</span>
</span>
<Switch v-model="nacosOrdinaryAccount" class="mt-0.5 shrink-0" />
</div>
<div v-if="nacosOrdinaryAccount" class="mt-3 grid gap-1.5 pl-0 sm:pl-4">
<div class="flex items-center justify-between gap-3">
<Label>{{ t("nacos.nacosManagedNamespaces") }}</Label>
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
</div>
<textarea
v-model="nacosManagedNamespacesText"
data-nacos-managed-namespaces
rows="2"
class="min-h-14 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('nacos.nacosManagedNamespacesPlaceholder')"
/>
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosManagedNamespacesHint") }}</p>
</div>
</div>
<p v-if="isNacosV3AdminPlane && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.nacosV3AdminNamespaceScopeHint") }}
</p>
<p v-else-if="nacosImplementation === 'rnacos' && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
{{ t("nacos.rnacosNamespaceAccessScopeHint") }}
</p>
</section>
<section data-nacos-advanced-hint class="flex items-start gap-3 rounded-lg border border-dashed bg-muted/20 px-4 py-3">
@@ -8106,6 +8270,17 @@ function openExternalUrl(url: string) {
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosAdvancedDescription") }}</p>
</div>
<div class="grid gap-5 p-4">
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t("connection.nacosContextPath") }}</Label>
<Button v-if="nacosContextPath" type="button" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="nacosContextPath = ''">
{{ t("connection.nacosContextPathRestoreAuto") }}
</Button>
</div>
<Input v-model="nacosContextPath" :placeholder="t('connection.nacosContextPathPlaceholder')" />
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosContextPathHint") }}</p>
</div>
<div class="grid gap-1.5">
<Label>{{ t("connection.nacosPageSize") }}</Label>
<Input v-model.number="nacosPageSize" type="number" min="1" max="500" />
@@ -8721,7 +8896,7 @@ function openExternalUrl(url: string) {
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
<Loader2 v-if="isLoadingVisibleNacosNamespaces" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t("nacos.nacosVisibleNamespacesTitle") }}
{{ t(nacosNamespacePickerTitleKey) }}
</Button>
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
@@ -8817,51 +8992,89 @@ function openExternalUrl(url: string) {
<Dialog v-model:open="showVisibleNacosNamespacesDialog">
<DialogContent class="sm:max-w-[460px]" @interact-outside.prevent @escape-key-down.prevent>
<DialogHeader>
<DialogTitle>{{ t("nacos.nacosVisibleNamespacesTitle") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("nacos.nacosVisibleNamespacesDescription", { name: form.name || selectedProfile().label }) }}</p>
<DialogTitle>{{ t(nacosNamespacePickerTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t(nacosNamespacePickerDescriptionKey, { name: form.name || selectedProfile().label }) }}</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
</div>
<Tabs v-model="visibleNacosNamespaceAccessMode" class="grid gap-3">
<TabsList class="grid h-9 w-full" :class="canDetectNacosNamespaceAccess ? 'grid-cols-2' : 'grid-cols-1'">
<TabsTrigger value="automatic">{{ t("nacos.nacosNamespaceAccessAutomatic") }}</TabsTrigger>
<TabsTrigger v-if="canDetectNacosNamespaceAccess" value="manual">{{ t("nacos.nacosNamespaceAccessManual") }}</TabsTrigger>
</TabsList>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<TabsContent value="automatic" class="m-0 grid gap-3">
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
</div>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredVisibleNacosNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
<button v-if="visibleNacosNamespaceDynamicAllSupported" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
</div>
</div>
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleNacosNamespaceListingPermissionDenied" class="grid gap-3 p-3 text-sm text-destructive">
<p>{{ visibleNacosNamespaceError }}</p>
<Button v-if="canDetectNacosNamespaceAccess" variant="outline" size="sm" class="justify-self-start" @click="visibleNacosNamespaceAccessMode = 'manual'">
{{ t("nacos.nacosNamespaceAccessManual") }}
</Button>
</div>
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
<template v-else>
<button
v-for="namespace in filteredVisibleNacosNamespaces"
:key="nacosNamespaceValue(namespace) || '__public__'"
type="button"
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
>
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
</button>
</template>
</div>
</TabsContent>
<TabsContent v-if="canDetectNacosNamespaceAccess" value="manual" class="m-0 grid gap-3">
<p class="text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosNamespaceAccessScopeHint") }}</p>
<div v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-300">
<ShieldAlert class="mt-0.5 h-4 w-4 shrink-0" />
<p>{{ t("nacos.nacosV3AdminManagedNamespacesHint") }}</p>
</div>
<div class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label>{{ t(nacosManualNamespaceLabelKey) }}</Label>
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
</div>
<textarea
v-model="nacosManagedNamespacesText"
data-nacos-managed-namespaces
rows="6"
class="min-h-32 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t(nacosManualNamespacePlaceholderKey)"
/>
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(nacosManualNamespaceHintKey) }}</p>
</div>
<p v-if="!visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosOrdinaryNamespacesRequired") }}</p>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" @click="showVisibleNacosNamespacesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError || !visibleNacosNamespaceCanSave" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
<Button :disabled="isResolvingManualNacosNamespaces || !visibleNacosNamespaceCanSave || (visibleNacosNamespaceAccessMode === 'automatic' && (isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError))" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, Trash2, UserRound } from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -11,9 +11,10 @@ import { Switch } from "@/components/ui/switch";
import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue";
import * as api from "@/lib/backend/api";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
import { normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { useConnectionStore } from "@/stores/connectionStore";
import type { NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
import type { NacosAdminConfig, NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
const props = defineProps<{
connectionId: string;
@@ -30,6 +31,7 @@ type UserDialogMode = "create" | "edit" | "reset";
const users = ref<NacosUserInfo[]>([]);
const usersLoading = ref(false);
const usersError = ref("");
const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null);
const userSearch = ref("");
const userPageNo = ref(1);
const userPageSize = ref(20);
@@ -53,6 +55,7 @@ const userFormNamespaces = ref<NacosNamespaceInfo[]>([]);
const userFormNamespacesLoading = ref(false);
const userFormNamespacesError = ref("");
const pendingUserDelete = ref<NacosUserInfo | null>(null);
const userDeleteConfirmation = ref("");
const deletingUser = ref(false);
const roleDialogOpen = ref(false);
const roleDialogError = ref("");
@@ -67,12 +70,15 @@ const rnacosCaptchaImage = ref("");
const rnacosCaptcha = ref("");
const rnacosAuthError = ref("");
let rnacosRetry: (() => Promise<void>) | null = null;
let latestUserFormNamespacesRequestId = 0;
let stopNacosNamespacesChangedListener: (() => void) | null = null;
const accessControl = computed(() => props.connectionInfo?.capabilities.accessControl);
const mode = computed(() => accessControl.value?.mode ?? "unavailable");
const isRNacos = computed(() => mode.value === "embeddedRoles");
const userTotalPages = computed(() => Math.max(1, Math.ceil(userTotal.value / Math.max(1, userPageSize.value))));
const roleTotalPages = computed(() => Math.max(1, Math.ceil(roleTotal.value / Math.max(1, rolePageSize.value))));
const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username);
function createUserForm() {
return {
@@ -264,20 +270,35 @@ async function loadRoles(page = rolePageNo.value) {
async function loadUserFormNamespaces(force = false) {
if (!isRNacos.value || userFormNamespacesLoading.value || (userFormNamespaces.value.length && !force)) return;
const connectionId = props.connectionId;
const requestId = ++latestUserFormNamespacesRequestId;
userFormNamespacesLoading.value = true;
userFormNamespacesError.value = "";
try {
userFormNamespaces.value = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId));
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
userFormNamespaces.value = namespaces;
} catch (error) {
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
userFormNamespacesError.value = error instanceof Error ? error.message : String(error);
} finally {
userFormNamespacesLoading.value = false;
if (requestId === latestUserFormNamespacesRequestId) userFormNamespacesLoading.value = false;
}
}
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
if (detail.connectionId !== props.connectionId) return;
latestUserFormNamespacesRequestId += 1;
userFormNamespaces.value = [];
userFormNamespacesError.value = "";
userFormNamespacesLoading.value = false;
if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true);
}
function openCreateUser() {
userDialogMode.value = "create";
userDialogError.value = "";
userOperationNotice.value = null;
userForm.value = createUserForm();
userDialogOpen.value = true;
void loadUserFormNamespaces();
@@ -286,6 +307,7 @@ function openCreateUser() {
function openEditUser(user: NacosUserInfo) {
userDialogMode.value = isRNacos.value ? "edit" : "reset";
userDialogError.value = "";
userOperationNotice.value = null;
const privilege = user.namespacePrivilege;
userForm.value = {
username: user.username,
@@ -305,8 +327,10 @@ function openEditUser(user: NacosUserInfo) {
watch(
() => props.connectionId,
() => {
latestUserFormNamespacesRequestId += 1;
userFormNamespaces.value = [];
userFormNamespacesError.value = "";
userFormNamespacesLoading.value = false;
},
);
@@ -320,6 +344,7 @@ function toggleRNacosRole(role: string, checked: boolean) {
async function saveUser() {
if (userSaving.value) return;
const username = userForm.value.username.trim();
const resettingPassword = userDialogMode.value === "reset";
if (!username || (userDialogMode.value !== "edit" && !userForm.value.password)) {
userDialogError.value = t("nacos.userCredentialsRequired");
return;
@@ -350,14 +375,61 @@ async function saveUser() {
}
userDialogOpen.value = false;
await loadUsers(1);
if (resettingPassword) {
const handledConnectionCredential = await syncCurrentConnectionPassword(username, userForm.value.password);
if (!handledConnectionCredential) {
userOperationNotice.value = { kind: "success", message: t("nacos.passwordResetSucceeded", { username }) };
}
}
});
} catch (error) {
userDialogError.value = error instanceof Error ? error.message : String(error);
const message = error instanceof Error ? error.message : String(error);
userDialogError.value = message;
if (resettingPassword) userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) };
} finally {
userSaving.value = false;
}
}
async function syncCurrentConnectionPassword(username: string, password: string): Promise<boolean> {
const config = connectionStore.getConfig(props.connectionId);
if (!config || config.db_type !== "nacos") return false;
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
const primaryAuth = externalConfig.auth;
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
const consoleAuth = externalConfig.rnacosConsoleAuth;
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
if (!primaryMatches && !consoleMatches) return false;
if (config.save_password === false) {
try {
await api.replaceNacosSessionCredential(props.connectionId, username, password);
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentSessionPasswordUpdated")}` };
} catch {
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
}
return true;
}
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
? {
...externalConfig,
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
}
: undefined;
try {
await connectionStore.updateConnection({
...config,
password: primaryMatches ? password : config.password,
external_config: nextExternal,
});
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordSaved")}` };
} catch {
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
}
return true;
}
async function deleteUser() {
const user = pendingUserDelete.value;
if (!user || deletingUser.value) return;
@@ -367,6 +439,7 @@ async function deleteUser() {
await withRNacosAuthentication(async () => {
await api.nacosDeleteUser(props.connectionId, user.username);
pendingUserDelete.value = null;
userDeleteConfirmation.value = "";
await loadUsers(Math.min(userPageNo.value, userTotalPages.value));
});
} catch (error) {
@@ -376,6 +449,16 @@ async function deleteUser() {
}
}
function openDeleteUser(user: NacosUserInfo) {
pendingUserDelete.value = user;
userDeleteConfirmation.value = "";
}
function closeDeleteUser() {
pendingUserDelete.value = null;
userDeleteConfirmation.value = "";
}
function openAssignRole() {
roleDialogError.value = "";
roleForm.value = { username: roleUsername.value.trim(), role: "" };
@@ -439,9 +522,20 @@ watch(
);
onMounted(() => {
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
if (props.tab === "users") void loadUsers(1);
else void loadRoles(1);
});
onBeforeUnmount(() => {
latestUserFormNamespacesRequestId += 1;
stopNacosNamespacesChangedListener?.();
stopNacosNamespacesChangedListener = null;
});
defineExpose({
refresh: () => (props.tab === "users" ? loadUsers() : loadRoles()),
});
</script>
<template>
@@ -464,6 +558,9 @@ onMounted(() => {
</header>
<div v-if="!listUsersCapability().supported" class="border-b bg-muted/30 px-3 py-2 text-xs text-muted-foreground">{{ capabilityReason(listUsersCapability()) }}</div>
<div v-if="usersError" class="border-b px-3 py-2 text-xs text-destructive">{{ usersError }}</div>
<div v-if="userOperationNotice" class="border-b px-3 py-2 text-xs" :class="userOperationNotice.kind === 'success' ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'" role="status" aria-live="polite">
{{ userOperationNotice.message }}
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="w-full min-w-[680px] text-sm">
<thead class="sticky top-0 z-10 bg-muted/70 text-left text-xs text-muted-foreground">
@@ -493,7 +590,7 @@ onMounted(() => {
<div class="flex justify-end gap-1">
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="!isWritable(updateUserCapability())" :title="isRNacos ? t('nacos.editUser') : t('nacos.resetUserPassword')" @click="openEditUser(user)"
><Pencil v-if="isRNacos" class="h-3.5 w-3.5" /><KeyRound v-else class="h-3.5 w-3.5" /></Button
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="pendingUserDelete = user"><Trash2 class="h-3.5 w-3.5" /></Button>
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="openDeleteUser(user)"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</td>
</tr>
@@ -519,7 +616,7 @@ onMounted(() => {
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="rolesLoading || !listRolesCapability().supported" :title="!listRolesCapability().supported ? capabilityReason(listRolesCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadRoles(1)"
><Loader2 v-if="rolesLoading" class="h-3.5 w-3.5 animate-spin" /><RefreshCw v-else class="h-3.5 w-3.5"
/></Button>
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><Plus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}</Button>
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}</Button>
</header>
<div v-if="rolesError" class="border-b px-3 py-2 text-xs text-destructive">{{ rolesError }}</div>
<div class="min-h-0 flex-1 overflow-auto">
@@ -645,14 +742,22 @@ onMounted(() => {
></Dialog
>
<Dialog :open="!!pendingUserDelete" @update:open="!$event && (pendingUserDelete = null)"
<Dialog :open="!!pendingUserDelete" @update:open="!$event && closeDeleteUser()"
><DialogContent class="sm:max-w-md"
><DialogHeader
><DialogTitle>{{ t("nacos.deleteUserTitle") }}</DialogTitle
><DialogDescription>{{ t("nacos.deleteUserDescription", { username: pendingUserDelete?.username }) }}</DialogDescription></DialogHeader
><DialogFooter
><Button variant="outline" :disabled="deletingUser" @click="pendingUserDelete = null">{{ t("nacos.cancel") }}</Button
><Button variant="destructive" :disabled="deletingUser" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
>
<div class="grid gap-1.5">
<Label for="legacy-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
<p id="legacy-delete-user-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ pendingUserDelete?.username }}</code>
</p>
<Input id="legacy-delete-user-confirm" v-model="userDeleteConfirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="legacy-delete-user-confirm-hint" />
</div>
<DialogFooter
><Button variant="outline" :disabled="deletingUser" @click="closeDeleteUser">{{ t("nacos.cancel") }}</Button
><Button variant="destructive" :disabled="deletingUser || !userDeleteConfirmed" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
></DialogContent
></Dialog
>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { KeyRound, Loader2, RefreshCw, ShieldCheck, UsersRound } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
@@ -16,6 +16,7 @@ const props = defineProps<{
}>();
type AccessTab = "users" | "roles";
type AccessControlWorkspace = { refresh: () => Promise<void> };
const { t } = useI18n();
const connectionStore = useConnectionStore();
@@ -23,6 +24,8 @@ const connectionInfo = ref<NacosConnectionInfo | null>(null);
const connectionError = ref("");
const loading = ref(false);
const activeTab = ref<AccessTab>("users");
const legacyWorkspace = ref<AccessControlWorkspace | null>(null);
const enhancedWorkspaceRef = ref<AccessControlWorkspace | null>(null);
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
@@ -30,13 +33,17 @@ const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings
const enhancedWorkspace = computed(() => accessControl.value?.enhancedWorkspace === true);
const permissionWorkspaceUnavailable = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listPermissions?.supported === false && accessControl.value.listPermissions.reason !== "versionUnsupported");
async function loadConnectionInfo() {
async function loadConnectionInfo(refreshWorkspace = false) {
loading.value = true;
connectionError.value = "";
try {
await connectionStore.ensureConnected(props.connectionId);
connectionInfo.value = await api.nacosTestConnection(props.connectionId);
connectionInfo.value = await api.nacosTestConnection(props.connectionId, refreshWorkspace);
if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles";
if (refreshWorkspace) {
await nextTick();
await (enhancedWorkspace.value ? enhancedWorkspaceRef.value : legacyWorkspace.value)?.refresh();
}
} catch (error) {
connectionInfo.value = null;
connectionError.value = error instanceof Error ? error.message : String(error);
@@ -74,7 +81,7 @@ watch(
<Badge v-if="connectionInfo?.serverVersion" variant="secondary">{{ connectionInfo.serverVersion }}</Badge>
<div class="flex-1" />
<Badge v-if="readOnly" variant="outline">{{ t("nacos.readOnly") }}</Badge>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo">
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo(true)">
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
{{ t("nacos.refresh") }}
</Button>
@@ -88,8 +95,8 @@ watch(
</div>
<template v-else-if="connectionInfo">
<div v-if="!supportsUsers && !supportsRoles" class="m-4 rounded border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">{{ t("nacos.accessControlUnavailable") }}</div>
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" ref="enhancedWorkspaceRef" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" ref="legacyWorkspace" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
</template>
</section>
</template>
@@ -313,6 +313,7 @@ const updateServiceCapability = computed(() => operationCapability(serviceCapabi
const deleteServiceCapability = computed(() => operationCapability(serviceCapabilities.value?.deleteService, createServiceCapability.value.supported));
const listInstancesCapability = computed(() => operationCapability(serviceCapabilities.value?.listInstances, listServicesCapability.value.supported));
const updateInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstance, legacyInstanceUpdateSupported.value));
const updateInstanceHealthCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstanceHealth, updateInstanceCapability.value.supported));
const registerInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.registerInstance, updateInstanceCapability.value.supported));
const deregisterInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.deregisterInstance, updateInstanceCapability.value.supported));
const supportsServiceManagement = computed(() => listServicesCapability.value.supported);
@@ -2876,9 +2877,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex items-center gap-1 rounded-md border bg-background p-1">
<span class="px-1 text-xs text-muted-foreground">{{ t("nacos.instances") }}</span>
<Button size="sm" class="h-7" :disabled="readOnly || !registerInstanceCapability.supported" :title="readOnly || !registerInstanceCapability.supported ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{
t("nacos.registerInstance")
}}</Button>
<Button v-if="registerInstanceCapability.supported" size="sm" class="h-7" :disabled="readOnly" :title="readOnly ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{ t("nacos.registerInstance") }}</Button>
</div>
</div>
</div>
@@ -2986,15 +2985,16 @@ onBeforeUnmount(() => {
<Loader2 v-if="isInstanceUpdating(instance)" class="h-3 w-3 animate-spin" />
{{ instance.enabled === false ? t("nacos.enable") : t("nacos.disable") }}
</Button>
<Button size="sm" variant="outline" class="h-7" :disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
<Button v-if="updateInstanceHealthCapability.supported" size="sm" variant="outline" class="h-7" :disabled="readOnly || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
{{ instance.healthy === false ? t("nacos.markHealthy") : t("nacos.markUnhealthy") }}
</Button>
<Button
v-if="deregisterInstanceCapability.supported"
size="sm"
variant="outline"
class="h-7 text-destructive"
:disabled="readOnly || !deregisterInstanceCapability.supported || isInstanceUpdating(instance)"
:title="readOnly || !deregisterInstanceCapability.supported ? capabilityReason(deregisterInstanceCapability) : undefined"
:disabled="readOnly || isInstanceUpdating(instance)"
:title="readOnly ? capabilityReason(deregisterInstanceCapability) : undefined"
@click="pendingInstanceDeregister = instance"
>{{ t("nacos.deregister") }}</Button
>
@@ -368,11 +368,11 @@ onUnmounted(() => {
<MetricCard :label="t('nacos.dashboardNamespaces')" :value="optionalCount(snapshot?.namespaceCount)" :icon="Layers3" />
<MetricCard :label="t('nacos.dashboardConfigs')" :value="optionalCount(snapshot?.configCount)" :sub="namespaceLabel" :icon="Braces" />
<MetricCard :label="t('nacos.dashboardServices')" :value="optionalCount(snapshot?.serviceCount)" :sub="namespaceLabel" :icon="Boxes" />
<MetricCard :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics?.instanceCount)" :icon="Network" />
<MetricCard :label="t('nacos.dashboardClients')" :value="optionalCount(metrics?.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics?.connectionBasedClientCount) })" :icon="Users" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics.instanceCount)" :icon="Network" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardClients')" :value="optionalCount(metrics.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics.connectionBasedClientCount) })" :icon="Users" />
<MetricCard :label="t('nacos.dashboardNodes')" :value="snapshot?.nodes.length ? `${healthyNodes} / ${snapshot.nodes.length}` : '—'" :icon="Server" />
<MetricCard :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics?.cpu)" :icon="Cpu" />
<MetricCard :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics?.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics?.load) })" :icon="HardDrive" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics.cpu)" :icon="Cpu" />
<MetricCard v-if="metrics" :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics.load) })" :icon="HardDrive" />
</div>
<div v-if="prometheus" class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
@@ -1,17 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, Trash2, UserRound, X } from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, ShieldPlus, Trash2, UserPlus, UserRound, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { mergeNacosNamespacePermissionAssignments, type NacosNamespacePermissionAction } from "@/lib/nacos/nacosAdmin";
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
import { useConnectionStore } from "@/stores/connectionStore";
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosAdminConfig, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
const props = defineProps<{
connectionId: string;
@@ -28,6 +30,7 @@ type PermissionAction = NacosNamespacePermissionAction;
type PermissionAssignment = { namespaceId: string; action: PermissionAction };
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const snapshot = ref<NacosAccessControlSnapshot | null>(null);
const loading = ref(false);
@@ -59,6 +62,8 @@ const operation = ref<NacosAccessOperationResult | null>(null);
const pendingAdvancedPermission = ref<NacosPermissionInfo | null>(null);
const advancedPermissionConfirmation = ref("");
const retryPasswords = reactive<Record<string, string>>({});
let latestSnapshotRequestId = 0;
let stopNacosNamespacesChangedListener: (() => void) | null = null;
const users = computed(() => snapshot.value?.users ?? []);
const roles = computed(() => snapshot.value?.roles ?? []);
@@ -122,6 +127,8 @@ const canRevokePermission = computed(() => !props.readOnly && props.capabilities
const canCreateRole = computed(() => canAssignRole.value && canGrantPermission.value);
const canEditUserRoles = computed(() => canAssignRole.value || canRemoveRole.value);
const canEditRole = computed(() => canAssignRole.value || canRemoveRole.value || canGrantPermission.value || canRevokePermission.value || canCreateUser.value);
const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username);
const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role);
function setHasAdded(before: Iterable<string>, after: Iterable<string>) {
const original = new Set(before);
@@ -179,10 +186,14 @@ const roleOperationWritable = computed(() => {
});
async function loadSnapshot(preserveSelection = true) {
const requestId = ++latestSnapshotRequestId;
const connectionId = props.connectionId;
loading.value = true;
error.value = "";
try {
snapshot.value = await api.nacosAccessSnapshot(props.connectionId);
const nextSnapshot = await api.nacosAccessSnapshot(connectionId);
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
snapshot.value = nextSnapshot;
if (!preserveSelection || !users.value.some((user) => user.username === selectedUserName.value)) {
selectedUserName.value = users.value[0]?.username ?? "";
}
@@ -190,12 +201,17 @@ async function loadSnapshot(preserveSelection = true) {
selectedRoleName.value = roles.value[0]?.role ?? "";
}
} catch (cause) {
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
loading.value = false;
if (requestId === latestSnapshotRequestId) loading.value = false;
}
}
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
if (detail.connectionId === props.connectionId) void loadSnapshot();
}
function isManagedPermission(permission: NacosPermissionInfo) {
return permission.parsedScope?.kind === "namespace" && ["r", "w", "rw"].includes(permission.actionRaw);
}
@@ -317,6 +333,45 @@ function openDeleteUser() {
userDialog.value = "delete";
}
async function syncCurrentConnectionPassword(username: string, password: string) {
if (snapshot.value?.currentUsername !== username) return;
const config = connectionStore.getConfig(props.connectionId);
if (!config || config.db_type !== "nacos") return;
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
const primaryAuth = externalConfig.auth;
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
const consoleAuth = externalConfig.rnacosConsoleAuth;
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
if (!primaryMatches && !consoleMatches) return;
if (config.save_password === false) {
try {
await api.replaceNacosSessionCredential(props.connectionId, username, password);
toast(t("nacos.currentSessionPasswordUpdated"), 4000);
} catch {
toast(t("nacos.currentPasswordNotSaved"), 5000);
}
return;
}
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
? {
...externalConfig,
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
}
: undefined;
try {
await connectionStore.updateConnection({
...config,
password: primaryMatches ? password : config.password,
external_config: nextExternal,
});
toast(t("nacos.currentPasswordSaved"), 4000);
} catch {
toast(t("nacos.currentPasswordNotSaved"), 5000);
}
}
function resetRoleForm(role = "") {
Object.assign(roleForm, { role, members: [], newUsers: [], permissions: [], confirmation: "" });
availableNamespaceSearch.value = "";
@@ -342,7 +397,7 @@ function openEditRole() {
function openDeleteRole() {
if (!selectedRole.value) return;
roleForm.confirmation = "";
resetRoleForm(selectedRole.value.role);
formError.value = "";
roleDialog.value = "delete";
}
@@ -360,7 +415,10 @@ function openAssociatedRole(role: string) {
function filterNamespaces<T extends { id?: string; namespaceId?: string; name: string }>(items: T[], query: string) {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return items;
return items.filter((item) => `${item.name} ${item.id ?? item.namespaceId ?? ""}`.toLocaleLowerCase().includes(normalized));
return items.filter((item) => {
const id = item.id ?? item.namespaceId ?? "";
return item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized);
});
}
function moveNamespacesToGranted() {
@@ -393,12 +451,17 @@ async function submitUser() {
if (userDialog.value === "password") {
if (!userForm.password) return void (formError.value = t("nacos.accessPasswordRequired"));
if (!(await confirmAccessMutation(`${t("nacos.accessUserDialog.password")}: ${userForm.username}`))) return;
const username = userForm.username;
saving.value = true;
try {
await api.nacosUpdateUser(props.connectionId, { username: userForm.username, password: userForm.password });
await api.nacosUpdateUser(props.connectionId, { username, password: userForm.password });
await syncCurrentConnectionPassword(username, userForm.password);
userDialog.value = null;
toast(t("nacos.passwordResetSucceeded", { username }), 2500);
} catch (cause) {
formError.value = cause instanceof Error ? cause.message : String(cause);
const message = cause instanceof Error ? cause.message : String(cause);
formError.value = message;
toast(t("nacos.passwordResetFailed", { message }), 5000);
} finally {
saving.value = false;
}
@@ -422,7 +485,8 @@ async function submitRole() {
await runOperation({ kind: "deleteRole", role: roleForm.role, confirmation: roleForm.confirmation || undefined }, () => (roleDialog.value = null));
return;
}
if (!roleForm.role.trim() || (!roleForm.members.length && !roleForm.newUsers.length)) {
const roleRequiresMember = roleDialog.value === "create" || selectedRole.value?.administrator;
if (!roleForm.role.trim() || (roleRequiresMember && !roleForm.members.length && !roleForm.newUsers.length)) {
formError.value = t("nacos.accessRoleMemberRequired");
return;
}
@@ -518,7 +582,17 @@ watch(
},
);
watch(normalizedSearch, () => (page.value = 1));
onMounted(() => void loadSnapshot(false));
onMounted(() => {
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
void loadSnapshot(false);
});
onBeforeUnmount(() => {
latestSnapshotRequestId += 1;
stopNacosNamespacesChangedListener?.();
stopNacosNamespacesChangedListener = null;
});
defineExpose({ refresh: () => loadSnapshot() });
</script>
<template>
@@ -529,8 +603,16 @@ onMounted(() => void loadSnapshot(false));
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input v-model="search" class="h-8 pl-8" :placeholder="tab === 'users' ? t('nacos.searchUsers') : t('nacos.accessSearchRoles')" />
</div>
<Button size="sm" variant="ghost" class="h-8 w-8 p-0" :aria-label="t('nacos.refresh')" :disabled="loading" @click="loadSnapshot()">
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
<Button
size="sm"
class="h-8 w-8 shrink-0 p-0"
:title="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
:aria-label="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
:disabled="tab === 'users' ? !canCreateUser : !canCreateRole"
@click="tab === 'users' ? openCreateUser() : openCreateRole()"
>
<UserPlus v-if="tab === 'users'" class="h-3.5 w-3.5" />
<ShieldPlus v-else class="h-3.5 w-3.5" />
</Button>
</div>
@@ -554,8 +636,7 @@ onMounted(() => void loadSnapshot(false));
<div v-if="!loading && !currentItems.length" class="flex h-32 items-center justify-center px-4 text-center text-xs text-muted-foreground">{{ tab === "users" ? t("nacos.noUsers") : t("nacos.accessNoRoles") }}</div>
</div>
<div class="shrink-0 space-y-2 border-t p-2">
<Button class="h-8 w-full" :disabled="tab === 'users' ? !canCreateUser : !canCreateRole" @click="tab === 'users' ? openCreateUser() : openCreateRole()"><Plus class="mr-1.5 h-3.5 w-3.5" />{{ tab === "users" ? t("nacos.createUser") : t("nacos.accessCreateRole") }}</Button>
<div class="shrink-0 border-t p-2">
<div class="flex h-7 items-center justify-between px-1 text-xs text-muted-foreground">
<span>{{ currentItems.length }}</span>
<div class="flex items-center gap-1">
@@ -787,20 +868,29 @@ onMounted(() => void loadSnapshot(false));
</div>
</div>
<div v-if="userDialog === 'delete'" class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.deleteUserDescription", { username: userForm.username }) }}</div>
<div v-if="userForm.roles.includes('ROLE_ADMIN') || (snapshot?.currentUsername === userForm.username && userDialog !== 'password')" class="grid gap-1.5">
<div v-if="userDialog === 'delete'" class="grid gap-1.5">
<Label for="access-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
<p id="access-delete-user-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ userForm.username }}</code>
</p>
<Input id="access-delete-user-confirm" v-model="userForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="access-delete-user-confirm-hint" />
</div>
<div v-if="userDialog !== 'delete' && (userForm.roles.includes('ROLE_ADMIN') || (snapshot?.currentUsername === userForm.username && userDialog !== 'password'))" class="grid gap-1.5">
<Label for="access-user-confirm">{{ t("nacos.accessTypeUsername", { value: userForm.username }) }}</Label
><Input id="access-user-confirm" v-model="userForm.confirmation" autocomplete="off" />
</div>
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
<DialogFooter
><Button variant="outline" :disabled="saving" @click="userDialog = null">{{ t("nacos.cancel") }}</Button
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable" @click="submitUser"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ userDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable || (userDialog === 'delete' && !userDeleteConfirmed)" @click="submitUser"
><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ userDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button
></DialogFooter
>
</DialogContent>
</Dialog>
<Dialog :open="!!roleDialog" @update:open="!$event && (roleDialog = null)">
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-2xl">
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-3xl">
<DialogHeader
><DialogTitle>{{ t(`nacos.accessRoleDialog.${roleDialog}`) }}</DialogTitle
><DialogDescription>{{ t(`nacos.accessRoleDialog.${roleDialog}Hint`) }}</DialogDescription></DialogHeader
@@ -815,7 +905,9 @@ onMounted(() => void loadSnapshot(false));
<div class="mb-2 flex items-end justify-between">
<div>
<Label>{{ t("nacos.accessMembers") }}</Label>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessMembersRequiredHint") }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">
{{ t(roleDialog === "edit" && !selectedRole?.administrator ? "nacos.accessMembersOptionalHint" : "nacos.accessMembersRequiredHint") }}
</p>
</div>
<Button size="sm" variant="outline" :disabled="!canCreateUser || !canAssignRole" @click="addNewUser"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("nacos.accessInlineCreateUser") }}</Button>
</div>
@@ -843,17 +935,21 @@ onMounted(() => void loadSnapshot(false));
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessPermissionRequiredHint") }}</p>
</div>
</div>
<div class="grid min-h-[17rem] grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1.15fr)] gap-2 sm:gap-3">
<div class="grid h-[min(28rem,45vh)] min-h-[17rem] grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1.15fr)] gap-2 sm:gap-3">
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessUnassignedNamespaces") }}</div>
<div class="border-b p-2">
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="availableNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="availableNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" :aria-label="t('nacos.accessSearchNamespaces')" />
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-1.5">
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-9 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/60">
<input type="checkbox" :checked="selectedAvailableNamespaceIds.includes(namespace.id)" @change="selectedAvailableNamespaceIds = toggle(selectedAvailableNamespaceIds, namespace.id, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate">{{ namespace.name }}</span>
<code v-if="namespace.name !== namespace.id" class="truncate text-[10px] text-muted-foreground">{{ namespace.id }}</code>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
<code v-if="namespace.name !== namespace.id" class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id">{{ namespace.id }}</code>
</span>
</label>
<div v-if="!availableNamespaces.length" class="flex h-24 items-center justify-center px-3 text-center text-xs text-muted-foreground">{{ t("nacos.accessNoUnassignedNamespaces") }}</div>
</div>
@@ -865,12 +961,16 @@ onMounted(() => void loadSnapshot(false));
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessGrantedNamespaces") }}</div>
<div class="border-b p-2">
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="grantedNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="grantedNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" :aria-label="t('nacos.accessSearchNamespaces')" />
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-1.5">
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-10 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/60">
<input type="checkbox" :checked="selectedGrantedNamespaceIds.includes(namespace.namespaceId)" @change="selectedGrantedNamespaceIds = toggle(selectedGrantedNamespaceIds, namespace.namespaceId, ($event.target as HTMLInputElement).checked)" />
<span class="min-w-0 flex-1 truncate font-medium">{{ namespace.name }}</span>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
</span>
<div class="flex shrink-0 items-center gap-2">
<label
v-for="action in ['r', 'w', 'rw'] as PermissionAction[]"
@@ -903,13 +1003,18 @@ onMounted(() => void loadSnapshot(false));
<template v-else
><div class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.accessDeleteRoleHint", { role: roleForm.role }) }}</div>
<div class="grid gap-1.5">
<Label for="access-delete-role-confirm">{{ t("nacos.accessTypeRole", { value: roleForm.role }) }}</Label
><Input id="access-delete-role-confirm" v-model="roleForm.confirmation" /></div
<Label for="access-delete-role-confirm">{{ t("nacos.accessDeleteRoleConfirmationLabel") }}</Label>
<p id="access-delete-role-confirm-hint" class="text-xs text-muted-foreground">
{{ t("nacos.accessDeleteRoleConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ roleForm.role }}</code>
</p>
<Input id="access-delete-role-confirm" v-model="roleForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteRoleConfirmationPlaceholder')" aria-describedby="access-delete-role-confirm-hint" /></div
></template>
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
<DialogFooter
><Button variant="outline" :disabled="saving" @click="roleDialog = null">{{ t("nacos.cancel") }}</Button
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable" @click="submitRole"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ roleDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable || (roleDialog === 'delete' && !roleDeleteConfirmed)" @click="submitRole"
><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ roleDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button
></DialogFooter
>
</DialogContent>
</Dialog>
@@ -9,15 +9,18 @@ import type { NacosConnectionInfo } from "@/types/nacos";
const mocks = vi.hoisted(() => ({
connectionInfo: null as NacosConnectionInfo | null,
ensureConnected: vi.fn(async () => undefined),
}));
vi.mock("@/lib/backend/api", () => ({
refreshLegacyWorkspace: vi.fn(async () => undefined),
refreshEnhancedWorkspace: vi.fn(async () => undefined),
nacosTestConnection: vi.fn(async () => {
if (!mocks.connectionInfo) throw new Error("missing Nacos connection fixture");
return mocks.connectionInfo;
}),
}));
vi.mock("@/lib/backend/api", () => ({
nacosTestConnection: (...args: unknown[]) => mocks.nacosTestConnection(...args),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
}));
@@ -31,7 +34,10 @@ vi.mock("@/components/nacos/NacosAccessControl.vue", async () => {
return {
default: {
props: ["tab"],
setup: (props: { tab: string }) => () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab }),
setup: (props: { tab: string }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
expose({ refresh: mocks.refreshLegacyWorkspace });
return () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab });
},
},
};
});
@@ -41,11 +47,14 @@ vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
return {
default: {
props: ["capabilities"],
setup: (props: { capabilities: { createUser: { supported: boolean } } }) => () =>
h("div", {
"data-testid": "nacos-role-access-control-workspace",
"data-can-create-user": String(props.capabilities.createUser.supported),
}),
setup: (props: { capabilities: { createUser: { supported: boolean } } }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
expose({ refresh: mocks.refreshEnhancedWorkspace });
return () =>
h("div", {
"data-testid": "nacos-role-access-control-workspace",
"data-can-create-user": String(props.capabilities.createUser.supported),
});
},
},
};
});
@@ -98,6 +107,39 @@ describe("NacosAccessControlConsole", () => {
expect(legacySource).not.toContain('<textarea v-model="userForm.blacklist"');
});
it("invalidates the r-nacos namespace picker when namespaces change", () => {
expect(legacySource).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
expect(legacySource).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
expect(legacySource).toContain("if (detail.connectionId !== props.connectionId) return");
expect(legacySource).toContain("if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true)");
expect(legacySource).toContain("stopNacosNamespacesChangedListener?.()");
});
it("notifies users when an official Nacos password reset succeeds or fails", () => {
expect(legacySource).toContain('const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null)');
expect(legacySource).toContain("await syncCurrentConnectionPassword(username, userForm.value.password)");
expect(legacySource).toContain('userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) }');
expect(legacySource).toContain('v-if="userOperationNotice"');
});
it("requires exact username confirmation before deleting a user in the legacy workspace", () => {
expect(legacySource).toContain("const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username)");
expect(legacySource).toContain('@click="openDeleteUser(user)"');
expect(legacySource).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(legacySource).toContain(':disabled="deletingUser || !userDeleteConfirmed"');
});
it("does not hide user deletion solely because the user has ROLE_ADMIN", () => {
expect(legacySource).not.toContain('if (user.roles.includes("ROLE_ADMIN")) return');
expect(legacySource).not.toContain("v-if=\"!user.roles.includes('ROLE_ADMIN')\"");
});
it("uses the same role creation icon in the legacy and enhanced workspaces", () => {
expect(legacySource).toContain('import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue"');
expect(legacySource).toContain('@click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}');
expect(readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosRoleAccessControl.vue"), "utf8")).toContain('<ShieldPlus v-else class="h-3.5 w-3.5" />');
});
it("switches tabs when navigating between associated users and roles", () => {
expect(source).toContain("@select-user=\"activeTab = 'users'\"");
expect(source).toContain("@select-role=\"activeTab = 'roles'\"");
@@ -202,6 +244,53 @@ describe("NacosAccessControlConsole", () => {
expect(workspace?.getAttribute("data-can-create-user")).toBe("false");
});
it("refreshes the enhanced workspace snapshot after refreshing connection capabilities", async () => {
const supported = { supported: true };
mocks.connectionInfo = {
serverAddr: "http://127.0.0.1:8848",
displayServerAddr: "http://127.0.0.1:8848",
namespace: "",
auth: "usernamePassword",
capabilities: {
supportsConfigManagement: true,
supportsServiceManagement: true,
supportsInstanceUpdate: true,
supportsRawApi: true,
accessControl: {
mode: "roleBindings",
listUsers: supported,
createUser: supported,
updateUser: supported,
deleteUser: supported,
listRoleBindings: supported,
assignRole: supported,
removeRole: supported,
listPermissions: supported,
grantPermission: supported,
revokePermission: supported,
enhancedWorkspace: true,
supportsNamespacePrivileges: false,
},
},
};
const host = document.createElement("div");
document.body.append(host);
const app = createApp(NacosAccessControlConsole, { connectionId: "nacos-refresh" });
mountedApps.push({ app, host });
app.mount(host);
await settle();
mocks.refreshEnhancedWorkspace.mockClear();
const refreshButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.includes("nacos.refresh"));
expect(refreshButton).toBeDefined();
refreshButton?.click();
await settle();
expect(mocks.nacosTestConnection).toHaveBeenLastCalledWith("nacos-refresh", true);
expect(mocks.refreshEnhancedWorkspace).toHaveBeenCalledTimes(1);
});
it("internationalizes the sidebar entry, including trees persisted with the old label", () => {
expect(connectionStoreSource).toContain('label: "nacos.accessControlSidebarLabel"');
expect(treeItemSource).toContain('if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel")');
@@ -197,6 +197,12 @@ describe("NacosAdminConsole config workbench layout", () => {
expect(source).toContain("border-destructive/50 text-destructive hover:bg-destructive/10");
});
it("hides instance operations that the selected API plane does not support", () => {
expect(source).toContain('v-if="registerInstanceCapability.supported"');
expect(source).toContain('v-if="updateInstanceHealthCapability.supported"');
expect(source).toContain('v-if="deregisterInstanceCapability.supported"');
});
it("separates the service header, filtering controls, and management actions", () => {
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
@@ -10,6 +10,15 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain("v-else-if=\"tab === 'roles' && selectedRole\"");
});
it("places the contextual create action beside search instead of duplicating refresh", () => {
expect(source).toContain(":aria-label=\"tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')\"");
expect(source).toContain("@click=\"tab === 'users' ? openCreateUser() : openCreateRole()\"");
expect(source).toContain("<UserPlus v-if=\"tab === 'users'\"");
expect(source).toContain("<ShieldPlus v-else");
expect(source).not.toContain(":aria-label=\"t('nacos.refresh')\"");
expect(source).not.toContain('class="h-8 w-full"');
});
it("keeps role creation complete with per-namespace permissions", () => {
expect(source).toContain("roleForm.newUsers");
expect(source).toContain("moveNamespacesToGranted");
@@ -20,6 +29,52 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain('kind: "createRole"');
});
it("keeps the selected role identity when opening the delete dialog", () => {
expect(source).toContain("resetRoleForm(selectedRole.value.role)");
});
it("makes the exact role-name confirmation requirement explicit before deletion", () => {
expect(source).toContain("const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role)");
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationHint")');
expect(source).toContain("roleDialog === 'delete' && !roleDeleteConfirmed");
});
it("requires the exact username before deleting any user", () => {
expect(source).toContain("const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username)");
expect(source).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
expect(source).toContain('t("nacos.accessDeleteUserConfirmationHint")');
expect(source).toContain("userDialog === 'delete' && !userDeleteConfirmed");
});
it("lets the backend workflow protect only the final ROLE_ADMIN member", () => {
expect(source).toContain("if (!selectedUser.value) return");
expect(source).not.toContain('selectedUser.value.roles.includes("ROLE_ADMIN")');
expect(source).not.toContain("v-if=\"!selectedUser.roles.includes('ROLE_ADMIN')\"");
});
it("shows complete IDs only in the unassigned namespace list", () => {
expect(source).toContain('class="block truncate font-medium" :title="namespace.name"');
expect(source).toContain('class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id"');
expect(source).not.toContain('<code v-if="namespace.name !== namespace.namespaceId"');
});
it("uses a wider, internally scrollable namespace picker and filters by name or ID", () => {
expect(source).toContain('class="max-h-[90vh] overflow-auto sm:max-w-3xl"');
expect(source).toContain("h-[min(28rem,45vh)]");
expect(source).toContain('const id = item.id ?? item.namespaceId ?? ""');
expect(source).toContain("item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized)");
expect(source).toContain(":aria-label=\"t('nacos.accessSearchNamespaces')\"");
});
it("refreshes the role namespace options after a namespace change for this connection", () => {
expect(source).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
expect(source).toContain("function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail)");
expect(source).toContain("if (detail.connectionId === props.connectionId) void loadSnapshot()");
expect(source).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
expect(source).toContain("stopNacosNamespacesChangedListener?.()");
});
it("merges split permission rows before editing an existing role", () => {
expect(source).toContain("mergeNacosNamespacePermissionAssignments(managedRolePermissions.value)");
});
@@ -30,8 +85,8 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain("const canGrantPermission = computed");
expect(source).toContain("const userOperationWritable = computed");
expect(source).toContain("const roleOperationWritable = computed");
expect(source).toContain(':disabled="saving || !userOperationWritable"');
expect(source).toContain(':disabled="saving || !roleOperationWritable"');
expect(source).toContain(':disabled="saving || !userOperationWritable ||');
expect(source).toContain(':disabled="saving || !roleOperationWritable ||');
});
it("surfaces partial operations instead of hiding them in a toast", () => {
@@ -41,6 +96,13 @@ describe("NacosRoleAccessControl", () => {
expect(source).toContain("accessOperationResult");
});
it("uses the global notification for password reset results", () => {
expect(source).toContain('import { useToast } from "@/composables/useToast"');
expect(source).toContain("const { toast } = useToast()");
expect(source).toContain('toast(t("nacos.passwordResetSucceeded", { username }), 2500)');
expect(source).toContain('toast(t("nacos.passwordResetFailed", { message }), 5000)');
});
it("renders associated roles with the same navigable card pattern as role members", () => {
expect(source).toContain('v-if="selectedUser.roles.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5"');
expect(source).toContain(":title=\"t('nacos.accessOpenRole', { role })\"");
@@ -95,6 +95,9 @@ const {
editNacosNamespaceDesc,
editNacosNamespaceLoading,
confirmEditNacosNamespace,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
confirmDeleteNacosNamespace,
showRenameMongoCollectionDialog,
renameMongoCollectionName,
renameMongoCollectionError,
@@ -195,6 +198,7 @@ watch(
showEditDatabasePropertiesDialog,
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showDeleteNacosNamespaceConfirm,
showRenameMongoCollectionDialog,
showCloneMongoCollectionDialog,
showCreateMongoIndexDialog,
@@ -741,6 +745,26 @@ watch(
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteNacosNamespaceConfirm">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.deleteNamespace") }}</DialogTitle>
</DialogHeader>
<div class="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<p class="min-w-0 break-words text-muted-foreground [overflow-wrap:anywhere]">
{{ t("nacos.deleteNamespaceDescription", { name: node.nacosNamespaceName || node.label, id: node.nacosNamespace || "public" }) }}
</p>
</div>
<DialogFooter>
<Button variant="outline" :disabled="deleteNacosNamespaceLoading" @click="showDeleteNacosNamespaceConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" :disabled="deleteNacosNamespaceLoading" @click="confirmDeleteNacosNamespace">
{{ deleteNacosNamespaceLoading ? t("nacos.deletingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateSchemaDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
@@ -254,6 +254,8 @@ import {
editNacosNamespaceName,
editNacosNamespaceDesc,
editNacosNamespaceLoading,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
createDatabaseCharsetOptions,
createDatabaseCollationsByCharset,
createDatabaseCharsetLoading,
@@ -497,6 +499,8 @@ const {
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
confirmEditNacosNamespace,
openDeleteNacosNamespaceConfirm,
confirmDeleteNacosNamespace,
dropMongoCollection,
dropMilvusCollection,
dropMongoIndex,
@@ -2898,6 +2902,8 @@ const canEditNacosNamespace = computed(() => {
return config?.db_type === "nacos" && !config.read_only;
});
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
const isDuckDbConnection = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "connection" && config?.db_type === "duckdb" && connectionNamespaceCreationTarget(config) === "attach";
@@ -4450,6 +4456,9 @@ function databaseSpecificDialogCapabilities() {
editNacosNamespaceDesc,
editNacosNamespaceLoading,
confirmEditNacosNamespace,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
confirmDeleteNacosNamespace,
showRenameMongoCollectionDialog,
renameMongoCollectionName,
renameMongoCollectionError,
@@ -5114,6 +5123,16 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
});
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
if (canDeleteNacosNamespace.value) {
items.push({ label: "", separator: true });
items.push({
label: t("nacos.deleteNamespace"),
action: openDeleteNacosNamespaceConfirm,
icon: Trash2,
shortcut: shortcutDelete,
variant: "destructive" as const,
});
}
return true;
}
@@ -96,6 +96,8 @@ export const showEditNacosNamespaceDialog = ref(false);
export const editNacosNamespaceName = ref("");
export const editNacosNamespaceDesc = ref("");
export const editNacosNamespaceLoading = ref(false);
export const showDeleteNacosNamespaceConfirm = ref(false);
export const deleteNacosNamespaceLoading = ref(false);
export const createDatabaseCharsetOptions = ref<string[]>(fallbackCreateDatabaseCharset.charsets);
export const createDatabaseCollationsByCharset = ref<Record<string, string[]>>(fallbackCreateDatabaseCharset.collationsByCharset);
export const createDatabaseCharsetLoading = ref(false);
@@ -1,13 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shallowRef } from "vue";
import type { TreeNode } from "@/types/database";
import { createNacosNamespaceDesc, createNacosNamespaceId, createNacosNamespaceLoading, createNacosNamespaceName, showCreateNacosNamespaceDialog, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
import {
createNacosNamespaceDesc,
createNacosNamespaceId,
createNacosNamespaceLoading,
createNacosNamespaceName,
editNacosNamespaceDesc,
editNacosNamespaceLoading,
editNacosNamespaceName,
deleteNacosNamespaceLoading,
showCreateNacosNamespaceDialog,
showDeleteNacosNamespaceConfirm,
showEditNacosNamespaceDialog,
sidebarDangerTarget,
sidebarFormTarget,
} from "@/components/sidebar/sidebarTreeDialogState";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
nacosCreateNamespace: vi.fn(),
nacosDeleteNamespace: vi.fn(),
nacosUpdateNamespace: vi.fn(),
notifyNacosNamespacesChanged: vi.fn(),
loadNacosNamespaces: vi.fn(),
updateConnection: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
@@ -22,6 +39,12 @@ vi.mock("@/composables/useToast", () => ({
vi.mock("@/lib/backend/api", () => ({
nacosCreateNamespace: (...args: unknown[]) => mocks.nacosCreateNamespace(...args),
nacosDeleteNamespace: (...args: unknown[]) => mocks.nacosDeleteNamespace(...args),
nacosUpdateNamespace: (...args: unknown[]) => mocks.nacosUpdateNamespace(...args),
}));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionContextGuard: vi.fn(async ({ execute }: { execute: () => Promise<unknown> }) => execute()),
}));
vi.mock("@/lib/nacos/nacosNamespaceCache", () => ({
@@ -40,6 +63,17 @@ function connectionNode(): TreeNode {
};
}
function namespaceNode(): TreeNode {
return {
id: "conn-1:nacos:existing-space",
label: "Existing Space",
type: "nacos-namespace",
connectionId: "conn-1",
nacosNamespace: "existing-space",
nacosNamespaceName: "Existing Space",
};
}
describe("Nacos namespace creation cache invalidation", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -94,3 +128,104 @@ describe("Nacos namespace creation cache invalidation", () => {
expect(createNacosNamespaceLoading.value).toBe(false);
});
});
describe("Nacos namespace edit cache invalidation", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.nacosUpdateNamespace.mockResolvedValue(undefined);
mocks.loadNacosNamespaces.mockResolvedValue(undefined);
sidebarFormTarget.value = namespaceNode();
editNacosNamespaceName.value = "Renamed Space";
editNacosNamespaceDesc.value = "Updated during sync";
editNacosNamespaceLoading.value = false;
showEditNacosNamespaceDialog.value = true;
});
it("notifies open views before refreshing the sidebar after an edit", async () => {
const { confirmEditNacosNamespace } = useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(namespaceNode()),
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
getConfig: () => undefined,
} as any,
});
await confirmEditNacosNamespace();
expect(mocks.nacosUpdateNamespace).toHaveBeenCalledWith("conn-1", {
namespaceId: "existing-space",
namespaceName: "Renamed Space",
namespaceDesc: "Updated during sync",
});
expect(mocks.notifyNacosNamespacesChanged).toHaveBeenCalledWith("conn-1");
expect(mocks.notifyNacosNamespacesChanged.mock.invocationCallOrder[0]).toBeLessThan(mocks.loadNacosNamespaces.mock.invocationCallOrder[0]);
expect(showEditNacosNamespaceDialog.value).toBe(false);
});
it("keeps namespace caches intact when an edit fails", async () => {
mocks.nacosUpdateNamespace.mockRejectedValueOnce(new Error("edit failed"));
const { confirmEditNacosNamespace } = useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(namespaceNode()),
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
getConfig: () => undefined,
} as any,
});
await confirmEditNacosNamespace();
expect(mocks.notifyNacosNamespacesChanged).not.toHaveBeenCalled();
expect(mocks.loadNacosNamespaces).not.toHaveBeenCalled();
expect(showEditNacosNamespaceDialog.value).toBe(true);
expect(editNacosNamespaceLoading.value).toBe(false);
});
});
describe("Nacos namespace deletion scope rollback", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.nacosDeleteNamespace.mockRejectedValue(new Error("remote delete failed"));
mocks.loadNacosNamespaces.mockResolvedValue(undefined);
sidebarDangerTarget.value = namespaceNode();
deleteNacosNamespaceLoading.value = false;
showDeleteNacosNamespaceConfirm.value = true;
});
it("restores the last managed namespace when the remote deletion fails", async () => {
let currentConfig: any = {
id: "conn-1",
db_type: "nacos",
visible_databases: ["existing-space"],
external_config: {
serverAddr: "http://127.0.0.1:8848",
managedNamespaces: ["existing-space"],
},
};
mocks.updateConnection.mockImplementation(async (config: any) => {
currentConfig = config;
});
const { confirmDeleteNacosNamespace } = useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(namespaceNode()),
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
getConfig: () => currentConfig,
updateConnection: mocks.updateConnection,
} as any,
});
await confirmDeleteNacosNamespace();
expect(mocks.nacosDeleteNamespace).toHaveBeenCalledWith("conn-1", "existing-space");
expect(mocks.updateConnection).toHaveBeenCalledTimes(2);
expect(mocks.updateConnection.mock.calls[0][0].visible_databases).toEqual([]);
expect(mocks.updateConnection.mock.calls[0][0].external_config.managedNamespaces).toEqual([]);
expect(currentConfig.visible_databases).toEqual(["existing-space"]);
expect(currentConfig.external_config.managedNamespaces).toEqual(["existing-space"]);
expect(mocks.notifyNacosNamespacesChanged).not.toHaveBeenCalled();
expect(showDeleteNacosNamespaceConfirm.value).toBe(true);
expect(deleteNacosNamespaceLoading.value).toBe(false);
});
});
@@ -35,7 +35,9 @@ import {
import { supportsMongoAllDriverMutations, supportsMongoIndexMutations, supportsNativeMongoDriverMutations } from "@/lib/mongo/mongoCapabilities";
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { uuid } from "@/lib/common/utils";
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
import type { NacosAdminConfig } from "@/types/nacos";
import {
sidebarDangerTarget,
sidebarFormTarget,
@@ -48,6 +50,8 @@ import {
editNacosNamespaceName,
editNacosNamespaceDesc,
editNacosNamespaceLoading,
showDeleteNacosNamespaceConfirm,
deleteNacosNamespaceLoading,
showDropMongoCollectionConfirm,
dropMongoCollectionLoading,
showDropMongoIndexConfirm,
@@ -668,11 +672,70 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
showCreateNacosNamespaceDialog.value = true;
}
interface ExplicitNacosNamespaceScopeSnapshot {
visibleDatabases?: string[];
managedNamespaces?: string[];
}
function snapshotExplicitNacosNamespaceScope(connectionId: string): ExplicitNacosNamespaceScopeSnapshot {
const config = connectionStore.getConfig(connectionId);
if (!config || config.db_type !== "nacos") return {};
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
return {
visibleDatabases: Array.isArray(config.visible_databases) ? [...config.visible_databases] : undefined,
managedNamespaces: Array.isArray(externalConfig.managedNamespaces) && externalConfig.managedNamespaces.length > 0 ? [...externalConfig.managedNamespaces] : undefined,
};
}
async function restoreExplicitNacosNamespaceScope(connectionId: string, snapshot: ExplicitNacosNamespaceScopeSnapshot): Promise<void> {
if (!snapshot.visibleDatabases && !snapshot.managedNamespaces) return;
const config = connectionStore.getConfig(connectionId);
if (!config || config.db_type !== "nacos") return;
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
await connectionStore.updateConnection({
...config,
visible_databases: snapshot.visibleDatabases ? [...snapshot.visibleDatabases] : config.visible_databases,
external_config: snapshot.managedNamespaces ? { ...externalConfig, managedNamespaces: [...snapshot.managedNamespaces] } : externalConfig,
});
}
async function updateExplicitNacosNamespaceScope(connectionId: string, namespaceId: string, action: "add" | "remove"): Promise<boolean> {
const config = connectionStore.getConfig(connectionId);
if (!config || config.db_type !== "nacos") return false;
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
const hasVisibleScope = Array.isArray(config.visible_databases);
const hasManagedScope = Array.isArray(externalConfig.managedNamespaces) && externalConfig.managedNamespaces.length > 0;
if (!hasVisibleScope && !hasManagedScope) return false;
const mutate = (values: string[] | undefined) => {
const normalized = new Set((values || []).map((value) => value.trim()).filter(Boolean));
if (action === "add") normalized.add(namespaceId);
else normalized.delete(namespaceId);
return [...normalized];
};
await connectionStore.updateConnection({
...config,
visible_databases: hasVisibleScope ? mutate(config.visible_databases) : config.visible_databases,
external_config: hasManagedScope ? { ...externalConfig, managedNamespaces: mutate(externalConfig.managedNamespaces) } : externalConfig,
});
return true;
}
async function refreshNacosNamespacesAfterMutation(connectionId: string) {
try {
await connectionStore.loadNacosNamespaces(connectionId, { force: true });
return true;
} catch (error: any) {
toast(translateBackendError(t, error), 5000);
return false;
}
}
async function confirmCreateNacosNamespace() {
const node = sidebarFormTarget.value ?? activeNode.value;
const namespaceName = createNacosNamespaceName.value.trim();
if (!node.connectionId || !namespaceName || createNacosNamespaceLoading.value) return;
const namespaceId = createNacosNamespaceId.value.trim();
const namespaceId = createNacosNamespaceId.value.trim() || uuid();
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(node.connectionId),
database: namespaceId || undefined,
@@ -684,16 +747,22 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
createNacosNamespaceLoading.value = true;
try {
await api.nacosCreateNamespace(node.connectionId, {
namespaceId: namespaceId || undefined,
namespaceId,
namespaceName,
namespaceDesc: createNacosNamespaceDesc.value.trim() || namespaceName,
});
try {
await updateExplicitNacosNamespaceScope(node.connectionId, namespaceId, "add");
} catch (error: any) {
toast(t("connection.saveFailed", { message: translateBackendError(t, error) }), 5000);
}
notifyNacosNamespacesChanged(node.connectionId);
showCreateNacosNamespaceDialog.value = false;
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, node);
if (liveNode) liveNode.isExpanded = true;
toast(t("nacos.namespaceCreated", { name: namespaceName }), 3000);
if (await refreshNacosNamespacesAfterMutation(node.connectionId)) {
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, node);
if (liveNode) liveNode.isExpanded = true;
}
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
@@ -727,9 +796,10 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
namespaceName,
namespaceDesc: editNacosNamespaceDesc.value.trim() || namespaceName,
});
notifyNacosNamespacesChanged(node.connectionId);
showEditNacosNamespaceDialog.value = false;
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
toast(t("nacos.namespaceUpdated", { name: namespaceName }), 3000);
await refreshNacosNamespacesAfterMutation(node.connectionId);
} catch (error: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
@@ -737,6 +807,50 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
}
}
function openDeleteNacosNamespaceConfirm() {
deleteNacosNamespaceLoading.value = false;
showDeleteNacosNamespaceConfirm.value = true;
}
async function confirmDeleteNacosNamespace() {
const node = sidebarDangerTarget.value ?? activeNode.value;
const namespaceId = node.nacosNamespace?.trim() || "";
if (node.type !== "nacos-namespace" || !node.connectionId || !namespaceId || deleteNacosNamespaceLoading.value) return;
const confirmed = await executeWithProductionContextGuard({
connection: connectionStore.getConfig(node.connectionId),
database: namespaceId,
reviewText: `${t("nacos.deleteNamespace")}: ${node.nacosNamespaceName || node.label}`,
source: t("production.sourceSidebar"),
execute: async () => true,
});
if (confirmed !== true) return;
deleteNacosNamespaceLoading.value = true;
const scopeSnapshot = snapshotExplicitNacosNamespaceScope(node.connectionId);
let scopeUpdated = false;
try {
// Persist the local scope first. A local save failure must abort before
// the irreversible remote deletion. If Nacos rejects the deletion, the
// original scope is restored below.
scopeUpdated = await updateExplicitNacosNamespaceScope(node.connectionId, namespaceId, "remove");
await api.nacosDeleteNamespace(node.connectionId, namespaceId);
notifyNacosNamespacesChanged(node.connectionId);
showDeleteNacosNamespaceConfirm.value = false;
toast(t("nacos.namespaceDeleted", { name: node.nacosNamespaceName || node.label }), 3000);
await refreshNacosNamespacesAfterMutation(node.connectionId);
} catch (error: any) {
if (scopeUpdated) {
try {
await restoreExplicitNacosNamespaceScope(node.connectionId, scopeSnapshot);
} catch (rollbackError: any) {
toast(t("connection.saveFailed", { message: translateBackendError(t, rollbackError) }), 5000);
}
}
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
deleteNacosNamespaceLoading.value = false;
}
}
function dropMongoCollection() {
dropMongoCollectionLoading.value = false;
showDropMongoCollectionConfirm.value = true;
@@ -1047,6 +1161,8 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
confirmCreateNacosNamespace,
openEditNacosNamespaceDialog,
confirmEditNacosNamespace,
openDeleteNacosNamespaceConfirm,
confirmDeleteNacosNamespace,
dropMongoCollection,
dropMongoIndex,
dropAllMongoIndexes,
+39 -3
View File
@@ -7961,6 +7961,11 @@ export default {
createUser: "Create user",
editUser: "Edit user",
resetUserPassword: "Reset password",
passwordResetSucceeded: "Reset password for {username}",
passwordResetFailed: "Failed to reset password: {message}",
currentPasswordSaved: "The saved connection credential was updated and the connection will use the new password.",
currentSessionPasswordUpdated: "The in-memory connection credential was updated for this session and was not saved to disk.",
currentPasswordNotSaved: "This connection does not save passwords. Enter the new password when reconnecting.",
deleteUser: "Delete user",
noUsers: "No users found",
noRoleBindings: "No role bindings found",
@@ -8014,7 +8019,7 @@ export default {
accessGlobalAdministrator: "Global administrator",
accessNeedsRepair: "Needs repair",
accessRoleSubtitle: "{members} associated members",
accessIncompleteRoleHint: "This historical role has no members or namespace permissions. Add both before saving changes.",
accessIncompleteRoleHint: "This role has no members or namespace permissions. Add namespace permissions before saving; members may remain empty.",
accessPermissionScope: "Permission scope",
accessPermissionScopeHint: "Namespace rules managed by this role.",
accessAllResources: "All resources",
@@ -8043,19 +8048,26 @@ export default {
accessInlineUserRequired: "Every new user requires a username and password.",
accessNoAssignableRoles: "No complete roles are available",
accessTypeUsername: "Type {value} to confirm this high-risk account change",
accessDeleteUserConfirmationLabel: "Enter the complete username to confirm deletion",
accessDeleteUserConfirmationHint: "Enter this text exactly (case-sensitive):",
accessDeleteUserConfirmationPlaceholder: "Complete username",
accessMembersRequiredHint: "Select existing users or create users inline. At least one is required.",
accessMembersOptionalHint: "An existing ordinary role may keep its permissions without any associated members.",
accessInlineCreateUser: "Create user inline",
accessAdminPermissionImmutable: "The global administrator permission is built into Nacos and cannot be edited as namespace rules.",
accessPermissionRequiredHint: "Select namespaces, then set the action for each one.",
accessUnassignedNamespaces: "Unassigned namespaces",
accessGrantedNamespaces: "Granted namespaces",
accessSearchNamespaces: "Search namespaces",
accessSearchNamespaces: "Search by namespace name or ID",
accessNoUnassignedNamespaces: "No namespaces available to add",
accessNoGrantedNamespaces: "Select namespaces to grant access",
accessGrantNamespaces: "Grant selected namespaces",
accessRevokeNamespaces: "Remove selected namespaces",
accessDeleteRoleHint: "Deleting {role} removes every permission and member binding. Successful deletions are not restored after a partial failure.",
accessTypeRole: "Type {value} to confirm role deletion",
accessDeleteRoleConfirmationLabel: "Enter the complete role name to confirm deletion",
accessDeleteRoleConfirmationHint: "Enter this text exactly (case-sensitive):",
accessDeleteRoleConfirmationPlaceholder: "Complete role name",
accessOperationResult: "Operation result",
accessReenterPassword: "Enter the password again to retry",
accessUndoCreated: "Undo created items",
@@ -8099,12 +8111,16 @@ export default {
namespace: "Namespace",
createNamespace: "Create Namespace",
editNamespace: "Edit Namespace",
deleteNamespace: "Delete Namespace",
deleteNamespaceDescription: "Delete namespace {name} ({id})? Its configurations and services may become inaccessible. This cannot be undone.",
creatingNamespace: "Creating...",
updatingNamespace: "Updating...",
deletingNamespace: "Deleting...",
namespaceCreated: 'Namespace "{name}" created',
namespaceUpdated: 'Namespace "{name}" updated',
namespaceDeleted: 'Namespace "{name}" deleted',
namespaceId: "Namespace ID",
namespaceIdPlaceholder: "Leave empty for Nacos to generate",
namespaceIdPlaceholder: "Leave empty for DBX to generate a UUID",
namespaceName: "Namespace Name",
namespaceNamePlaceholder: "Enter a namespace name",
namespaceDesc: "Description",
@@ -8335,10 +8351,18 @@ export default {
readOnly: "Read only",
nacosConnectionPlan: "Connection plan",
nacosConnectionPlanDescription: "Choose the service implementation and API version.",
nacosApiPlane: "Nacos 3 API",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "Recommended · full management",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "Restricted · works with console-only access",
nacosServiceAddress: "Service address",
nacosServiceAddressHint: "Enter the service address. The default port is 8848, for example http://host:8848/nacos.",
nacosConsoleServiceAddressHint: "Enter the Console API address. The default port is 8080, for example http://host:8080.",
nacosManagedNamespaces: "Managed namespace IDs",
nacosManagedNamespacesNameOrId: "Managed namespace names or IDs",
nacosManagedNamespacesPlaceholder: "For example: public, team-a",
nacosManagedNamespacesIdPlaceholder: "For example: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "Enter the namespace IDs this ordinary user can access, separated by commas or new lines. The connection test verifies configuration and service read access for each ID.",
nacosOrdinaryAccount: "Ordinary user",
nacosOrdinaryAccountHint: "If the account above is an ordinary user, enable this option and select its authorized namespaces; otherwise the connection will not work correctly.",
@@ -8346,6 +8370,16 @@ export default {
nacosOrdinaryNamespacesRequired: "Enter at least one namespace ID that this ordinary user is allowed to manage.",
nacosManagedNamespacesRequired: "This Nacos ordinary user cannot list namespaces or authorization data. Enter at least one namespace ID it is allowed to manage and try again.",
nacosManagedNamespaceAccessDenied: "These namespaces failed the configuration or service read-access check: {detail}",
nacosNamespaceAccessScope: "Namespace access scope",
nacosNamespaceAccessScopeHint: "Detect accessible namespaces first. If enumeration is unavailable, enter verified namespace IDs manually.",
nacosNamespaceAccessAutomatic: "Detect automatically",
nacosNamespaceAccessManual: "Enter manually",
nacosDetectAccessibleNamespaces: "Detect and select accessible namespaces",
nacosDetectAccessibleNamespacesHint: "DBX verifies configuration and service read access before offering namespaces for {name}.",
nacosV3AdminNamespaceScopeHint: "Nacos 3 Admin APIs normally require an administrator account. Use the Console API plane for a restricted account and its namespace access scope.",
nacosV3AdminManagedNamespacesHint: "This uses the Nacos 3 Admin API (normally port 8848). An ordinary user must enter namespace IDs, not display names. Copy the ID from Nacos Console namespace details or role-permission resources.",
nacosV3ConsoleManagedNamespacesHint: "This uses the Nacos 3 Console API (normally port 8080). Enter a namespace name or ID; DBX converts a name to its actual ID when it can read the directory.",
rnacosNamespaceAccessScopeHint: "r-nacos derives namespace access from the current user's namespace privileges when its console profile is available.",
nacosAuth: "Authentication",
nacosAuthHint: "These credentials are used for connection tests and daily management.",
nacosUsernamePassword: "Username / password",
@@ -8353,8 +8387,10 @@ export default {
nacosAdvancedHintDescription: "Configure Prometheus metrics, page size, TLS verification, and r-nacos extensions there.",
nacosGoAdvanced: "Go to Advanced",
nacosV3ConsolePortWarning: "8080 is the default Nacos 3 Web Console port. Use the Server / Admin API address on port 8848 for service management, for example http://127.0.0.1:8848/nacos.",
nacosV3AdminPortWarning: "8848 is normally the Admin API port. For Console API mode, use the Console address, normally http://127.0.0.1:8080.",
nacosAdvancedTitle: "Nacos advanced connection options",
nacosAdvancedDescription: "These settings have safe defaults and usually only need adjustment for custom deployments, metrics, or r-nacos extensions.",
nacosContextPathHint: "Leave empty to use the selected profile default. Enter / to explicitly use the server root, or provide a reverse-proxy prefix such as /nacos.",
nacosPageSizeHint: "Used for configuration and service lists; 1–500 items per page.",
nacosMetricsHint: "Query metrics from the service address automatically; choose a custom URL when the metrics endpoint differs.",
nacosRnacosExtension: "r-nacos console features",
+21 -1
View File
@@ -6577,7 +6577,7 @@ export default withEnglishFallback({
namespaceCreated: 'Namespace "{name}" creado',
namespaceUpdated: 'Namespace "{name}" actualizado',
namespaceId: "ID del Namespace",
namespaceIdPlaceholder: "Déjalo vacío para que Nacos lo genere",
namespaceIdPlaceholder: "Déjalo vacío para que DBX genere un UUID",
namespaceName: "Nombre del Namespace",
namespaceNamePlaceholder: "Ingresa un nombre de namespace",
namespaceDesc: "Descripción",
@@ -6808,10 +6808,18 @@ export default withEnglishFallback({
readOnly: "Solo lectura",
nacosConnectionPlan: "Plan de conexión",
nacosConnectionPlanDescription: "Elige la implementación del servicio y la versión de la API.",
nacosApiPlane: "Modo de API de Nacos 3",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "Recomendado · gestión completa",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "Limitado · para acceso solo a la consola",
nacosServiceAddress: "Dirección del servicio",
nacosServiceAddressHint: "Introduce la dirección del servicio. El puerto predeterminado es 8848, por ejemplo http://host:8848/nacos.",
nacosConsoleServiceAddressHint: "Introduce la dirección de Console API. El puerto predeterminado es 8080, por ejemplo http://host:8080.",
nacosManagedNamespaces: "ID de espacios administrados",
nacosManagedNamespacesNameOrId: "Nombres o ID de espacios administrados",
nacosManagedNamespacesPlaceholder: "Por ejemplo: public, team-a",
nacosManagedNamespacesIdPlaceholder: "Por ejemplo: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "Introduzca los ID accesibles para este usuario normal, separados por comas o líneas. La prueba de conexión valida la lectura de configuraciones y servicios para cada ID.",
nacosOrdinaryAccount: "Usuario normal",
nacosOrdinaryAccountHint: "Si la cuenta anterior es un usuario normal, active esta opción y seleccione sus espacios autorizados; de lo contrario, la conexión no funcionará correctamente.",
@@ -6819,6 +6827,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "Introduzca al menos un ID de espacio que este usuario normal pueda administrar.",
nacosManagedNamespacesRequired: "Este usuario normal de Nacos no puede listar los espacios ni los datos de autorización. Introduzca al menos un ID que tenga permiso para administrar y vuelva a intentarlo.",
nacosManagedNamespaceAccessDenied: "Estos espacios no superaron la comprobación de lectura de configuraciones o servicios: {detail}",
nacosNamespaceAccessScope: "Alcance de acceso a namespaces",
nacosNamespaceAccessScopeHint: "Primero detecta los namespaces accesibles. Si no se pueden enumerar, introduce manualmente IDs verificados.",
nacosNamespaceAccessAutomatic: "Detectar automáticamente",
nacosNamespaceAccessManual: "Introducir manualmente",
nacosDetectAccessibleNamespaces: "Detectar y seleccionar namespaces accesibles",
nacosDetectAccessibleNamespacesHint: "DBX verifica el acceso de lectura a configuraciones y servicios antes de ofrecer namespaces para {name}.",
nacosV3AdminNamespaceScopeHint: "Las API Admin de Nacos 3 suelen requerir una cuenta administradora. Para una cuenta restringida, usa el plano Console API.",
nacosV3AdminManagedNamespacesHint: "Esta conexión usa la API Admin de Nacos 3 (normalmente el puerto 8848). Un usuario normal debe introducir ID de namespace, no nombres visibles. Copia el ID desde los detalles del namespace o los permisos de rol en Nacos Console.",
nacosV3ConsoleManagedNamespacesHint: "Esta conexión usa la API Console de Nacos 3 (normalmente el puerto 8080). Introduce un nombre o ID de namespace; DBX convierte el nombre al ID real cuando puede leer el directorio.",
rnacosNamespaceAccessScopeHint: "r-nacos obtiene el acceso a namespaces de los privilegios del usuario actual cuando su perfil de consola está disponible.",
nacosAuth: "Autenticación",
nacosAuthHint: "Estas credenciales se usan para probar la conexión y para la administración diaria.",
nacosUsernamePassword: "Usuario / contraseña",
@@ -6826,6 +6844,7 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "Configura allí las métricas de Prometheus, el tamaño de página, la validación TLS y las extensiones de r-nacos.",
nacosGoAdvanced: "Ir a Avanzado",
nacosV3ConsolePortWarning: "8080 es el puerto predeterminado de la consola web de Nacos 3. Para administrar servicios, usa la dirección Server / Admin API del puerto 8848, por ejemplo http://127.0.0.1:8848/nacos.",
nacosV3AdminPortWarning: "8848 suele ser el puerto de Admin API. Para Console API, usa la dirección de la consola, normalmente http://127.0.0.1:8080.",
nacosAdvancedTitle: "Opciones avanzadas de conexión de Nacos",
nacosAdvancedDescription: "Estos parámetros tienen valores seguros y normalmente solo deben ajustarse para despliegues personalizados, métricas o extensiones de r-nacos.",
nacosPageSizeHint: "Se usa para las listas de configuraciones y servicios; entre 1 y 500 elementos por página.",
@@ -6910,6 +6929,7 @@ export default withEnglishFallback({
restore: "Restaurar",
registerInstance: "Registrar instancia",
deregister: "Dar de baja",
nacosContextPathHint: "Cuando se deja vacío, se usa el valor predeterminado del modo de conexión actual; si se usa explícitamente la ruta raíz del servicio, complete /; en escenarios de proxy inverso, puede completar prefijos como /nacos.",
},
databaseBrowser: {
search: "Buscar bases de datos...",
+21 -1
View File
@@ -6578,7 +6578,7 @@ export default withEnglishFallback({
namespaceCreated: 'Namespace "{name}" creato',
namespaceUpdated: 'Namespace "{name}" aggiornato',
namespaceId: "ID Namespace",
namespaceIdPlaceholder: "Lascia vuoto per generazione automatica Nacos",
namespaceIdPlaceholder: "Lascia vuoto per far generare a DBX un UUID",
namespaceName: "Nome Namespace",
namespaceNamePlaceholder: "Inserisci un nome namespace",
namespaceDesc: "Descrizione",
@@ -6809,10 +6809,18 @@ export default withEnglishFallback({
readOnly: "Sola lettura",
nacosConnectionPlan: "Piano di connessione",
nacosConnectionPlanDescription: "Scegli l'implementazione del servizio e la versione API.",
nacosApiPlane: "Modalità API Nacos 3",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "Consigliata · gestione completa",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "Limitata · per accesso alla sola console",
nacosServiceAddress: "Indirizzo del servizio",
nacosServiceAddressHint: "Inserisci l'indirizzo del servizio. La porta predefinita è 8848, ad esempio http://host:8848/nacos.",
nacosConsoleServiceAddressHint: "Inserisci l'indirizzo della Console API. La porta predefinita è 8080, ad esempio http://host:8080.",
nacosManagedNamespaces: "ID namespace gestiti",
nacosManagedNamespacesNameOrId: "Nomi o ID dei namespace gestiti",
nacosManagedNamespacesPlaceholder: "Ad esempio: public, team-a",
nacosManagedNamespacesIdPlaceholder: "Ad esempio: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "Inserire gli ID accessibili a questo utente normale, separati da virgole o righe. Il test di connessione verifica la lettura di configurazioni e servizi per ogni ID.",
nacosOrdinaryAccount: "Utente normale",
nacosOrdinaryAccountHint: "Se l'account sopra è un utente normale, attivare questa opzione e selezionare i namespace autorizzati; altrimenti la connessione non funzionerà correttamente.",
@@ -6820,6 +6828,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "Inserire almeno un ID namespace che questo utente normale può gestire.",
nacosManagedNamespacesRequired: "Questo utente normale Nacos non può elencare i namespace o i dati di autorizzazione. Inserire almeno un ID che è autorizzato a gestire e riprovare.",
nacosManagedNamespaceAccessDenied: "Questi namespace non hanno superato il controllo di lettura di configurazioni o servizi: {detail}",
nacosNamespaceAccessScope: "Ambito di accesso ai namespace",
nacosNamespaceAccessScopeHint: "Rileva prima i namespace accessibili. Se non possono essere elencati, inserisci manualmente ID verificati.",
nacosNamespaceAccessAutomatic: "Rileva automaticamente",
nacosNamespaceAccessManual: "Inserisci manualmente",
nacosDetectAccessibleNamespaces: "Rileva e seleziona namespace accessibili",
nacosDetectAccessibleNamespacesHint: "DBX verifica la lettura di configurazioni e servizi prima di proporre i namespace per {name}.",
nacosV3AdminNamespaceScopeHint: "Le API Admin di Nacos 3 richiedono normalmente un account amministratore. Per un account limitato usa il piano Console API.",
nacosV3AdminManagedNamespacesHint: "Questa connessione usa l'API Admin di Nacos 3 (normalmente porta 8848). Un utente normale deve inserire gli ID dei namespace, non i nomi visualizzati. Copia l'ID dai dettagli del namespace o dalle autorizzazioni del ruolo in Nacos Console.",
nacosV3ConsoleManagedNamespacesHint: "Questa connessione usa l'API Console di Nacos 3 (normalmente porta 8080). Inserisci un nome o ID namespace; DBX converte il nome nell'ID effettivo quando puo leggere la directory.",
rnacosNamespaceAccessScopeHint: "r-nacos ricava l'accesso ai namespace dai privilegi dell'utente corrente quando il profilo console è disponibile.",
nacosAuth: "Autenticazione",
nacosAuthHint: "Queste credenziali vengono usate per i test di connessione e la gestione quotidiana.",
nacosUsernamePassword: "Nome utente / password",
@@ -6827,6 +6845,7 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "Configura qui metriche Prometheus, dimensione pagina, verifica TLS ed estensioni r-nacos.",
nacosGoAdvanced: "Vai ad Avanzate",
nacosV3ConsolePortWarning: "8080 è la porta predefinita della Web Console Nacos 3. Per la gestione dei servizi usa l'indirizzo Server / Admin API sulla porta 8848, ad esempio http://127.0.0.1:8848/nacos.",
nacosV3AdminPortWarning: "8848 è normalmente la porta Admin API. Per Console API usa l'indirizzo della console, normalmente http://127.0.0.1:8080.",
nacosAdvancedTitle: "Opzioni di connessione avanzate Nacos",
nacosAdvancedDescription: "Questi parametri hanno valori sicuri e di solito richiedono modifiche solo per distribuzioni personalizzate, metriche o estensioni r-nacos.",
nacosPageSizeHint: "Usato per gli elenchi di configurazioni e servizi; da 1 a 500 elementi per pagina.",
@@ -6911,6 +6930,7 @@ export default withEnglishFallback({
restore: "Ripristina",
registerInstance: "Registra istanza",
deregister: "Rimuovi",
nacosContextPathHint: "Se lasciato vuoto, viene utilizzato il valore predefinito della modalità di connessione corrente; se si utilizza esplicitamente il percorso radice del servizio, inserire /; in scenari di proxy inverso, è possibile inserire prefissi come /nacos.",
},
databaseBrowser: {
search: "Cerca database...",
+21 -1
View File
@@ -6854,17 +6854,25 @@ export default withEnglishFallback({
namespaceCreated: "名前空間「{name}」を作成しました",
namespaceUpdated: "名前空間「{name}」を更新しました",
namespaceId: "名前空間ID",
namespaceIdPlaceholder: "空欄の場合はNacosが生成",
namespaceIdPlaceholder: "空欄の場合はDBXがUUIDを生成",
namespaceName: "名前空間名",
namespaceNamePlaceholder: "名前空間名を入力",
namespaceDesc: "説明",
namespaceDescPlaceholder: "デフォルトは名前空間名",
nacosConnectionPlan: "接続方式",
nacosConnectionPlanDescription: "サービス実装と API バージョンを選択します。",
nacosApiPlane: "Nacos 3 API モード",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "推奨 · 完全な管理機能",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "制限付き · Console のみの環境向け",
nacosServiceAddress: "サービスアドレス",
nacosServiceAddressHint: "サービスアドレスを入力してください。既定のポートは 8848 です(例: http://host:8848/nacos)。",
nacosConsoleServiceAddressHint: "Console API アドレスを入力してください。既定のポートは 8080 です(例: http://host:8080)。",
nacosManagedNamespaces: "管理対象の名前空間 ID",
nacosManagedNamespacesNameOrId: "管理対象の名前空間名または ID",
nacosManagedNamespacesPlaceholder: "例: public, team-a",
nacosManagedNamespacesIdPlaceholder: "例: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "この一般ユーザーがアクセスできる名前空間 ID をカンマまたは改行区切りで入力してください。接続テストで各 ID の設定・サービス読み取り権限を確認します。",
nacosOrdinaryAccount: "一般ユーザー",
nacosOrdinaryAccountHint: "上記のアカウントが一般ユーザーの場合、この項目を有効にしてアクセス権のある名前空間を指定してください。指定しないと接続後に正常に使用できません。",
@@ -6872,6 +6880,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "一般ユーザーが管理を許可された名前空間 ID を 1 つ以上入力してください。",
nacosManagedNamespacesRequired: "この Nacos 一般ユーザーは名前空間または権限情報を一覧表示できません。管理を許可された名前空間 ID を 1 つ以上入力して再試行してください。",
nacosManagedNamespaceAccessDenied: "次の名前空間は設定またはサービスの読み取り権限チェックに失敗しました: {detail}",
nacosNamespaceAccessScope: "名前空間のアクセス範囲",
nacosNamespaceAccessScopeHint: "まずアクセス可能な名前空間を検出してください。一覧取得できない場合は、確認済みの ID を手動で入力できます。",
nacosNamespaceAccessAutomatic: "自動検出",
nacosNamespaceAccessManual: "手動入力",
nacosDetectAccessibleNamespaces: "アクセス可能な名前空間を検出して選択",
nacosDetectAccessibleNamespacesHint: "DBX は {name} の名前空間を提示する前に、設定とサービスの読み取りアクセスを検証します。",
nacosV3AdminNamespaceScopeHint: "Nacos 3 の Admin API には通常管理者アカウントが必要です。制限付きアカウントでは Console API を使用してください。",
nacosV3AdminManagedNamespacesHint: "この接続は Nacos 3 Admin API(通常はポート 8848)を使用します。一般ユーザーは表示名ではなく名前空間 ID を入力する必要があります。Nacos Console の名前空間詳細またはロール権限リソースから ID をコピーしてください。",
nacosV3ConsoleManagedNamespacesHint: "この接続は Nacos 3 Console API(通常はポート 8080)を使用します。名前空間名または ID を入力できます。ディレクトリを読み取れる場合、DBX は名前を実際の ID に変換します。",
rnacosNamespaceAccessScopeHint: "r-nacos は Console プロファイルが利用可能な場合、現在のユーザーの名前空間権限からアクセス範囲を取得します。",
nacosAuth: "認証",
nacosAuthHint: "接続テストと日常の管理に使用する認証情報です。",
nacosUsernamePassword: "ユーザー名 / パスワード",
@@ -6879,6 +6897,7 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "Prometheus メトリクス、ページサイズ、TLS 検証、r-nacos 拡張を設定できます。",
nacosGoAdvanced: "詳細へ移動",
nacosV3ConsolePortWarning: "8080 は Nacos 3 Web Console の既定ポートです。サービス管理には 8848 の Server / Admin API アドレス(例: http://127.0.0.1:8848/nacos)を使用してください。",
nacosV3AdminPortWarning: "8848 は通常 Admin API ポートです。Console API モードでは、通常 http://127.0.0.1:8080 の Console アドレスを使用してください。",
nacosAdvancedTitle: "Nacos 詳細接続オプション",
nacosAdvancedDescription: "安全な既定値が設定されています。通常はカスタム構成、メトリクス、r-nacos 拡張を使用する場合だけ変更します。",
nacosPageSizeHint: "設定とサービスの一覧に使用します。1 ページあたり 1~500 件です。",
@@ -6963,6 +6982,7 @@ export default withEnglishFallback({
restore: "元に戻す",
registerInstance: "インスタンスを登録",
deregister: "解除",
nacosContextPathHint: "空欄の場合は現在の接続モードのデフォルト値を使用します。サービスルートパスを明示的に使用する場合は / を入力し、リバースプロキシのシナリオでは /nacos などのプレフィックスを入力できます。",
},
databaseBrowser: {
search: "データベースを検索...",
+25 -1
View File
@@ -7335,12 +7335,16 @@ export default withEnglishFallback({
namespace: "네임스페이스",
createNamespace: "네임스페이스 만들기",
editNamespace: "네임스페이스 편집",
deleteNamespace: "네임스페이스 삭제",
deleteNamespaceDescription: "네임스페이스 {name}({id})을 삭제하시겠습니까? 구성과 서비스에 더 이상 접근하지 못할 수 있으며 이 작업은 되돌릴 수 없습니다.",
creatingNamespace: "만드는 중...",
updatingNamespace: "업데이트 중...",
deletingNamespace: "삭제 중...",
namespaceCreated: '네임스페이스 "{name}" 생성됨',
namespaceUpdated: '네임스페이스 "{name}" 업데이트됨',
namespaceDeleted: '네임스페이스 "{name}" 삭제됨',
namespaceId: "네임스페이스 ID",
namespaceIdPlaceholder: "Nacos가 자동 생성하려면 비워 두세요",
namespaceIdPlaceholder: "DBX가 UUID를 자동 생성하도록 비워 두세요",
namespaceName: "네임스페이스 이름",
namespaceNamePlaceholder: "네임스페이스 이름 입력",
namespaceDesc: "설명",
@@ -7571,10 +7575,18 @@ export default withEnglishFallback({
readOnly: "읽기 전용",
nacosConnectionPlan: "연결 방식",
nacosConnectionPlanDescription: "서비스 구현과 API 버전을 선택합니다.",
nacosApiPlane: "Nacos 3 API 모드",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "권장 · 전체 관리 기능",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "제한됨 · Console 전용 환경용",
nacosServiceAddress: "서비스 주소",
nacosServiceAddressHint: "서비스 주소를 입력하세요. 기본 포트는 8848입니다(예: http://host:8848/nacos).",
nacosConsoleServiceAddressHint: "Console API 주소를 입력하세요. 기본 포트는 8080입니다(예: http://host:8080).",
nacosManagedNamespaces: "관리할 네임스페이스 ID",
nacosManagedNamespacesNameOrId: "관리할 네임스페이스 이름 또는 ID",
nacosManagedNamespacesPlaceholder: "예: public, team-a",
nacosManagedNamespacesIdPlaceholder: "예: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "이 일반 사용자가 접근할 수 있는 네임스페이스 ID를 쉼표 또는 줄바꿈으로 입력하세요. 연결 테스트에서 각 ID의 구성 및 서비스 읽기 권한을 확인합니다.",
nacosOrdinaryAccount: "일반 사용자",
nacosOrdinaryAccountHint: "위 계정이 일반 사용자인 경우 이 옵션을 켜고 권한이 있는 네임스페이스를 지정하세요. 지정하지 않으면 연결 후 정상적으로 사용할 수 없습니다.",
@@ -7582,6 +7594,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "일반 사용자가 관리할 수 있는 네임스페이스 ID를 하나 이상 입력하세요.",
nacosManagedNamespacesRequired: "이 Nacos 일반 사용자는 네임스페이스 또는 권한 정보를 조회할 수 없습니다. 관리 권한이 있는 네임스페이스 ID를 하나 이상 입력한 후 다시 시도하세요.",
nacosManagedNamespaceAccessDenied: "다음 네임스페이스가 구성 또는 서비스 읽기 권한 검사에 실패했습니다: {detail}",
nacosNamespaceAccessScope: "네임스페이스 접근 범위",
nacosNamespaceAccessScopeHint: "먼저 접근 가능한 네임스페이스를 감지하세요. 나열할 수 없으면 확인된 ID를 직접 입력할 수 있습니다.",
nacosNamespaceAccessAutomatic: "자동 감지",
nacosNamespaceAccessManual: "수동 입력",
nacosDetectAccessibleNamespaces: "접근 가능한 네임스페이스 감지 및 선택",
nacosDetectAccessibleNamespacesHint: "DBX는 {name}에 대해 네임스페이스를 표시하기 전에 구성 및 서비스 읽기 권한을 확인합니다.",
nacosV3AdminNamespaceScopeHint: "Nacos 3 Admin API는 일반적으로 관리자 계정이 필요합니다. 제한된 계정은 Console API를 사용하세요.",
nacosV3AdminManagedNamespacesHint: "이 연결은 Nacos 3 Admin API(일반적으로 포트 8848)를 사용합니다. 일반 사용자는 표시 이름이 아니라 네임스페이스 ID를 입력해야 합니다. Nacos Console의 네임스페이스 상세 정보 또는 역할 권한 리소스에서 ID를 복사하세요.",
nacosV3ConsoleManagedNamespacesHint: "이 연결은 Nacos 3 Console API(일반적으로 포트 8080)를 사용합니다. 네임스페이스 이름이나 ID를 입력할 수 있으며, 디렉터리를 읽을 수 있으면 DBX가 이름을 실제 ID로 변환합니다.",
rnacosNamespaceAccessScopeHint: "r-nacos는 콘솔 프로필을 사용할 수 있을 때 현재 사용자의 네임스페이스 권한에서 접근 범위를 가져옵니다.",
nacosAuth: "인증",
nacosAuthHint: "이 자격 증명은 연결 테스트와 일상적인 관리에 사용됩니다.",
nacosUsernamePassword: "사용자 이름 / 비밀번호",
@@ -7589,8 +7611,10 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "Prometheus 메트릭, 페이지 크기, TLS 검증 및 r-nacos 확장을 설정합니다.",
nacosGoAdvanced: "고급으로 이동",
nacosV3ConsolePortWarning: "8080은 Nacos 3 Web Console의 기본 포트입니다. 서비스 관리는 8848 포트의 Server / Admin API 주소(예: http://127.0.0.1:8848/nacos)를 사용하세요.",
nacosV3AdminPortWarning: "8848은 일반적으로 Admin API 포트입니다. Console API 모드에서는 보통 http://127.0.0.1:8080인 Console 주소를 사용하세요.",
nacosAdvancedTitle: "Nacos 고급 연결 옵션",
nacosAdvancedDescription: "안전한 기본값이 적용되어 있으며, 사용자 지정 배포·메트릭·r-nacos 확장을 사용할 때만 조정하면 됩니다.",
nacosContextPathHint: "선택한 프로필의 기본값을 사용하려면 비워 두세요. 서버 루트를 명시하려면 /를 입력하고, 리버스 프록시 접두사는 /nacos와 같이 입력하세요.",
nacosPageSizeHint: "구성 및 서비스 목록에 사용되며 페이지당 1~500개입니다.",
nacosMetricsHint: "서비스 주소에서 메트릭을 자동으로 조회합니다. 메트릭 엔드포인트가 다르면 사용자 지정 URL을 선택하세요.",
nacosRnacosExtension: "r-nacos 확장",
@@ -25,6 +25,15 @@ export const nacosAccessControlMessages = {
createUser: "Create user",
editUser: "Edit user",
resetUserPassword: "Reset password",
passwordResetSucceeded: "Reset password for {username}",
passwordResetFailed: "Failed to reset password: {message}",
currentPasswordSaved: "The saved connection credential was updated and the connection will use the new password.",
currentSessionPasswordUpdated: "The in-memory connection credential was updated for this session and was not saved to disk.",
currentPasswordNotSaved: "This connection does not save passwords. Enter the new password when reconnecting.",
deleteNamespace: "Delete namespace",
deleteNamespaceDescription: "Delete namespace {name} ({id})? Its configurations and services may become inaccessible. This cannot be undone.",
deletingNamespace: "Deleting...",
namespaceDeleted: 'Namespace "{name}" deleted',
deleteUser: "Delete user",
noUsers: "No users found",
noRoleBindings: "No role bindings found",
@@ -78,7 +87,7 @@ export const nacosAccessControlMessages = {
accessGlobalAdministrator: "Global administrator",
accessNeedsRepair: "Needs repair",
accessRoleSubtitle: "{members} associated members",
accessIncompleteRoleHint: "This historical role has no members or namespace permissions. Add both before saving changes.",
accessIncompleteRoleHint: "This role has no members or namespace permissions. Add namespace permissions before saving; members may remain empty.",
accessPermissionScope: "Permission scope",
accessPermissionScopeHint: "Namespace rules managed by this role.",
accessAllResources: "All resources",
@@ -107,19 +116,26 @@ export const nacosAccessControlMessages = {
accessInlineUserRequired: "Every new user requires a username and password.",
accessNoAssignableRoles: "No complete roles are available",
accessTypeUsername: "Type {value} to confirm this high-risk account change",
accessDeleteUserConfirmationLabel: "Enter the complete username to confirm deletion",
accessDeleteUserConfirmationHint: "Enter this text exactly (case-sensitive):",
accessDeleteUserConfirmationPlaceholder: "Complete username",
accessMembersRequiredHint: "Select existing users or create users inline. At least one is required.",
accessMembersOptionalHint: "An existing ordinary role may keep its permissions without any associated members.",
accessInlineCreateUser: "Create user inline",
accessAdminPermissionImmutable: "The global administrator permission is built into Nacos and cannot be edited as namespace rules.",
accessPermissionRequiredHint: "Select namespaces, then set the action for each one.",
accessUnassignedNamespaces: "Unassigned namespaces",
accessGrantedNamespaces: "Granted namespaces",
accessSearchNamespaces: "Search namespaces",
accessSearchNamespaces: "Search by namespace name or ID",
accessNoUnassignedNamespaces: "No namespaces available to add",
accessNoGrantedNamespaces: "Select namespaces to grant access",
accessGrantNamespaces: "Grant selected namespaces",
accessRevokeNamespaces: "Remove selected namespaces",
accessDeleteRoleHint: "Deleting {role} removes every permission and member binding. Successful deletions are not restored after a partial failure.",
accessTypeRole: "Type {value} to confirm role deletion",
accessDeleteRoleConfirmationLabel: "Enter the complete role name to confirm deletion",
accessDeleteRoleConfirmationHint: "Enter this text exactly (case-sensitive):",
accessDeleteRoleConfirmationPlaceholder: "Complete role name",
accessOperationResult: "Operation result",
accessReenterPassword: "Enter the password again to retry",
accessUndoCreated: "Undo created items",
+21 -1
View File
@@ -6579,7 +6579,7 @@ export default withEnglishFallback({
namespaceCreated: 'Namespace "{name}" criado',
namespaceUpdated: 'Namespace "{name}" atualizado',
namespaceId: "ID do Namespace",
namespaceIdPlaceholder: "Deixe vazio para o Nacos gerar",
namespaceIdPlaceholder: "Deixe vazio para o DBX gerar um UUID",
namespaceName: "Nome do Namespace",
namespaceNamePlaceholder: "Digite um nome de namespace",
namespaceDesc: "Descrição",
@@ -6810,10 +6810,18 @@ export default withEnglishFallback({
readOnly: "Somente leitura",
nacosConnectionPlan: "Plano de conexão",
nacosConnectionPlanDescription: "Escolha a implementação do serviço e a versão da API.",
nacosApiPlane: "Modo de API do Nacos 3",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "Recomendado · gerenciamento completo",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "Limitado · para acesso somente ao console",
nacosServiceAddress: "Endereço do serviço",
nacosServiceAddressHint: "Informe o endereço do serviço. A porta padrão é 8848, por exemplo http://host:8848/nacos.",
nacosConsoleServiceAddressHint: "Informe o endereço da Console API. A porta padrão é 8080, por exemplo http://host:8080.",
nacosManagedNamespaces: "IDs de namespaces gerenciados",
nacosManagedNamespacesNameOrId: "Nomes ou IDs de namespaces gerenciados",
nacosManagedNamespacesPlaceholder: "Por exemplo: public, team-a",
nacosManagedNamespacesIdPlaceholder: "Por exemplo: public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "Informe os IDs acessíveis para este usuário comum, separados por vírgulas ou linhas. O teste de conexão valida a leitura de configurações e serviços para cada ID.",
nacosOrdinaryAccount: "Usuário comum",
nacosOrdinaryAccountHint: "Se a conta acima for um usuário comum, ative esta opção e selecione os namespaces autorizados; caso contrário, a conexão não funcionará corretamente.",
@@ -6821,6 +6829,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "Informe pelo menos um ID de namespace que este usuário comum pode gerenciar.",
nacosManagedNamespacesRequired: "Este usuário comum do Nacos não pode listar namespaces nem dados de autorização. Informe pelo menos um ID que ele tenha permissão para gerenciar e tente novamente.",
nacosManagedNamespaceAccessDenied: "Estes namespaces falharam na verificação de leitura de configurações ou serviços: {detail}",
nacosNamespaceAccessScope: "Escopo de acesso aos namespaces",
nacosNamespaceAccessScopeHint: "Primeiro detecte os namespaces acessíveis. Se não puder listá-los, informe manualmente IDs verificados.",
nacosNamespaceAccessAutomatic: "Detectar automaticamente",
nacosNamespaceAccessManual: "Informar manualmente",
nacosDetectAccessibleNamespaces: "Detectar e selecionar namespaces acessíveis",
nacosDetectAccessibleNamespacesHint: "O DBX verifica a leitura de configurações e serviços antes de oferecer namespaces para {name}.",
nacosV3AdminNamespaceScopeHint: "As APIs Admin do Nacos 3 normalmente exigem uma conta de administrador. Para uma conta restrita, use o plano Console API.",
nacosV3AdminManagedNamespacesHint: "Esta conexao usa a API Admin do Nacos 3 (normalmente a porta 8848). Um usuario comum deve informar IDs de namespace, nao nomes exibidos. Copie o ID dos detalhes do namespace ou das permissoes da funcao no Nacos Console.",
nacosV3ConsoleManagedNamespacesHint: "Esta conexao usa a API Console do Nacos 3 (normalmente a porta 8080). Informe um nome ou ID de namespace; o DBX converte o nome no ID real quando consegue ler o diretorio.",
rnacosNamespaceAccessScopeHint: "O r-nacos deriva o acesso aos namespaces dos privilégios do usuário atual quando o perfil do console está disponível.",
nacosAuth: "Autenticação",
nacosAuthHint: "Essas credenciais são usadas nos testes de conexão e no gerenciamento diário.",
nacosUsernamePassword: "Usuário / senha",
@@ -6828,6 +6846,7 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "Configure ali as métricas do Prometheus, o tamanho da página, a validação TLS e as extensões do r-nacos.",
nacosGoAdvanced: "Ir para Avançado",
nacosV3ConsolePortWarning: "8080 é a porta padrão do Web Console do Nacos 3. Para gerenciar serviços, use o endereço Server / Admin API na porta 8848, por exemplo http://127.0.0.1:8848/nacos.",
nacosV3AdminPortWarning: "8848 normalmente é a porta Admin API. No modo Console API, use o endereço do console, normalmente http://127.0.0.1:8080.",
nacosAdvancedTitle: "Opções avançadas de conexão do Nacos",
nacosAdvancedDescription: "Esses parâmetros têm valores seguros e normalmente só precisam ser ajustados para implantações personalizadas, métricas ou extensões do r-nacos.",
nacosPageSizeHint: "Usado nas listas de configurações e serviços; de 1 a 500 itens por página.",
@@ -6912,6 +6931,7 @@ export default withEnglishFallback({
restore: "Restaurar",
registerInstance: "Registrar instância",
deregister: "Remover registro",
nacosContextPathHint: "Quando deixado em branco, use o valor padrão do modo de conexão atual; se usar explicitamente o caminho raiz do serviço, preencha /; em cenários de proxy reverso, você pode preencher prefixos como /nacos.",
},
databaseBrowser: {
search: "Pesquisar bancos de dados...",
+39 -3
View File
@@ -7944,6 +7944,11 @@ export default withEnglishFallback({
createUser: "新建用户",
editUser: "编辑用户",
resetUserPassword: "重置密码",
passwordResetSucceeded: "已重置 {username} 的密码",
passwordResetFailed: "重置密码失败:{message}",
currentPasswordSaved: "已同步更新当前连接保存的凭据,后续将使用新密码连接。",
currentSessionPasswordUpdated: "已更新本次运行期的内存凭据,后续连接将使用新密码,且不会写入磁盘。",
currentPasswordNotSaved: "当前连接未保存密码,重新连接时需要输入新密码。",
deleteUser: "删除用户",
noUsers: "暂无用户",
noRoleBindings: "暂无角色绑定",
@@ -7997,7 +8002,7 @@ export default withEnglishFallback({
accessGlobalAdministrator: "全局管理员",
accessNeedsRepair: "需要修复",
accessRoleSubtitle: "已关联 {members} 位成员",
accessIncompleteRoleHint: "这是一个历史异常角色,缺少成员或命名空间权限。保存修改前必须补齐两项配置。",
accessIncompleteRoleHint: "该角色当前没有成员或命名空间权限。保存前需补充命名空间权限,成员可以保持为空。",
accessPermissionScope: "权限范围",
accessPermissionScopeHint: "由该角色管理的命名空间权限规则。",
accessAllResources: "全部资源",
@@ -8026,19 +8031,26 @@ export default withEnglishFallback({
accessInlineUserRequired: "每个待创建用户都必须填写用户名和密码。",
accessNoAssignableRoles: "暂无可分配的完整角色",
accessTypeUsername: "输入 {value} 确认此次高风险账号变更",
accessDeleteUserConfirmationLabel: "输入完整用户名以确认删除",
accessDeleteUserConfirmationHint: "请准确输入以下内容(区分大小写):",
accessDeleteUserConfirmationPlaceholder: "请输入完整用户名",
accessMembersRequiredHint: "选择已有用户或直接创建新用户,至少需要一位成员。",
accessMembersOptionalHint: "已有普通角色可以在没有关联成员时继续保留其权限。",
accessInlineCreateUser: "同时创建用户",
accessAdminPermissionImmutable: "全局管理员权限由 Nacos 内置,不能按普通命名空间权限编辑。",
accessPermissionRequiredHint: "选择命名空间后,为每一项分别设置权限动作。",
accessUnassignedNamespaces: "未授权命名空间",
accessGrantedNamespaces: "已授权命名空间",
accessSearchNamespaces: "搜索命名空间",
accessSearchNamespaces: "按命名空间名称或 ID 搜索",
accessNoUnassignedNamespaces: "没有可添加的命名空间",
accessNoGrantedNamespaces: "请选择需要授权的命名空间",
accessGrantNamespaces: "授予选中的命名空间",
accessRevokeNamespaces: "移除选中的命名空间",
accessDeleteRoleHint: "删除 {role} 会移除其全部权限和成员绑定。部分成功后,已删除内容不会自动恢复。",
accessTypeRole: "输入 {value} 确认删除角色",
accessDeleteRoleConfirmationLabel: "输入完整角色名称以确认删除",
accessDeleteRoleConfirmationHint: "请准确输入以下内容(区分大小写):",
accessDeleteRoleConfirmationPlaceholder: "请输入完整角色名称",
accessOperationResult: "操作结果",
accessReenterPassword: "重新输入密码后重试",
accessUndoCreated: "撤销本次创建",
@@ -8082,12 +8094,16 @@ export default withEnglishFallback({
namespace: "命名空间",
createNamespace: "新建命名空间",
editNamespace: "编辑命名空间",
deleteNamespace: "删除命名空间",
deleteNamespaceDescription: "确定删除命名空间 {name}({id})吗?其中的配置和服务可能将无法访问,此操作不可撤销。",
creatingNamespace: "正在创建...",
updatingNamespace: "正在更新...",
deletingNamespace: "正在删除...",
namespaceCreated: "命名空间「{name}」已创建",
namespaceUpdated: "命名空间「{name}」已更新",
namespaceDeleted: "命名空间「{name}」已删除",
namespaceId: "命名空间 ID",
namespaceIdPlaceholder: "留空则由 Nacos 自动生成",
namespaceIdPlaceholder: "留空则由 DBX 自动生成 UUID",
namespaceName: "命名空间名",
namespaceNamePlaceholder: "请输入命名空间名",
namespaceDesc: "描述",
@@ -8318,10 +8334,18 @@ export default withEnglishFallback({
readOnly: "只读",
nacosConnectionPlan: "连接方案",
nacosConnectionPlanDescription: "选择服务实现和 API 版本。",
nacosApiPlane: "Nacos 3 接口模式",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "推荐 · 完整管理能力",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "受限 · 适用于仅开放控制台的环境",
nacosServiceAddress: "服务地址",
nacosServiceAddressHint: "填写服务地址即可,默认端口为 8848,例如 http://主机:8848/nacos。",
nacosConsoleServiceAddressHint: "填写 Console API 地址,默认端口为 8080,例如 http://主机:8080。",
nacosManagedNamespaces: "可管理命名空间 ID",
nacosManagedNamespacesNameOrId: "可管理命名空间名称或 ID",
nacosManagedNamespacesPlaceholder: "例如:public, team-a",
nacosManagedNamespacesIdPlaceholder: "例如:public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "填写该普通用户有权访问的命名空间 ID,支持逗号或换行分隔;连接测试会逐项验证配置与服务读取权限。",
nacosOrdinaryAccount: "普通用户",
nacosOrdinaryAccountHint: "如果上方填写的是普通用户,请开启此选项并指定该用户有权限的命名空间,否则连接后将无法正常使用。",
@@ -8329,6 +8353,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "普通用户至少需要填写一个有权管理的命名空间 ID。",
nacosManagedNamespacesRequired: "当前 Nacos 普通用户无权获取命名空间目录或权限管理信息,请填写至少一个有权管理的命名空间 ID 后重试。",
nacosManagedNamespaceAccessDenied: "以下命名空间未通过配置或服务读取权限校验:{detail}",
nacosNamespaceAccessScope: "命名空间访问范围",
nacosNamespaceAccessScopeHint: "请先检测可访问命名空间;无法枚举时,可手动填写已确认有权限的命名空间 ID。",
nacosNamespaceAccessAutomatic: "自动检测",
nacosNamespaceAccessManual: "手动填写",
nacosDetectAccessibleNamespaces: "检测并选择可访问命名空间",
nacosDetectAccessibleNamespacesHint: "DBX 会校验「{name}」中每个命名空间的配置与服务读取权限后再供选择。",
nacosV3AdminNamespaceScopeHint: "Nacos 3 Admin API 通常需要管理员账号。受限账号请改用 Console API 平面,并在那里配置命名空间访问范围。",
nacosV3AdminManagedNamespacesHint: "当前使用 Nacos 3 Admin API(通常为 8848)。普通用户必须填写命名空间 ID,不能填写显示名称。请从 Nacos Console 的命名空间详情或角色权限资源中复制 ID。",
nacosV3ConsoleManagedNamespacesHint: "当前使用 Nacos 3 Console API(通常为 8080)。可填写命名空间名称或 ID;DBX 能读取目录时会自动将名称转换为实际 ID。",
rnacosNamespaceAccessScopeHint: "配置了 r-nacos Console 用户资料后,DBX 会根据当前用户的命名空间权限确定访问范围。",
nacosAuth: "认证",
nacosAuthHint: "认证信息同时用于连接测试和日常管理。",
nacosUsernamePassword: "账号密码",
@@ -8336,8 +8370,10 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "可在高级页配置 Prometheus 指标、分页大小、TLS 证书校验和 r-nacos 扩展。",
nacosGoAdvanced: "前往高级",
nacosV3ConsolePortWarning: "8080 是 Nacos 3 默认 Web 控制台端口。服务管理请使用 8848 端口的 Server / Admin API 地址,例如 http://127.0.0.1:8848/nacos。",
nacosV3AdminPortWarning: "8848 通常是 Admin API 端口。Console API 模式请填写控制台地址,默认是 http://127.0.0.1:8080。",
nacosAdvancedTitle: "Nacos 高级连接选项",
nacosAdvancedDescription: "这些参数均有安全默认值,通常只有自定义部署、指标采集或 r-nacos 扩展能力需要调整。",
nacosContextPathHint: "留空时使用当前连接模式的默认值;明确使用服务根路径请填写 /,反向代理场景可填写 /nacos 等前缀。",
nacosPageSizeHint: "用于配置和服务列表,每页 1–500 条。",
nacosMetricsHint: "自动使用服务地址查询指标;若指标端点不同,请选择“自定义地址”。",
nacosRnacosExtension: "r-nacos 控制台功能",
+21 -1
View File
@@ -6126,17 +6126,25 @@ export default withEnglishFallback({
namespaceCreated: "命名空間「{name}」已建立",
namespaceUpdated: "命名空間「{name}」已更新",
namespaceId: "命名空間 ID",
namespaceIdPlaceholder: "留空由 Nacos 自動產生",
namespaceIdPlaceholder: "留空則由 DBX 自動產生 UUID",
namespaceName: "命名空間名稱",
namespaceNamePlaceholder: "輸入命名空間名稱",
namespaceDesc: "描述",
namespaceDescPlaceholder: "預設為命名空間名稱",
nacosConnectionPlan: "連線方案",
nacosConnectionPlanDescription: "選擇服務實作與 API 版本。",
nacosApiPlane: "Nacos 3 API 模式",
nacosApiPlaneAdmin: "Admin API",
nacosApiPlaneAdminHint: "建議 · 完整管理能力",
nacosApiPlaneConsole: "Console API",
nacosApiPlaneConsoleHint: "受限 · 適用於僅開放控制台的環境",
nacosServiceAddress: "服務位址",
nacosServiceAddressHint: "填寫服務位址即可,預設連接埠為 8848,例如 http://host:8848/nacos。",
nacosConsoleServiceAddressHint: "填寫 Console API 位址,預設連接埠為 8080,例如 http://host:8080。",
nacosManagedNamespaces: "可管理命名空間 ID",
nacosManagedNamespacesNameOrId: "可管理命名空間名稱或 ID",
nacosManagedNamespacesPlaceholder: "例如:public, team-a",
nacosManagedNamespacesIdPlaceholder: "例如:public, f8b8c4e2-8195-4041-adb2-683a8259384b",
nacosManagedNamespacesHint: "填寫該一般使用者有權存取的命名空間 ID,支援逗號或換行分隔;連線測試會逐項驗證設定與服務讀取權限。",
nacosOrdinaryAccount: "一般使用者",
nacosOrdinaryAccountHint: "如果上方填寫的是一般使用者,請開啟此選項並指定該使用者有權限的命名空間,否則連線後將無法正常使用。",
@@ -6144,6 +6152,16 @@ export default withEnglishFallback({
nacosOrdinaryNamespacesRequired: "一般使用者至少需要填寫一個有權管理的命名空間 ID。",
nacosManagedNamespacesRequired: "目前的 Nacos 一般使用者無權取得命名空間目錄或權限管理資訊,請填寫至少一個有權管理的命名空間 ID 後重試。",
nacosManagedNamespaceAccessDenied: "以下命名空間未通過設定或服務讀取權限驗證:{detail}",
nacosNamespaceAccessScope: "命名空間存取範圍",
nacosNamespaceAccessScopeHint: "請先偵測可存取的命名空間;無法列舉時,可手動填寫已確認有權限的命名空間 ID。",
nacosNamespaceAccessAutomatic: "自動偵測",
nacosNamespaceAccessManual: "手動填寫",
nacosDetectAccessibleNamespaces: "偵測並選擇可存取的命名空間",
nacosDetectAccessibleNamespacesHint: "DBX 會驗證「{name}」中每個命名空間的設定與服務讀取權限後再供選擇。",
nacosV3AdminNamespaceScopeHint: "Nacos 3 Admin API 通常需要管理員帳號。受限帳號請改用 Console API 平面,並在其中設定命名空間存取範圍。",
nacosV3AdminManagedNamespacesHint: "目前使用 Nacos 3 Admin API(通常為 8848)。一般使用者必須填寫命名空間 ID,而非顯示名稱。請從 Nacos Console 的命名空間詳細資料或角色權限資源複製 ID。",
nacosV3ConsoleManagedNamespacesHint: "目前使用 Nacos 3 Console API(通常為 8080)。可填寫命名空間名稱或 ID;DBX 能讀取目錄時會自動將名稱轉換為實際 ID。",
rnacosNamespaceAccessScopeHint: "設定 r-nacos Console 使用者資料後,DBX 會依目前使用者的命名空間權限決定存取範圍。",
nacosAuth: "驗證",
nacosAuthHint: "這些驗證資訊會用於連線測試與日常管理。",
nacosUsernamePassword: "使用者名稱 / 密碼",
@@ -6151,6 +6169,7 @@ export default withEnglishFallback({
nacosAdvancedHintDescription: "可在此設定 Prometheus 指標、分頁大小、TLS 驗證與 r-nacos 擴充功能。",
nacosGoAdvanced: "前往進階",
nacosV3ConsolePortWarning: "8080 是 Nacos 3 Web Console 的預設連接埠。服務管理請使用 8848 連接埠的 Server / Admin API 位址,例如 http://127.0.0.1:8848/nacos。",
nacosV3AdminPortWarning: "8848 通常是 Admin API 連接埠。Console API 模式請填寫控制台位址,預設為 http://127.0.0.1:8080。",
nacosAdvancedTitle: "Nacos 進階連線選項",
nacosAdvancedDescription: "這些參數都有安全的預設值,通常只有自訂部署、指標或 r-nacos 擴充功能需要調整。",
nacosPageSizeHint: "用於設定與服務清單,每頁 1–500 筆。",
@@ -6235,6 +6254,7 @@ export default withEnglishFallback({
restore: "還原",
registerInstance: "註冊實例",
deregister: "註銷",
nacosContextPathHint: "留空時使用目前連線模式的預設值;明確使用服務根路徑請填寫 /,反向代理情境可填寫 /nacos 等前綴。",
},
userAdmin: {
title: "使用者與權限",
@@ -13,6 +13,13 @@ describe("Nacos connection dialog layout", () => {
expect(source).not.toContain("dockerNacosConsoleFallbackUrl");
});
it("persists the explicit Nacos 3 Admin or Console API selection", () => {
expect(source).toContain('const nacosApiPlane = ref<NacosApiPlane>("admin")');
expect(source).toContain('config?.apiPlane || "admin"');
expect(source).toContain('apiPlane: nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" ? nacosApiPlane.value : undefined');
expect(source).toContain("nacos.nacosConsoleServiceAddressHint");
});
it("keeps the primary form focused on endpoints and authentication", () => {
const mainStart = source.indexOf("data-nacos-profile-selector");
const mainEnd = source.indexOf("<!-- Redis: host, port, user, password, ssl -->", mainStart);
@@ -24,13 +31,11 @@ describe("Nacos connection dialog layout", () => {
expect(main).toContain('t("nacos.nacosAdvancedHint")');
expect(main).toContain("@click=\"configTab = 'advanced'\"");
expect(main).toContain('v-model="nacosServerAddr"');
expect(main).toContain('v-model="nacosManagedNamespacesText"');
expect(main).toContain('t("nacos.nacosManagedNamespacesHint")');
expect(main).toContain("data-nacos-ordinary-user-toggle");
expect(main).toContain('v-model="nacosOrdinaryAccount"');
expect(main).not.toContain('v-model="nacosManagedNamespacesText"');
expect(main).not.toContain("data-nacos-namespace-access-scope");
expect(main).not.toContain("data-nacos-ordinary-user-toggle");
expect(main).not.toContain("nacosOrdinaryAccount");
expect(main).toContain("nacosAuthKind === 'usernamePassword'");
expect(main).toContain("nacosImplementation === 'nacos' && nacosAuthKind === 'usernamePassword'");
expect(main).not.toContain("nacosVersionMode === 'v3' && nacosAuthKind === 'usernamePassword'");
expect(main).not.toContain('v-model="nacosNamespace"');
expect(main).toContain('t("nacos.nacosAuthHint")');
expect(main).not.toContain('v-model="nacosMetricsMode"');
@@ -44,6 +49,8 @@ describe("Nacos connection dialog layout", () => {
expect(advanced).not.toContain('v-model="nacosContextPathInput"');
expect(advanced).not.toContain("配置上下文路径");
expect(advanced).toContain('v-model="nacosContextPath"');
expect(advanced).toContain('t("nacos.nacosContextPathHint")');
expect(advanced).toContain('v-model="nacosMetricsMode"');
expect(advanced).toContain('v-model="nacosRNacosConsoleAddr"');
expect(advanced).toContain('v-if="nacosRNacosConsoleAddr.trim()"');
@@ -58,17 +65,68 @@ describe("Nacos connection dialog layout", () => {
expect(source).not.toContain("DBX 不需要配置该地址");
const mainStart = source.indexOf("data-nacos-profile-selector");
const mainEnd = source.indexOf("<!-- Redis: host, port, user, password, ssl -->", mainStart);
expect(source.slice(mainStart, mainEnd)).toContain("data-nacos-managed-namespaces");
expect(source.slice(mainStart, mainEnd)).not.toContain("data-nacos-managed-namespaces");
expect(source.slice(mainStart, mainEnd)).not.toContain('placeholder="http://127.0.0.1:8080"');
expect(source).not.toContain("http://127.0.0.1:8010");
expect(source).not.toContain("http://127.0.0.1:8818");
});
it("uses a dedicated namespace selector instead of the database selector", () => {
expect(source).toContain('t("nacos.nacosVisibleNamespacesTitle")');
expect(source).toContain("t(nacosNamespacePickerTitleKey)");
expect(source).toContain('"nacos.nacosDetectAccessibleNamespaces"');
expect(source).toContain("openVisibleNacosNamespacesPicker");
expect(source).toContain("loadReadableNacosNamespaces(draftId, api)");
expect(source).toContain("showVisibleNacosNamespacesDialog");
expect(source).toContain('v-model="visibleNacosNamespaceAccessMode"');
expect(source).toContain('TabsTrigger value="automatic"');
expect(source).toContain('TabsTrigger v-if="canDetectNacosNamespaceAccess" value="manual"');
expect(source).toContain('v-model="nacosManagedNamespacesText"');
expect(source).not.toContain("!isNacosV3AdminPlane.value");
expect(source).toContain('visibleNacosNamespaceAccessMode.value === "manual"');
});
it("guides namespace listing permission failures to manual scope entry", () => {
expect(source).toContain("function isNacosNamespaceListingPermissionError(error: unknown): boolean");
expect(source).toContain("NACOS_ERROR\\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\\]");
expect(source).toContain("/\\/v3\\/(?:admin|console)\\/core\\/namespace\\/list/");
expect(source).toContain("visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e)");
expect(source).toContain('t("nacos.nacosManagedNamespacesRequired")');
expect(source).toContain('v-else-if="visibleNacosNamespaceListingPermissionDenied"');
expect(source).toContain("@click=\"visibleNacosNamespaceAccessMode = 'manual'\"");
});
it("resolves unique manually entered namespace names to their Nacos IDs", () => {
expect(source).toContain("function normalizeManualNacosNamespaceNames(namespaces: string[], availableNamespaces: NacosNamespaceInfo[]): string[]");
expect(source).toContain("matchingNames.length === 1 ? matchingNames[0] : namespace");
expect(source).toContain("async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<string[]>");
expect(source).toContain("managedNamespaces: undefined");
expect(source).toContain(": await resolveManualNacosNamespaceNames(manualNamespaces)");
});
it("distinguishes the manual namespace input for the V3 Admin and Console API planes", () => {
expect(source).toContain("const isNacosV3ConsolePlane = computed(");
expect(source).toContain('isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"');
expect(source).toContain('isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"');
expect(source).toContain('if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint"');
expect(source).toContain('if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint"');
expect(source).toContain('v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30');
expect(source).toMatch(/const resolvedNamespaces = isNacosV3AdminPlane\.value\s*\?\s*manualNamespaces\s*:\s*await resolveManualNacosNamespaceNames\(manualNamespaces\);/);
});
it("opens namespace access setup instead of showing a save validation error", () => {
expect(source).toContain("if (!hasNacosNamespaceScopeForSave()) {");
expect(source).toContain("await openVisibleNacosNamespacesPicker();");
expect(source).not.toContain('message: t("nacos.nacosNamespaceScopeRequiredBeforeSave")');
});
it("stores automatic full access as a dynamic namespace scope", () => {
expect(source).toContain("const nacosDynamicAllNamespaces = ref(false)");
expect(source).toContain("const useDynamicAll = selectsEntireReadableList && visibleNacosNamespaceDynamicAllSupported.value");
expect(source).toContain("const readableSet = new Set(readableIds.map(nacosNamespaceIdentity))");
expect(source).toContain("readableSet.size === sidebarSet.size");
expect(source).toContain("form.value.visible_databases = useDynamicAll ? undefined : selected");
expect(source).toContain("nacosDynamicAllNamespaces.value = useDynamicAll");
expect(source).toContain("function showAllVisibleNacosNamespaces()");
});
it("uses namespaces when scoping Nacos production safeguards", () => {
+10
View File
@@ -51,6 +51,16 @@ describe("nacosAdmin helpers", () => {
contextPath: "/nacos",
detectedVersion: "v3",
});
expect(normalizeNacosEndpoint("http://127.0.0.1:8080", { implementation: "nacos", versionMode: "v3", apiPlane: "console" })).toMatchObject({
serverAddr: "http://127.0.0.1:8080",
contextPath: "",
detectedVersion: "v3",
});
expect(normalizeNacosEndpoint("http://127.0.0.1:18080/console", { implementation: "nacos", versionMode: "v3", apiPlane: "console" })).toMatchObject({
serverAddr: "http://127.0.0.1:18080",
contextPath: "/console",
detectedVersion: "v3",
});
expect(normalizeNacosEndpoint("http://127.0.0.1:8848", { implementation: "nacos", versionMode: "v3", contextPath: "/" })).toMatchObject({
serverAddr: "http://127.0.0.1:8848",
contextPath: "/",
+2
View File
@@ -60,6 +60,7 @@ export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
export const disconnectDb = forward("disconnectDb");
export const sessionCredentialStatus = forward("sessionCredentialStatus");
export const forgetSessionCredential = forward("forgetSessionCredential");
export const replaceNacosSessionCredential = forward("replaceNacosSessionCredential");
export const checkConnectionHealth = forward("checkConnectionHealth");
export const connectionIdentifierQuote = forward("connectionIdentifierQuote");
export const closeDatabaseConnection = forward("closeDatabaseConnection");
@@ -368,6 +369,7 @@ export const nacosListNamespaces = forward("nacosListNamespaces");
export const nacosSidebarSnapshot = forward("nacosSidebarSnapshot");
export const nacosCreateNamespace = forward("nacosCreateNamespace");
export const nacosUpdateNamespace = forward("nacosUpdateNamespace");
export const nacosDeleteNamespace = forward("nacosDeleteNamespace");
export const nacosListConfigs = forward("nacosListConfigs");
export const nacosGetConfig = forward("nacosGetConfig");
export const nacosPublishConfig = forward("nacosPublishConfig");
+10 -2
View File
@@ -409,6 +409,10 @@ export async function forgetSessionCredential(connectionId: string): Promise<voi
return post("/api/connection/forget-session-credential", { connectionId });
}
export async function replaceNacosSessionCredential(connectionId: string, username: string, password: string): Promise<void> {
return post("/api/connection/replace-nacos-session-credential", { connectionId, username, password });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return post("/api/connection/check-health", { connectionId });
}
@@ -3258,8 +3262,8 @@ export async function consulMeshExportedServicesApply(connectionId: string, name
// Nacos
// ---------------------------------------------------------------------------
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
return post("/api/nacos/test-connection", { connectionId });
export async function nacosTestConnection(connectionId: string, forceRefresh = false): Promise<NacosConnectionInfo> {
return post("/api/nacos/test-connection", { connectionId, forceRefresh });
}
export async function nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]> {
@@ -3278,6 +3282,10 @@ export async function nacosUpdateNamespace(connectionId: string, req: NacosNames
return post("/api/nacos/namespaces/update", { connectionId, req });
}
export async function nacosDeleteNamespace(connectionId: string, namespaceId: string): Promise<void> {
return post("/api/nacos/namespaces/delete", { connectionId, namespaceId });
}
export async function nacosListConfigs(connectionId: string, query: NacosConfigQuery): Promise<NacosConfigList> {
return post("/api/nacos/configs/list", { connectionId, query });
}
+6 -2
View File
@@ -49,8 +49,8 @@ import type {
NacosSearchProgress,
} from "@/types/nacos";
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
return invoke("nacos_test_connection", { connectionId });
export async function nacosTestConnection(connectionId: string, forceRefresh = false): Promise<NacosConnectionInfo> {
return invoke("nacos_test_connection", { connectionId, forceRefresh });
}
export async function nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]> {
@@ -69,6 +69,10 @@ export async function nacosUpdateNamespace(connectionId: string, req: NacosNames
return invoke("nacos_update_namespace", { connectionId, req });
}
export async function nacosDeleteNamespace(connectionId: string, namespaceId: string): Promise<void> {
return invoke("nacos_delete_namespace", { connectionId, namespaceId });
}
export async function nacosListConfigs(connectionId: string, query: NacosConfigQuery): Promise<NacosConfigList> {
return invoke("nacos_list_configs", { connectionId, query });
}
+4
View File
@@ -953,6 +953,10 @@ export async function forgetSessionCredential(connectionId: string): Promise<voi
return invokeBackend("forget_session_credential", { connectionId });
}
export async function replaceNacosSessionCredential(connectionId: string, username: string, password: string): Promise<void> {
return invokeBackend("replace_nacos_session_credential", { connectionId, username, password });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return invokeBackend("check_connection_health", { connectionId });
}
+4 -2
View File
@@ -1,4 +1,4 @@
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosPermissionInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import type { NacosApiPlane, NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosPermissionInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import { diffArrays, diffChars } from "diff";
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
@@ -58,6 +58,7 @@ export interface NacosEndpointNormalization {
export interface NacosEndpointNormalizationOptions {
implementation?: NacosImplementation;
versionMode?: NacosVersionMode;
apiPlane?: NacosApiPlane;
contextPath?: string;
}
@@ -78,6 +79,7 @@ export function normalizeNacosEndpoint(input: string, options: NacosEndpointNorm
const rawPath = url.pathname.replace(/\/+$/, "");
const implementation = options.implementation;
const versionMode = options.versionMode || "auto";
const apiPlane = options.apiPlane || "admin";
const warnings: string[] = [];
const hasRNacosSuffix = /\/rnacos$/i.test(rawPath);
const hasNacosSuffix = /\/nacos$/i.test(rawPath);
@@ -91,7 +93,7 @@ export function normalizeNacosEndpoint(input: string, options: NacosEndpointNorm
contextPath = hasNacosSuffix ? rawPath : options.contextPath?.trim() || "/nacos";
} else if (detectedVersion === "v3" || hasNacos3UiSuffix) {
contextPath = rawPath.replace(/\/(?:next(?:\/index\.html)?|index\.html)$/i, "");
if (!contextPath) contextPath = options.contextPath?.trim() || "/nacos";
if (!contextPath) contextPath = options.contextPath?.trim() || (apiPlane === "console" ? "" : "/nacos");
if (hasNacos3UiSuffix) warnings.push("The Nacos 3 console route was removed from the API context.");
} else if (!contextPath) {
contextPath = options.contextPath?.trim() || (versionMode === "v2" ? "/nacos" : "");
@@ -4,7 +4,11 @@ type NacosNamespaceAccessClient = {
nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]>;
};
/** The backend applies account authorization or an explicitly configured namespace scope. */
/**
* The backend returns namespaces verified through authorization metadata, or,
* for this explicit user action, by checking configuration and service reads.
* It never returns merely enumerable namespaces as selectable access scope.
*/
export async function loadReadableNacosNamespaces(connectionId: string, client: NacosNamespaceAccessClient): Promise<NacosNamespaceInfo[]> {
return normalizeNacosNamespacesForDisplay(await client.nacosListNamespaces(connectionId));
}
+3
View File
@@ -38,6 +38,7 @@ export interface NacosServiceCapabilities {
deleteService: NacosOperationCapability;
listInstances: NacosOperationCapability;
updateInstance: NacosOperationCapability;
updateInstanceHealth?: NacosOperationCapability;
registerInstance: NacosOperationCapability;
deregisterInstance: NacosOperationCapability;
}
@@ -241,12 +242,14 @@ export interface NacosAuthConfig {
export type NacosImplementation = "nacos" | "rnacos";
export type NacosVersionMode = "auto" | "v2" | "v3";
export type NacosApiPlane = "admin" | "console";
export type NacosMetricsMode = "auto" | "disabled" | "custom";
export type NacosRNacosConsoleAuth = { kind: "inherit" } | { kind: "usernamePassword"; username: string; password: string };
export interface NacosAdminConfig {
implementation?: NacosImplementation;
versionMode?: NacosVersionMode;
apiPlane?: NacosApiPlane;
serverAddr: string;
contextPath?: string;
/** Namespace IDs used when an official Nacos ordinary user cannot enumerate namespaces or authorization data. */
+1 -1
View File
@@ -47,7 +47,7 @@ redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tls-rustl
rustls = { version = "0.23", features = ["aws-lc-rs"] }
rustls-pemfile = "2.2"
tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] }
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "stream", "rustls-tls", "socks"] }
prometheus-parse = "=0.2.5"
futures = "0.3"
mongodb = { version = "3.2.5", features = ["socks5-proxy"] }
+43 -7
View File
@@ -1044,7 +1044,9 @@ async fn build_sensitive_payload(
push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string);
}
push_mq_external_config_secrets(&mut connection_secrets, config);
push_nacos_external_config_secrets(&mut connection_secrets, config);
if config.save_password {
push_nacos_external_config_secrets(&mut connection_secrets, config);
}
}
Ok(SensitiveSyncPayload {
@@ -1191,7 +1193,7 @@ async fn apply_sensitive_payload(
}
// Metadata is applied before secrets. Never let an older encrypted snapshot
// restore a password after the user chose not to retain it locally.
if secret.key == "password"
if matches!(secret.key.as_str(), "password" | NACOS_AUTH_PASSWORD_KEY | NACOS_RNACOS_CONSOLE_PASSWORD_KEY)
&& connections.iter().any(|config| config.id == secret.connection_id && !config.save_password)
{
continue;
@@ -1601,10 +1603,11 @@ fn parent_collection_paths(remote_path: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
apply_sensitive_payload, apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets,
decrypt_sensitive_payload, encrypt_sensitive_payload, encrypt_snippet_snapshot, finalize_snippet_migration,
forget_webdav_sync_secrets_passphrase, gitee_snippet_payload, is_legacy_dbx_snapshot, normalized_remote_path,
parent_collection_paths, parse_legacy_dbx_snapshot, parse_snippet_snapshot, prepare_legacy_snippet_snapshot,
apply_sensitive_payload, apply_sync_snapshot, build_sensitive_payload, build_sync_snapshot,
build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload,
encrypt_snippet_snapshot, finalize_snippet_migration, forget_webdav_sync_secrets_passphrase,
gitee_snippet_payload, is_legacy_dbx_snapshot, normalized_remote_path, parent_collection_paths,
parse_legacy_dbx_snapshot, parse_snippet_snapshot, prepare_legacy_snippet_snapshot,
resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup, save_snippet_sync_id,
save_webdav_sync_secrets_preference, scrub_connection_secrets, snapshot_for_snippet_upload,
snippet_file_content, snippet_response_id, snippet_sync_settings, webdav_endpoint_uses_direct_connection,
@@ -1612,7 +1615,7 @@ mod tests {
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, WebDavClient, WebDavConfig, DEFAULT_SNIPPET_FILE_NAME,
};
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY;
use crate::connection_secrets::{NACOS_AUTH_PASSWORD_KEY, NACOS_RNACOS_CONSOLE_PASSWORD_KEY};
use crate::models::connection::{
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
};
@@ -2609,6 +2612,39 @@ mod tests {
}));
}
#[tokio::test]
async fn sync_never_snapshots_or_restores_nacos_passwords_when_saving_is_disabled() {
let source = Storage::open(&temp_db_path("sync-no-save-nacos-source")).await.unwrap();
let mut config = nacos_connection("nacos", "transient-secret");
config.save_password = false;
let payload = build_sensitive_payload(&source, std::slice::from_ref(&config), &[]).await.unwrap();
assert!(!payload.connection_secrets.iter().any(|secret| {
secret.connection_id == "nacos"
&& matches!(secret.key.as_str(), NACOS_AUTH_PASSWORD_KEY | NACOS_RNACOS_CONSOLE_PASSWORD_KEY)
}));
let legacy_payload = SensitiveSyncPayload {
connection_secrets: vec![
ConnectionSecretSnapshot {
connection_id: "nacos".to_string(),
key: NACOS_AUTH_PASSWORD_KEY.to_string(),
secret: "legacy-primary-secret".to_string(),
},
ConnectionSecretSnapshot {
connection_id: "nacos".to_string(),
key: NACOS_RNACOS_CONSOLE_PASSWORD_KEY.to_string(),
secret: "legacy-console-secret".to_string(),
},
],
ai_configs: None,
ai_config: None,
tunnel_profiles: None,
};
apply_sensitive_payload(&source, &legacy_payload, &[config]).await.unwrap();
assert_eq!(source.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None);
assert_eq!(source.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None);
}
#[tokio::test]
async fn sync_snapshot_round_trips_tunnel_profiles() {
let storage = Storage::open(&temp_db_path("tunnel-profiles-src")).await.unwrap();
+192 -12
View File
@@ -27,6 +27,7 @@ use crate::models::connection::{
ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig,
};
use crate::mongo_oidc::MongoOidcBrowserOpener;
use crate::nacos::config::{NACOS_CONSOLE_SESSION_PASSWORD, NACOS_PRIMARY_SESSION_PASSWORD};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
use crate::query_cancel::RunningQueries;
@@ -3170,7 +3171,28 @@ impl AppState {
connection_id: &str,
config: &ConnectionConfig,
) -> Result<crate::nacos::config::NacosAdminConfig, String> {
let nacos_config = crate::nacos::config::NacosAdminConfig::from_connection(config)?;
let mut nacos_config = crate::nacos::config::NacosAdminConfig::from_connection(config)?;
if !config.save_password {
let owner = crate::session_credentials::current_credential_owner().unwrap_or_default();
let primary_password = self
.session_credentials
.get_for_purpose(&owner, connection_id, NACOS_PRIMARY_SESSION_PASSWORD)
.or_else(|| self.session_credentials.get(&owner, connection_id));
if let (Some(password), crate::nacos::config::NacosAuthConfig::UsernamePassword { password: target, .. }) =
(primary_password, &mut nacos_config.auth)
{
*target = password;
}
if let (
Some(password),
crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword { password: target, .. },
) = (
self.session_credentials.get_for_purpose(&owner, connection_id, NACOS_CONSOLE_SESSION_PASSWORD),
&mut nacos_config.rnacos_console_auth,
) {
*target = password;
}
}
if !config.has_effective_transport_layers() {
return Ok(nacos_config);
}
@@ -3236,6 +3258,57 @@ impl AppState {
}
}
pub async fn replace_nacos_session_credential(
&self,
owner_scope: &str,
connection_id: &str,
username: &str,
password: &str,
) -> Result<(), String> {
let username = username.trim();
if username.is_empty() || password.is_empty() {
return Err("Nacos username and replacement password are required".to_string());
}
let config = self.configs.read().await.get(connection_id).cloned().ok_or("Connection not found")?;
if config.db_type != DatabaseType::Nacos {
return Err("Connection is not a Nacos connection".to_string());
}
if config.save_password {
return Err("Connection saves its password; update the persisted connection instead".to_string());
}
let nacos_config = crate::nacos::config::NacosAdminConfig::from_connection(&config)?;
let primary_matches = matches!(
&nacos_config.auth,
crate::nacos::config::NacosAuthConfig::UsernamePassword { username: current, .. } if current == username
);
let console_matches = matches!(
&nacos_config.rnacos_console_auth,
crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword { username: current, .. } if current == username
);
if !primary_matches && !console_matches {
return Err(format!("Nacos user {username} is not used by this connection"));
}
if primary_matches {
let _ = self.session_credentials.set(owner_scope, connection_id, password);
self.session_credentials.set_for_purpose(
owner_scope,
connection_id,
NACOS_PRIMARY_SESSION_PASSWORD,
password,
);
}
if console_matches {
self.session_credentials.set_for_purpose(
owner_scope,
connection_id,
NACOS_CONSOLE_SESSION_PASSWORD,
password,
);
}
self.nacos_registry.drop_connection(connection_id).await;
Ok(())
}
async fn remove_stale_connection_pool(&self, pool_key: &str) -> bool {
if self.running_queries.is_pool_active(pool_key) {
return false;
@@ -5114,6 +5187,60 @@ pub fn connection_configs_pool_equivalent(a: &ConnectionConfig, b: &ConnectionCo
false
}
/// Whether transient credentials can safely survive a persisted config update.
///
/// This comparison is intentionally conservative: only presentation, local
/// visibility, and runtime-policy fields are ignored. Endpoint, account,
/// transport, TLS, driver, and unclassified external-config changes still
/// invalidate credentials. Nacos managed namespaces are a local discovery
/// scope and therefore do not change which account a transient password
/// belongs to.
pub fn connection_configs_session_credentials_compatible(a: &ConnectionConfig, b: &ConnectionConfig) -> bool {
fn normalize(mut config: ConnectionConfig) -> ConnectionConfig {
config.name.clear();
config.note.clear();
config.driver_label = None;
config.default_schema = None;
config.visible_databases = None;
config.visible_schemas = None;
config.show_system_schemas = false;
config.color = None;
config.docs_notes_path = None;
config.connect_timeout_secs = 0;
config.query_timeout_secs = 0;
config.idle_timeout_secs = 0;
config.keepalive_interval_secs = 0;
config.redis_key_separator.clear();
config.redis_scan_page_size = None;
config.redis_database_aliases.clear();
config.one_time = false;
config.read_only = false;
config.is_production = false;
config.production_databases.clear();
config.database_info = None;
if config.db_type == DatabaseType::Nacos {
if let Some(external) = config.external_config.as_mut().and_then(serde_json::Value::as_object_mut) {
external.remove("managedNamespaces");
external.remove("managed_namespaces");
if !config.save_password {
for key in ["auth", "rnacosConsoleAuth", "rnacos_console_auth"] {
if let Some(auth) = external.get_mut(key).and_then(serde_json::Value::as_object_mut) {
auth.insert("password".to_string(), serde_json::Value::String(String::new()));
}
}
}
}
}
if !config.save_password {
config.password.clear();
}
config
}
normalize(a.clone()) == normalize(b.clone())
}
fn pool_key_for_session_role(
config: Option<&ConnectionConfig>,
base_pool_key: String,
@@ -5561,17 +5688,17 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
#[cfg(test)]
mod tests {
use super::{
agent_connect_timeout, connection_configs_pool_equivalent, connection_probe_endpoints,
connection_remote_endpoint, connection_url_for_endpoint, database_connection_config,
database_connection_config_with_catalog, gaussdb_identifier_quote_from_query_result,
gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, kafka_single_loopback_bootstrap_endpoint,
metadata_connection_config, mysql_metadata_fallback_url, mysql_pool_setup_queries,
oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint,
redis_sentinel_transport_id, redis_sentinel_transport_prefix, sqlserver_legacy_agent_config,
sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, task_client_session_id,
upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, validate_connection_url_params,
validate_h2_database_path, AppState, MysqlMode, PoolKind, TxnConnection, GAUSSDB_M_JDBC_DRIVER_CLASS,
GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS,
agent_connect_timeout, connection_configs_pool_equivalent, connection_configs_session_credentials_compatible,
connection_probe_endpoints, connection_remote_endpoint, connection_url_for_endpoint,
database_connection_config, database_connection_config_with_catalog,
gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver,
kafka_single_loopback_bootstrap_endpoint, metadata_connection_config, mysql_metadata_fallback_url,
mysql_pool_setup_queries, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint,
redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix,
sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver,
task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe,
validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, TxnConnection,
GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS,
};
use crate::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver,
@@ -5735,6 +5862,59 @@ mod tests {
assert!(connection_configs_pool_equivalent(&a, &a.clone()));
}
#[test]
fn session_credentials_survive_nacos_scope_and_display_updates() {
let mut initial = mysql_config(None);
initial.db_type = DatabaseType::Nacos;
initial.save_password = false;
initial.password = "old-session-secret".to_string();
initial.visible_databases = Some(vec!["namespace-a".to_string()]);
initial.external_config = Some(serde_json::json!({
"implementation": "nacos",
"versionMode": "v3",
"apiPlane": "admin",
"serverAddr": "http://127.0.0.1:8848",
"managedNamespaces": ["namespace-a"],
"auth": {
"kind": "usernamePassword",
"username": "ordinary-user",
"password": "old-session-secret"
}
}));
let mut updated = initial.clone();
updated.name = "Renamed Nacos".to_string();
updated.note = "Local note".to_string();
updated.visible_databases = Some(vec!["namespace-a".to_string(), "namespace-b".to_string()]);
updated.external_config.as_mut().unwrap()["managedNamespaces"] =
serde_json::json!(["namespace-a", "namespace-b"]);
updated.external_config.as_mut().unwrap()["auth"]["password"] = serde_json::json!("new-session-secret");
assert!(connection_configs_session_credentials_compatible(&initial, &updated));
let mut scrubbed = updated.clone();
scrubbed.external_config.as_mut().unwrap()["auth"].as_object_mut().unwrap().remove("password");
assert!(connection_configs_session_credentials_compatible(&initial, &scrubbed));
updated.external_config.as_mut().unwrap()["serverAddr"] = serde_json::json!("http://127.0.0.1:8080");
assert!(!connection_configs_session_credentials_compatible(&initial, &updated));
}
#[test]
fn session_credentials_do_not_survive_nacos_account_updates() {
let mut initial = mysql_config(None);
initial.db_type = DatabaseType::Nacos;
initial.save_password = false;
initial.external_config = Some(serde_json::json!({
"serverAddr": "http://127.0.0.1:8848",
"auth": { "kind": "usernamePassword", "username": "user-a", "password": "secret" }
}));
let mut updated = initial.clone();
updated.external_config.as_mut().unwrap()["auth"]["username"] = serde_json::json!("user-b");
assert!(!connection_configs_session_credentials_compatible(&initial, &updated));
}
#[tokio::test]
async fn apply_session_credential_injects_saved_password_only_for_no_save_connections() {
let dir = std::env::temp_dir().join(format!("dbx-core-session-cred-{}", std::process::id()));
+37 -23
View File
@@ -511,14 +511,17 @@ fn validate_replayed_commands(snapshot: &NacosAccessControlSnapshot, commands: &
.filter(|(bound_username, _)| bound_username == username)
.map(|(_, role)| role.clone())
.collect();
if matches!(command, StepCommand::DeleteCreatedUser { .. }) && !roles.is_empty() {
return Err(format!(
"Cannot undo creation of Nacos user {username} because it now has external role bindings: {}",
roles.join(", ")
));
}
for role in roles {
let member_count = bindings.iter().filter(|(_, bound_role)| bound_role == &role).count();
if role == ADMIN_ROLE && member_count <= 1 {
return Err("At least one ROLE_ADMIN member must remain".to_string());
}
if role != ADMIN_ROLE && member_count <= 1 {
return Err(format!("Add another member to {role} before deleting its last member"));
}
}
bindings.retain(|(bound_username, _)| bound_username != username);
}
@@ -632,8 +635,6 @@ fn plan_operation(
return Err("At least one ROLE_ADMIN member must remain".to_string());
}
validate_admin_member_change(snapshot, &current_members, &desired_members, confirmation.as_deref())?;
} else if desired_members.is_empty() {
return Err("A role requires at least one member".to_string());
}
let desired_permissions: BTreeSet<_> = if role == ADMIN_ROLE {
BTreeSet::new()
@@ -677,10 +678,8 @@ fn plan_operation(
if !snapshot.users.iter().any(|user| user.username == username) {
return Err(format!("Nacos user {username} does not exist"));
}
if snapshot.current_username.as_deref() == Some(username.as_str())
&& confirmation.as_deref() != Some(username.as_str())
{
return Err("Deleting the current account requires typing the current username".to_string());
if confirmation.as_deref() != Some(username.as_str()) {
return Err("Deleting a user requires typing its username".to_string());
}
let current: BTreeSet<_> = snapshot
.role_bindings
@@ -797,7 +796,7 @@ fn validate_role_removals(
) -> Result<(), String> {
for role in current.difference(desired) {
let count = snapshot.role_bindings.iter().filter(|binding| binding.role == *role).count();
if count <= 1 {
if role == ADMIN_ROLE && count <= 1 {
return Err(format!("Add another member to {role} before removing its last member"));
}
if role == ADMIN_ROLE
@@ -817,9 +816,6 @@ fn validate_role_removal_count(role: &str, member_count: usize, mode: RoleRemova
if role == ADMIN_ROLE && (mode == RoleRemovalMode::DeleteRole || member_count <= 1) {
return Err("At least one ROLE_ADMIN member must remain".to_string());
}
if role != ADMIN_ROLE && mode == RoleRemovalMode::PreserveRole && member_count <= 1 {
return Err(format!("Add another member to {role} before removing its last member"));
}
Ok(())
}
@@ -1217,19 +1213,34 @@ mod tests {
}
#[test]
fn deleting_current_account_requires_typed_confirmation_even_without_roles() {
let snapshot = snapshot_with_user("nacos");
fn deleting_any_user_requires_exact_typed_username_confirmation() {
let mut snapshot = snapshot_with_user("alice");
snapshot.current_username = None;
let error = plan_operation(
&snapshot,
NacosAccessOperationRequest::DeleteUser { username: "nacos".to_string(), confirmation: None },
NacosAccessOperationRequest::DeleteUser {
username: "alice".to_string(),
confirmation: Some("Alice".to_string()),
},
)
.unwrap_err();
assert!(error.contains("current account"));
assert!(error.contains("typing its username"));
let (commands, _, _) = plan_operation(
&snapshot,
NacosAccessOperationRequest::DeleteUser {
username: "alice".to_string(),
confirmation: Some("alice".to_string()),
},
)
.unwrap();
assert!(matches!(commands.as_slice(), [StepCommand::DeleteUser { username }] if username == "alice"));
}
#[test]
fn removing_the_last_regular_role_member_is_rejected() {
fn removing_the_last_regular_role_member_preserves_its_permissions() {
let mut snapshot = snapshot_with_user("alice");
snapshot.current_username = None;
snapshot.role_bindings.push(NacosRoleBinding { username: "alice".to_string(), role: "ops".to_string() });
snapshot.users[0].roles.push("ops".to_string());
snapshot.roles.push(NacosRoleSummary {
@@ -1239,7 +1250,7 @@ mod tests {
complete: true,
administrator: false,
});
let error = plan_operation(
let (commands, _, _) = plan_operation(
&snapshot,
NacosAccessOperationRequest::UpdateUserRoles {
username: "alice".to_string(),
@@ -1247,8 +1258,12 @@ mod tests {
confirmation: None,
},
)
.unwrap_err();
assert!(error.contains("last member"));
.unwrap();
assert!(matches!(
commands.as_slice(),
[StepCommand::RemoveRole { username, role, mode: RoleRemovalMode::PreserveRole }]
if username == "alice" && role == "ops"
));
}
#[test]
@@ -1338,8 +1353,7 @@ mod tests {
#[test]
fn role_removal_modes_distinguish_edits_from_role_deletion() {
let preserve_error = validate_role_removal_count("ops", 1, RoleRemovalMode::PreserveRole).unwrap_err();
assert!(preserve_error.contains("last member"));
assert!(validate_role_removal_count("ops", 1, RoleRemovalMode::PreserveRole).is_ok());
assert!(validate_role_removal_count("ops", 1, RoleRemovalMode::DeleteRole).is_ok());
let admin_error = validate_role_removal_count(ADMIN_ROLE, 2, RoleRemovalMode::DeleteRole).unwrap_err();
+113 -1
View File
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::models::connection::ConnectionConfig;
use crate::models::connection::{ConnectionConfig, DatabaseType};
pub const NACOS_PRIMARY_SESSION_PASSWORD: &str = "nacos-primary-password";
pub const NACOS_CONSOLE_SESSION_PASSWORD: &str = "nacos-console-password";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase")]
@@ -33,6 +36,14 @@ pub enum NacosVersionMode {
V3,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosApiPlane {
#[default]
Admin,
Console,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosMetricsMode {
@@ -60,6 +71,8 @@ pub struct NacosAdminConfig {
pub implementation: Option<NacosImplementation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_mode: Option<NacosVersionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_plane: Option<NacosApiPlane>,
pub server_addr: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub display_server_addr: String,
@@ -99,6 +112,47 @@ pub struct NacosAdminConfig {
pub connect_override: Option<NacosConnectOverride>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct NacosTransientPasswords {
pub primary: Option<String>,
pub console: Option<String>,
}
fn take_username_password(value: Option<&mut serde_json::Value>) -> Option<String> {
let auth = value?.as_object_mut()?;
if auth.get("kind").and_then(serde_json::Value::as_str) != Some("usernamePassword") {
return None;
}
let password = auth.get_mut("password")?;
let secret = password.as_str()?.to_string();
*password = serde_json::Value::String(String::new());
(!secret.is_empty()).then_some(secret)
}
/// Removes no-save Nacos passwords from a runtime configuration and returns
/// them for placement in the owner-scoped in-memory credential store.
pub fn take_transient_passwords(config: &mut ConnectionConfig) -> NacosTransientPasswords {
if config.db_type != DatabaseType::Nacos || config.save_password {
return NacosTransientPasswords::default();
}
let top_level_password = std::mem::take(&mut config.password);
let Some(external) = config.external_config.as_mut().and_then(serde_json::Value::as_object_mut) else {
return NacosTransientPasswords {
primary: (!top_level_password.is_empty()).then_some(top_level_password),
console: None,
};
};
NacosTransientPasswords {
// NacosAdminConfig::from_connection treats external_config as the
// canonical source when present, so a stale top-level password must
// never override an explicitly empty external auth password.
primary: take_username_password(external.get_mut("auth")),
console: take_username_password(external.get_mut("rnacosConsoleAuth")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NacosConnectOverride {
@@ -127,6 +181,7 @@ impl NacosAdminConfig {
NacosAdminConfig {
implementation: None,
version_mode: None,
api_plane: None,
server_addr: format!("{scheme}://{}:{}", cfg.host.trim(), cfg.port),
display_server_addr: String::new(),
namespace: cfg.database.clone().unwrap_or_default(),
@@ -169,6 +224,7 @@ impl NacosAdminConfig {
&& !context_path_is_explicit_root
&& matches!(self.implementation, Some(NacosImplementation::Nacos))
&& matches!(self.version_mode, Some(NacosVersionMode::V3))
&& self.api_plane() == NacosApiPlane::Admin
{
self.context_path = "/nacos".to_string();
}
@@ -209,6 +265,10 @@ impl NacosAdminConfig {
self
}
pub fn api_plane(&self) -> NacosApiPlane {
self.api_plane.unwrap_or_default()
}
pub fn with_server_endpoint(mut self, host: &str, port: u16) -> Result<Self, String> {
let mut url =
reqwest::Url::parse(&self.server_addr).map_err(|e| format!("Nacos server address is invalid: {e}"))?;
@@ -485,6 +545,21 @@ mod tests {
assert_eq!(parsed.context_path, "/nacos");
}
#[test]
fn explicit_nacos_v3_console_defaults_to_root_context() {
let cfg = connection_with_external(serde_json::json!({
"implementation": "nacos",
"versionMode": "v3",
"apiPlane": "console",
"serverAddr": "http://127.0.0.1:8080",
"auth": { "kind": "none" }
}));
let parsed = NacosAdminConfig::from_connection(&cfg).unwrap();
assert_eq!(parsed.api_plane(), NacosApiPlane::Console);
assert_eq!(parsed.context_path, "");
}
#[test]
fn explicit_nacos_v3_root_context_is_preserved() {
let cfg = connection_with_external(serde_json::json!({
@@ -511,6 +586,43 @@ mod tests {
assert!(matches!(parsed.auth, NacosAuthConfig::UsernamePassword { .. }));
}
#[test]
fn takes_no_save_external_passwords_without_removing_other_fields() {
let mut cfg = connection_with_external(serde_json::json!({
"implementation": "rnacos",
"serverAddr": "http://127.0.0.1:8848",
"managedNamespaces": ["team-a"],
"auth": { "kind": "usernamePassword", "username": "primary", "password": "primary-secret" },
"rnacosConsoleAuth": { "kind": "usernamePassword", "username": "console", "password": "console-secret" }
}));
cfg.save_password = false;
cfg.password = "stale-top-level-secret".to_string();
let passwords = take_transient_passwords(&mut cfg);
assert_eq!(passwords.primary.as_deref(), Some("primary-secret"));
assert_eq!(passwords.console.as_deref(), Some("console-secret"));
assert!(cfg.password.is_empty());
let external = cfg.external_config.as_ref().unwrap();
assert_eq!(external["auth"]["password"], "");
assert_eq!(external["rnacosConsoleAuth"]["password"], "");
assert_eq!(external["managedNamespaces"], serde_json::json!(["team-a"]));
}
#[test]
fn takes_legacy_top_level_password_only_without_external_config() {
let mut cfg = connection_with_external(serde_json::Value::Null);
cfg.external_config = None;
cfg.save_password = false;
cfg.password = "legacy-secret".to_string();
let passwords = take_transient_passwords(&mut cfg);
assert_eq!(passwords.primary.as_deref(), Some("legacy-secret"));
assert_eq!(passwords.console, None);
assert!(cfg.password.is_empty());
}
#[test]
fn with_server_endpoint_rewrites_only_host_and_port() {
let cfg = connection_with_external(serde_json::json!({
File diff suppressed because it is too large Load Diff
+1
View File
@@ -176,6 +176,7 @@ mod tests {
NacosAdminConfig {
implementation: Some(NacosImplementation::RNacos),
version_mode: None,
api_plane: None,
server_addr: "http://127.0.0.1:8848".to_string(),
display_server_addr: "http://127.0.0.1:8848".to_string(),
namespace: "public".to_string(),
+258 -4
View File
@@ -2,6 +2,7 @@ use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use futures::{stream, StreamExt};
use sha2::{Digest, Sha256};
use crate::nacos::port::{NacosAdmin, NacosNamespaceAuthorizationSnapshot};
@@ -11,6 +12,10 @@ const AUTH_PAGE_SIZE: u32 = 500;
const CACHE_TTL: Duration = Duration::from_secs(60);
const CACHE_MAX_CONNECTIONS: usize = 64;
const ADMIN_ROLE: &str = "ROLE_ADMIN";
// Namespace probing is deliberately reserved for the explicit connection-form
// action. Keep enough parallelism for useful feedback without overwhelming a
// Nacos server that contains many namespaces.
const DISCOVERY_CONCURRENCY: usize = 8;
#[derive(Clone)]
struct NamespaceAccessCacheEntry {
@@ -49,6 +54,100 @@ pub async fn list_readable_namespaces(
Ok(load_readable_namespaces(connection_id, connection_fingerprint, admin).await?.namespaces)
}
/// Lists namespaces for the explicit connection-form access-scope selector.
///
/// Authorization metadata provides the fast path. Some ordinary Nacos
/// accounts may enumerate namespaces but cannot read that metadata; for that
/// case only, this user-initiated operation verifies both configuration and
/// service access before offering a namespace. Sidebar loading never calls
/// this function, so it cannot trigger an N-per-namespace scan.
pub async fn list_displayable_namespaces(
connection_id: &str,
connection_fingerprint: String,
admin: Arc<dyn NacosAdmin>,
) -> Result<Vec<NacosNamespaceInfo>, String> {
let namespaces = admin.list_namespaces().await?;
match resolve_readable_namespaces(connection_id, connection_fingerprint, admin.clone(), namespaces.clone()).await {
Ok(result) => Ok(result.namespaces),
Err(error) if is_managed_namespaces_required_error(&error) => {
probe_displayable_namespaces(namespaces, admin).await
}
Err(error) => Err(error),
}
}
async fn probe_displayable_namespaces(
namespaces: Vec<NacosNamespaceInfo>,
admin: Arc<dyn NacosAdmin>,
) -> Result<Vec<NacosNamespaceInfo>, String> {
let mut checked = stream::iter(namespaces.into_iter().enumerate().map(|(index, namespace)| {
let admin = admin.clone();
async move { probe_namespace_access(admin, namespace).await.map(|accessible| (index, accessible)) }
}))
.buffer_unordered(DISCOVERY_CONCURRENCY)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
checked.sort_by_key(|(index, _)| *index);
Ok(checked.into_iter().filter_map(|(_, namespace)| namespace).collect())
}
async fn probe_namespace_access(
admin: Arc<dyn NacosAdmin>,
namespace: NacosNamespaceInfo,
) -> Result<Option<NacosNamespaceInfo>, String> {
let namespace_id = namespace.namespace.clone();
match admin
.list_configs(NacosConfigQuery {
namespace: Some(namespace_id.clone()),
group: None,
group_contains: false,
data_id: None,
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(1),
})
.await
{
Ok(_) => {}
Err(error) if is_access_denied_error(&error) => return Ok(None),
Err(error) => return Err(namespace_probe_error(&namespace_id, "configuration", &error)),
}
match admin
.list_services(NacosServiceQuery {
namespace: Some(namespace_id.clone()),
group_name: None,
service_name: None,
page_no: Some(1),
page_size: Some(1),
})
.await
{
Ok(_) => Ok(Some(namespace)),
Err(error) if is_access_denied_error(&error) => Ok(None),
Err(error) => Err(namespace_probe_error(&namespace_id, "service", &error)),
}
}
fn is_access_denied_error(error: &str) -> bool {
let error = error.to_ascii_lowercase();
error.contains("403")
|| error.contains("forbidden")
|| error.contains("access denied")
|| error.contains("authorization failed")
|| error.contains("authfailed")
}
fn namespace_probe_error(namespace: &str, capability: &str, detail: &str) -> String {
format!(
"NACOS_ERROR[namespaceAccessDetectionFailed]: unable to verify {capability} access for namespace `{namespace}`: {detail}"
)
}
pub async fn sidebar_snapshot(
connection_id: &str,
connection_fingerprint: String,
@@ -75,17 +174,53 @@ pub async fn sidebar_snapshot(
}
}
/// Applies a user-saved display scope without relying on Nacos role metadata.
/// The scope only narrows the namespaces already returned by the server.
pub async fn sidebar_snapshot_with_visible_scope(
connection_id: &str,
connection_fingerprint: String,
admin: Arc<dyn NacosAdmin>,
visible_scope: Option<&[String]>,
) -> Result<NacosNamespaceSidebarSnapshot, String> {
let Some(visible_scope) = visible_scope else {
return sidebar_snapshot(connection_id, connection_fingerprint, admin).await;
};
let namespaces = admin.list_namespaces().await?;
let visible = visible_scope.iter().map(|namespace| namespace_identity(namespace)).collect();
Ok(NacosNamespaceSidebarSnapshot {
namespaces: filter_namespaces(namespaces, &visible),
// A saved display scope only limits the namespace tree. It must not
// suppress the independent probe that determines whether the account
// can open the user and role workspace.
access_control: admin.refresh_access_control_capabilities().await,
})
}
fn is_namespace_authorization_error(error: &str) -> bool {
error.contains("NACOS_ERROR[managedNamespacesRequired]")
|| error.contains("NACOS_ERROR[namespaceAuthorizationUnavailable]")
}
fn is_managed_namespaces_required_error(error: &str) -> bool {
error.contains("NACOS_ERROR[managedNamespacesRequired]")
}
async fn load_readable_namespaces(
connection_id: &str,
connection_fingerprint: String,
admin: Arc<dyn NacosAdmin>,
) -> Result<ReadableNamespaces, String> {
let namespaces = admin.list_namespaces().await?;
resolve_readable_namespaces(connection_id, connection_fingerprint, admin, namespaces).await
}
async fn resolve_readable_namespaces(
connection_id: &str,
connection_fingerprint: String,
admin: Arc<dyn NacosAdmin>,
namespaces: Vec<NacosNamespaceInfo>,
) -> Result<ReadableNamespaces, String> {
let Some(username) =
admin.current_username().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
else {
@@ -420,13 +555,18 @@ mod tests {
struct CountingAdmin {
namespaces: Vec<NacosNamespaceInfo>,
readable_ids: BTreeSet<String>,
role_error: bool,
permission_error: bool,
config_transport_error: bool,
service_denied_ids: BTreeSet<String>,
explicitly_scoped_ids: Option<Vec<String>>,
namespace_calls: AtomicUsize,
capability_calls: AtomicUsize,
access_control_refresh_calls: AtomicUsize,
role_calls: AtomicUsize,
permission_calls: AtomicUsize,
config_calls: AtomicUsize,
service_calls: AtomicUsize,
}
impl CountingAdmin {
@@ -441,13 +581,18 @@ mod tests {
Self {
namespaces,
readable_ids,
role_error: false,
permission_error: false,
config_transport_error: false,
service_denied_ids: BTreeSet::new(),
explicitly_scoped_ids: None,
namespace_calls: AtomicUsize::new(0),
capability_calls: AtomicUsize::new(0),
access_control_refresh_calls: AtomicUsize::new(0),
role_calls: AtomicUsize::new(0),
permission_calls: AtomicUsize::new(0),
config_calls: AtomicUsize::new(0),
service_calls: AtomicUsize::new(0),
}
}
@@ -457,6 +602,7 @@ mod tests {
+ self.role_calls.load(Ordering::SeqCst)
+ self.permission_calls.load(Ordering::SeqCst)
+ self.config_calls.load(Ordering::SeqCst)
+ self.service_calls.load(Ordering::SeqCst)
}
}
@@ -478,6 +624,11 @@ mod tests {
self.explicitly_scoped_ids.clone()
}
async fn refresh_access_control_capabilities(&self) -> NacosAccessControlCapabilities {
self.access_control_refresh_calls.fetch_add(1, Ordering::SeqCst);
self.access_control_capabilities()
}
async fn refresh_namespace_authorization(
&self,
username: &str,
@@ -524,9 +675,16 @@ mod tests {
Err("unused".to_string())
}
async fn list_configs(&self, _: NacosConfigQuery) -> Result<NacosConfigList, String> {
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String> {
self.config_calls.fetch_add(1, Ordering::SeqCst);
Err("per-namespace probes are forbidden in the sidebar path".to_string())
if self.config_transport_error {
return Err("connection reset by peer".to_string());
}
let namespace = query.namespace.unwrap_or_default();
if !self.readable_ids.contains(&namespace) {
return Err("403 Forbidden".to_string());
}
Ok(NacosConfigList { page_no: 1, page_size: 1, total_count: 0, items: Vec::new() })
}
async fn search_config_content_page(
@@ -573,6 +731,9 @@ mod tests {
async fn list_role_bindings(&self, _: NacosRoleQuery) -> Result<NacosRoleList, String> {
self.role_calls.fetch_add(1, Ordering::SeqCst);
if self.role_error {
return Err("403 Forbidden".to_string());
}
Ok(NacosRoleList {
page_no: 1,
page_size: AUTH_PAGE_SIZE,
@@ -602,8 +763,13 @@ mod tests {
Ok(NacosPermissionList { page_no: 1, page_size: AUTH_PAGE_SIZE, total_count: items.len() as u64, items })
}
async fn list_services(&self, _: NacosServiceQuery) -> Result<NacosServiceList, String> {
Err("unused".to_string())
async fn list_services(&self, query: NacosServiceQuery) -> Result<NacosServiceList, String> {
self.service_calls.fetch_add(1, Ordering::SeqCst);
let namespace = query.namespace.unwrap_or_default();
if !self.readable_ids.contains(&namespace) || self.service_denied_ids.contains(&namespace) {
return Err("403 Forbidden".to_string());
}
Ok(NacosServiceList { page_no: 1, page_size: 1, total_count: 0, items: Vec::new() })
}
async fn get_service(&self, _: NacosServiceQuery) -> Result<NacosServiceDetail, String> {
@@ -755,6 +921,64 @@ mod tests {
assert_eq!(admin.total_authorization_calls(), 4);
}
#[tokio::test]
async fn explicit_namespace_detection_probes_config_and_service_when_role_read_is_forbidden() {
let connection_id = "displayable-namespaces-role-forbidden";
invalidate(connection_id);
let mut admin = CountingAdmin::restricted(4);
admin.role_error = true;
let admin = Arc::new(admin);
let namespaces =
list_displayable_namespaces(connection_id, "server-a".to_string(), admin.clone()).await.unwrap();
assert_eq!(
namespaces.iter().map(|namespace| namespace.namespace.as_str()).collect::<Vec<_>>(),
vec!["team-0", "team-2"]
);
assert_eq!(admin.namespace_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.capability_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.role_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.permission_calls.load(Ordering::SeqCst), 0);
assert_eq!(admin.config_calls.load(Ordering::SeqCst), 4);
assert_eq!(admin.service_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn explicit_namespace_detection_aborts_on_unexpected_probe_failure() {
let connection_id = "displayable-namespaces-probe-failure";
invalidate(connection_id);
let mut admin = CountingAdmin::restricted(4);
admin.role_error = true;
admin.config_transport_error = true;
let admin = Arc::new(admin);
let error =
list_displayable_namespaces(connection_id, "server-a".to_string(), admin.clone()).await.unwrap_err();
assert!(error.contains("NACOS_ERROR[namespaceAccessDetectionFailed]"));
assert_eq!(admin.namespace_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.role_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.permission_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn explicit_namespace_detection_requires_service_access_too() {
let connection_id = "displayable-namespaces-service-denied";
invalidate(connection_id);
let mut admin = CountingAdmin::restricted(4);
admin.role_error = true;
admin.service_denied_ids.insert("team-0".to_string());
let admin = Arc::new(admin);
let namespaces =
list_displayable_namespaces(connection_id, "server-a".to_string(), admin.clone()).await.unwrap();
assert_eq!(namespaces.iter().map(|namespace| namespace.namespace.as_str()).collect::<Vec<_>>(), vec!["team-2"]);
assert_eq!(admin.config_calls.load(Ordering::SeqCst), 4);
assert_eq!(admin.service_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn roles_only_sidebar_keeps_access_control_entry_without_widening_namespaces() {
let connection_id = "roles-only-sidebar-authorization";
@@ -791,6 +1015,36 @@ mod tests {
assert!(!snapshot.access_control.list_users.supported);
}
#[tokio::test]
async fn visible_sidebar_scope_refreshes_access_control_without_authorization_inference() {
let connection_id = "visible-sidebar-scope";
invalidate(connection_id);
let admin = CountingAdmin::restricted(100);
let admin = Arc::new(admin);
let visible_scope = vec!["team-2".to_string(), "team-78".to_string()];
let snapshot = sidebar_snapshot_with_visible_scope(
connection_id,
"server-a".to_string(),
admin.clone(),
Some(&visible_scope),
)
.await
.unwrap();
assert_eq!(
snapshot.namespaces.iter().map(|namespace| namespace.namespace.as_str()).collect::<Vec<_>>(),
vec!["team-2", "team-78"]
);
assert_eq!(admin.namespace_calls.load(Ordering::SeqCst), 1);
assert!(snapshot.access_control.list_users.supported);
assert_eq!(admin.access_control_refresh_calls.load(Ordering::SeqCst), 1);
assert_eq!(admin.capability_calls.load(Ordering::SeqCst), 0);
assert_eq!(admin.role_calls.load(Ordering::SeqCst), 0);
assert_eq!(admin.permission_calls.load(Ordering::SeqCst), 0);
assert_eq!(admin.config_calls.load(Ordering::SeqCst), 0);
}
#[test]
fn cache_is_bound_to_connection_fingerprint_and_namespace_set() {
let connection_id = "namespace-cache-test";
+3
View File
@@ -64,6 +64,9 @@ pub trait NacosAdmin: Send + Sync {
async fn list_namespaces(&self) -> Result<Vec<NacosNamespaceInfo>, String>;
async fn create_namespace(&self, req: NacosNamespaceCreate) -> Result<(), String>;
async fn update_namespace(&self, req: NacosNamespaceUpdate) -> Result<(), String>;
async fn delete_namespace(&self, _: String) -> Result<(), String> {
Err("Nacos namespace deletion is unavailable for this connection".to_string())
}
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String>;
/// Returns `Ok(None)` only when the server does not expose a native
/// content-search endpoint. Authentication, throttling and transport
+6 -1
View File
@@ -6,7 +6,7 @@ use futures::StreamExt;
use prometheus_parse::{Labels, Sample, Scrape, Value};
use sha2::{Digest, Sha256};
use crate::nacos::config::{NacosAdminConfig, NacosImplementation, NacosMetricsMode};
use crate::nacos::config::{NacosAdminConfig, NacosApiPlane, NacosImplementation, NacosMetricsMode};
use crate::nacos::types::{
NacosPrometheusConfigMetrics, NacosPrometheusNamingMetrics, NacosPrometheusResourceMetrics,
NacosPrometheusSnapshot, NacosPrometheusSource, NacosPrometheusTrafficMetrics,
@@ -22,6 +22,10 @@ pub(crate) fn endpoint_candidates(cfg: &NacosAdminConfig) -> Result<Vec<String>,
NacosMetricsMode::Auto => {}
}
if cfg.api_plane() == NacosApiPlane::Console {
return Ok(Vec::new());
}
let base = cfg.server_addr.trim_end_matches('/');
let context = cfg.context_path.trim_end_matches('/');
let raw = match cfg.implementation.as_ref().unwrap_or(&NacosImplementation::Nacos) {
@@ -802,6 +806,7 @@ app_memory_usage 1
NacosAdminConfig {
implementation: Some(implementation),
version_mode: None,
api_plane: None,
server_addr: server_addr.to_string(),
display_server_addr: server_addr.to_string(),
namespace: String::new(),
+39 -5
View File
@@ -40,7 +40,11 @@ fn ensure_access_control_operation(
))
}
pub async fn nacos_test_connection_core(state: &AppState, conn_id: &str) -> Result<NacosConnectionInfo, String> {
pub async fn nacos_test_connection_core(
state: &AppState,
conn_id: &str,
force_access_control_refresh: bool,
) -> Result<NacosConnectionInfo, String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
if cfg.db_type != DatabaseType::Nacos {
return Err("Connection is not a Nacos admin connection".to_string());
@@ -49,20 +53,31 @@ pub async fn nacos_test_connection_core(state: &AppState, conn_id: &str) -> Resu
// Keep this probe on the connection's shared adapter so an r-nacos console
// session verified for configuration history can also expose its version.
let admin = state.nacos_registry.get_or_build_config(conn_id, admin_config).await?;
if force_access_control_refresh {
admin.invalidate_access_control_capabilities();
}
admin.inspect_connection().await
}
pub async fn nacos_list_namespaces_core(state: &AppState, conn_id: &str) -> Result<Vec<NacosNamespaceInfo>, String> {
let (admin, fingerprint) = get_admin_with_operation_fingerprint(state, conn_id).await?;
crate::nacos::namespace_access::list_readable_namespaces(conn_id, fingerprint, admin).await
crate::nacos::namespace_access::list_displayable_namespaces(conn_id, fingerprint, admin).await
}
pub async fn nacos_sidebar_snapshot_core(
state: &AppState,
conn_id: &str,
) -> Result<NacosNamespaceSidebarSnapshot, String> {
let visible_scope =
state.configs.read().await.get(conn_id).ok_or("Connection not found")?.visible_databases.clone();
let (admin, fingerprint) = get_admin_with_operation_fingerprint(state, conn_id).await?;
crate::nacos::namespace_access::sidebar_snapshot(conn_id, fingerprint, admin).await
crate::nacos::namespace_access::sidebar_snapshot_with_visible_scope(
conn_id,
fingerprint,
admin,
visible_scope.as_deref(),
)
.await
}
pub async fn nacos_create_namespace_core(
@@ -72,7 +87,11 @@ pub async fn nacos_create_namespace_core(
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Create Nacos namespace").await?;
let admin = get_admin(state, conn_id).await?;
admin.create_namespace(req).await
let result = admin.create_namespace(req).await;
if result.is_ok() {
crate::nacos::namespace_access::invalidate(conn_id);
}
result
}
pub async fn nacos_update_namespace_core(
@@ -82,7 +101,21 @@ pub async fn nacos_update_namespace_core(
) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Update Nacos namespace").await?;
let admin = get_admin(state, conn_id).await?;
admin.update_namespace(req).await
let result = admin.update_namespace(req).await;
if result.is_ok() {
crate::nacos::namespace_access::invalidate(conn_id);
}
result
}
pub async fn nacos_delete_namespace_core(state: &AppState, conn_id: &str, namespace_id: String) -> Result<(), String> {
ensure_connection_writable(state, conn_id, "Delete Nacos namespace").await?;
let admin = get_admin(state, conn_id).await?;
let result = admin.delete_namespace(namespace_id).await;
if result.is_ok() {
crate::nacos::namespace_access::invalidate(conn_id);
}
result
}
pub async fn nacos_list_configs_core(
@@ -571,6 +604,7 @@ mod tests {
display_server_addr: "http://127.0.0.1:3848".to_string(),
namespace: "public".to_string(),
version_mode: Some(NacosVersionMode::Auto),
api_plane: None,
context_path: "/nacos".to_string(),
managed_namespaces: Vec::new(),
rnacos_console_addr: String::new(),
+3
View File
@@ -161,6 +161,8 @@ pub struct NacosServiceCapabilities {
pub delete_service: NacosOperationCapability,
pub list_instances: NacosOperationCapability,
pub update_instance: NacosOperationCapability,
#[serde(default)]
pub update_instance_health: NacosOperationCapability,
pub register_instance: NacosOperationCapability,
pub deregister_instance: NacosOperationCapability,
}
@@ -175,6 +177,7 @@ impl NacosServiceCapabilities {
delete_service: NacosOperationCapability::unsupported(reason),
list_instances: NacosOperationCapability::supported(),
update_instance: NacosOperationCapability::unsupported(reason),
update_instance_health: NacosOperationCapability::unsupported(reason),
register_instance: NacosOperationCapability::unsupported(reason),
deregister_instance: NacosOperationCapability::unsupported(reason),
}
+96 -1
View File
@@ -45,6 +45,18 @@ impl CredentialKey {
}
}
#[derive(Clone, Eq, Hash, PartialEq)]
struct PurposeCredentialKey {
credential: CredentialKey,
purpose: String,
}
impl PurposeCredentialKey {
fn new(owner_scope: &str, connection_id: &str, purpose: &str) -> Self {
Self { credential: CredentialKey::new(owner_scope, connection_id), purpose: purpose.to_string() }
}
}
struct SessionCredential {
password: String,
generation: u64,
@@ -53,6 +65,7 @@ struct SessionCredential {
#[derive(Default)]
struct SessionCredentialState {
credentials: HashMap<CredentialKey, SessionCredential>,
purpose_credentials: HashMap<PurposeCredentialKey, SessionCredential>,
pool_credential_owners: HashMap<String, String>,
next_generation: u64,
}
@@ -62,6 +75,11 @@ pub struct SessionCredentialWriteToken {
generation: u64,
}
pub struct PurposeSessionCredentialWriteToken {
key: PurposeCredentialKey,
generation: u64,
}
/// 当前请求的认证会话作用域(Web 会话 token / 桌面端空串)。
///
/// 由 Web 鉴权中间件在请求边界通过 [`with_credential_owner`] 注入;未注入(桌面端、
@@ -120,6 +138,39 @@ impl SessionCredentialStore {
state.credentials.get(&CredentialKey::new(owner_scope, connection_id)).map(|entry| entry.password.clone())
}
/// Stores an additional transient secret for a connection-specific purpose.
pub fn set_for_purpose(&self, owner_scope: &str, connection_id: &str, purpose: &str, password: &str) {
let _ = self.set_for_purpose_with_token(owner_scope, connection_id, purpose, password);
}
/// Stores a purpose-specific secret and returns a token that can roll back
/// this exact write without deleting a newer concurrent value.
pub fn set_for_purpose_with_token(
&self,
owner_scope: &str,
connection_id: &str,
purpose: &str,
password: &str,
) -> Option<PurposeSessionCredentialWriteToken> {
if purpose.is_empty() || password.is_empty() {
return None;
}
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
state.next_generation = state.next_generation.checked_add(1).expect("session credential generation overflow");
let key = PurposeCredentialKey::new(owner_scope, connection_id, purpose);
let generation = state.next_generation;
state.purpose_credentials.insert(key.clone(), SessionCredential { password: password.to_string(), generation });
Some(PurposeSessionCredentialWriteToken { key, generation })
}
pub fn get_for_purpose(&self, owner_scope: &str, connection_id: &str, purpose: &str) -> Option<String> {
let state = self.state.read().unwrap_or_else(|error| error.into_inner());
state
.purpose_credentials
.get(&PurposeCredentialKey::new(owner_scope, connection_id, purpose))
.map(|entry| entry.password.clone())
}
/// 某个 owner 作用域下是否存在本次运行期临时密码。
pub fn has(&self, owner_scope: &str, connection_id: &str) -> bool {
self.get(owner_scope, connection_id).is_some()
@@ -129,6 +180,9 @@ impl SessionCredentialStore {
pub fn remove(&self, owner_scope: &str, connection_id: &str) {
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
state.credentials.remove(&CredentialKey::new(owner_scope, connection_id));
state.purpose_credentials.retain(|key, _| {
key.credential.owner_scope != owner_scope || key.credential.connection_id != connection_id
});
}
/// 仅当指定写入仍是当前值时删除,用于连接失败回滚,避免旧 attempt 删除更新密码。
@@ -142,11 +196,24 @@ impl SessionCredentialStore {
is_current
}
pub fn remove_purpose_if_current(&self, token: &PurposeSessionCredentialWriteToken) -> bool {
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
let is_current = state
.purpose_credentials
.get(&token.key)
.is_some_and(|credential| credential.generation == token.generation);
if is_current {
state.purpose_credentials.remove(&token.key);
}
is_current
}
/// 清除某个 owner 作用域下的全部凭据(Web 登出 / 登录会话失效),不影响其他
/// 登录会话的凭据。
pub fn clear_owner(&self, owner_scope: &str) {
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
state.credentials.retain(|key, _| key.owner_scope != owner_scope);
state.purpose_credentials.retain(|key, _| key.credential.owner_scope != owner_scope);
}
/// 全局连接配置被编辑、删除或复用 ID 时,原子清除所有 owner 的临时凭据和
@@ -154,6 +221,7 @@ impl SessionCredentialStore {
pub fn clear_connection(&self, connection_id: &str) {
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
state.credentials.retain(|key, _| key.connection_id != connection_id);
state.purpose_credentials.retain(|key, _| key.credential.connection_id != connection_id);
let pool_prefix = format!("{connection_id}:");
state
.pool_credential_owners
@@ -164,6 +232,7 @@ impl SessionCredentialStore {
pub fn clear(&self) {
let mut state = self.state.write().unwrap_or_else(|error| error.into_inner());
state.credentials.clear();
state.purpose_credentials.clear();
}
pub fn record_pool_owner(&self, pool_key: &str, owner_scope: &str) {
@@ -195,7 +264,7 @@ impl SessionCredentialStore {
fn credential_count(&self) -> usize {
let state = self.state.read().unwrap_or_else(|error| error.into_inner());
state.credentials.len()
state.credentials.len() + state.purpose_credentials.len()
}
}
@@ -288,6 +357,32 @@ mod tests {
assert!(store.has("token-x", "conn-a"));
}
#[test]
fn purpose_credentials_follow_owner_and_connection_cleanup() {
let store = SessionCredentialStore::new();
store.set_for_purpose("token-x", "conn-a", "nacos-primary", "new-secret");
store.set_for_purpose("token-y", "conn-a", "nacos-primary", "other-secret");
assert_eq!(store.get_for_purpose("token-x", "conn-a", "nacos-primary").as_deref(), Some("new-secret"));
assert_eq!(store.get_for_purpose("token-y", "conn-a", "nacos-primary").as_deref(), Some("other-secret"));
store.clear_owner("token-x");
assert_eq!(store.get_for_purpose("token-x", "conn-a", "nacos-primary"), None);
store.clear_connection("conn-a");
assert_eq!(store.get_for_purpose("token-y", "conn-a", "nacos-primary"), None);
}
#[test]
fn stale_purpose_write_token_cannot_remove_newer_credential() {
let store = SessionCredentialStore::new();
let first = store.set_for_purpose_with_token("token-x", "conn-a", "nacos-console", "first-secret").unwrap();
let second = store.set_for_purpose_with_token("token-x", "conn-a", "nacos-console", "second-secret").unwrap();
assert!(!store.remove_purpose_if_current(&first));
assert_eq!(store.get_for_purpose("token-x", "conn-a", "nacos-console").as_deref(), Some("second-secret"));
assert!(store.remove_purpose_if_current(&second));
assert_eq!(store.get_for_purpose("token-x", "conn-a", "nacos-console"), None);
}
#[test]
fn debug_output_hides_password_values() {
let store = SessionCredentialStore::new();
+115 -1
View File
@@ -2513,6 +2513,7 @@ impl Storage {
// This preference is an exception: retaining the old password would
// make a no-save connection silently authenticate without prompting.
persist_secret_in_tx(&tx, &config.id, "password", "")?;
delete_secret_prefix_in_tx(&tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?;
}
let mut sanitized = config;
sanitized.password = String::new();
@@ -2919,6 +2920,31 @@ impl Storage {
if config.db_type != DatabaseType::Nacos {
return Ok(false);
}
if !config.save_password {
let primary_needs_rewrite = nacos_auth_object(config.external_config.as_ref())
.filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword"))
.and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str))
.is_some_and(|password| !password.is_empty());
let console_needs_rewrite = nacos_console_auth_object(config.external_config.as_ref())
.filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword"))
.and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str))
.is_some_and(|password| !password.is_empty());
scrub_nacos_auth_secrets(config);
let connection_id = connection_id.to_string();
let key_prefix = format!("{NACOS_AUTH_SECRET_PREFIX}%");
self.with_conn(move |conn| {
conn.execute(
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2",
params![connection_id, key_prefix],
)
.map(|_| ())
.map_err(|error| error.to_string())
})
.await?;
return Ok(primary_needs_rewrite || console_needs_rewrite);
}
let mut rewritten = false;
if let Some(auth) = nacos_auth_object_mut(config.external_config.as_mut()) {
if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") {
@@ -4161,7 +4187,7 @@ fn persist_mq_token_signing_secret_in_tx(
}
fn persist_nacos_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> {
if config.db_type != DatabaseType::Nacos {
if config.db_type != DatabaseType::Nacos || !config.save_password {
delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?;
return Ok(());
}
@@ -4823,6 +4849,24 @@ mod tests {
config.external_config.as_ref()?.get("auth")?.get("password")?.as_str()
}
fn nacos_console_auth_password(config: &ConnectionConfig) -> Option<&str> {
config.external_config.as_ref()?.get("rnacosConsoleAuth")?.get("password")?.as_str()
}
fn nacos_connection_with_console_auth(
id: &str,
primary_password: &str,
console_password: &str,
) -> ConnectionConfig {
let mut config = nacos_connection(id, primary_password);
config.external_config.as_mut().unwrap()["rnacosConsoleAuth"] = serde_json::json!({
"kind": "usernamePassword",
"username": "console",
"password": console_password
});
config
}
async fn create_data_dir_with_connection(name: &str, connection_id: &str, token: &str) -> std::path::PathBuf {
let data_dir = temp_data_dir(name);
let storage = Storage::open(&data_dir.join("dbx.db")).await.unwrap();
@@ -5263,6 +5307,76 @@ mod tests {
);
}
#[tokio::test]
async fn save_connections_does_not_persist_nacos_passwords_when_save_password_is_false() {
let path = temp_db_path("nacos-auth-no-save");
let storage = Storage::open(&path).await.unwrap();
let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret");
config.save_password = false;
storage.save_connections(&[config]).await.unwrap();
assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None);
assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None);
let loaded = storage.load_connections().await.unwrap();
assert_eq!(nacos_auth_password(&loaded[0]), Some(""));
assert_eq!(nacos_console_auth_password(&loaded[0]), Some(""));
}
#[tokio::test]
async fn switching_nacos_password_saving_off_removes_all_stored_auth_secrets() {
let path = temp_db_path("nacos-auth-disable-save");
let storage = Storage::open(&path).await.unwrap();
let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret");
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
assert!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().is_some());
assert!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap().is_some());
config.save_password = false;
storage.save_connections(&[config]).await.unwrap();
assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None);
assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None);
}
#[tokio::test]
async fn metadata_sync_removes_nacos_auth_secrets_when_password_saving_is_disabled() {
let path = temp_db_path("nacos-auth-no-save-metadata-sync");
let storage = Storage::open(&path).await.unwrap();
let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret");
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
config.save_password = false;
storage.save_connection_metadata_preserving_secrets(&[config]).await.unwrap();
assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None);
assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None);
let loaded = storage.load_connections().await.unwrap();
assert_eq!(nacos_auth_password(&loaded[0]), Some(""));
assert_eq!(nacos_console_auth_password(&loaded[0]), Some(""));
}
#[tokio::test]
async fn load_connections_cleans_legacy_nacos_passwords_when_saving_is_disabled() {
let path = temp_db_path("nacos-auth-no-save-legacy-cleanup");
let storage = Storage::open(&path).await.unwrap();
let mut config = nacos_connection_with_console_auth("nacos", "legacy-primary-secret", "legacy-console-secret");
config.save_password = false;
insert_raw_connection(&storage, &config).await;
storage.set_secret("nacos", NACOS_AUTH_PASSWORD_KEY, "stale-primary-secret").await.unwrap();
storage.set_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY, "stale-console-secret").await.unwrap();
let loaded = storage.load_connections().await.unwrap();
assert_eq!(nacos_auth_password(&loaded[0]), Some(""));
assert_eq!(nacos_console_auth_password(&loaded[0]), Some(""));
assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None);
assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None);
let raw_json = raw_connection_json(&storage, "nacos").await;
assert!(!raw_json.contains("legacy-primary-secret"));
assert!(!raw_json.contains("legacy-console-secret"));
}
#[tokio::test]
async fn load_connections_migrates_legacy_nacos_auth_password_out_of_config_json() {
let path = temp_db_path("nacos-auth-legacy-migration");
+5
View File
@@ -336,6 +336,10 @@ async fn main() {
.route("/connection/check-health", post(routes::connection::check_connection_health))
.route("/connection/session-credential-status", post(routes::connection::session_credential_status))
.route("/connection/forget-session-credential", post(routes::connection::forget_session_credential))
.route(
"/connection/replace-nacos-session-credential",
post(routes::connection::replace_nacos_session_credential),
)
.route("/connection/identifier-quote", post(routes::connection::connection_identifier_quote))
.route("/connection/close-database", post(routes::connection::close_database_connection))
.route("/connection/save", post(routes::connection::save_connections))
@@ -739,6 +743,7 @@ async fn main() {
.route("/nacos/sidebar/snapshot", post(routes::nacos::sidebar_snapshot))
.route("/nacos/namespaces/create", post(routes::nacos::create_namespace))
.route("/nacos/namespaces/update", post(routes::nacos::update_namespace))
.route("/nacos/namespaces/delete", post(routes::nacos::delete_namespace))
.route("/nacos/configs/list", post(routes::nacos::list_configs))
.route("/nacos/configs/get", post(routes::nacos::get_config))
.route("/nacos/configs/publish", post(routes::nacos::publish_config))
+272 -28
View File
@@ -4,11 +4,17 @@ use std::sync::Arc;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::Json;
use dbx_core::connection::{connection_configs_pool_equivalent, AppState, PoolKind};
use dbx_core::connection::{
connection_configs_pool_equivalent, connection_configs_session_credentials_compatible, AppState, PoolKind,
};
use dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType};
use dbx_core::nacos::config::{
take_transient_passwords, NACOS_CONSOLE_SESSION_PASSWORD, NACOS_PRIMARY_SESSION_PASSWORD,
};
use dbx_core::runtime_config::{
release_runtime_config_on_disconnect, should_retain_runtime_config, TEST_PROBE_ID_PREFIX,
};
use dbx_core::session_credentials::{PurposeSessionCredentialWriteToken, SessionCredentialWriteToken};
use serde::Deserialize;
use crate::auth::session_token_from_headers;
@@ -18,6 +24,74 @@ use crate::state::WebState;
const MONGO_LEGACY_DRIVER_PROFILE: &str = "mongodb-legacy";
const MONGO_LEGACY_DRIVER_LABEL: &str = "MongoDB (Legacy)";
#[derive(Default)]
struct NoSaveRuntimeSecrets {
primary: Option<String>,
console: Option<String>,
}
#[derive(Default)]
struct SessionCredentialWrites {
primary: Option<SessionCredentialWriteToken>,
purposes: Vec<PurposeSessionCredentialWriteToken>,
}
fn prepare_runtime_config(mut config: ConnectionConfig) -> (ConnectionConfig, NoSaveRuntimeSecrets) {
if config.save_password {
return (config, NoSaveRuntimeSecrets::default());
}
if config.db_type == DatabaseType::Nacos {
let passwords = take_transient_passwords(&mut config);
return (config, NoSaveRuntimeSecrets { primary: passwords.primary, console: passwords.console });
}
let primary = std::mem::take(&mut config.password);
(config, NoSaveRuntimeSecrets { primary: (!primary.is_empty()).then_some(primary), console: None })
}
fn record_session_credentials(
app: &AppState,
owner: &str,
connection_id: &str,
secrets: &NoSaveRuntimeSecrets,
nacos: bool,
) -> SessionCredentialWrites {
let primary =
secrets.primary.as_deref().and_then(|password| app.session_credentials.set(owner, connection_id, password));
let mut purposes = Vec::new();
if nacos {
if let Some(token) = secrets.primary.as_deref().and_then(|password| {
app.session_credentials.set_for_purpose_with_token(
owner,
connection_id,
NACOS_PRIMARY_SESSION_PASSWORD,
password,
)
}) {
purposes.push(token);
}
if let Some(token) = secrets.console.as_deref().and_then(|password| {
app.session_credentials.set_for_purpose_with_token(
owner,
connection_id,
NACOS_CONSOLE_SESSION_PASSWORD,
password,
)
}) {
purposes.push(token);
}
}
SessionCredentialWrites { primary, purposes }
}
fn rollback_session_credential_writes(app: &AppState, writes: &SessionCredentialWrites) {
for token in &writes.purposes {
app.session_credentials.remove_purpose_if_current(token);
}
if let Some(token) = writes.primary.as_ref() {
app.session_credentials.remove_if_current(token);
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectRequest {
@@ -45,6 +119,14 @@ pub struct SessionCredentialStatusRequest {
pub connection_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplaceNacosSessionCredentialRequest {
pub connection_id: String,
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionIdentifierQuoteRequest {
@@ -299,14 +381,14 @@ pub async fn connect_db(
// 只有本次请求确实写入/替换了会话凭据(输入了非空密码)时,才需要在连接
// 失败时回滚它;无密码重连复用的是既有有效凭据,失败不应清掉它,否则瞬时的
// 数据库/网络抖动就会让"本次会话内记住密码"失效、下次又弹窗。
let session_credential_write = (!config.save_password)
.then(|| app.session_credentials.set(&owner, &connection_id, &config.password))
.flatten();
let no_save_password = !config.save_password;
let mut runtime_config = config.clone();
if no_save_password {
runtime_config.password.clear();
}
let (runtime_config, runtime_secrets) = prepare_runtime_config(config.clone());
let session_credential_writes = record_session_credentials(
app,
&owner,
&connection_id,
&runtime_secrets,
config.db_type == DatabaseType::Nacos,
);
app.remove_connection_pools_detached(&connection_id).await;
app.nacos_registry.drop_connection(&connection_id).await;
@@ -316,17 +398,13 @@ pub async fn connect_db(
if let Err(error) = app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await {
// 连接失败:仅回滚本次请求刚写入的会话凭据,避免前端误判"已记住密码"而用
// 失败密码免弹窗重试;无密码重连复用的既有凭据不受瞬时失败影响。
if let Some(token) = session_credential_write.as_ref() {
app.session_credentials.remove_if_current(token);
}
rollback_session_credential_writes(app, &session_credential_writes);
return Err(AppError::from(error));
}
if let Err(error) = sync_mongo_legacy_driver_fallback(&state, &config).await {
app.remove_connection_pools_detached(&connection_id).await;
app.reset_connection_transport_for_config(&connection_id, &config).await;
if let Some(token) = session_credential_write.as_ref() {
app.session_credentials.remove_if_current(token);
}
rollback_session_credential_writes(app, &session_credential_writes);
return Err(error);
}
@@ -380,17 +458,18 @@ pub async fn connection_final_proxy_port(
let db_config = dbx_core::connection::metadata_connection_config(&runtime_config);
// Tunnel resolution needs a runtime config lookup, but a no-save password
// must never enter the shared Web config cache and bypass owner isolation.
let mut stored_config = runtime_config.clone();
if !stored_config.save_password {
stored_config.password.clear();
}
let (stored_config, runtime_secrets) = prepare_runtime_config(runtime_config.clone());
app.configs.write().await.insert(connection_id.clone(), stored_config);
let (_, port) = app.connection_host_port(&connection_id, &db_config).await.map_err(AppError::from)?;
if !runtime_config.save_password && !runtime_config.password.is_empty() {
let owner = session_token_from_headers(&headers).unwrap_or_default();
let _ = app.session_credentials.set(&owner, &connection_id, &runtime_config.password);
}
let owner = session_token_from_headers(&headers).unwrap_or_default();
record_session_credentials(
app,
&owner,
&connection_id,
&runtime_secrets,
runtime_config.db_type == DatabaseType::Nacos,
);
Ok(Json(port))
}
@@ -458,6 +537,20 @@ pub async fn forget_session_credential(
Ok(Json(()))
}
pub async fn replace_nacos_session_credential(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
Json(body): Json<ReplaceNacosSessionCredentialRequest>,
) -> Result<Json<()>, AppError> {
let owner = session_token_from_headers(&headers).unwrap_or_default();
state
.app
.replace_nacos_session_credential(&owner, &body.connection_id, &body.username, &body.password)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn connection_identifier_quote(
State(state): State<Arc<WebState>>,
Json(body): Json<ConnectionIdentifierQuoteRequest>,
@@ -481,7 +574,7 @@ pub async fn close_database_connection(
pub async fn save_connections(
State(state): State<Arc<WebState>>,
_headers: HeaderMap,
headers: HeaderMap,
Json(body): Json<SaveConnectionsRequest>,
) -> Result<Json<()>, AppError> {
for config in &body.configs {
@@ -495,7 +588,13 @@ pub async fn save_connections(
}
}
state.app.storage.save_connections(&body.configs).await.map_err(AppError::from)?;
let sync = sync_connection_configs(&state, &body.configs).await;
let owner = session_token_from_headers(&headers).unwrap_or_default();
let runtime_configs = body.configs.iter().cloned().map(prepare_runtime_config).collect::<Vec<_>>();
let sanitized_configs = runtime_configs.iter().map(|(config, _)| config.clone()).collect::<Vec<_>>();
let sync = sync_connection_configs(&state, &sanitized_configs).await;
for (config, secrets) in &runtime_configs {
record_session_credentials(&state.app, &owner, &config.id, secrets, config.db_type == DatabaseType::Nacos);
}
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
drop_nacos_adapters_for_connection_ids(&state, &sync.nacos_adapter_ids_to_drop).await;
drop_mq_adapters_for_connection_ids(&state, &sync.mq_adapter_ids_to_drop).await;
@@ -601,12 +700,14 @@ async fn sync_connection_configs(state: &WebState, configs: &[ConnectionConfig])
if previous.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
mq_adapter_ids_to_drop.insert(config.id.clone());
}
if !connection_configs_session_credentials_compatible(&previous, config) {
// 全局端点或认证身份变化时清除所有 owner 的旧凭据;显示范围等
// 本地设置不改变凭据归属,必须保留各 owner 的 no-save 密码。
state.app.session_credentials.clear_connection(&config.id);
}
// 仅在真实连接参数变化时销毁池;save_password=false 连接因持久化
// 空密码与运行态密码产生的差异被忽略(见 connection_configs_pool_equivalent)。
if !connection_configs_pool_equivalent(&previous, config) {
// 连接端点/认证参数已全局变化:清除所有 owner 的旧凭据与池 owner,
// 避免其他登录会话把旧密码或旧池复用到新的连接定义。
state.app.session_credentials.clear_connection(&config.id);
connection_pool_ids_to_drop.insert(config.id.clone());
}
}
@@ -659,6 +760,9 @@ mod tests {
AttachedDatabaseConfig, ConnectionConfig, DatabaseConnectionInfo, DatabaseType, ProxyTunnelConfig, ProxyType,
TransportLayerConfig,
};
use dbx_core::nacos::config::{
NacosAuthConfig, NacosRNacosConsoleAuth, NACOS_CONSOLE_SESSION_PASSWORD, NACOS_PRIMARY_SESSION_PASSWORD,
};
use dbx_core::storage::{McpGlobalPolicy, Storage};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -737,6 +841,36 @@ mod tests {
config
}
fn nacos_config(id: &str) -> ConnectionConfig {
let mut config = sqlite_config(id, "");
config.name = "Nacos".to_string();
config.db_type = DatabaseType::Nacos;
config.host = "127.0.0.1".to_string();
config.port = 8848;
config.username = "ordinary-user".to_string();
config.save_password = false;
config.visible_databases = Some(vec!["namespace-a".to_string()]);
config.external_config = Some(serde_json::json!({
"implementation": "nacos",
"versionMode": "v3",
"apiPlane": "admin",
"serverAddr": "http://127.0.0.1:8848",
"managedNamespaces": ["namespace-a"],
"rnacosConsoleAddr": "http://127.0.0.1:10848",
"rnacosConsoleAuth": {
"kind": "usernamePassword",
"username": "console-user",
"password": "console-password"
},
"auth": {
"kind": "usernamePassword",
"username": "ordinary-user",
"password": "old-password"
}
}));
config
}
fn consul_config(id: &str, server_addr: &str) -> ConnectionConfig {
let parsed = reqwest::Url::parse(server_addr).unwrap();
let mut config = sqlite_config(id, "");
@@ -1312,6 +1446,75 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn save_connections_scopes_all_nacos_no_save_passwords_to_the_request_owner() {
let (state, dir) = test_web_state().await;
let config = nacos_config("nacos-a");
let headers_a = cookie_headers("token-a");
let _ =
save_connections(State(state.clone()), headers_a, Json(SaveConnectionsRequest { configs: vec![config] }))
.await
.unwrap();
let runtime = state.app.configs.read().await.get("nacos-a").cloned().unwrap();
assert!(runtime.password.is_empty());
let external = runtime.external_config.as_ref().unwrap();
assert_eq!(external["auth"]["password"], "");
assert_eq!(external["rnacosConsoleAuth"]["password"], "");
assert_eq!(
state
.app
.session_credentials
.get_for_purpose("token-a", "nacos-a", NACOS_PRIMARY_SESSION_PASSWORD)
.as_deref(),
Some("old-password")
);
assert_eq!(
state
.app
.session_credentials
.get_for_purpose("token-a", "nacos-a", NACOS_CONSOLE_SESSION_PASSWORD)
.as_deref(),
Some("console-password")
);
assert_eq!(
state.app.session_credentials.get_for_purpose("token-b", "nacos-a", NACOS_PRIMARY_SESSION_PASSWORD),
None
);
assert_eq!(
state.app.session_credentials.get_for_purpose("token-b", "nacos-a", NACOS_CONSOLE_SESSION_PASSWORD),
None
);
dbx_core::session_credentials::with_credential_owner(Some("token-a".to_string()), async {
let parsed = state.app.nacos_admin_config_for_connection("nacos-a", &runtime).await.unwrap();
assert!(matches!(
parsed.auth,
NacosAuthConfig::UsernamePassword { ref password, .. } if password == "old-password"
));
assert!(matches!(
parsed.rnacos_console_auth,
NacosRNacosConsoleAuth::UsernamePassword { ref password, .. } if password == "console-password"
));
})
.await;
dbx_core::session_credentials::with_credential_owner(Some("token-b".to_string()), async {
let parsed = state.app.nacos_admin_config_for_connection("nacos-a", &runtime).await.unwrap();
assert!(matches!(
parsed.auth,
NacosAuthConfig::UsernamePassword { ref password, .. } if password.is_empty()
));
assert!(matches!(
parsed.rnacos_console_auth,
NacosRNacosConsoleAuth::UsernamePassword { ref password, .. } if password.is_empty()
));
})
.await;
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn connection_final_proxy_port_sanitizes_no_save_passwords() {
let (state, dir) = test_web_state().await;
@@ -1455,6 +1658,47 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn sync_connection_configs_preserves_all_nacos_owner_passwords_for_scope_updates() {
let (state, dir) = test_web_state().await;
let initial = nacos_config("nacos-a");
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
for (owner, password) in [("token-a", "owner-a-secret"), ("token-b", "owner-b-secret")] {
let _ = state.app.session_credentials.set(owner, &initial.id, password);
state.app.session_credentials.set_for_purpose(owner, &initial.id, "nacos-primary-password", password);
}
let mut scope_updated = initial.clone();
scope_updated.visible_databases = Some(vec!["namespace-a".to_string(), "namespace-b".to_string()]);
scope_updated.external_config.as_mut().unwrap()["managedNamespaces"] =
serde_json::json!(["namespace-a", "namespace-b"]);
let sync = sync_connection_configs(&state, std::slice::from_ref(&scope_updated)).await;
assert_eq!(sync.connection_pool_ids_to_drop.as_slice(), std::slice::from_ref(&initial.id));
for (owner, password) in [("token-a", "owner-a-secret"), ("token-b", "owner-b-secret")] {
assert_eq!(state.app.session_credentials.get(owner, &initial.id).as_deref(), Some(password));
assert_eq!(
state.app.session_credentials.get_for_purpose(owner, &initial.id, "nacos-primary-password").as_deref(),
Some(password)
);
}
let mut endpoint_updated = scope_updated;
endpoint_updated.host = "nacos.internal".to_string();
endpoint_updated.external_config.as_mut().unwrap()["serverAddr"] =
serde_json::json!("http://nacos.internal:8848");
sync_connection_configs(&state, std::slice::from_ref(&endpoint_updated)).await;
for owner in ["token-a", "token-b"] {
assert!(!state.app.session_credentials.has(owner, &initial.id));
assert_eq!(
state.app.session_credentials.get_for_purpose(owner, &initial.id, "nacos-primary-password"),
None
);
}
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn sync_connection_configs_clears_all_owner_credentials_on_delete_and_id_reuse() {
let (state, dir) = test_web_state().await;
+23 -3
View File
@@ -22,6 +22,8 @@ const NACOS_SEARCH_PROGRESS_BUFFER: usize = 16;
#[serde(rename_all = "camelCase")]
pub(crate) struct ConnReq {
connection_id: String,
#[serde(default)]
force_refresh: bool,
}
#[derive(serde::Deserialize)]
@@ -38,6 +40,13 @@ pub(crate) struct NamespaceUpdateReq {
req: dbx_core::nacos::NacosNamespaceUpdate,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct NamespaceDeleteReq {
connection_id: String,
namespace_id: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigListReq {
@@ -264,9 +273,10 @@ pub async fn test_connection(
State(state): State<Arc<WebState>>,
Json(req): Json<ConnReq>,
) -> Result<Json<dbx_core::nacos::NacosConnectionInfo>, AppError> {
let result = dbx_core::nacos::service::nacos_test_connection_core(&state.app, &req.connection_id)
.await
.map_err(AppError::from)?;
let result =
dbx_core::nacos::service::nacos_test_connection_core(&state.app, &req.connection_id, req.force_refresh)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
@@ -310,6 +320,16 @@ pub async fn update_namespace(
Ok(Json(()))
}
pub async fn delete_namespace(
State(state): State<Arc<WebState>>,
Json(req): Json<NamespaceDeleteReq>,
) -> Result<Json<()>, AppError> {
dbx_core::nacos::service::nacos_delete_namespace_core(&state.app, &req.connection_id, req.namespace_id)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn list_configs(
State(state): State<Arc<WebState>>,
Json(req): Json<ConfigListReq>,
+1 -1
View File
@@ -1,3 +1,3 @@
# Nacos 2.5 initialization
After the Nacos service becomes healthy, the database environment command synchronously sets the first-run `nacos` administrator password to `123456` (or `DB_PASSWORD`) through the authentication API. Later starts verify those credentials without overwriting the account. The named data volume preserves the initialized account.
After the Nacos service becomes healthy, the database environment command synchronously sets the first-run `nacos` administrator password to `123456` (or `DB_PASSWORD`) through the authentication API. Later starts verify those credentials without overwriting the account. The smoke check also verifies that an unauthenticated configuration read is rejected. The named data volume preserves the initialized account.
+1 -1
View File
@@ -15,5 +15,5 @@
"steps": [{ "name": "initialize administrator password", "command": ["sh", "-ec", "curl --fail --silent --show-error --request POST --data-urlencode \"password=$1\" http://127.0.0.1:8848/nacos/v1/auth/users/admin >/dev/null; curl --fail --silent --show-error --request POST http://127.0.0.1:8848/nacos/v1/auth/login --data-urlencode username=nacos --data-urlencode \"password=$1\" | grep -q '\"accessToken\"'; printf 'administrator initialized\\n'", "sh", "${DB_PASSWORD}"], "expect": "administrator initialized" }]
},
"shell": ["curl", "--fail", "--silent", "--show-error", "--request", "POST", "http://127.0.0.1:8848/nacos/v1/auth/login", "--data-urlencode", "username=nacos", "--data-urlencode", "password=${DB_PASSWORD}"],
"smoke": { "steps": [{ "name": "log in with initialized administrator credentials", "command": ["curl", "--fail", "--silent", "--show-error", "--request", "POST", "http://127.0.0.1:8848/nacos/v1/auth/login", "--data-urlencode", "username=nacos", "--data-urlencode", "password=${DB_PASSWORD}"], "expect": "accessToken" }] }
"smoke": { "steps": [{ "name": "log in with initialized administrator credentials", "command": ["curl", "--fail", "--silent", "--show-error", "--request", "POST", "http://127.0.0.1:8848/nacos/v1/auth/login", "--data-urlencode", "username=nacos", "--data-urlencode", "password=${DB_PASSWORD}"], "expect": "accessToken" }, { "name": "reject unauthenticated configuration reads", "command": ["sh", "-ec", "test \"$(curl --silent --output /dev/null --write-out '%{http_code}' 'http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=auth-check&group=DEFAULT_GROUP')\" = 403; printf 'unauthenticated access rejected\\n'"], "expect": "unauthenticated access rejected" }] }
}
+10
View File
@@ -209,6 +209,16 @@ test('initializes both Nacos versions with the shared administrator credentials'
}
});
test('verifies Nacos 2.5 rejects unauthenticated configuration reads', () => {
const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'nacos@2.5');
const authenticationStep = recipe.smoke.steps.find((step) => step.name === 'reject unauthenticated configuration reads');
assert.ok(authenticationStep);
assert.match(authenticationStep.command.join(' '), /nacos\/v1\/cs\/configs/);
assert.match(authenticationStep.command.join(' '), /403/);
assert.equal(authenticationStep.expect, 'unauthenticated access rejected');
});
test('configures r-nacos with its admin account and console port', () => {
const recipe = discoverRecipes().find((item) => recipeSelector(item) === 'rnacos@0.8');
assert.equal(recipe.connection.username, 'admin');
+767
View File
@@ -0,0 +1,767 @@
// Command nacos-service-seed creates deterministic Nacos services and persistent instances.
//
// The defaults match the local environments started by this repository:
//
// make db DB=nacos@2.5
// make db DB=nacos@3.2
// go run ./scripts/nacos-service-seed.go -target both -serve
//
// Nacos 3 registration intentionally uses the 8848 Admin API. The generated
// instances can then be inspected through a separate 8080 Console API DBX
// connection to verify the reduced Console capability set.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
)
const (
maxResponseBytes = 1 << 20
serviceOwnerSource = "dbx-nacos-service-seed"
catalogPageSize = 100
)
type options struct {
target string
action string
v2URL string
v3URL string
username string
password string
namespace string
group string
cluster string
prefix string
serviceCount int
instancesPerSvc int
instanceIP string
instanceBasePort int
serve bool
forceExisting bool
listenAddress string
timeout time.Duration
}
type demoServers struct {
servers []*http.Server
addresses []string
serveErrors chan error
}
type nacosTarget struct {
name string
baseURL string
loginPath string
servicePath string
instancePath string
instanceListPath string
instanceHealthPath string
instanceListCatalog bool
supportsHealthUpdate bool
tokenInHeader bool
token string
client *http.Client
}
type nacosInstance struct {
IP string
Port int
ClusterName string
Ephemeral bool
}
func main() {
opts := parseFlags()
if err := validateOptions(opts); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var listeners *demoServers
if opts.serve {
var err error
listeners, err = startDemoServers(opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := listeners.shutdown(shutdownCtx); err != nil {
fmt.Fprintln(os.Stderr, "error: stop demo servers:", err)
}
}()
fmt.Printf("[demo] listening on %s\n", strings.Join(listeners.addresses, ", "))
}
for _, target := range selectedTargets(opts) {
fmt.Printf("\n[%s] %s %d services x %d instances at %s\n", target.name, opts.action, opts.serviceCount, opts.instancesPerSvc, target.baseURL)
if err := target.login(ctx, opts.username, opts.password); err != nil {
fmt.Fprintf(os.Stderr, "[%s] login failed: %v\n", target.name, err)
os.Exit(1)
}
var err error
if opts.action == "cleanup" {
err = target.cleanup(ctx, opts)
} else {
err = target.seed(ctx, opts)
}
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] %s failed: %v\n", target.name, opts.action, err)
os.Exit(1)
}
}
if listeners != nil {
fmt.Println("[demo] serving registered instances; press Ctrl+C to stop")
if err := listeners.wait(ctx); err != nil {
fmt.Fprintln(os.Stderr, "error: demo server:", err)
os.Exit(1)
}
}
}
func parseFlags() options {
var opts options
flag.StringVar(&opts.target, "target", "both", "target environment: v2, v3, or both")
flag.StringVar(&opts.action, "action", "seed", "action: seed or cleanup")
flag.StringVar(&opts.v2URL, "v2-url", "http://127.0.0.1:11000/nacos", "Nacos V2 base URL")
flag.StringVar(&opts.v3URL, "v3-url", "http://127.0.0.1:11003/nacos", "Nacos V3 Admin base URL (8848 mapping, not the 8080 Console URL)")
flag.StringVar(&opts.username, "username", "nacos", "Nacos username")
flag.StringVar(&opts.password, "password", "123456", "Nacos password")
flag.StringVar(&opts.namespace, "namespace", "public", "existing namespace ID")
flag.StringVar(&opts.group, "group", "DEFAULT_GROUP", "service group")
flag.StringVar(&opts.cluster, "cluster", "DEFAULT", "instance cluster")
flag.StringVar(&opts.prefix, "prefix", "dbx-test-service", "generated service name prefix")
flag.IntVar(&opts.serviceCount, "services", 5, "number of services")
flag.IntVar(&opts.instancesPerSvc, "instances", 2, "persistent instances per service")
flag.StringVar(&opts.instanceIP, "ip", "127.0.0.1", "registered instance IP")
flag.IntVar(&opts.instanceBasePort, "port", 28080, "first registered instance port")
flag.BoolVar(&opts.serve, "serve", false, "keep an HTTP demo server listening on every registered instance port")
flag.BoolVar(&opts.forceExisting, "force-existing", false, "allow seed or cleanup to modify matching services not created by this script")
flag.StringVar(&opts.listenAddress, "listen-address", "127.0.0.1", "address used by -serve listeners")
flag.DurationVar(&opts.timeout, "timeout", 10*time.Second, "HTTP request timeout")
flag.Parse()
return opts
}
func validateOptions(opts options) error {
if opts.target != "v2" && opts.target != "v3" && opts.target != "both" {
return fmt.Errorf("invalid -target %q; use v2, v3, or both", opts.target)
}
if opts.action != "seed" && opts.action != "cleanup" {
return fmt.Errorf("invalid -action %q; use seed or cleanup", opts.action)
}
if opts.serve && opts.action != "seed" {
return errors.New("-serve can only be used with -action seed")
}
if opts.username == "" || opts.password == "" {
return errors.New("-username and -password must not be empty")
}
if opts.namespace == "" || opts.group == "" || opts.cluster == "" || opts.prefix == "" {
return errors.New("-namespace, -group, -cluster, and -prefix must not be empty")
}
if opts.serviceCount < 1 || opts.instancesPerSvc < 1 {
return errors.New("-services and -instances must be greater than zero")
}
lastPort := opts.instanceBasePort + opts.serviceCount*opts.instancesPerSvc - 1
if opts.instanceBasePort < 1 || lastPort > 65535 {
return fmt.Errorf("generated instance port range %d-%d is invalid", opts.instanceBasePort, lastPort)
}
for label, rawURL := range map[string]string{"v2-url": opts.v2URL, "v3-url": opts.v3URL} {
parsed, err := url.ParseRequestURI(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("-%s must be an absolute HTTP URL", label)
}
}
return nil
}
func startDemoServers(opts options) (*demoServers, error) {
set := &demoServers{serveErrors: make(chan error, opts.serviceCount*opts.instancesPerSvc)}
for serviceIndex := 0; serviceIndex < opts.serviceCount; serviceIndex++ {
serviceName := generatedServiceName(opts.prefix, serviceIndex)
for instanceIndex := 0; instanceIndex < opts.instancesPerSvc; instanceIndex++ {
port := generatedPort(opts, serviceIndex, instanceIndex)
listener, err := net.Listen("tcp", net.JoinHostPort(opts.listenAddress, strconv.Itoa(port)))
if err != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = set.shutdown(shutdownCtx)
cancel()
return nil, fmt.Errorf("listen on %s:%d: %w", opts.listenAddress, port, err)
}
server := &http.Server{Handler: demoHandler(serviceName, port)}
set.servers = append(set.servers, server)
set.addresses = append(set.addresses, listener.Addr().String())
go func(server *http.Server, listener net.Listener) {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
set.serveErrors <- err
}
}(server, listener)
}
}
return set, nil
}
func demoHandler(serviceName string, port int) http.Handler {
payload, _ := json.Marshal(map[string]any{"service": serviceName, "port": port, "status": "healthy"})
return http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(payload)
})
}
func (set *demoServers) wait(ctx context.Context) error {
select {
case <-ctx.Done():
return nil
case err := <-set.serveErrors:
return err
}
}
func (set *demoServers) shutdown(ctx context.Context) error {
var failures []string
for _, server := range set.servers {
if err := server.Shutdown(ctx); err != nil {
failures = append(failures, err.Error())
}
}
if len(failures) > 0 {
return errors.New(strings.Join(failures, "; "))
}
return nil
}
func selectedTargets(opts options) []*nacosTarget {
client := &http.Client{Timeout: opts.timeout}
v2 := &nacosTarget{
name: "Nacos V2", baseURL: strings.TrimRight(opts.v2URL, "/"), loginPath: "/v1/auth/login",
servicePath: "/v1/ns/service", instancePath: "/v1/ns/instance", instanceListPath: "/v1/ns/catalog/instances", instanceHealthPath: "/v1/ns/instance", instanceListCatalog: true, supportsHealthUpdate: true, client: client,
}
v3 := &nacosTarget{
name: "Nacos V3 Admin", baseURL: strings.TrimRight(opts.v3URL, "/"), loginPath: "/v3/auth/user/login",
servicePath: "/v3/admin/ns/service", instancePath: "/v3/admin/ns/instance", instanceListPath: "/v3/admin/ns/instance/list", tokenInHeader: true, client: client,
}
switch opts.target {
case "v2":
return []*nacosTarget{v2}
case "v3":
return []*nacosTarget{v3}
default:
return []*nacosTarget{v2, v3}
}
}
func (target *nacosTarget) login(ctx context.Context, username, password string) error {
body, _, err := target.rawRequest(ctx, http.MethodPost, target.loginPath, nil, url.Values{
"username": {username},
"password": {password},
}, false)
if err != nil {
return err
}
token, err := accessToken(body)
if err != nil {
return err
}
target.token = token
return nil
}
func (target *nacosTarget) seed(ctx context.Context, opts options) error {
createdInstances := 0
for serviceIndex := 0; serviceIndex < opts.serviceCount; serviceIndex++ {
serviceName := generatedServiceName(opts.prefix, serviceIndex)
if err := target.ensureService(ctx, opts, serviceName, serviceIndex); err != nil {
return fmt.Errorf("service %s: %w", serviceName, err)
}
for instanceIndex := 0; instanceIndex < opts.instancesPerSvc; instanceIndex++ {
port := generatedPort(opts, serviceIndex, instanceIndex)
metadata, _ := json.Marshal(map[string]any{
"source": serviceOwnerSource, "target": target.name,
"serviceIndex": serviceIndex + 1, "instanceIndex": instanceIndex + 1,
})
form := url.Values{
"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName},
"clusterName": {opts.cluster}, "ip": {opts.instanceIP}, "port": {strconv.Itoa(port)},
"weight": {strconv.FormatFloat(1+float64(instanceIndex)/10, 'f', 1, 64)},
"healthy": {"true"}, "enabled": {"true"}, "ephemeral": {"false"}, "metadata": {string(metadata)},
}
if err := target.operation(ctx, http.MethodPost, target.instancePath, nil, form, false); err != nil {
return fmt.Errorf("register %s %s:%d: %w", serviceName, opts.instanceIP, port, err)
}
if target.supportsHealthUpdate {
if err := target.markInstanceHealthy(ctx, opts, serviceName, port); err != nil {
return fmt.Errorf("mark healthy %s %s:%d: %w", serviceName, opts.instanceIP, port, err)
}
}
createdInstances++
}
fmt.Printf("[%s] ready %-28s instances %d-%d\n", target.name, serviceName, generatedPort(opts, serviceIndex, 0), generatedPort(opts, serviceIndex, opts.instancesPerSvc-1))
}
fmt.Printf("[%s] complete: %d services, %d persistent instances\n", target.name, opts.serviceCount, createdInstances)
return nil
}
// markInstanceHealthy is only used by Nacos V2. Nacos V3 has no compatible
// health-update endpoint for persistent instances.
func (target *nacosTarget) markInstanceHealthy(ctx context.Context, opts options, serviceName string, port int) error {
form := url.Values{
"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName},
"clusterName": {opts.cluster}, "ip": {opts.instanceIP}, "port": {strconv.Itoa(port)},
"healthy": {"true"}, "ephemeral": {"false"},
}
return target.operation(ctx, http.MethodPut, target.instanceHealthPath, nil, form, false)
}
func (target *nacosTarget) ensureService(ctx context.Context, opts options, serviceName string, serviceIndex int) error {
query := url.Values{"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName}}
exists, owned, err := target.serviceStatus(ctx, query)
if err != nil {
return err
}
if exists && !owned && !opts.forceExisting {
return errors.New("matching service already exists without the script ownership marker; choose another -prefix or pass -force-existing to replace it")
}
metadata, _ := json.Marshal(map[string]any{
"source": serviceOwnerSource, "target": target.name, "serviceIndex": serviceIndex + 1,
})
form := url.Values{
"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName},
"protectThreshold": {"0"}, "ephemeral": {"false"}, "metadata": {string(metadata)},
"selector": {`{"type":"none","contextType":"NONE"}`},
}
method := http.MethodPost
if exists {
method = http.MethodPut
}
return target.operation(ctx, method, target.servicePath, nil, form, false)
}
func (target *nacosTarget) serviceStatus(ctx context.Context, query url.Values) (bool, bool, error) {
body, status, err := target.rawRequest(ctx, http.MethodGet, target.servicePath, query, nil, true)
if err != nil {
if responseMeansMissing([]byte(err.Error())) {
return false, false, nil
}
return false, false, err
}
if status == http.StatusNotFound || responseMeansMissing(body) {
return false, false, nil
}
if err := operationResponseError(body); err != nil {
if responseMeansMissing([]byte(err.Error())) {
return false, false, nil
}
return false, false, err
}
return true, serviceOwnedBySeed(body), nil
}
func serviceOwnedBySeed(body []byte) bool {
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
metadata := serviceMetadata(payload)
return metadata != nil && metadata["source"] == serviceOwnerSource
}
func serviceMetadata(payload any) map[string]any {
value, ok := payload.(map[string]any)
if !ok {
return nil
}
if metadata, exists := value["metadata"]; exists {
switch metadata := metadata.(type) {
case map[string]any:
return metadata
case string:
var parsed map[string]any
if json.Unmarshal([]byte(metadata), &parsed) == nil {
return parsed
}
}
}
return serviceMetadata(value["data"])
}
func (target *nacosTarget) cleanup(ctx context.Context, opts options) error {
var failures []string
for serviceIndex := opts.serviceCount - 1; serviceIndex >= 0; serviceIndex-- {
serviceName := generatedServiceName(opts.prefix, serviceIndex)
query := url.Values{"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName}}
exists, owned, err := target.serviceStatus(ctx, query)
if err != nil {
failures = append(failures, fmt.Sprintf("inspect %s: %v", serviceName, err))
continue
}
if !exists {
continue
}
if !owned && !opts.forceExisting {
failures = append(failures, fmt.Sprintf("refusing to remove %s because it does not have the script ownership marker; pass -force-existing to override", serviceName))
continue
}
instances, err := target.listInstances(ctx, opts, serviceName)
if err != nil {
failures = append(failures, fmt.Sprintf("list %s: %v", serviceName, err))
continue
}
for _, instance := range instances {
query := url.Values{
"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName},
"clusterName": {instance.ClusterName}, "ip": {instance.IP},
"port": {strconv.Itoa(instance.Port)}, "ephemeral": {strconv.FormatBool(instance.Ephemeral)},
}
if err := target.operation(ctx, http.MethodDelete, target.instancePath, query, nil, true); err != nil {
failures = append(failures, fmt.Sprintf("remove %s %s:%d: %v", serviceName, instance.IP, instance.Port, err))
}
}
if err := target.operation(ctx, http.MethodDelete, target.servicePath, query, nil, true); err != nil {
failures = append(failures, err.Error())
} else {
fmt.Printf("[%s] removed %s\n", target.name, serviceName)
}
}
if len(failures) > 0 {
return fmt.Errorf("cleanup completed with %d errors: %s", len(failures), strings.Join(failures, "; "))
}
return nil
}
func (target *nacosTarget) listInstances(ctx context.Context, opts options, serviceName string) ([]nacosInstance, error) {
if target.instanceListCatalog {
return target.listCatalogInstances(ctx, opts, serviceName)
}
query := url.Values{
"namespaceId": {opts.namespace}, "groupName": {opts.group}, "serviceName": {serviceName}, "clusterName": {opts.cluster},
}
body, _, err := target.rawRequest(ctx, http.MethodGet, target.instanceListPath, query, nil, true)
if err != nil {
if responseMeansMissing([]byte(err.Error())) {
return nil, nil
}
return nil, err
}
if err := operationResponseError(body); err != nil {
if responseMeansMissing([]byte(err.Error())) {
return nil, nil
}
return nil, err
}
instances, err := parseInstances(body)
if err != nil {
return nil, err
}
return instances, nil
}
func (target *nacosTarget) listCatalogInstances(ctx context.Context, opts options, serviceName string) ([]nacosInstance, error) {
qualifiedServiceName := serviceName
if !strings.Contains(serviceName, "@@") && opts.group != "" {
qualifiedServiceName = opts.group + "@@" + serviceName
}
instances := make([]nacosInstance, 0)
seen := make(map[string]struct{})
for pageNo := 1; ; pageNo++ {
query := url.Values{
"namespaceId": {opts.namespace}, "serviceName": {qualifiedServiceName}, "clusterName": {opts.cluster},
"pageNo": {strconv.Itoa(pageNo)}, "pageSize": {strconv.Itoa(catalogPageSize)},
}
body, _, err := target.rawRequest(ctx, http.MethodGet, target.instanceListPath, query, nil, true)
if err != nil {
if responseMeansMissing([]byte(err.Error())) {
return instances, nil
}
return nil, err
}
if err := operationResponseError(body); err != nil {
if responseMeansMissing([]byte(err.Error())) {
return instances, nil
}
return nil, err
}
page, total, err := parseInstancePage(body)
if err != nil {
return nil, err
}
added := 0
for _, instance := range page {
key := fmt.Sprintf("%s\x00%d\x00%s\x00%t", instance.IP, instance.Port, instance.ClusterName, instance.Ephemeral)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
instances = append(instances, instance)
added++
}
if total != nil {
if len(instances) >= *total || len(page) == 0 {
break
}
} else if len(page) < catalogPageSize {
break
}
if added == 0 {
return nil, errors.New("Nacos instance pagination made no progress; the server repeated a page")
}
}
return instances, nil
}
func parseInstances(body []byte) ([]nacosInstance, error) {
instances, _, err := parseInstancePage(body)
return instances, err
}
func parseInstancePage(body []byte) ([]nacosInstance, *int, error) {
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, fmt.Errorf("invalid instance-list response: %s", compactBody(body))
}
items := instanceItems(payload)
instances := make([]nacosInstance, 0, len(items))
for _, item := range items {
ip, _ := item["ip"].(string)
port := numberValue(item["port"])
if ip == "" || port < 1 || port > 65535 {
continue
}
clusterName, _ := item["clusterName"].(string)
if clusterName == "" {
clusterName = "DEFAULT"
}
ephemeral, _ := item["ephemeral"].(bool)
instances = append(instances, nacosInstance{IP: ip, Port: port, ClusterName: clusterName, Ephemeral: ephemeral})
}
total, ok := instanceTotal(payload)
if !ok {
return instances, nil, nil
}
return instances, &total, nil
}
func instanceItems(payload any) []map[string]any {
switch value := payload.(type) {
case []any:
items := make([]map[string]any, 0, len(value))
for _, item := range value {
if instance, ok := item.(map[string]any); ok && instance["ip"] != nil {
items = append(items, instance)
}
}
return items
case map[string]any:
for _, key := range []string{"hosts", "list", "pageItems", "data"} {
if items := instanceItems(value[key]); len(items) > 0 {
return items
}
}
}
return nil
}
func instanceTotal(payload any) (int, bool) {
value, ok := payload.(map[string]any)
if !ok {
return 0, false
}
for _, key := range []string{"totalCount", "count"} {
if raw, exists := value[key]; exists {
if total, valid := nonnegativeNumberValue(raw); valid {
return total, true
}
}
}
return instanceTotal(value["data"])
}
func nonnegativeNumberValue(value any) (int, bool) {
switch value := value.(type) {
case float64:
if value < 0 {
return 0, false
}
return int(value), true
case string:
parsed, err := strconv.Atoi(value)
return parsed, err == nil && parsed >= 0
default:
return 0, false
}
}
func numberValue(value any) int {
switch value := value.(type) {
case float64:
return int(value)
case string:
parsed, _ := strconv.Atoi(value)
return parsed
default:
return 0
}
}
func generatedServiceName(prefix string, index int) string {
return fmt.Sprintf("%s-%02d", prefix, index+1)
}
func generatedPort(opts options, serviceIndex, instanceIndex int) int {
return opts.instanceBasePort + serviceIndex*opts.instancesPerSvc + instanceIndex
}
func (target *nacosTarget) operation(ctx context.Context, method, path string, query, form url.Values, allowMissing bool) error {
body, _, err := target.rawRequest(ctx, method, path, query, form, true)
if err != nil {
if allowMissing && responseMeansMissing([]byte(err.Error())) {
return nil
}
return err
}
if err := operationResponseError(body); err != nil {
if allowMissing && responseMeansMissing([]byte(err.Error())) {
return nil
}
return err
}
return nil
}
func (target *nacosTarget) rawRequest(ctx context.Context, method, path string, query, form url.Values, authenticate bool) ([]byte, int, error) {
endpoint, err := url.Parse(target.baseURL + path)
if err != nil {
return nil, 0, err
}
values := endpoint.Query()
for key, entries := range query {
for _, value := range entries {
values.Add(key, value)
}
}
if authenticate && target.token != "" && !target.tokenInHeader {
values.Set("accessToken", target.token)
}
endpoint.RawQuery = values.Encode()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
if err != nil {
return nil, 0, err
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if authenticate && target.token != "" && target.tokenInHeader {
req.Header.Set("accessToken", target.token)
}
resp, err := target.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return responseBody, resp.StatusCode, fmt.Errorf("%s %s returned %s: %s", method, path, resp.Status, compactBody(responseBody))
}
return responseBody, resp.StatusCode, nil
}
func accessToken(body []byte) (string, error) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("invalid login response: %s", compactBody(body))
}
if data, ok := payload["data"].(map[string]any); ok {
payload = data
}
for _, key := range []string{"accessToken", "access_token", "token"} {
if token, ok := payload[key].(string); ok && token != "" {
return token, nil
}
}
return "", fmt.Errorf("login response did not include an access token: %s", compactBody(body))
}
func operationResponseError(body []byte) error {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" || strings.EqualFold(trimmed, "true") || strings.EqualFold(trimmed, "ok") {
return nil
}
if strings.EqualFold(trimmed, "false") {
return errors.New("Nacos returned false")
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil
}
message := responseMessage(payload)
if code, ok := payload["code"]; ok && !successfulCode(code) {
return fmt.Errorf("Nacos returned code %v: %s", code, message)
}
if success, ok := payload["success"].(bool); ok && !success {
return fmt.Errorf("Nacos operation failed: %s", message)
}
if data, ok := payload["data"].(bool); ok && !data {
return fmt.Errorf("Nacos operation returned false: %s", message)
}
return nil
}
func successfulCode(code any) bool {
switch value := code.(type) {
case float64:
return value == 0 || value == 200
case string:
return value == "0" || value == "200"
default:
return false
}
}
func responseMessage(payload map[string]any) string {
for _, key := range []string{"message", "msg", "error"} {
if value, ok := payload[key].(string); ok && value != "" {
return value
}
}
return "unknown error"
}
func responseMeansMissing(body []byte) bool {
lower := strings.ToLower(string(body))
return strings.Contains(lower, "not found") || strings.Contains(lower, "not exist") || strings.Contains(lower, "doesn't exist")
}
func compactBody(body []byte) string {
text := strings.TrimSpace(string(body))
if len(text) > 400 {
return text[:400] + "..."
}
return text
}
+374
View File
@@ -0,0 +1,374 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestAccessTokenSupportsV2AndV3Responses(t *testing.T) {
for name, body := range map[string]string{
"v2": `{"accessToken":"v2-token","tokenTtl":18000}`,
"v3": `{"code":0,"message":"success","data":{"accessToken":"v3-token","tokenTtl":18000}}`,
} {
t.Run(name, func(t *testing.T) {
token, err := accessToken([]byte(body))
if err != nil {
t.Fatal(err)
}
if token != name+"-token" {
t.Fatalf("unexpected token %q", token)
}
})
}
}
func TestOperationResponseErrorRejectsBusinessFailures(t *testing.T) {
for _, body := range []string{
`false`,
`{"code":403,"message":"authorization failed"}`,
`{"code":0,"message":"failed","data":false}`,
} {
if err := operationResponseError([]byte(body)); err == nil {
t.Fatalf("expected %s to fail", body)
}
}
for _, body := range []string{`true`, `{"code":0,"message":"success","data":true}`, `{"code":200,"data":"ok"}`} {
if err := operationResponseError([]byte(body)); err != nil {
t.Fatalf("expected %s to succeed: %v", body, err)
}
}
}
func TestSelectedTargetsUseRepositoryPortsAndVersionedAPIs(t *testing.T) {
opts := options{target: "both", v2URL: "http://127.0.0.1:11000/nacos", v3URL: "http://127.0.0.1:11003/nacos", timeout: 1}
targets := selectedTargets(opts)
if len(targets) != 2 {
t.Fatalf("expected two targets, got %d", len(targets))
}
if !strings.Contains(targets[0].servicePath, "/v1/") || targets[0].tokenInHeader {
t.Fatalf("unexpected V2 target: %+v", targets[0])
}
if !strings.Contains(targets[1].servicePath, "/v3/admin/") || !targets[1].tokenInHeader {
t.Fatalf("unexpected V3 target: %+v", targets[1])
}
if !targets[0].supportsHealthUpdate || targets[0].instanceHealthPath != "/v1/ns/instance" || targets[0].instanceListPath != "/v1/ns/catalog/instances" || !targets[0].instanceListCatalog || targets[1].supportsHealthUpdate || targets[1].instanceListPath != "/v3/admin/ns/instance/list" || targets[1].instanceListCatalog {
t.Fatalf("unexpected instance health-update support: %+v", targets)
}
}
func TestServiceOwnedBySeedSupportsWrappedAndStringMetadata(t *testing.T) {
for name, body := range map[string]string{
"wrapped object": `{"code":0,"data":{"metadata":{"source":"dbx-nacos-service-seed"}}}`,
"top-level string": `{"metadata":"{\"source\":\"dbx-nacos-service-seed\"}"}`,
} {
t.Run(name, func(t *testing.T) {
if !serviceOwnedBySeed([]byte(body)) {
t.Fatalf("expected script ownership for %s", body)
}
})
}
for _, body := range []string{
`{"code":0,"data":{"metadata":{"source":"another-tool"}}}`,
`{"code":0,"data":{"metadata":{}}}`,
`{"code":0,"data":true}`,
} {
if serviceOwnedBySeed([]byte(body)) {
t.Fatalf("unexpected script ownership for %s", body)
}
}
}
func TestSeedRefusesToReplaceUnownedServiceUnlessForced(t *testing.T) {
test := func(force bool) ([]string, error) {
var requests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"code":0,"data":{"metadata":{"source":"another-tool"}}}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"data":true}`))
}))
defer server.Close()
target := &nacosTarget{baseURL: server.URL, servicePath: "/v3/admin/ns/service", instancePath: "/v3/admin/ns/instance", client: server.Client(), tokenInHeader: true}
opts := options{
namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service",
serviceCount: 1, instancesPerSvc: 1, instanceIP: "127.0.0.1", instanceBasePort: 28080, forceExisting: force,
}
return requests, target.seed(context.Background(), opts)
}
requests, err := test(false)
if err == nil || !strings.Contains(err.Error(), "-force-existing") {
t.Fatalf("expected ownership error, got %v", err)
}
if len(requests) != 1 || requests[0] != "GET /v3/admin/ns/service" {
t.Fatalf("unowned service was modified: %#v", requests)
}
requests, err = test(true)
if err != nil {
t.Fatal(err)
}
if len(requests) != 3 || requests[1] != "PUT /v3/admin/ns/service" || requests[2] != "POST /v3/admin/ns/instance" {
t.Fatalf("forced seed did not replace and register: %#v", requests)
}
}
func TestCleanupRefusesToDeleteUnownedServiceUnlessForced(t *testing.T) {
test := func(force bool) ([]string, error) {
var requests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v3/admin/ns/service":
_, _ = w.Write([]byte(`{"code":0,"data":{"metadata":{"source":"another-tool"}}}`))
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{"code":0,"data":[]}`))
default:
_, _ = w.Write([]byte(`{"code":0,"data":true}`))
}
}))
defer server.Close()
target := &nacosTarget{
baseURL: server.URL, servicePath: "/v3/admin/ns/service", instancePath: "/v3/admin/ns/instance", instanceListPath: "/v3/admin/ns/instance/list",
client: server.Client(), tokenInHeader: true,
}
opts := options{namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service", serviceCount: 1, instancesPerSvc: 1, forceExisting: force}
return requests, target.cleanup(context.Background(), opts)
}
requests, err := test(false)
if err == nil || !strings.Contains(err.Error(), "-force-existing") {
t.Fatalf("expected ownership error, got %v", err)
}
if len(requests) != 1 || requests[0] != "GET /v3/admin/ns/service" {
t.Fatalf("unowned service was inspected beyond its ownership marker: %#v", requests)
}
requests, err = test(true)
if err != nil {
t.Fatal(err)
}
if len(requests) != 3 || requests[1] != "GET /v3/admin/ns/instance/list" || requests[2] != "DELETE /v3/admin/ns/service" {
t.Fatalf("forced cleanup did not remove the service: %#v", requests)
}
}
func TestCleanupUsesActualInstanceIdentities(t *testing.T) {
type request struct {
method string
path string
query url.Values
}
var requests []request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, request{method: r.Method, path: r.URL.Path, query: r.URL.Query()})
if r.Method == http.MethodGet && r.URL.Path == "/v3/admin/ns/service" {
_, _ = w.Write([]byte(`{"code":0,"data":{"metadata":{"source":"dbx-nacos-service-seed"}}}`))
return
}
if r.Method == http.MethodGet && r.URL.Path == "/v3/admin/ns/instance/list" {
_, _ = w.Write([]byte(`{"code":0,"data":[{"ip":"10.91.0.99","port":28080,"clusterName":"blue","ephemeral":false}]}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"data":true}`))
}))
defer server.Close()
target := &nacosTarget{
baseURL: server.URL, servicePath: "/v3/admin/ns/service", instancePath: "/v3/admin/ns/instance", instanceListPath: "/v3/admin/ns/instance/list",
client: server.Client(), tokenInHeader: true,
}
opts := options{namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service", serviceCount: 1, instancesPerSvc: 3}
if err := target.cleanup(context.Background(), opts); err != nil {
t.Fatal(err)
}
if len(requests) != 4 {
t.Fatalf("expected ownership lookup, list, instance delete, and service delete; got %#v", requests)
}
instanceDelete := requests[2]
if instanceDelete.method != http.MethodDelete || instanceDelete.path != "/v3/admin/ns/instance" || instanceDelete.query.Get("ip") != "10.91.0.99" || instanceDelete.query.Get("port") != "28080" || instanceDelete.query.Get("clusterName") != "blue" {
t.Fatalf("unexpected instance cleanup request: %#v", instanceDelete)
}
}
func TestV2CleanupUsesPaginatedCatalogAndIncludesDisabledInstances(t *testing.T) {
type request struct {
method string
path string
query url.Values
}
var requests []request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, request{method: r.Method, path: r.URL.Path, query: r.URL.Query()})
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/ns/service":
_, _ = w.Write([]byte(`{"metadata":{"source":"dbx-nacos-service-seed"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/ns/catalog/instances" && r.URL.Query().Get("pageNo") == "1":
_, _ = w.Write([]byte(`{"list":[{"ip":"127.0.0.1","port":28080,"clusterName":"manual","enabled":false,"ephemeral":false}],"count":2}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/ns/catalog/instances" && r.URL.Query().Get("pageNo") == "2":
_, _ = w.Write([]byte(`{"list":[{"ip":"127.0.0.1","port":28081,"clusterName":"manual","enabled":true,"ephemeral":false}],"count":2}`))
default:
_, _ = w.Write([]byte("true"))
}
}))
defer server.Close()
target := &nacosTarget{
baseURL: server.URL, servicePath: "/v1/ns/service", instancePath: "/v1/ns/instance", instanceListPath: "/v1/ns/catalog/instances",
instanceListCatalog: true, client: server.Client(),
}
opts := options{namespace: "public", group: "DEFAULT_GROUP", cluster: "manual", prefix: "dbx-demo-service", serviceCount: 1, instancesPerSvc: 2}
if err := target.cleanup(context.Background(), opts); err != nil {
t.Fatal(err)
}
if len(requests) != 6 {
t.Fatalf("expected ownership lookup, two catalog pages, two instance deletes, and service delete; got %#v", requests)
}
for index, pageNo := range []string{"1", "2"} {
catalog := requests[index+1]
if catalog.path != "/v1/ns/catalog/instances" || catalog.query.Get("serviceName") != "DEFAULT_GROUP@@dbx-demo-service-01" || catalog.query.Get("groupName") != "" || catalog.query.Get("clusterName") != "manual" || catalog.query.Get("pageNo") != pageNo || catalog.query.Get("pageSize") != "100" {
t.Fatalf("unexpected catalog request: %#v", catalog)
}
}
deletedPorts := []string{requests[3].query.Get("port"), requests[4].query.Get("port")}
if strings.Join(deletedPorts, ",") != "28080,28081" {
t.Fatalf("disabled or paginated instance was not deleted: %#v", requests)
}
}
func TestSeedMarksPersistentInstancesHealthyAfterRegistration(t *testing.T) {
type request struct {
method string
path string
form url.Values
}
var requests []request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(body))
requests = append(requests, request{method: r.Method, path: r.URL.Path, form: form})
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/ns/service":
http.NotFound(w, r)
default:
_, _ = w.Write([]byte("true"))
}
}))
defer server.Close()
target := &nacosTarget{
baseURL: server.URL, servicePath: "/v1/ns/service", instancePath: "/v1/ns/instance", instanceHealthPath: "/v1/ns/instance",
supportsHealthUpdate: true, client: server.Client(),
}
opts := options{
namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service",
serviceCount: 1, instancesPerSvc: 1, instanceIP: "127.0.0.1", instanceBasePort: 28080,
}
if err := target.seed(context.Background(), opts); err != nil {
t.Fatal(err)
}
if len(requests) != 4 {
t.Fatalf("expected service lookup, creation, registration, and health update; got %#v", requests)
}
registration, healthUpdate := requests[2], requests[3]
if registration.method != http.MethodPost || registration.path != "/v1/ns/instance" || registration.form.Get("ephemeral") != "false" {
t.Fatalf("unexpected registration request: %#v", registration)
}
if healthUpdate.method != http.MethodPut || healthUpdate.path != "/v1/ns/instance" || healthUpdate.form.Get("healthy") != "true" || healthUpdate.form.Get("ephemeral") != "false" {
t.Fatalf("unexpected health update request: %#v", healthUpdate)
}
}
func TestV3SeedSetsHealthDuringRegistrationWithoutAHealthUpdate(t *testing.T) {
type request struct {
method string
path string
form url.Values
}
var requests []request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
form, _ := url.ParseQuery(string(body))
requests = append(requests, request{method: r.Method, path: r.URL.Path, form: form})
if r.Method == http.MethodGet && r.URL.Path == "/v3/admin/ns/service" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"code":0,"data":true}`))
}))
defer server.Close()
target := &nacosTarget{
baseURL: server.URL, servicePath: "/v3/admin/ns/service", instancePath: "/v3/admin/ns/instance",
client: server.Client(), tokenInHeader: true,
}
opts := options{
namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service",
serviceCount: 1, instancesPerSvc: 1, instanceIP: "127.0.0.1", instanceBasePort: 28080,
}
if err := target.seed(context.Background(), opts); err != nil {
t.Fatal(err)
}
if len(requests) != 3 {
t.Fatalf("expected service lookup, creation, and registration; got %#v", requests)
}
registration := requests[2]
if registration.method != http.MethodPost || registration.path != "/v3/admin/ns/instance" || registration.form.Get("healthy") != "true" {
t.Fatalf("unexpected V3 registration request: %#v", registration)
}
}
func TestServeStartsHTTPServerForEveryGeneratedPort(t *testing.T) {
opts := options{
prefix: "dbx-demo-service", serviceCount: 1, instancesPerSvc: 1,
listenAddress: "127.0.0.1", instanceBasePort: 0,
}
servers, err := startDemoServers(opts)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := servers.shutdown(context.Background()); err != nil {
t.Fatal(err)
}
}()
if len(servers.addresses) != 1 {
t.Fatalf("expected one listener, got %v", servers.addresses)
}
response, err := http.Get("http://" + servers.addresses[0] + "/health")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %s", response.Status)
}
var payload map[string]any
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload["service"] != "dbx-demo-service-01" || payload["status"] != "healthy" {
t.Fatalf("unexpected health response: %#v", payload)
}
}
func TestValidateOptionsRejectsServeDuringCleanup(t *testing.T) {
err := validateOptions(options{
target: "v2", action: "cleanup", username: "nacos", password: "123456", namespace: "public", group: "DEFAULT_GROUP", cluster: "DEFAULT", prefix: "dbx-demo-service",
serviceCount: 1, instancesPerSvc: 1, instanceBasePort: 28080, v2URL: "http://127.0.0.1:11000/nacos", v3URL: "http://127.0.0.1:11003/nacos", serve: true,
})
if err == nil || !strings.Contains(err.Error(), "-serve") {
t.Fatalf("expected -serve validation error, got %v", err)
}
}
+91 -6
View File
@@ -8,9 +8,9 @@ pub use dbx_core::agent_connection::{
};
pub use dbx_core::connection::{
agent_connect_timeout, connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_configs_pool_equivalent,
connection_url_for_endpoint, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver,
metadata_connection_config, prestosql_jdbc_config_for_endpoint, probe_connection_endpoint,
redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind,
connection_configs_session_credentials_compatible, connection_url_for_endpoint, gaussdb_m_jdbc_config_for_endpoint,
gaussdb_uses_m_jdbc_driver, metadata_connection_config, prestosql_jdbc_config_for_endpoint,
probe_connection_endpoint, redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind,
};
use dbx_core::database_capabilities;
use dbx_core::db;
@@ -301,6 +301,37 @@ mod tests {
config
}
fn nacos_config(id: &str) -> ConnectionConfig {
let mut config = mongodb_config();
config.id = id.to_string();
config.name = "Nacos".to_string();
config.db_type = DatabaseType::Nacos;
config.driver_profile = None;
config.driver_label = None;
config.url_params = None;
config.host = "127.0.0.1".to_string();
config.port = 8848;
config.username = "ordinary-user".to_string();
config.password.clear();
config.database = None;
config.connection_string = None;
config.save_password = false;
config.visible_databases = Some(vec!["namespace-a".to_string()]);
config.external_config = Some(serde_json::json!({
"implementation": "nacos",
"versionMode": "v3",
"apiPlane": "admin",
"serverAddr": "http://127.0.0.1:8848",
"managedNamespaces": ["namespace-a"],
"auth": {
"kind": "usernamePassword",
"username": "ordinary-user",
"password": "old-password"
}
}));
config
}
#[tokio::test]
async fn sqlite_connect_from_config_restores_attached_databases() {
let dir = std::env::temp_dir().join(format!("dbx-tauri-sqlite-attach-{}", uuid::Uuid::new_v4()));
@@ -791,6 +822,41 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn sync_connection_configs_preserves_nacos_session_password_for_scope_updates() {
let dir = std::env::temp_dir().join(format!("dbx-tauri-nacos-scope-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new_with_plugin_dir(storage, dir.join("plugins"));
let initial = nacos_config("nacos-a");
state.configs.write().await.insert(initial.id.clone(), initial.clone());
let _ = state.session_credentials.set("", &initial.id, "new-password");
state.session_credentials.set_for_purpose("", &initial.id, "nacos-primary-password", "new-password");
let mut scope_updated = initial.clone();
scope_updated.visible_databases = Some(vec!["namespace-a".to_string(), "namespace-b".to_string()]);
scope_updated.external_config.as_mut().unwrap()["managedNamespaces"] =
serde_json::json!(["namespace-a", "namespace-b"]);
let sync = sync_connection_configs(&state, std::slice::from_ref(&scope_updated)).await;
assert_eq!(sync.connection_pool_ids_to_drop.as_slice(), std::slice::from_ref(&initial.id));
assert_eq!(state.session_credentials.get("", &initial.id).as_deref(), Some("new-password"));
assert_eq!(
state.session_credentials.get_for_purpose("", &initial.id, "nacos-primary-password").as_deref(),
Some("new-password")
);
let mut endpoint_updated = scope_updated;
endpoint_updated.host = "nacos.internal".to_string();
endpoint_updated.external_config.as_mut().unwrap()["serverAddr"] =
serde_json::json!("http://nacos.internal:8848");
sync_connection_configs(&state, std::slice::from_ref(&endpoint_updated)).await;
assert!(!state.session_credentials.has("", &initial.id));
assert_eq!(state.session_credentials.get_for_purpose("", &initial.id, "nacos-primary-password"), None);
let _ = std::fs::remove_dir_all(dir);
}
}
#[tauri::command]
@@ -859,12 +925,14 @@ async fn sync_connection_configs(state: &AppState, configs: &[ConnectionConfig])
if previous.db_type == DatabaseType::MessageQueue {
mq_adapter_ids_to_drop.insert(config.id.clone());
}
if !connection_configs_session_credentials_compatible(&previous, config) {
// 端点或认证身份变化后,旧密码不能安全复用。显示范围等本地设置
// 不影响凭据归属,因此必须保留 no-save 连接的新会话密码。
state.session_credentials.clear_connection(&config.id);
}
// 仅在真实连接参数变化时销毁池;save_password=false 连接因持久化
// 空密码与运行态密码产生的差异被忽略(见 connection_configs_pool_equivalent)。
if !connection_configs_pool_equivalent(&previous, config) {
// 连接端点/认证参数已变:旧会话凭据不再适配,清除以便下次重新输入,
// 避免复用旧密码去连新端点而直接认证失败。
state.session_credentials.clear_connection(&config.id);
connection_pool_ids_to_drop.insert(config.id.clone());
}
}
@@ -1903,6 +1971,23 @@ pub async fn forget_session_credential(state: State<'_, Arc<AppState>>, connecti
Ok(())
}
#[tauri::command]
pub async fn replace_nacos_session_credential(
state: State<'_, Arc<AppState>>,
connection_id: String,
username: String,
password: String,
) -> Result<(), String> {
state
.replace_nacos_session_credential(
dbx_core::session_credentials::DESKTOP_OWNER,
&connection_id,
&username,
&password,
)
.await
}
/// 清空全部运行期会话凭据(桌面端退出前调用;Web 端登出时走 `auth.rs logout`)。
/// 密码只存在于本次进程内存,进程退出本就会丢失;显式清除用于退出前兜底。
#[tauri::command]
+11 -1
View File
@@ -10,8 +10,9 @@ use crate::commands::connection::AppState;
pub async fn nacos_test_connection(
state: State<'_, Arc<AppState>>,
connection_id: String,
force_refresh: Option<bool>,
) -> Result<dbx_core::nacos::NacosConnectionInfo, String> {
dbx_core::nacos::service::nacos_test_connection_core(&state, &connection_id).await
dbx_core::nacos::service::nacos_test_connection_core(&state, &connection_id, force_refresh.unwrap_or(false)).await
}
#[tauri::command]
@@ -48,6 +49,15 @@ pub async fn nacos_update_namespace(
dbx_core::nacos::service::nacos_update_namespace_core(&state, &connection_id, req).await
}
#[tauri::command]
pub async fn nacos_delete_namespace(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace_id: String,
) -> Result<(), String> {
dbx_core::nacos::service::nacos_delete_namespace_core(&state, &connection_id, namespace_id).await
}
#[tauri::command]
pub async fn nacos_list_configs(
state: State<'_, Arc<AppState>>,
+2
View File
@@ -1622,6 +1622,7 @@ pub fn run() {
commands::connection::close_database_connection,
commands::connection::session_credential_status,
commands::connection::forget_session_credential,
commands::connection::replace_nacos_session_credential,
commands::connection::clear_all_session_credentials,
commands::connection::refresh_connections,
commands::connection::check_connection_health,
@@ -1954,6 +1955,7 @@ pub fn run() {
commands::nacos_cmd::nacos_sidebar_snapshot,
commands::nacos_cmd::nacos_create_namespace,
commands::nacos_cmd::nacos_update_namespace,
commands::nacos_cmd::nacos_delete_namespace,
commands::nacos_cmd::nacos_list_configs,
commands::nacos_cmd::nacos_get_config,
commands::nacos_cmd::nacos_publish_config,