feat(etcd): restrict key browsing and writes by auth permissions
This commit is contained in:
32 files changed
+1404
-100
No files matched your search
@@ -154,11 +154,25 @@ const transferLoadingDetail = ref("");
|
||||
const transferPreviewLoaded = ref(false);
|
||||
const syncConfigurationExpanded = ref(true);
|
||||
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const etcdAccess = computed(() => connectionStore.getEtcdAccessCapabilities(props.connectionId));
|
||||
const canManageEtcd = computed(() => etcdAccess.value.admin);
|
||||
const canWriteEtcdKeys = computed(() => etcdAccess.value.writable);
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)) || !canWriteEtcdKeys.value);
|
||||
function canWriteConnectionKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean {
|
||||
const config = connectionStore.getConfig(connectionId);
|
||||
return config?.db_type === "etcd" && !connectionIsEffectivelyReadOnly(config) && connectionStore.canWriteEtcdKey(connectionId, key, keyBytes);
|
||||
}
|
||||
function canWriteCurrentKey(route: { key: string; keyBytes?: api.KvValue | null }): boolean {
|
||||
return canWriteConnectionKey(props.connectionId, route.key, route.keyBytes);
|
||||
}
|
||||
function permissionDenied(): Error {
|
||||
return new Error("ETCD_PERMISSION_DENIED: The current etcd user is not authorized to write this Key");
|
||||
}
|
||||
// etcd 2.x connections speak the v2 API: no key history, no leases, no maintenance.
|
||||
const isV2Api = computed(() => connectionStore.getConfig(props.connectionId)?.driver_profile === "etcd-v2");
|
||||
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
|
||||
const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)));
|
||||
const targetReadOnly = computed(() => !targetConnectionId.value || connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)) || !connectionStore.getEtcdAccessCapabilities(targetConnectionId.value).writable);
|
||||
const selectedTreeKeysWritable = computed(() => selectedTreeKeys.value.length > 0 && selectedTreeKeys.value.every((item) => canWriteCurrentKey(item)));
|
||||
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
|
||||
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
|
||||
const filteredTransferRows = computed(() => {
|
||||
@@ -274,13 +288,23 @@ const etcdApi = computed(() => ({
|
||||
return { ...result, key: shown, keyIdentity: identity };
|
||||
},
|
||||
getMetadata: (connectionId: string, key: string, options?: api.KvGetOptions | null) => api.etcdGet(connectionId, key, { ...keyOptions(key), ...options, metadataOnly: true }),
|
||||
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) =>
|
||||
api.etcdPut(connectionId, key, value, {
|
||||
put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) => {
|
||||
const keyBytes = options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes);
|
||||
if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied();
|
||||
return api.etcdPut(connectionId, key, value, {
|
||||
...options,
|
||||
keyBytes: options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes),
|
||||
}),
|
||||
deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => api.etcdDelete(connectionId, key, { ...options, keyBytes: options?.keyBytes ?? keyOptions(key).keyBytes }),
|
||||
rename: api.etcdRename,
|
||||
keyBytes,
|
||||
});
|
||||
},
|
||||
deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => {
|
||||
const keyBytes = options?.keyBytes ?? keyOptions(key).keyBytes;
|
||||
if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied();
|
||||
return api.etcdDelete(connectionId, key, { ...options, keyBytes });
|
||||
},
|
||||
rename: (connectionId: string, request: { key: string; keyBytes?: api.KvValue | null; newKey: string; expectedModRevision?: api.KvInt64 | null }) => {
|
||||
if (!canWriteConnectionKey(connectionId, request.key, request.keyBytes) || !canWriteConnectionKey(connectionId, request.newKey)) throw permissionDenied();
|
||||
return api.etcdRename(connectionId, request);
|
||||
},
|
||||
// The v2 API has no event history; hiding the entry keeps KvKeyBrowser from
|
||||
// offering restore/compare actions it cannot fulfill.
|
||||
history: isV2Api.value ? undefined : api.etcdHistory,
|
||||
@@ -344,6 +368,11 @@ const labels = computed(() => ({
|
||||
}));
|
||||
|
||||
async function refreshTtlCapability() {
|
||||
if (!canManageEtcd.value) {
|
||||
supportsTtl.value = false;
|
||||
ttlCapabilityKnown.value = true;
|
||||
return;
|
||||
}
|
||||
const connectionId = props.connectionId;
|
||||
if (ttlCapabilityInFlightConnection === connectionId) return;
|
||||
const request = ++ttlCapabilityRequest;
|
||||
@@ -373,8 +402,16 @@ function stopTtlCapabilityRefresh() {
|
||||
|
||||
function startTtlCapabilityRefresh() {
|
||||
stopTtlCapabilityRefresh();
|
||||
void refreshTtlCapability();
|
||||
ttlCapabilityRefreshTimer = setInterval(() => void refreshTtlCapability(), ttlCapabilityRefreshIntervalMs);
|
||||
const refreshAccessAndTtl = async () => {
|
||||
try {
|
||||
await connectionStore.ensureEtcdAccessCapabilities(props.connectionId, { force: true, verifyHealth: false });
|
||||
} catch {
|
||||
// The Key browser surfaces connection failures through its own requests.
|
||||
}
|
||||
await refreshTtlCapability();
|
||||
};
|
||||
void refreshAccessAndTtl();
|
||||
ttlCapabilityRefreshTimer = setInterval(() => void refreshAccessAndTtl(), ttlCapabilityRefreshIntervalMs);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -400,6 +437,7 @@ function normalizedLease(metadata?: api.KvKeyMetadata | null) {
|
||||
|
||||
function isTransferRowSelectable(row: TransferRow): boolean {
|
||||
if (["unchanged", "skipped", "applied"].includes(row.operation)) return false;
|
||||
if (!row.source || !canWriteConnectionKey(targetConnectionId.value, row.displayKey, row.source.key)) return false;
|
||||
if (transferMode.value !== "sync" || row.operation !== "update") return true;
|
||||
return transferConflictPolicy.value === "OVERWRITE";
|
||||
}
|
||||
@@ -533,7 +571,7 @@ function selectedTreeKeyDetails(): string {
|
||||
|
||||
async function deleteSelectedTreeKeys() {
|
||||
const selected = [...selectedTreeKeys.value];
|
||||
if (!selected.length || readOnly.value) return;
|
||||
if (!selected.length || readOnly.value || !selected.every((item) => canWriteCurrentKey(item))) return;
|
||||
batchDeleting.value = true;
|
||||
const completed: EtcdMultiSelection[] = [];
|
||||
let deleted = 0;
|
||||
@@ -708,6 +746,7 @@ async function onImportFile(event: Event) {
|
||||
}
|
||||
|
||||
async function openSync() {
|
||||
if (readOnly.value) return;
|
||||
transferMode.value = "sync";
|
||||
targetConnectionId.value = etcdConnections.value.find((connection) => connection.id !== props.connectionId)?.id || "";
|
||||
transferKeyFilter.value = "";
|
||||
@@ -768,12 +807,17 @@ async function previewTransfer() {
|
||||
transferError.value = "";
|
||||
transferPreviewLoaded.value = false;
|
||||
try {
|
||||
await connectionStore.ensureEtcdAccessCapabilities(targetId, { force: true, verifyHealth: false });
|
||||
if (generation !== transferPreviewGeneration) return;
|
||||
let compared = 0;
|
||||
const sourceRows = await mapWithConcurrency(bundle.entries, TARGET_LOOKUP_CONCURRENCY, async (source) => {
|
||||
const shown = displayKey(source.key);
|
||||
if (normalizedLease(source.metadata) !== "0") {
|
||||
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: "Leased keys are skipped by default.", selected: false };
|
||||
}
|
||||
if (!canWriteConnectionKey(targetId, shown, source.key)) {
|
||||
return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: t("etcd.targetKeyWriteDenied"), selected: false };
|
||||
}
|
||||
const target = await api.etcdGet(targetId, shown, { keyBytes: source.key });
|
||||
if (generation !== transferPreviewGeneration) throw new Error("同步预览已被新的请求替换。");
|
||||
compared++;
|
||||
@@ -809,6 +853,10 @@ async function applyTransfer() {
|
||||
const targetId = targetConnectionId.value;
|
||||
const rows = [...selectedTransferRows.value];
|
||||
if (!targetId || rows.length === 0) return;
|
||||
if (!rows.every((row) => row.source && canWriteConnectionKey(targetId, row.displayKey, row.source.key))) {
|
||||
transferError.value = t("etcd.selectedKeysWriteDenied");
|
||||
return;
|
||||
}
|
||||
// Invalidate any preview that is still resolving before writes begin.
|
||||
transferPreviewGeneration++;
|
||||
transferApplying.value = true;
|
||||
@@ -966,8 +1014,16 @@ async function openSearchResult(result: SearchResult) {
|
||||
}
|
||||
|
||||
async function openOperations(nextMode: Extract<WorkbenchMode, "maintenance" | "watch" | "lease">) {
|
||||
if ((nextMode === "maintenance" || nextMode === "lease") && !canManageEtcd.value) return;
|
||||
activeOperation.value = nextMode;
|
||||
mode.value = nextMode;
|
||||
operationsStatus.value = null;
|
||||
// A watch is scoped to the Key/prefix supplied by the user. Do not probe
|
||||
// cluster status first: ordinary etcd users normally lack that privilege.
|
||||
if (nextMode === "watch") {
|
||||
operationsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
operationsLoading.value = true;
|
||||
try {
|
||||
operationsStatus.value = await api.etcdStatus(props.connectionId);
|
||||
@@ -988,6 +1044,7 @@ function openWatchWorkspaceAfterCreate() {
|
||||
}
|
||||
|
||||
async function refreshLeaseOptions() {
|
||||
if (!canManageEtcd.value) return;
|
||||
try {
|
||||
leaseOptions.value = (await api.etcdLeaseList(props.connectionId)).leases;
|
||||
} catch (error) {
|
||||
@@ -1034,7 +1091,7 @@ defineExpose({ focusSearch, refresh });
|
||||
<Button size="sm" :variant="mode === 'keys' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'keys'"><KeyRound class="h-4 w-4" /> {{ t("etcd.key") }}</Button>
|
||||
<Button size="sm" :variant="mode === 'search' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-3 text-sm" @click="mode = 'search'"><Search class="h-4 w-4" /> {{ t("etcd.globalSearch") }}</Button>
|
||||
</div>
|
||||
<Button v-if="!isV2Api" size="sm" :variant="mode === 'maintenance' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('maintenance')"><Wrench class="h-4 w-4" />{{ t("etcd.admin.maintenance") }}</Button>
|
||||
<Button v-if="!isV2Api && canManageEtcd" size="sm" :variant="mode === 'maintenance' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('maintenance')"><Wrench class="h-4 w-4" />{{ t("etcd.admin.maintenance") }}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="mode === 'watch' ? 'secondary' : 'ghost'"
|
||||
@@ -1045,7 +1102,7 @@ defineExpose({ focusSearch, refresh });
|
||||
"
|
||||
><Activity class="h-4 w-4" />{{ t("etcd.admin.watch") }}</Button
|
||||
>
|
||||
<Button v-if="!isV2Api" size="sm" :variant="mode === 'lease' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('lease')"><KeyRound class="h-4 w-4" />{{ t("etcd.admin.lease") }}</Button>
|
||||
<Button v-if="!isV2Api && canManageEtcd" size="sm" :variant="mode === 'lease' ? 'secondary' : 'ghost'" class="h-8 gap-1.5 px-2.5 text-sm" @click="openOperations('lease')"><KeyRound class="h-4 w-4" />{{ t("etcd.admin.lease") }}</Button>
|
||||
<div class="flex-1" />
|
||||
<Badge v-if="readOnly" variant="outline">{{ t("connection.readOnly") }}</Badge>
|
||||
<DropdownMenu>
|
||||
@@ -1064,9 +1121,9 @@ defineExpose({ focusSearch, refresh });
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="readOnly || selectedTreeKeys.length === 0 || batchDeleting" @click="batchDeleteOpen = true"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.delete") }}</Button>
|
||||
<Button size="sm" variant="destructive" class="h-8 gap-1.5" :disabled="readOnly || !selectedTreeKeysWritable || batchDeleting" @click="batchDeleteOpen = true"><Trash2 class="h-3.5 w-3.5" />{{ t("etcd.delete") }}</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly" @click="fileInput?.click()"><Upload class="h-3.5 w-3.5" /> {{ t("etcd.import") }}</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="etcdConnections.length < 2" @click="openSync"><ArrowRightLeft class="h-3.5 w-3.5" /> {{ t("etcd.sync") }}</Button>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="readOnly || etcdConnections.length < 2" @click="openSync"><ArrowRightLeft class="h-3.5 w-3.5" /> {{ t("etcd.sync") }}</Button>
|
||||
<input ref="fileInput" type="file" accept="application/json,.json" class="hidden" @change="onImportFile" />
|
||||
</div>
|
||||
|
||||
@@ -1077,13 +1134,14 @@ defineExpose({ focusSearch, refresh });
|
||||
:connection-id="props.connectionId"
|
||||
:api="etcdApi"
|
||||
:labels="labels"
|
||||
:supports-ttl="supportsTtl"
|
||||
:supports-lease-binding="!isV2Api"
|
||||
:supports-ttl="supportsTtl && canManageEtcd"
|
||||
:supports-lease-binding="!isV2Api && canManageEtcd"
|
||||
:ttl-capability-known="ttlCapabilityKnown"
|
||||
:enable-node-actions="true"
|
||||
:safe-write="true"
|
||||
:allow-binary-edit="true"
|
||||
:read-only="readOnly"
|
||||
:can-write-key="canWriteCurrentKey"
|
||||
:enable-multi-select="true"
|
||||
:on-watch-key="openWatchForKey"
|
||||
export-format="dbx-etcd-bundle"
|
||||
@@ -1164,10 +1222,10 @@ defineExpose({ focusSearch, refresh });
|
||||
<div v-else class="flex h-52 items-center justify-center px-6 text-center text-sm text-muted-foreground">输入关键词后开始搜索。可使用 Prefix 限定扫描范围。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="operationsStatus || operationsLoading || watchPreset" v-show="isOperationsMode" class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="isOperationsMode" class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="operationsLoading && !operationsStatus" class="flex h-32 items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 h-4 w-4 animate-spin" />加载集群状态...</div>
|
||||
<EtcdAdminConsole
|
||||
v-else-if="operationsStatus || watchPreset"
|
||||
v-else-if="operationsStatus || activeOperation === 'watch'"
|
||||
:connection-id="connectionId"
|
||||
:status="operationsStatus"
|
||||
:sections="[activeOperation]"
|
||||
|
||||
@@ -11,9 +11,14 @@ const backend = vi.hoisted(() => ({
|
||||
etcdDelete: vi.fn(),
|
||||
etcdRename: vi.fn(),
|
||||
etcdHistory: vi.fn(),
|
||||
etcdStatus: vi.fn(),
|
||||
etcdGetCalls: [] as unknown[][],
|
||||
capturedKvBrowserApi: null as any,
|
||||
capturedCanWriteKey: null as any,
|
||||
canWriteEtcdKey: vi.fn(),
|
||||
ensureEtcdAccessCapabilities: vi.fn(),
|
||||
selectKeyCalls: [] as unknown[],
|
||||
access: { admin: true, writable: true },
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
@@ -27,13 +32,17 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
etcdDelete: backend.etcdDelete,
|
||||
etcdRename: backend.etcdRename,
|
||||
etcdHistory: backend.etcdHistory,
|
||||
etcdStatus: backend.etcdStatus,
|
||||
etcdSupportsTtl: backend.etcdSupportsTtl,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
connections: [],
|
||||
getConfig: () => undefined,
|
||||
getConfig: () => ({ id: "etcd-1", db_type: "etcd" }),
|
||||
getEtcdAccessCapabilities: () => backend.access,
|
||||
ensureEtcdAccessCapabilities: backend.ensureEtcdAccessCapabilities,
|
||||
canWriteEtcdKey: backend.canWriteEtcdKey,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -45,11 +54,15 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
|
||||
api: { type: Object, required: true },
|
||||
connectionId: { type: String, required: true },
|
||||
supportsTtl: { type: Boolean, default: false },
|
||||
supportsLeaseBinding: { type: Boolean, default: false },
|
||||
ttlCapabilityKnown: { type: Boolean, default: true },
|
||||
readOnly: { type: Boolean, default: false },
|
||||
canWriteKey: { type: Function, default: undefined },
|
||||
},
|
||||
emits: ["refreshRequested", "selectionChange"],
|
||||
setup(props, { emit, expose }) {
|
||||
backend.capturedKvBrowserApi = props.api;
|
||||
backend.capturedCanWriteKey = props.canWriteKey;
|
||||
expose({
|
||||
focusSearch: () => true,
|
||||
selectKey: (route: unknown) => {
|
||||
@@ -73,7 +86,9 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({
|
||||
{
|
||||
id: "kv-browser-stub",
|
||||
"data-supports-ttl": String(props.supportsTtl),
|
||||
"data-supports-lease-binding": String(props.supportsLeaseBinding),
|
||||
"data-capability-known": String(props.ttlCapabilityKnown),
|
||||
"data-read-only": String(props.readOnly),
|
||||
onClick: () => emit("refreshRequested"),
|
||||
},
|
||||
"refresh",
|
||||
@@ -112,6 +127,15 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/etcd/EtcdAdminConsole.vue", () => ({
|
||||
default: defineComponent({
|
||||
name: "EtcdAdminConsoleStub",
|
||||
setup() {
|
||||
return () => h("div", { id: "etcd-admin-console-stub" });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: defineComponent({
|
||||
name: "DropdownMenuStub",
|
||||
@@ -175,10 +199,16 @@ beforeEach(() => {
|
||||
backend.etcdDelete.mockReset();
|
||||
backend.etcdRename.mockReset();
|
||||
backend.etcdHistory.mockReset();
|
||||
backend.etcdStatus.mockReset();
|
||||
backend.etcdListPrefix.mockResolvedValue({ keys: [], continuation: null, revision: "1" });
|
||||
backend.etcdGetCalls.length = 0;
|
||||
backend.capturedKvBrowserApi = null;
|
||||
backend.capturedCanWriteKey = null;
|
||||
backend.canWriteEtcdKey.mockReset().mockReturnValue(true);
|
||||
backend.ensureEtcdAccessCapabilities.mockReset().mockImplementation(async () => backend.access);
|
||||
backend.selectKeyCalls.length = 0;
|
||||
backend.access.admin = true;
|
||||
backend.access.writable = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -236,6 +266,37 @@ describe("EtcdKeyBrowser TTL capability recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EtcdKeyBrowser restricted etcd access", () => {
|
||||
it("loads access capabilities when a restored tab mounts without sidebar expansion", async () => {
|
||||
await mountBrowser();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(backend.ensureEtcdAccessCapabilities).toHaveBeenCalledWith("etcd-1", {
|
||||
force: true,
|
||||
verifyHealth: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides cluster controls and opens a Key watch without reading cluster status", async () => {
|
||||
backend.access.admin = false;
|
||||
backend.access.writable = false;
|
||||
|
||||
const browser = await mountBrowser();
|
||||
expect(browser.dataset.readOnly).toBe("true");
|
||||
expect(browser.dataset.supportsLeaseBinding).toBe("false");
|
||||
expect(root!.textContent).not.toContain("etcd.admin.maintenance");
|
||||
expect(root!.textContent).not.toContain("etcd.admin.lease");
|
||||
|
||||
const watch = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.admin.watch"));
|
||||
watch?.click();
|
||||
await flushUi();
|
||||
|
||||
expect(root!.querySelector("#etcd-admin-console-stub")).toBeTruthy();
|
||||
expect(backend.etcdStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EtcdKeyBrowser global search", () => {
|
||||
it("clears results and counters while retaining the search form", async () => {
|
||||
backend.etcdListPrefix.mockResolvedValue({
|
||||
@@ -456,4 +517,18 @@ describe("EtcdKeyBrowser tree batch operations", () => {
|
||||
const selectedExports = [...root!.querySelectorAll("button")].filter((button) => button.textContent?.includes("etcd.exportSelection"));
|
||||
expect(selectedExports).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks a selected Key outside the user's write ranges before any delete", async () => {
|
||||
backend.canWriteEtcdKey.mockImplementation((_connectionId, _key, keyBytes) => keyBytes?.data !== "/w==");
|
||||
|
||||
await mountBrowser();
|
||||
root!.querySelector<HTMLButtonElement>("#emit-tree-selection")!.click();
|
||||
await flushUi();
|
||||
|
||||
const batchDelete = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.delete")) as HTMLButtonElement;
|
||||
expect(batchDelete.disabled).toBe(true);
|
||||
expect(() => backend.capturedKvBrowserApi.deleteKey("etcd-1", "[base64:/w==]", { keyBytes: { encoding: "base64", data: "/w==" } })).toThrow("ETCD_PERMISSION_DENIED");
|
||||
expect(backend.etcdDelete).not.toHaveBeenCalled();
|
||||
expect(backend.capturedCanWriteKey({ key: "[base64:/w==]", keyBytes: { encoding: "base64", data: "/w==" } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,7 @@ const props = withDefaults(
|
||||
exportFileExtension?: string;
|
||||
exportFallbackName?: string;
|
||||
enableMultiSelect?: boolean;
|
||||
canWriteKey?: (route: KvKeyRoute) => boolean;
|
||||
onWatchKey?: (route: KvKeyRoute) => void;
|
||||
onDeletePrefix?: (prefix: string) => void;
|
||||
watchActiveKey?: string | null;
|
||||
@@ -413,6 +414,21 @@ const selectedValueClipboardText = computed(() => {
|
||||
return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value;
|
||||
});
|
||||
const selectedKeyBytes = computed(() => selectedValue.value?.keyBytes ?? selectedRouteKeyBytes.value ?? null);
|
||||
function keyIsWritable(key: string, keyBytes?: KvValue | null): boolean {
|
||||
return !props.readOnly && (props.canWriteKey?.({ key, keyBytes }) ?? true);
|
||||
}
|
||||
const selectedKeyWritable = computed(() => Boolean(selectedKey.value && keyIsWritable(selectedKey.value, selectedKeyBytes.value)));
|
||||
const editKeyWritable = computed(() => {
|
||||
const key = editKey.value.trim();
|
||||
if (!key) return false;
|
||||
const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null;
|
||||
return keyIsWritable(key, keyBytes);
|
||||
});
|
||||
const renameTargetWritable = computed(() => {
|
||||
const target = renameValue.value.trim();
|
||||
if (!target) return false;
|
||||
return keyIsWritable(target) && (renameMode.value === "copy" || selectedKeyWritable.value);
|
||||
});
|
||||
const selectedKeyLocked = computed(() => Boolean(String(selectedMetadata.value?.session ?? "").trim()));
|
||||
const isWatchingSelectedKey = computed(() => Boolean(selectedKey.value && props.watchActiveKey === selectedKey.value));
|
||||
const activeSearchHighlight = computed(() => (props.searchHighlight?.key === selectedKey.value ? props.searchHighlight : null));
|
||||
@@ -460,8 +476,8 @@ const editValueSize = computed(() => {
|
||||
}
|
||||
return new TextEncoder().encode(editValue.value).length;
|
||||
});
|
||||
const canEditSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit));
|
||||
const canDeleteSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value);
|
||||
const canEditSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit));
|
||||
const canDeleteSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value);
|
||||
const consulMetadataRows = computed(() => {
|
||||
const metadata = selectedMetadata.value;
|
||||
return [
|
||||
@@ -1258,6 +1274,11 @@ async function saveKey() {
|
||||
return;
|
||||
}
|
||||
const key = props.lazyHierarchy ? normalizeLazyKvPath(rawKey, props.lazyPathStyle) : rawKey;
|
||||
const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null;
|
||||
if (!keyIsWritable(key, keyBytes)) {
|
||||
editError.value = t("connection.readOnly");
|
||||
return;
|
||||
}
|
||||
const validationError = validateKvValue(editValue.value, editFormat.value);
|
||||
if (validationError) {
|
||||
editError.value = validationError;
|
||||
@@ -1275,6 +1296,7 @@ async function saveKey() {
|
||||
async function confirmSaveKey() {
|
||||
if (!pendingSave.value) return;
|
||||
const { key, value, options } = pendingSave.value;
|
||||
if (!keyIsWritable(key, options?.keyBytes)) return;
|
||||
saving.value = true;
|
||||
editError.value = "";
|
||||
editErrorKind.value = "request";
|
||||
@@ -1367,7 +1389,8 @@ async function selectNodeForAction(node: BrowserTreeNode) {
|
||||
}
|
||||
|
||||
async function openDeleteForNode(node: BrowserTreeNode) {
|
||||
if (props.readOnly) return;
|
||||
const route = routeFromNode(node);
|
||||
if (!keyIsWritable(route.key, route.keyBytes)) return;
|
||||
await selectNodeForAction(node);
|
||||
if (!selectedKey.value || !selectedValue.value?.found || !canDeleteSelectedValue.value) return;
|
||||
showDeleteConfirm.value = true;
|
||||
@@ -1499,7 +1522,7 @@ function onEditFormatChange(value: unknown) {
|
||||
}
|
||||
|
||||
function openRenameDialog() {
|
||||
if (!selectedKey.value || !props.api.rename || props.readOnly) return;
|
||||
if (!selectedKey.value || !props.api.rename || !selectedKeyWritable.value) return;
|
||||
renameMode.value = "rename";
|
||||
renameValue.value = selectedKey.value;
|
||||
renameError.value = "";
|
||||
@@ -1515,6 +1538,10 @@ async function moveOrCopySelectedKey() {
|
||||
renameError.value = props.labels.keyRequired;
|
||||
return;
|
||||
}
|
||||
if (!renameTargetWritable.value) {
|
||||
renameError.value = t("connection.readOnly");
|
||||
return;
|
||||
}
|
||||
renaming.value = true;
|
||||
renameError.value = "";
|
||||
try {
|
||||
@@ -1564,7 +1591,7 @@ function compareHistory(event: KvHistoryEvent) {
|
||||
}
|
||||
|
||||
async function restoreHistory() {
|
||||
if (!selectedKey.value || !historyRestoreValue.value) return;
|
||||
if (!selectedKey.value || !historyRestoreValue.value || !selectedKeyWritable.value) return;
|
||||
restoring.value = true;
|
||||
try {
|
||||
await props.api.put(props.connectionId, selectedKey.value, historyRestoreValue.value, {
|
||||
@@ -1584,6 +1611,8 @@ async function restoreHistory() {
|
||||
|
||||
function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
|
||||
if (!props.enableNodeActions) return [];
|
||||
const route = routeFromNode(node);
|
||||
const nodeWritable = keyIsWritable(route.key, route.keyBytes);
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: props.labels.add || props.labels.newKey,
|
||||
@@ -1598,7 +1627,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
|
||||
label: props.labels.edit,
|
||||
icon: Pencil,
|
||||
action: () => void loadSelectedKey(routeFromNode(node)).then(openEditDialog),
|
||||
disabled: props.readOnly,
|
||||
disabled: !nodeWritable,
|
||||
},
|
||||
{
|
||||
label: props.labels.clone || "Clone",
|
||||
@@ -1617,7 +1646,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
|
||||
label: props.labels.rename || "Rename",
|
||||
icon: Pencil,
|
||||
action: () => void loadSelectedKey(routeFromNode(node)).then(openRenameDialog),
|
||||
disabled: props.readOnly,
|
||||
disabled: !nodeWritable,
|
||||
});
|
||||
}
|
||||
if (props.api.history) {
|
||||
@@ -1649,7 +1678,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] {
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
action: () => void openDeleteForNode(node),
|
||||
disabled: props.readOnly || !nodeHasValue(node),
|
||||
disabled: !nodeWritable || !nodeHasValue(node),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -2239,7 +2268,7 @@ defineExpose({
|
||||
</div>
|
||||
<DialogFooter class="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/10 px-6 py-5">
|
||||
<Button variant="outline" class="h-10 min-w-20" @click="showEditDialog = false">{{ t("common.cancel") }}</Button>
|
||||
<Button class="h-10 min-w-20" :disabled="saving || readOnly" @click="saveKey">
|
||||
<Button class="h-10 min-w-20" :disabled="saving || !editKeyWritable" @click="saveKey">
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
@@ -2260,7 +2289,7 @@ defineExpose({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="renaming" @click="showRenameDialog = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="renaming || readOnly" @click="moveOrCopySelectedKey">
|
||||
<Button :disabled="renaming || !renameTargetWritable" @click="moveOrCopySelectedKey">
|
||||
<Loader2 v-if="renaming" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ renameMode === "copy" ? labels.clone || "Clone" : labels.rename || "Rename" }}
|
||||
</Button>
|
||||
@@ -2317,7 +2346,7 @@ defineExpose({
|
||||
:before="selectedTextValue"
|
||||
:after="historyRestoreValue?.data || ''"
|
||||
:loading="restoring"
|
||||
:show-confirm="!readOnly && !!historyRestoreValue"
|
||||
:show-confirm="selectedKeyWritable && !!historyRestoreValue"
|
||||
:confirm-label="labels.restore || 'Restore'"
|
||||
@confirm="restoreHistory"
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("KvKeyBrowser edit dialog layout", () => {
|
||||
it("cancels the shared footer negative margin and preserves bottom safe spacing", () => {
|
||||
expect(browserSource).toContain('<DialogFooter class="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/10 px-6 py-5">');
|
||||
expect(browserSource).toContain('variant="outline" class="h-10 min-w-20"');
|
||||
expect(browserSource).toContain('<Button class="h-10 min-w-20" :disabled="saving || readOnly"');
|
||||
expect(browserSource).toContain('<Button class="h-10 min-w-20" :disabled="saving || !editKeyWritable"');
|
||||
});
|
||||
|
||||
it("applies the same safe spacing to the history dialog footer", () => {
|
||||
|
||||
@@ -217,6 +217,15 @@ const emit = defineEmits<ContentAreaSurfaceEmits>();
|
||||
const { t, locale } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const canAccessEtcdAdmin = computed(() => connectionStore.getEtcdAccessCapabilities(props.activeTab.connectionId).admin);
|
||||
watch(
|
||||
() => [props.activeTab.connectionId, props.activeTab.mode] as const,
|
||||
([connectionId, mode]) => {
|
||||
if (mode !== "etcd-dashboard" && mode !== "etcd-access-control") return;
|
||||
void connectionStore.ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false }).catch(() => undefined);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function groupedQueryReadonlyColumnIndexes(tab: QueryTab): number[] | undefined {
|
||||
if (!tab.queryAnalysis?.groupByColumns?.length || !tab.querySourceColumns || !tab.tableMeta?.primaryKeys.length) return undefined;
|
||||
@@ -2308,13 +2317,15 @@ defineExpose({
|
||||
<!-- etcd Dashboard: cluster observation -->
|
||||
<template v-else-if="activeTab.mode === 'etcd-dashboard'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<EtcdDashboard ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
<EtcdDashboard v-if="canAccessEtcdAdmin" ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'etcd-access-control'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<EtcdAccessControl :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
<EtcdAccessControl v-if="canAccessEtcdAdmin" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||||
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -696,14 +696,23 @@ function isGroupLabel(node: TreeNode): boolean {
|
||||
async function openDirectNavigationNode(node: TreeNode, requestId: number) {
|
||||
if (!node.connectionId) return;
|
||||
const isNacosNavigation = node.type === "nacos-namespace" || node.type === "nacos-access-control";
|
||||
await connectionStore.ensureConnected(node.connectionId, isNacosNavigation ? { verifyHealth: false } : undefined);
|
||||
const isEtcdNavigation = node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "etcd-access-control";
|
||||
await connectionStore.ensureConnected(node.connectionId, isNacosNavigation || isEtcdNavigation ? { verifyHealth: false } : undefined);
|
||||
if (!isCurrentNavigationRequest(requestId)) return;
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || (isNacosNavigation ? "Nacos" : "Consul");
|
||||
if ((node.type === "etcd-dashboard" || node.type === "etcd-access-control") && !connectionStore.getEtcdAccessCapabilities(node.connectionId).admin) return;
|
||||
const connectionName = connectionStore.getConfig(node.connectionId)?.name || (isNacosNavigation ? "Nacos" : isEtcdNavigation ? "etcd" : "Consul");
|
||||
if (node.type === "consul-root") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "consul");
|
||||
refreshActiveKvBrowserAfterOpen("consul", node.connectionId);
|
||||
} else if (node.type === "consul-overview") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:${t("consul.ui.overview")}`, "consul-overview");
|
||||
} else if (node.type === "etcd-root") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:keys`, "etcd");
|
||||
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
|
||||
} else if (node.type === "etcd-dashboard") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:dashboard`, "etcd-dashboard");
|
||||
} else if (node.type === "etcd-access-control") {
|
||||
queryStore.createTab(node.connectionId, "", `${connectionName}:access-control`, "etcd-access-control");
|
||||
} else if (node.type === "nacos-namespace") {
|
||||
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
|
||||
} else if (node.type === "nacos-access-control") {
|
||||
@@ -918,19 +927,6 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const topicFromId = node.id.endsWith(":mqtt-topic:__console__") ? undefined : node.id.split(":mqtt-topic:")[1] || node.label;
|
||||
queryStore.openMqttAdmin(node.connectionId, topicFromId ? { initialTopic: topicFromId } : undefined);
|
||||
} else if (node.type === "etcd-root" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
|
||||
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
|
||||
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
|
||||
} else if (node.type === "etcd-dashboard" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:dashboard`;
|
||||
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-dashboard");
|
||||
} else if (node.type === "etcd-access-control" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:access-control`;
|
||||
queryStore.createTab(node.connectionId, "", tabTitle, "etcd-access-control");
|
||||
} else if (node.type === "zookeeper-root" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "ZooKeeper"}:keys`;
|
||||
@@ -6239,6 +6235,14 @@ function handleRowClick(node: TreeNode, clickDetail: number) {
|
||||
}
|
||||
|
||||
function handleRowDoubleClick(node: TreeNode, event: MouseEvent) {
|
||||
// Direct-navigation rows already handle every click in the click sequence.
|
||||
// Starting a new request for their trailing dblclick would invalidate the
|
||||
// still-pending single-click navigation without providing a replacement in
|
||||
// single-click activation mode.
|
||||
if (isDirectNavigationTreeNode(node.type)) {
|
||||
activateRuntimeNode(node);
|
||||
return;
|
||||
}
|
||||
beginNavigationRequest();
|
||||
activateRuntimeNode(node);
|
||||
onDoubleClick(event);
|
||||
|
||||
+92
@@ -16,6 +16,7 @@ const connectionStore = {
|
||||
canUseLoadedTreeNodeToggle: vi.fn(() => true),
|
||||
releaseCollapsedTreeNodeChildren: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })),
|
||||
getEtcdAccessCapabilities: vi.fn(() => ({ admin: true, writable: true, writePermissions: null })),
|
||||
ensureConnected: vi.fn(async () => undefined),
|
||||
loadPackageMembers: vi.fn(async (node: TreeNode) => {
|
||||
node.isExpanded = true;
|
||||
@@ -63,6 +64,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" });
|
||||
connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: true, writable: true, writePermissions: null });
|
||||
});
|
||||
|
||||
describe("SidebarTreeRuntimeHost expansion", () => {
|
||||
@@ -264,6 +266,96 @@ describe("SidebarTreeRuntimeHost expansion", () => {
|
||||
expect(systemNode.isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("opens etcd workspaces without waiting for a connection health probe", async () => {
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
|
||||
connectionStore.ensureConnected.mockImplementation(async (...args: any[]) => {
|
||||
const options = args[1] as { verifyHealth?: boolean } | undefined;
|
||||
if (options?.verifyHealth === false) return;
|
||||
await new Promise<void>(() => undefined);
|
||||
});
|
||||
const nodes: TreeNode[] = [
|
||||
{ id: "etcd:root", label: "Keys", type: "etcd-root", connectionId: "etcd" },
|
||||
{ id: "etcd:access-control", label: "Users & Roles", type: "etcd-access-control", connectionId: "etcd" },
|
||||
{ id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" },
|
||||
];
|
||||
connectionStore.treeNodes = nodes;
|
||||
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: nodes[0], depth: 0 }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
host.value!.handleRowClick(node, index + 1);
|
||||
await vi.waitFor(() => expect(queryStore.createTab).toHaveBeenCalledTimes(index + 1));
|
||||
}
|
||||
|
||||
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(1, "etcd", { verifyHealth: false });
|
||||
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(2, "etcd", { verifyHealth: false });
|
||||
expect(connectionStore.ensureConnected).toHaveBeenNthCalledWith(3, "etcd", { verifyHealth: false });
|
||||
expect(queryStore.createTab).toHaveBeenNthCalledWith(1, "etcd", "", "local-etcd-v3:keys", "etcd");
|
||||
expect(queryStore.createTab).toHaveBeenNthCalledWith(2, "etcd", "", "local-etcd-v3:access-control", "etcd-access-control");
|
||||
expect(queryStore.createTab).toHaveBeenNthCalledWith(3, "etcd", "", "local-etcd-v3:dashboard", "etcd-dashboard");
|
||||
});
|
||||
|
||||
it("does not let the trailing dblclick cancel a pending etcd navigation", async () => {
|
||||
let resolveConnection!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
resolveConnection = resolve;
|
||||
});
|
||||
connectionStore.ensureConnected.mockReturnValue(connection);
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
|
||||
const node: TreeNode = { id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" };
|
||||
connectionStore.treeNodes = [node];
|
||||
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node, depth: 0 }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
host.value!.handleRowClick(node, 1);
|
||||
host.value!.handleRowDoubleClick(node, new MouseEvent("dblclick", { detail: 2 }));
|
||||
resolveConnection();
|
||||
|
||||
await vi.waitFor(() => expect(queryStore.createTab).toHaveBeenCalledWith("etcd", "", "local-etcd-v3:dashboard", "etcd-dashboard"));
|
||||
});
|
||||
|
||||
it("does not reopen a stale etcd admin node after access becomes restricted", async () => {
|
||||
connectionStore.getConfig.mockReturnValue({ db_type: "etcd", name: "local-etcd-v3" });
|
||||
connectionStore.getEtcdAccessCapabilities.mockReturnValue({ admin: false, writable: true, writePermissions: [] });
|
||||
const node: TreeNode = { id: "etcd:dashboard", label: "Dashboard", type: "etcd-dashboard", connectionId: "etcd" };
|
||||
connectionStore.treeNodes = [node];
|
||||
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node, depth: 0 }) }));
|
||||
mountedApps.push(app);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
host.value!.handleRowClick(node, 1);
|
||||
await vi.waitFor(() => expect(connectionStore.ensureConnected).toHaveBeenCalled());
|
||||
expect(queryStore.createTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
|
||||
const group: TreeNode = {
|
||||
id: "mysql:basic:__tables",
|
||||
|
||||
+22
@@ -503,3 +503,25 @@ describe("TreeItem searched connection activation", () => {
|
||||
expect(host.handleRowDoubleClick).toHaveBeenCalledWith(node, expect.any(MouseEvent));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TreeItem etcd workspace activation", () => {
|
||||
it.each([
|
||||
["etcd-root", 1],
|
||||
["etcd-access-control", 2],
|
||||
["etcd-dashboard", 3],
|
||||
] as const)("activates %s with click detail %i even in double-click mode", async (type, detail) => {
|
||||
settingsStore.editorSettings.sidebarActivation = "double";
|
||||
const node: TreeNode = {
|
||||
id: `connection-1:${type}`,
|
||||
label: type,
|
||||
type,
|
||||
connectionId: "connection-1",
|
||||
};
|
||||
const { row, host } = await mountTreeItem(node);
|
||||
|
||||
row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail }));
|
||||
|
||||
expect(host.handleRowClick).toHaveBeenCalledWith(node, detail);
|
||||
expect(host.handleRowDoubleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4352,6 +4352,9 @@ export default {
|
||||
relationshipUpdated: "Relationship updated",
|
||||
},
|
||||
etcd: {
|
||||
adminPermissionRequired: "This etcd view requires the root role.",
|
||||
targetKeyWriteDenied: "The target connection does not have permission to write this Key.",
|
||||
selectedKeysWriteDenied: "The target connection does not have permission to write the selected Keys; no changes were made.",
|
||||
prefixPlaceholder: "Search Key path, e.g. /app/ or service",
|
||||
newKey: "New Key",
|
||||
loadingKeys: "Loading keys...",
|
||||
|
||||
@@ -4530,6 +4530,9 @@ export default withEnglishFallback({
|
||||
leaseGrantCancelled: "Espera cancelada; etcd puede estar procesando aún la solicitud de concesión; actualice para confirmar si se ha creado el Lease.",
|
||||
leaseCreated: "Lease {id} creado",
|
||||
},
|
||||
adminPermissionRequired: "Esta página de etcd solo está abierta al rol root.",
|
||||
targetKeyWriteDenied: "La conexión de destino no tiene permiso para escribir esta Key.",
|
||||
selectedKeysWriteDenied: "La conexión de destino no tiene permiso para escribir las Keys seleccionadas; no se realizaron cambios.",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
|
||||
@@ -4528,6 +4528,9 @@ export default withEnglishFallback({
|
||||
leaseGrantCancelled: "Attesa annullata; etcd potrebbe ancora elaborare la richiesta di concessione. Aggiorna per verificare se il Lease è stato creato.",
|
||||
leaseCreated: "Lease {id} creato",
|
||||
},
|
||||
adminPermissionRequired: "Questa pagina etcd è accessibile solo al ruolo root.",
|
||||
targetKeyWriteDenied: "La connessione di destinazione non dispone dell'autorizzazione per scrivere questa Key.",
|
||||
selectedKeysWriteDenied: "La connessione di destinazione non dispone dell'autorizzazione per scrivere le Key selezionate; non è stata eseguita alcuna modifica.",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
|
||||
@@ -4595,6 +4595,9 @@ export default withEnglishFallback({
|
||||
leaseGrantCancelled: "待機をキャンセルしました。etcdがまだこの付与リクエストを処理している可能性があります。リースが作成されたかどうかリフレッシュして確認してください。",
|
||||
leaseCreated: "リース {id} を作成しました",
|
||||
},
|
||||
adminPermissionRequired: "この etcd ページは root ロールのみに開放されています。",
|
||||
targetKeyWriteDenied: "対象の接続にはこの Key への書き込み権限がありません。",
|
||||
selectedKeysWriteDenied: "対象の接続には選択した Key への書き込み権限がないため、変更は実行されませんでした。",
|
||||
},
|
||||
redis: {
|
||||
setDatabaseAlias: "データベースの別名を設定",
|
||||
|
||||
@@ -4161,6 +4161,9 @@ export default withEnglishFallback({
|
||||
healthy: "정상",
|
||||
},
|
||||
},
|
||||
adminPermissionRequired: "이 etcd 페이지는 root 역할에만 공개됩니다.",
|
||||
targetKeyWriteDenied: "대상 연결에는 이 Key에 쓸 권한이 없습니다.",
|
||||
selectedKeysWriteDenied: "대상 연결에는 선택한 Key에 쓸 권한이 없어 변경 사항이 적용되지 않았습니다.",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
|
||||
@@ -4530,6 +4530,9 @@ export default withEnglishFallback({
|
||||
leaseGrantCancelled: "Aguardando cancelado; etcd pode ainda estar processando a solicitação de concessão. Atualize para confirmar se o Lease foi criado.",
|
||||
leaseCreated: "Lease {id} criado",
|
||||
},
|
||||
adminPermissionRequired: "Esta página do etcd está disponível apenas para o papel root.",
|
||||
targetKeyWriteDenied: "A conexão de destino não tem permissão para gravar esta Key.",
|
||||
selectedKeysWriteDenied: "A conexão de destino não tem permissão para gravar as Keys selecionadas; nenhuma alteração foi feita.",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
|
||||
@@ -4336,6 +4336,9 @@ export default withEnglishFallback({
|
||||
relationshipUpdated: "关系已更新",
|
||||
},
|
||||
etcd: {
|
||||
adminPermissionRequired: "此 etcd 页面仅对 root 角色开放。",
|
||||
targetKeyWriteDenied: "目标连接没有此 Key 的写权限。",
|
||||
selectedKeysWriteDenied: "目标连接没有所选 Key 的写权限,未执行任何写入。",
|
||||
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
|
||||
newKey: "新建键(Key)",
|
||||
loadingKeys: "正在加载 Key...",
|
||||
|
||||
@@ -7583,6 +7583,9 @@ export default withEnglishFallback({
|
||||
leaseGrantCancelled: "已取消等待;etcd 可能仍在處理該授權請求,請重新整理確認是否已建立 Lease。",
|
||||
leaseCreated: "已建立 Lease {id}",
|
||||
},
|
||||
adminPermissionRequired: "此 etcd 頁面僅對 root 角色開放。",
|
||||
targetKeyWriteDenied: "目標連線沒有此 Key 的寫入權限。",
|
||||
selectedKeysWriteDenied: "目標連線沒有所選 Key 的寫入權限,未執行任何寫入。",
|
||||
},
|
||||
consul,
|
||||
sqlServerTrace,
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("etcd v2 feature trimming", () => {
|
||||
});
|
||||
|
||||
it("hides lease binding and the maintenance/lease workspaces for v2 connections", () => {
|
||||
expect(browserSource).toContain(':supports-lease-binding="!isV2Api"');
|
||||
expect(browserSource).toContain('<Button v-if="!isV2Api"');
|
||||
expect(browserSource).toContain(':supports-lease-binding="!isV2Api && canManageEtcd"');
|
||||
expect(browserSource).toContain('<Button v-if="!isV2Api && canManageEtcd"');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,18 @@ describe("treeNodeClick", () => {
|
||||
expect(treeNodeRowAction("meilisearch-system", false, "double")).toBe("toggle");
|
||||
});
|
||||
|
||||
it("opens etcd workspaces on one click regardless of object activation preference", () => {
|
||||
for (const type of ["etcd-root", "etcd-access-control", "etcd-dashboard"] as const) {
|
||||
expect(isDirectNavigationTreeNode(type), type).toBe(true);
|
||||
expect(shouldActivateTreeNodeOnSingleClick(type, "double"), type).toBe(true);
|
||||
const action = treeNodeRowAction(type, false, "double");
|
||||
expect(action, type).toBe("toggle");
|
||||
expect(isRepeatableNavigationTreeNode(type), type).toBe(true);
|
||||
expect(shouldRunTreeNodeRowAction(action, 2, isRepeatableNavigationTreeNode(type)), type).toBe(true);
|
||||
expect(shouldRunTreeNodeRowAction(action, 3, isRepeatableNavigationTreeNode(type)), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Nacos namespace and access-control navigation responsive during rapid row switching", () => {
|
||||
for (const type of ["nacos-namespace", "nacos-access-control"] as const) {
|
||||
expect(isDirectNavigationTreeNode(type), type).toBe(true);
|
||||
|
||||
@@ -3140,6 +3140,7 @@ export interface EtcdAuthUserListResponse {
|
||||
export interface EtcdAuthUserDetail {
|
||||
user: string;
|
||||
roles: string[];
|
||||
authEnabled?: boolean;
|
||||
}
|
||||
export interface EtcdAuthPermission {
|
||||
access: "read" | "write" | "readwrite";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
|
||||
import { etcdPermissionAllowsKey, etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
|
||||
|
||||
const utf8 = (data: string): KvValue => ({ encoding: "utf8", data });
|
||||
const base64 = (bytes: number[]): KvValue => ({ encoding: "base64", data: btoa(String.fromCharCode(...bytes)) });
|
||||
|
||||
function permission(access: EtcdAuthPermission["access"], key: KvValue, rangeEnd: KvValue, resource: EtcdAuthPermission["resource"]): EtcdAuthPermission {
|
||||
return { access, key, rangeEnd, resource };
|
||||
}
|
||||
|
||||
describe("etcd Key write permissions", () => {
|
||||
it("matches exact Key grants only", () => {
|
||||
const grant = permission("write", utf8("/app/key"), utf8(""), "key");
|
||||
expect(etcdPermissionAllowsKey(grant, utf8("/app/key"))).toBe(true);
|
||||
expect(etcdPermissionAllowsKey(grant, utf8("/app/key/child"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses etcd's half-open byte ranges", () => {
|
||||
const grant = permission("readwrite", utf8("/app/"), utf8("/app0"), "prefix");
|
||||
expect(etcdPermissionAllowsKey(grant, utf8("/app/a"))).toBe(true);
|
||||
expect(etcdPermissionAllowsKey(grant, utf8("/app0"))).toBe(false);
|
||||
expect(etcdPermissionAllowsKey(grant, utf8("/other"))).toBe(false);
|
||||
});
|
||||
|
||||
it("supports binary Keys and the unbounded range sentinel", () => {
|
||||
const grant = permission("write", base64([0x80]), base64([0]), "prefix");
|
||||
expect(etcdPermissionAllowsKey(grant, base64([0x80, 0x01]))).toBe(true);
|
||||
expect(etcdPermissionAllowsKey(grant, base64([0x7f]))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores read-only grants when permissions are combined", () => {
|
||||
const grants = [permission("read", utf8("/readonly/"), utf8("/readonly0"), "prefix"), permission("readwrite", utf8("/editable/"), utf8("/editable0"), "prefix")];
|
||||
expect(etcdPermissionsAllowKey(grants, utf8("/readonly/key"))).toBe(false);
|
||||
expect(etcdPermissionsAllowKey(grants, utf8("/editable/key"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
|
||||
|
||||
const unboundedRangeEnd = new Uint8Array([0]);
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = globalThis.atob(value.replace(/\s+/g, ""));
|
||||
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function etcdKvValueBytes(value: KvValue): Uint8Array {
|
||||
return value.encoding === "base64" ? decodeBase64(value.data) : new TextEncoder().encode(value.data);
|
||||
}
|
||||
|
||||
function compareBytes(left: Uint8Array, right: Uint8Array): number {
|
||||
const length = Math.min(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = left[index]! - right[index]!;
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return left.length - right.length;
|
||||
}
|
||||
|
||||
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
|
||||
return compareBytes(left, right) === 0;
|
||||
}
|
||||
|
||||
function isUnboundedRangeEnd(value: Uint8Array): boolean {
|
||||
return bytesEqual(value, unboundedRangeEnd);
|
||||
}
|
||||
|
||||
/** Tests one Key against etcd's byte-oriented, half-open permission range. */
|
||||
export function etcdPermissionAllowsKey(permission: EtcdAuthPermission, key: KvValue): boolean {
|
||||
if (permission.access === "read") return false;
|
||||
if (permission.resource === "all") return true;
|
||||
|
||||
const keyBytes = etcdKvValueBytes(key);
|
||||
const start = etcdKvValueBytes(permission.key);
|
||||
const end = etcdKvValueBytes(permission.rangeEnd);
|
||||
if (permission.resource === "key" || end.length === 0) return bytesEqual(keyBytes, start);
|
||||
return compareBytes(keyBytes, start) >= 0 && (isUnboundedRangeEnd(end) || compareBytes(keyBytes, end) < 0);
|
||||
}
|
||||
|
||||
export function etcdPermissionsAllowKey(permissions: readonly EtcdAuthPermission[], key: KvValue): boolean {
|
||||
return permissions.some((permission) => etcdPermissionAllowsKey(permission, key));
|
||||
}
|
||||
@@ -35,8 +35,8 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
|
||||
// These are application entry points rather than database objects. They should
|
||||
// always navigate on a single click, even when the user prefers double-click
|
||||
// activation for ordinary tree objects.
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
|
||||
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["nacos-namespace", "nacos-access-control"]);
|
||||
const directNavigationTreeNodeTypes = new Set<TreeNodeType>(["consul-root", "consul-overview", "etcd-root", "etcd-dashboard", "etcd-access-control", "nacos-namespace", "nacos-access-control", "meilisearch-system"]);
|
||||
const repeatableNavigationTreeNodeTypes = new Set<TreeNodeType>(["etcd-root", "etcd-dashboard", "etcd-access-control", "nacos-namespace", "nacos-access-control"]);
|
||||
|
||||
export function isDirectNavigationTreeNode(type: TreeNodeType): boolean {
|
||||
return directNavigationTreeNodeTypes.has(type);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EtcdAuthPermission, KvValue } from "@/lib/backend/api";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
const utf8 = (data: string): KvValue => ({ encoding: "utf8", data });
|
||||
const readPermission: EtcdAuthPermission = { access: "read", key: utf8(""), rangeEnd: utf8("\0"), resource: "all" };
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
describe("connectionStore etcd access", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
async function loadEtcdTree(roles: string[], permissions: EtcdAuthPermission[] = [readPermission], options: { connection?: Partial<ConnectionConfig>; authEnabled?: boolean; authUser?: string; loadRoot?: boolean } = {}) {
|
||||
const connection = {
|
||||
id: "etcd-reader",
|
||||
name: "restricted etcd",
|
||||
db_type: "etcd",
|
||||
host: "127.0.0.1",
|
||||
port: 2379,
|
||||
username: "reader",
|
||||
password: "secret",
|
||||
database: "",
|
||||
...options.connection,
|
||||
} as ConnectionConfig;
|
||||
const etcdAuthCall = vi.fn((_connectionId: string, operation: string, args: { user?: string; role?: string }) => {
|
||||
if (operation === "user_get") return Promise.resolve({ user: args.user || options.authUser || "reader", roles, authEnabled: options.authEnabled ?? true });
|
||||
if (operation === "role_get") return Promise.resolve({ role: args.role, permissions });
|
||||
throw new Error(`Unexpected etcd auth operation: ${operation}`);
|
||||
});
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
etcdAuthCall,
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const root: TreeNode = {
|
||||
id: connection.id,
|
||||
label: connection.name,
|
||||
type: "connection",
|
||||
connectionId: connection.id,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
};
|
||||
store.connections = [connection];
|
||||
store.treeNodes = [root];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
if (options.loadRoot !== false) await store.loadEtcdRoot(connection.id);
|
||||
return { root, etcdAuthCall, store };
|
||||
}
|
||||
|
||||
it("hides management views for a regular etcd user", async () => {
|
||||
const { root, etcdAuthCall, store } = await loadEtcdTree(["reader-role"]);
|
||||
|
||||
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
|
||||
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "role_get", { role: "reader-role" });
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
|
||||
});
|
||||
|
||||
it("shows management views only for the root role", async () => {
|
||||
const { root } = await loadEtcdTree(["reader-role", "root"]);
|
||||
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
|
||||
});
|
||||
|
||||
it("keeps Key mutations available to a non-admin role with write access", async () => {
|
||||
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
|
||||
const { root, store } = await loadEtcdTree(["key-writer"], [writePermission]);
|
||||
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: [writePermission] });
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads capabilities without requiring the sidebar connection to be expanded", async () => {
|
||||
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
|
||||
const { root, store } = await loadEtcdTree(["key-writer"], [writePermission], { loadRoot: false });
|
||||
|
||||
expect(root.isExpanded).toBe(false);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
|
||||
|
||||
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
|
||||
|
||||
expect(root.isExpanded).toBe(false);
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves confirmed write permissions when a forced refresh fails transiently", async () => {
|
||||
const writePermission: EtcdAuthPermission = { access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
|
||||
const { etcdAuthCall, store } = await loadEtcdTree(["key-writer"], [writePermission]);
|
||||
|
||||
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
|
||||
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
|
||||
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: [writePermission] });
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a confirmed root role when a forced refresh fails transiently", async () => {
|
||||
const { etcdAuthCall, store } = await loadEtcdTree(["root"]);
|
||||
|
||||
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
|
||||
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
|
||||
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: true, writable: true, writePermissions: null });
|
||||
});
|
||||
|
||||
it("fails closed when initial capability discovery fails", async () => {
|
||||
const { etcdAuthCall, store } = await loadEtcdTree(["key-writer"], [readPermission], { loadRoot: false });
|
||||
|
||||
etcdAuthCall.mockRejectedValueOnce(new Error("temporary auth timeout"));
|
||||
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
|
||||
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
|
||||
});
|
||||
|
||||
it("does not let a read grant inherit write access from another range", async () => {
|
||||
const permissions: EtcdAuthPermission[] = [
|
||||
{ access: "read", key: utf8("/readonly/"), rangeEnd: utf8("/readonly0"), resource: "prefix" },
|
||||
{ access: "readwrite", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" },
|
||||
];
|
||||
const { store } = await loadEtcdTree(["mixed-role"], permissions);
|
||||
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the agent's certificate identity for a restricted TLS user", async () => {
|
||||
const { root, etcdAuthCall, store } = await loadEtcdTree(["cert-reader-role"], [readPermission], {
|
||||
connection: { username: "", password: "", client_cert_path: "/tmp/cert.pem", client_key_path: "/tmp/key.pem" },
|
||||
authUser: "cert-reader",
|
||||
});
|
||||
|
||||
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: false, writePermissions: [] });
|
||||
});
|
||||
|
||||
it("keeps certificate connections unrestricted when server auth is disabled", async () => {
|
||||
const { root } = await loadEtcdTree([], [readPermission], {
|
||||
connection: { username: "", password: "", client_cert_path: "/tmp/cert.pem", client_key_path: "/tmp/key.pem" },
|
||||
authEnabled: false,
|
||||
authUser: "transport-only-cert",
|
||||
});
|
||||
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
|
||||
});
|
||||
|
||||
it("keeps anonymous connections unrestricted when server auth is disabled", async () => {
|
||||
const { root, etcdAuthCall, store } = await loadEtcdTree([], [readPermission], {
|
||||
connection: { username: "", password: "", client_cert_path: "", client_key_path: "" },
|
||||
authEnabled: false,
|
||||
authUser: "",
|
||||
});
|
||||
|
||||
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", {});
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root", "etcd-access-control", "etcd-dashboard"]);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: true, writable: true, writePermissions: null });
|
||||
});
|
||||
|
||||
it("queries the configured user when resolving etcd v2 capabilities", async () => {
|
||||
const writePermission: EtcdAuthPermission = { access: "write", key: utf8("/editable/"), rangeEnd: utf8("/editable0"), resource: "prefix" };
|
||||
const { root, etcdAuthCall, store } = await loadEtcdTree(["v2-writer"], [writePermission], {
|
||||
connection: { driver_profile: "etcd-v2" },
|
||||
});
|
||||
|
||||
expect(etcdAuthCall).toHaveBeenCalledWith("etcd-reader", "user_get", { user: "reader" });
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/editable/key")).toBe(true);
|
||||
expect(store.canWriteEtcdKey("etcd-reader", "/readonly/key")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps anonymous etcd v2 Key operations available without probing an empty user", async () => {
|
||||
const { root, etcdAuthCall, store } = await loadEtcdTree([], [readPermission], {
|
||||
connection: { driver_profile: "etcd-v2", username: "", password: "" },
|
||||
});
|
||||
|
||||
expect(etcdAuthCall).not.toHaveBeenCalled();
|
||||
expect(root.children?.map((child) => child.type)).toEqual(["etcd-root"]);
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: null });
|
||||
});
|
||||
|
||||
it("preserves etcd v2 Key operations when auth capability discovery is unavailable", async () => {
|
||||
const { etcdAuthCall, store } = await loadEtcdTree(["v2-writer"], [readPermission], {
|
||||
connection: { driver_profile: "etcd-v2" },
|
||||
loadRoot: false,
|
||||
});
|
||||
etcdAuthCall.mockRejectedValueOnce(new Error("v2 auth endpoint unavailable"));
|
||||
|
||||
await store.ensureEtcdAccessCapabilities("etcd-reader", { force: true, verifyHealth: false });
|
||||
|
||||
expect(store.getEtcdAccessCapabilities("etcd-reader")).toEqual({ admin: false, writable: true, writePermissions: null });
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,7 @@ import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
|
||||
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
|
||||
import { etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
|
||||
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
|
||||
@@ -195,6 +196,19 @@ const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23";
|
||||
const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt";
|
||||
const SHARDINGSPHERE_PROXY_VERSION_MARKER = "shardingsphere-proxy";
|
||||
|
||||
export interface EtcdAccessCapabilities {
|
||||
/** May access cluster-wide administration APIs such as status and leases. */
|
||||
admin: boolean;
|
||||
/** Has at least one etcd role that permits writes to a Key range. */
|
||||
writable: boolean;
|
||||
/** Null means unrestricted; otherwise writes are limited to these etcd ranges. */
|
||||
writePermissions: api.EtcdAuthPermission[] | null;
|
||||
}
|
||||
|
||||
const unrestrictedEtcdAccess: EtcdAccessCapabilities = { admin: true, writable: true, writePermissions: null };
|
||||
const restrictedEtcdAccess: EtcdAccessCapabilities = { admin: false, writable: false, writePermissions: [] };
|
||||
const fallbackEtcdV2Access: EtcdAccessCapabilities = { admin: false, writable: true, writePermissions: null };
|
||||
|
||||
function usesShardingSphereLogicalTables(databaseInfo: DatabaseConnectionInfo | undefined): boolean {
|
||||
return databaseInfo?.productVersion?.toLowerCase().includes(SHARDINGSPHERE_PROXY_VERSION_MARKER) === true;
|
||||
}
|
||||
@@ -441,6 +455,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const activePinnedTreeNodeReorderKey = ref<string | null>(null);
|
||||
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
|
||||
const connectedIds = ref<Set<string>>(new Set());
|
||||
const etcdAccessCapabilities = ref<Record<string, EtcdAccessCapabilities>>({});
|
||||
const etcdAccessCapabilityGenerations = new Map<string, number>();
|
||||
const etcdAccessCapabilityLoads = new Map<string, Promise<EtcdAccessCapabilities>>();
|
||||
const identifierQuotes = ref<Record<string, string>>({});
|
||||
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
|
||||
const agentDrivers = ref<AgentDriverInstallState[]>([]);
|
||||
@@ -657,6 +674,99 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return configById.value.get(connectionId);
|
||||
}
|
||||
|
||||
function getEtcdAccessCapabilities(connectionId: string): EtcdAccessCapabilities {
|
||||
const config = getConfig(connectionId);
|
||||
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
|
||||
const username = config.username?.trim() || "";
|
||||
// Only an explicitly configured root user can be trusted before probing
|
||||
// the server. Anonymous and certificate connections may still target an
|
||||
// auth-enabled cluster, so keep privileged controls hidden until the
|
||||
// agent reports the effective identity and authentication status.
|
||||
if (username === "root") return unrestrictedEtcdAccess;
|
||||
return etcdAccessCapabilities.value[connectionId] ?? (config.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
|
||||
}
|
||||
|
||||
function canWriteEtcdKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean {
|
||||
const access = getEtcdAccessCapabilities(connectionId);
|
||||
if (!access.writable) return false;
|
||||
if (access.writePermissions === null) return true;
|
||||
return etcdPermissionsAllowKey(access.writePermissions, keyBytes ?? { encoding: "utf8", data: key });
|
||||
}
|
||||
|
||||
async function resolveEtcdAccessCapabilities(connectionId: string, config: ConnectionConfig | undefined): Promise<EtcdAccessCapabilities> {
|
||||
const username = config?.username?.trim() || "";
|
||||
if (username === "root") return unrestrictedEtcdAccess;
|
||||
// The v2 auth API cannot resolve the identity behind the active HTTP
|
||||
// connection. Query an explicitly configured user, while anonymous v2
|
||||
// connections retain their historical Key access and rely on the server
|
||||
// as the authorization boundary.
|
||||
if (config?.driver_profile === "etcd-v2" && !username) return fallbackEtcdV2Access;
|
||||
// An empty user asks the agent for the authenticated identity. This also
|
||||
// covers client-certificate CN authentication without duplicating X.509
|
||||
// parsing in the UI.
|
||||
const userParams = config?.driver_profile === "etcd-v2" ? { user: username } : {};
|
||||
const user = await api.etcdAuthCall<api.EtcdAuthUserDetail>(connectionId, "user_get", userParams);
|
||||
if (user.authEnabled === false) return unrestrictedEtcdAccess;
|
||||
if (user.roles.includes("root")) return unrestrictedEtcdAccess;
|
||||
const roles = await Promise.all(user.roles.map((role) => api.etcdAuthCall<api.EtcdAuthRoleDetail>(connectionId, "role_get", { role })));
|
||||
const writePermissions = roles.flatMap((role) => role.permissions.filter((permission) => permission.access === "write" || permission.access === "readwrite"));
|
||||
return {
|
||||
admin: false,
|
||||
writable: writePermissions.length > 0,
|
||||
writePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureEtcdAccessCapabilities(connectionId: string, options: { force?: boolean; verifyHealth?: boolean } = {}): Promise<EtcdAccessCapabilities> {
|
||||
const config = getConfig(connectionId);
|
||||
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
|
||||
const cached = etcdAccessCapabilities.value[connectionId];
|
||||
if (cached && !options.force) return cached;
|
||||
const existing = etcdAccessCapabilityLoads.get(connectionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const load = (async () => {
|
||||
await ensureConnected(connectionId, { verifyHealth: options.verifyHealth ?? false });
|
||||
const currentConfig = getConfig(connectionId);
|
||||
if (currentConfig?.db_type !== "etcd") return unrestrictedEtcdAccess;
|
||||
const generation = etcdAccessCapabilityGenerations.get(connectionId) ?? 0;
|
||||
const configFingerprint = connectionConfigFingerprint(currentConfig);
|
||||
let access: EtcdAccessCapabilities;
|
||||
try {
|
||||
access = await resolveEtcdAccessCapabilities(connectionId, currentConfig);
|
||||
} catch {
|
||||
// Initial v3 discovery fails closed. Once a capability snapshot has
|
||||
// been confirmed, however, a transient Auth RPC failure must not
|
||||
// overwrite it and make controls flicker between states.
|
||||
// The v2 API cannot discover an implicit current user, so preserve its
|
||||
// pre-capability behavior on discovery errors and let etcd authorize
|
||||
// each Key mutation. Administrative views remain hidden.
|
||||
access = etcdAccessCapabilities.value[connectionId] ?? (currentConfig.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
|
||||
}
|
||||
if (generation === (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) && connectedIds.value.has(connectionId) && connectionConfigFingerprint(getConfig(connectionId) ?? currentConfig) === configFingerprint) {
|
||||
etcdAccessCapabilities.value = { ...etcdAccessCapabilities.value, [connectionId]: access };
|
||||
return access;
|
||||
}
|
||||
return getEtcdAccessCapabilities(connectionId);
|
||||
})();
|
||||
etcdAccessCapabilityLoads.set(connectionId, load);
|
||||
try {
|
||||
return await load;
|
||||
} finally {
|
||||
if (etcdAccessCapabilityLoads.get(connectionId) === load) etcdAccessCapabilityLoads.delete(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEtcdAccessCapabilities(connectionId: string) {
|
||||
etcdAccessCapabilityGenerations.set(connectionId, (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) + 1);
|
||||
etcdAccessCapabilityLoads.delete(connectionId);
|
||||
if (connectionId in etcdAccessCapabilities.value) {
|
||||
const next = { ...etcdAccessCapabilities.value };
|
||||
delete next[connectionId];
|
||||
etcdAccessCapabilities.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionIdentifierQuote(connectionId?: string): string | undefined {
|
||||
if (!connectionId) return undefined;
|
||||
const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId));
|
||||
@@ -1099,6 +1209,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
|
||||
function markConnectionLost(connectionId: string, error: unknown) {
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearEtcdAccessCapabilities(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
@@ -3248,6 +3359,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const id of removedIds) {
|
||||
clearConnectionError(id);
|
||||
connectionErrorRevisions.delete(id);
|
||||
clearEtcdAccessCapabilities(id);
|
||||
connectedIds.value.delete(id);
|
||||
clearSidebarStorageCaches(id);
|
||||
clearPrimaryVisibleObjectNames(id);
|
||||
@@ -3288,6 +3400,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
syncTimeoutInheritanceBackup();
|
||||
rebuildTreeNodes();
|
||||
if (!runtimeConfigChanged) return;
|
||||
clearEtcdAccessCapabilities(config.id);
|
||||
clearPrimaryVisibleObjectNames(config.id);
|
||||
connectedIds.value.delete(config.id);
|
||||
clearSidebarStorageCaches(config.id);
|
||||
@@ -3819,6 +3932,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!cancelled) return false;
|
||||
clearConnectionError(connectionId);
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearEtcdAccessCapabilities(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
@@ -3839,6 +3953,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
cancelLocalConnectionAttempt(connectionId);
|
||||
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearEtcdAccessCapabilities(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
@@ -3963,6 +4078,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
cancelObjectMetadataLoadsForConnection(connectionId);
|
||||
clearMetadataRuntimeCacheForConnection(connectionId);
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearEtcdAccessCapabilities(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionHealthCheck(connectionId);
|
||||
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
||||
@@ -4498,42 +4614,41 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
load = reclaimTreeNodeLoad(load, node);
|
||||
const targetNode = treeNodeLoadTarget(load);
|
||||
if (!targetNode) return;
|
||||
setChildren(
|
||||
targetNode,
|
||||
withSavedSqlRoot(
|
||||
const etcdAccess = await ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false });
|
||||
const children: TreeNode[] = [
|
||||
{
|
||||
id: `${connectionId}:etcd`,
|
||||
label: kvRootNodeLabel("etcd"),
|
||||
type: "etcd-root" as const,
|
||||
connectionId,
|
||||
[
|
||||
{
|
||||
id: `${connectionId}:etcd`,
|
||||
label: kvRootNodeLabel("etcd"),
|
||||
type: "etcd-root" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `${connectionId}:etcd-access-control`,
|
||||
label: "用户和角色",
|
||||
type: "etcd-access-control" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `${connectionId}:etcd-dashboard`,
|
||||
label: "服务仪表盘",
|
||||
type: "etcd-dashboard" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
targetNode,
|
||||
),
|
||||
);
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
if (etcdAccess.admin) {
|
||||
children.push(
|
||||
{
|
||||
id: `${connectionId}:etcd-access-control`,
|
||||
label: "用户和角色",
|
||||
type: "etcd-access-control" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `${connectionId}:etcd-dashboard`,
|
||||
label: "服务仪表盘",
|
||||
type: "etcd-dashboard" as const,
|
||||
connectionId,
|
||||
database: "",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
setChildren(targetNode, withSavedSqlRoot(connectionId, children, targetNode));
|
||||
targetNode.isExpanded = true;
|
||||
} catch (e) {
|
||||
recordMetadataLoadError(connectionId, e, load);
|
||||
@@ -8894,6 +9009,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectionGroupOptions,
|
||||
selectedConnectionGroupId,
|
||||
getConfig,
|
||||
getEtcdAccessCapabilities,
|
||||
ensureEtcdAccessCapabilities,
|
||||
canWriteEtcdKey,
|
||||
connectionIdentifierQuote,
|
||||
isTreeNodePinned,
|
||||
orderByPinnedTreeNodes,
|
||||
|
||||
Reference in new issue
Block a user