feat(safety): temporary write-unlock window for read-only connections

This commit is contained in:
Farahani authored and GitHub committed 2026-08-27 17:37:54 +08:00
1 parent 7933b7656f
commit 955558c81f
66 files changed
+1176 -124

No files matched your search

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Lock, Unlock } from "@lucide/vue";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useConnectionStore } from "@/stores/connectionStore";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
import { formatWriteUnlockRemaining } from "@/lib/database/readOnlyWriteAccess";
const props = defineProps<{
connectionId: string;
compact?: boolean;
showLabel?: boolean;
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const unlockStore = useReadOnlyUnlockStore();
const persistentlyReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
const remainingMs = computed(() => unlockStore.remainingMs(props.connectionId));
const unlocked = computed(() => remainingMs.value > 0);
const remainingLabel = computed(() => formatWriteUnlockRemaining(remainingMs.value));
const tooltip = computed(() => {
if (unlocked.value) return `${t("readOnlyUnlock.unlockedBadge")} · ${t("readOnlyUnlock.remaining", { time: remainingLabel.value })}`;
return t("connection.readOnlyBadge");
});
async function onActivate() {
if (!persistentlyReadOnly.value) return;
if (unlocked.value) {
await unlockStore.lockNow(props.connectionId);
return;
}
await unlockStore.requestUnlock({
connectionId: props.connectionId,
connectionName: connectionStore.getConfig(props.connectionId)?.name,
source: t("readOnlyUnlock.sourceStatus"),
});
}
watch(
() => props.connectionId,
(connectionId) => {
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(connectionId);
},
);
onMounted(() => {
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(props.connectionId);
});
</script>
<template>
<Tooltip v-if="persistentlyReadOnly">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center gap-0.5 rounded text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="compact ? 'h-4 px-0.5' : 'h-4 px-1'"
:aria-label="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
:title="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
@click.stop.prevent="onActivate"
>
<Unlock v-if="unlocked" class="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
<Lock v-else class="h-3 w-3 shrink-0" />
<span v-if="showLabel && !unlocked" class="text-[10px] leading-none">{{ t("connection.readOnlyBadge") }}</span>
<span v-if="unlocked" class="text-[10px] tabular-nums leading-none">{{ remainingLabel }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ tooltip }}</TooltipContent>
</Tooltip>
</template>
@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import * as api from "@/lib/backend/api";
import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul";
import { useI18n } from "vue-i18n";
@@ -13,7 +14,7 @@ import { useI18n } from "vue-i18n";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const { t } = useI18n();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const active = ref<ConsulAclKind>("token");
const items = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
@@ -15,6 +15,7 @@ import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { kvExportFilenameStem, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useConsulStore } from "@/stores/consulStore";
import { isCurrentWatchEvent, nextWatchIndex } from "@/lib/consul/watchState";
import type { ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulImportConflictPolicy, ConsulImportPreview, ConsulImportReport, ConsulKvBundle, ConsulSearchMatch, ConsulScope, ConsulWatchEvent } from "@/types/consul";
@@ -26,7 +27,7 @@ const connectionStore = useConnectionStore();
const consulStore = useConsulStore();
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
const fileInputRef = ref<HTMLInputElement>();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const consulConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "consul"));
const transferTargetConnections = computed(() => (transferMode.value === "migration" ? consulConnections.value.filter((connection) => connection.id !== props.connectionId) : consulConnections.value));
@@ -66,7 +67,7 @@ const transferReport = ref<ConsulImportReport | null>(null);
const transferLoading = ref(false);
const transferApplying = ref(false);
const transferError = ref("");
const transferTargetReadOnly = computed(() => Boolean(connectionStore.getConfig(transferTargetId.value)?.read_only));
const transferTargetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(transferTargetId.value)));
const deleteOpen = ref(false);
const deletePrefix = ref("");
@@ -10,13 +10,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul";
import { useI18n } from "vue-i18n";
type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const { t } = useI18n();
function capabilityLabel(value?: string) {
if (value === "supported") return t("consul.ui.capabilitySupported");
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import * as api from "@/lib/backend/api";
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
@@ -26,7 +27,7 @@ const settings = computed(() => {
const external = connectionStore.getConfig(props.connectionId)?.external_config;
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
});
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
@@ -6,13 +6,14 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const { t } = useI18n();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const active = ref<ConsulEnterpriseKind>("namespace");
const items = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
@@ -11,6 +11,7 @@ import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/l
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew";
import { normalizeConsulSession } from "@/lib/consul/sessionModel";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useConsulStore } from "@/stores/consulStore";
import { useI18n } from "vue-i18n";
import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul";
@@ -19,7 +20,7 @@ const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const store = useConnectionStore();
const consulStore = useConsulStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
function behaviorLabel(behavior: string) {
if (behavior === "delete") return t("consul.ui.behaviorDelete");
if (behavior === "release") return t("consul.ui.behaviorRelease");
@@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import * as api from "@/lib/backend/api";
import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
type ToolTab = "queries" | "events" | "coordinates";
@@ -21,7 +22,7 @@ function capabilityLabel(value?: string) {
return t("consul.ui.capabilityUnknown");
}
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported");
const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported");
const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || ""));
@@ -18,6 +18,7 @@ import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGr
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
const props = defineProps<{
@@ -62,7 +63,7 @@ const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checked
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const mongoProvider = documentStoreProviderFor("mongodb");
const activeStructuredRuleCount = computed(() => {
if (!appliedStructuredFilter.value) return 0;
@@ -11,6 +11,7 @@ import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
const props = defineProps<{
@@ -39,7 +40,7 @@ const filterBuilderOpen = ref(false);
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -60,6 +60,7 @@ import {
type DoltClientSessionScope,
} from "@/lib/dolt/doltVersionControl";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -240,7 +241,7 @@ const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE);
const tableDiffTotalRows = ref(0);
const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value)));
const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value));
const connectionReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const connectionReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const createsNamedRef = computed(() => branchDialog.value === "create" || branchDialog.value === "create-tag");
const mutationDialogTitle = computed(() => {
if (branchDialog.value === "create") return t("doltVersionControl.createBranch");
@@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type AccessView = "users" | "roles";
type PermissionAccess = "read" | "write" | "readwrite";
@@ -52,7 +53,7 @@ const approvalExpected = ref("");
let pendingApproval: (() => Promise<void>) | null = null;
let detailRequest = 0;
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
const initialUserRoleLabel = computed(() => (newUserRoles.value.length ? t("etcd.access.createAndAssignRoles", { count: newUserRoles.value.length }) : t("etcd.access.createUserAction")));
@@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
import { detectKvValueFormat } from "@/lib/kv/kvValueFormat";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type WorkbenchMode = "keys" | "search" | "maintenance" | "watch" | "lease";
type SearchScope = "key" | "value" | "all";
@@ -153,9 +154,9 @@ const transferLoadingDetail = ref("");
const transferPreviewLoaded = ref(false);
const syncConfigurationExpanded = ref(true);
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
const targetReadOnly = computed(() => Boolean(connectionStore.getConfig(targetConnectionId.value)?.read_only));
const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)));
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
const filteredTransferRows = computed(() => {
@@ -17,6 +17,7 @@ import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
@@ -61,7 +62,7 @@ const deleteTableLoading = ref(false);
const deleteTableError = ref("");
const hasTable = computed(() => props.table.trim().length > 0);
const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const qualifiedTableLabel = computed(() => {
if (!hasTable.value) return props.namespace;
return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table;
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import {
@@ -713,6 +714,9 @@ async function goNext() {
async function startImport() {
saveActiveBatchTask();
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) {
return;
}
if (isBatchImport.value) {
await startBatchImport();
return;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch, defineAsyncComponent } from "vue";
import { computed, ref, watch, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -24,6 +24,7 @@ const DataGenerateDialog = defineAsyncComponent(() => import("@/components/gener
import { useConnectionStore } from "@/stores/connectionStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useReadOnlyUnlockStore, WRITE_UNLOCK_FIVE_MINUTES_SECS, WRITE_UNLOCK_ONE_MINUTE_SECS, type WriteUnlockDurationSecs } from "@/stores/readOnlyUnlockStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
@@ -92,6 +93,8 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const readOnlyUnlockStore = useReadOnlyUnlockStore();
const unlockDuration = ref<WriteUnlockDurationSecs>(WRITE_UNLOCK_ONE_MINUTE_SECS);
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
@@ -103,6 +106,20 @@ const productionConfirmationDetails = computed(() => {
source: request.source || "-",
});
});
const readOnlyUnlockDetails = computed(() => {
const request = readOnlyUnlockStore.pending;
if (!request) return "";
return t("readOnlyUnlock.confirmDetails", {
connection: request.connectionName || "-",
source: request.source || "-",
});
});
watch(
() => readOnlyUnlockStore.pending,
(pending) => {
if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS;
},
);
const multiDbDangerDetails = computed(() => {
const request = sqlExecutionDangerStore.pending;
if (!request) return "";
@@ -178,6 +195,32 @@ watch(
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<DangerConfirmDialog
v-if="readOnlyUnlockStore.pending"
:open="true"
:title="t('readOnlyUnlock.confirmTitle')"
:message="t('readOnlyUnlock.confirmMessage')"
:details-text="readOnlyUnlockDetails"
:sql="readOnlyUnlockStore.pending.sql"
:confirm-label="t('readOnlyUnlock.confirmAction')"
:close-on-confirm="false"
@update:open="(open) => !open && readOnlyUnlockStore.cancel()"
@confirm="readOnlyUnlockStore.confirm(unlockDuration)"
>
<template #options>
<fieldset class="mb-3 space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<legend class="text-xs font-medium text-foreground">{{ t("readOnlyUnlock.durationLabel") }}</legend>
<label class="flex items-center gap-2 text-sm">
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_ONE_MINUTE_SECS" />
{{ t("readOnlyUnlock.durationOneMinute") }}
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_FIVE_MINUTES_SECS" />
{{ t("readOnlyUnlock.durationFiveMinutes") }}
</label>
</fieldset>
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-if="sqlExecutionDangerStore.pending"
:open="true"
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -16,7 +16,8 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
@@ -740,12 +741,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
<TooltipTrigger as-child>
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
</Tooltip>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="queryStore.closeTab(tab.id)">
<X class="h-3 w-3" />
</button>
@@ -852,7 +848,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
<span class="w-5 shrink-0">
<button
@@ -950,12 +946,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
<TooltipTrigger as-child>
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
</Tooltip>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
</button>
@@ -1008,7 +999,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
<span class="w-5 shrink-0">
<button
@@ -162,6 +162,7 @@ import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollecti
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type DataGridHandle = DataGridColumnLayoutHandle & {
onToolbarRefresh: () => Promise<void> | void;
@@ -2226,7 +2227,7 @@ defineExpose({
<template v-else-if="activeTab.mode === 'nacos-access-control'">
<div class="flex-1 min-h-0">
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="activeConnection?.read_only ?? false" />
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
</div>
</template>
@@ -2297,7 +2298,7 @@ defineExpose({
<template v-else-if="activeTab.mode === 'mq'">
<div class="flex-1 min-h-0">
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
</div>
</template>
@@ -2318,7 +2319,7 @@ defineExpose({
:target-group="activeTab.nacosTargetGroup"
:target-keyword="activeTab.nacosTargetKeyword"
:target-request-id="activeTab.nacosTargetRequestId"
:read-only="activeConnection?.read_only ?? false"
:read-only="connectionIsEffectivelyReadOnly(activeConnection)"
/>
</div>
</template>
@@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { loadMeilisearchKeyColumns, saveMeilisearchKeyColumns, type MeilisearchKeyColumnKey } from "@/lib/meilisearch/meilisearchKeyColumns";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
import type { KeyCreateInput, KeyListItem } from "@/types/meilisearchManagement";
@@ -53,7 +54,7 @@ const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const rows = ref<KeyListItem[]>([]);
const offset = ref(0);
@@ -12,6 +12,7 @@ import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
import {
formatMeilisearchTaskDateTime,
@@ -32,7 +33,7 @@ const props = defineProps<{ connectionId: string; fixedIndexUid?: string }>();
const { locale, t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const rows = ref<MeilisearchTask[]>([]);
const total = ref(0);
@@ -55,6 +55,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import type {
NacosBatchPreview,
NacosBatchReport,
@@ -280,7 +281,7 @@ const namespace = computed(() => props.namespace ?? connectionInfo.value?.namesp
const nacosProductionContext = computed(() => productionContextForDatabase(connectionStore.getConfig(props.connectionId), namespace.value));
const batchTargetConnections = computed<NacosConfigTransferTarget[]>(() =>
connectionStore.connections
.filter((connection) => connection.db_type === "nacos" && !connection.read_only)
.filter((connection) => connection.db_type === "nacos" && !connectionIsEffectivelyReadOnly(connection))
.map((connection) => {
const address = [connection.host, connection.port].filter(Boolean).join(":");
return { id: connection.id, label: connection.name ? `${connection.name} (${address})` : address || connection.id };
@@ -111,6 +111,7 @@ import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
@@ -335,7 +336,7 @@ const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tabl
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
const supportsVacuumTable = computed(() => !props.connection.read_only && supportsTableVacuum(effectiveDatabaseType.value));
const supportsVacuumTable = computed(() => !connectionIsEffectivelyReadOnly(props.connection) && supportsTableVacuum(effectiveDatabaseType.value));
const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu.vacuumTableRunningHint") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk")));
const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value));
const sourceFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
@@ -2212,7 +2213,7 @@ function canTransferTableClipboard(): boolean {
if (isVictoriaMetrics.value) return false;
const entries = normalizedObjectBrowserTableClipboardEntries();
const target = pasteTableTargetContext();
if (entries.length === 0 || props.connection.read_only) return false;
if (entries.length === 0 || connectionIsEffectivelyReadOnly(props.connection)) return false;
const source = tableClipboardSourceContext(entries);
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target);
@@ -184,6 +184,7 @@ import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
@@ -1153,7 +1154,7 @@ function canTransferTreeClipboardToCurrentNode(): boolean {
const source = tableClipboardSourceContext(entries);
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
const targetConfig = connectionStore.getConfig(target.connectionId);
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !targetConfig.read_only && !tableClipboardMatchesTarget(entries, target);
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !connectionIsEffectivelyReadOnly(targetConfig) && !tableClipboardMatchesTarget(entries, target);
}
function openTransferFromTreeClipboard(): boolean {
@@ -3013,7 +3014,7 @@ async function confirmBatchEmpty() {
const canCreateTable = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
const supportsHBaseTableCreation = config?.db_type === "hbase" && !config.read_only;
const supportsHBaseTableCreation = config?.db_type === "hbase" && !connectionIsEffectivelyReadOnly(config);
return (
(activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") &&
!isSqlServerLinkedNode(activeNode.value) &&
@@ -3029,13 +3030,13 @@ const canCreateDatabase = computed(() => {
const canCreateNacosNamespace = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !config.read_only;
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
});
const canEditNacosNamespace = computed(() => {
if (activeNode.value.type !== "nacos-namespace" || !activeNode.value.connectionId || !activeNode.value.nacosNamespace) return false;
const config = connectionStore.getConfig(activeNode.value.connectionId);
return config?.db_type === "nacos" && !config.read_only;
return config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
});
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
@@ -3083,7 +3084,7 @@ const canRenameDatabase = computed(() => {
const node = activeNode.value;
if (node.type !== "database" || !node.database) return false;
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
return !!config && !config.read_only && supportsDatabaseRename(config.db_type);
return !!config && !connectionIsEffectivelyReadOnly(config) && supportsDatabaseRename(config.db_type);
});
const renameObjectDialogTitle = computed(() => {
@@ -3104,7 +3105,7 @@ const canDropSchema = computed(() => {
const canEditSchemaComment = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "schema" && !!activeNode.value.database && !config?.read_only && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
return activeNode.value.type === "schema" && !!activeNode.value.database && !connectionIsEffectivelyReadOnly(config) && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
});
const canBatchDropCascade = computed(() => {
@@ -5221,7 +5222,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
icon: RefreshCw,
shortcut: shortcutRefresh,
});
if (!connectionStore.getConfig(node.connectionId || "")?.read_only) {
if (!connectionIsEffectivelyReadOnly(connectionStore.getConfig(node.connectionId || ""))) {
items.push({ label: "", separator: true });
items.push({
label: t("hbase.deleteTable"),
@@ -32,7 +32,6 @@ import {
CalendarClock,
Gauge,
ShieldCheck,
Lock,
Archive,
Square,
Minus,
@@ -46,6 +45,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -1510,7 +1510,7 @@ function onKeydown(event: KeyboardEvent) {
</div>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"> <Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }} </Badge>
<ReadOnlySessionControl v-if="isConnectionReadonly && activeNode.connectionId" :connection-id="activeNode.connectionId" show-label />
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
<button
@@ -16,6 +16,7 @@ import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
@@ -357,6 +358,12 @@ async function refreshTargetAfterImport() {
async function startExecution() {
if (!canStart.value || previews.value.length === 0) return;
// Await the unlock guard only when it can actually prompt/block (effectively
// read-only); writable or already-unlocked connections stay synchronous so
// the running state flips in the same tick as the click.
if (connectionIsEffectivelyReadOnly(selectedConnection.value)) {
if (!(await ensureReadOnlyWriteAccess({ connection: selectedConnection.value, source: t("readOnlyUnlock.sourceSqlFile"), treatAsMutation: true }))) return;
}
const productionContext = productionContextForDatabase(selectedConnection.value, database.value);
if (productionContext.active) {
// File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview.
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import * as api from "@/lib/backend/api";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
@@ -735,6 +736,9 @@ function swapSourceAndTarget() {
async function startTransfer() {
if (!canStart.value || isSubmitting.value) return;
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(targetConnectionId.value), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }))) {
return;
}
isSubmitting.value = true;
const effectiveSourceSchema = sourceSchema.value || sourceDatabaseName.value;
@@ -19,6 +19,9 @@ vi.mock("@/lib/backend/api", () => ({
executeConditionalUpdate: mocks.executeConditionalUpdate,
cancelConditionalUpdate: mocks.cancelConditionalUpdate,
executeInTransaction: mocks.executeInTransaction,
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: mocks.getConfig }),
@@ -10,7 +10,7 @@ vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: (id: string) => {
if (id === "missing") return undefined;
if (id === "readonly") return { name: "ro", read_only: true, is_production: false };
if (id === "readonly") return { id: "readonly", name: "ro", read_only: true, is_production: false, db_type: "mq" };
if (id === "prod") return { name: "prod-mq", read_only: false, is_production: true };
return { name: "dev-mq", read_only: false, is_production: false };
},
@@ -21,6 +21,12 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation }),
}));
vi.mock("@/lib/backend/api", () => ({
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast }),
}));
@@ -32,6 +38,7 @@ vi.mock("vue-i18n", () => ({
}));
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
describe("useMqMutationGuard", () => {
beforeEach(() => {
@@ -41,14 +48,20 @@ describe("useMqMutationGuard", () => {
toast.mockReset();
});
it("denies missing and read-only connections without prompting", async () => {
it("denies missing connections and prompts to unlock read-only ones", async () => {
const missing = useMqMutationGuard("missing");
const readonly = useMqMutationGuard("readonly");
await expect(missing.confirmMqWrite("send")).resolves.toBe(false);
await expect(readonly.confirmMqWrite("send")).resolves.toBe(false);
expect(requestConfirmation).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith("mqAdmin.connectionMissing");
expect(toast).toHaveBeenCalledWith("mqAdmin.writeDeniedReadOnly");
const unlockStore = useReadOnlyUnlockStore();
const readonly = useMqMutationGuard("readonly");
const pending = readonly.confirmMqWrite("send");
await vi.waitFor(() => {
expect(unlockStore.pending?.connectionId).toBe("readonly");
});
expect(requestConfirmation).not.toHaveBeenCalled();
unlockStore.cancel();
await expect(pending).resolves.toBe(false);
});
it("allows non-production writes immediately", async () => {
@@ -17,6 +17,9 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => ({
saveEditorSettings: vi.fn(),
saveHistory: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
function installLocalStorage() {
@@ -1199,8 +1202,9 @@ SELECT @value AS Message;`;
});
const pendingExecution = execution.tryExecute();
await Promise.resolve();
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
await vi.waitFor(() => {
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
});
expect(executeCurrentSql).not.toHaveBeenCalled();
productionSafetyStore.confirm();
@@ -28,6 +28,9 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => ({
saveEditorSettings: vi.fn(),
saveHistory: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
function installLocalStorage() {
@@ -11,6 +11,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess, isWriteUnlockActive } from "@/lib/database/readOnlyWriteAccess";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -1598,6 +1599,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
saveError.value = "";
const connection = connectionStore.getConfig(connectionId.value);
if (!(await ensureReadOnlyWriteAccess({ connection, sql: statement, source: i18n.global.t("readOnlyUnlock.sourceDataEditor") }))) return null;
const productionAssessment = assessProductionSql(statement, connection, database.value);
if (productionAssessment.active && productionAssessment.isMutation) {
const confirmed = await productionSafetyStore.requestConfirmation({
@@ -1605,7 +1607,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) return null;
}
@@ -1701,6 +1703,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const customHandler = customSaveHandler?.value;
const connection = connectionStore.getConfig(connectionId.value ?? "");
if (connection?.read_only) {
if (saveOptions.autoSave && !isWriteUnlockActive(connection.id)) return;
if (!(await ensureReadOnlyWriteAccess({ connection, sql: describeDataGridChanges(snapshot), source: i18n.global.t("readOnlyUnlock.sourceDataEditor"), treatAsMutation: true }))) {
return;
}
}
const customHandlerProductionContext = productionContextForDatabase(connection, database.value);
if (customHandler && customHandlerProductionContext.active) {
// Custom data sources may not expose SQL, but their row mutations still need the same production interlock.
@@ -1712,7 +1720,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: customHandlerProductionContext.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) return;
}
@@ -1793,7 +1801,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) {
await finishInterruptedSaveChanges(snapshot);
@@ -2,6 +2,7 @@ import { toValue, type MaybeRefOrGetter } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
/**
@@ -24,8 +25,7 @@ export function useMqMutationGuard(connectionId: MaybeRefOrGetter<string>) {
toast(t("mqAdmin.connectionMissing"));
return false;
}
if (config.read_only) {
toast(t("mqAdmin.writeDeniedReadOnly"));
if (!(await ensureReadOnlyWriteAccess({ connection: config, sql: operation, source: t("production.sourceMq"), treatAsMutation: true }))) {
return false;
}
if (!config.is_production) return true;
@@ -4,6 +4,7 @@ import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import type { DatabaseType, TreeNode } from "@/types/database";
import { supportsTableTruncate, supportsTableVacuum } from "@/lib/database/databaseCapabilities";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import {
buildDropTableSql,
buildEmptyTableSql,
@@ -66,7 +67,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
const supportsTruncate = computed(() => supportsTableTruncate(currentDatabaseType()));
const supportsVacuum = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "table" && !config?.read_only && supportsTableVacuum(currentDatabaseType());
return activeNode.value.type === "table" && !connectionIsEffectivelyReadOnly(config) && supportsTableVacuum(currentDatabaseType());
});
const canDropTableCascade = computed(() => activeNode.value.type === "table" && supportsDropTableCascade(currentDatabaseType()));
const canTruncateTableCascade = computed(() => activeNode.value.type === "table" && supportsTruncateTableCascade(currentDatabaseType()));
@@ -19,6 +19,7 @@ import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlPa
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
@@ -176,6 +177,7 @@ export function useSqlExecution(deps: {
}
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
@@ -358,6 +360,9 @@ export function useSqlExecution(deps: {
const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count;
if (cancelRequested()) return finish({ status: "cancelled" });
if (!sql.trim()) return finish({ status: "failed", errorMessage: t("explain.emptySql") });
if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceMultiDbSql") }))) {
return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
if (requiresDatabaseSelection(executionTab, connection, sql)) {
return finish({ status: "failed", errorMessage: t("editor.selectDatabaseRequired") });
}
@@ -544,6 +549,7 @@ export function useSqlExecution(deps: {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
}
+18
View File
@@ -5170,6 +5170,24 @@ export default {
sourceAdmin: "Database administration",
aiReviewRequired: "Production SQL was added to the editor. Review it and run it manually to confirm.",
},
readOnlyUnlock: {
confirmTitle: "Unlock writes",
confirmMessage: "This connection is read-only. Writes stay blocked unless you unlock them for a short window. The saved read-only setting will not be turned off.",
confirmDetails: "Connection: {connection}\nSource: {source}\nWrites will be allowed for the selected duration, then read-only resumes automatically.",
confirmAction: "Unlock writes",
durationLabel: "Allow writes for",
durationOneMinute: "1 minute",
durationFiveMinutes: "5 minutes",
remaining: "{time} left",
unlockedBadge: "Writes unlocked",
lockNow: "Lock writes now",
unlockAction: "Unlock writes…",
sourceImport: "Table import",
sourceTransfer: "Data transfer",
sourceSqlFile: "SQL file execution",
sourceStatus: "Read-only control",
sourceDataEditor: "Data editor",
},
sqlParameters: {
title: "SQL Parameters",
description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.",
+18
View File
@@ -8100,6 +8100,24 @@ export default withEnglishFallback({
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
sourceMultiDbSql: "Ejecución SQL multi-base de datos",
},
readOnlyUnlock: {
confirmTitle: "Desbloquear escrituras",
confirmMessage: "Esta conexión es de solo lectura. Las escrituras siguen bloqueadas a menos que las desbloquee durante un breve intervalo. El ajuste de solo lectura guardado no se desactivará.",
confirmDetails: "Conexión: {connection}\nOrigen: {source}\nLas escrituras se permitirán durante el tiempo seleccionado y luego se reanudará automáticamente el modo de solo lectura.",
confirmAction: "Desbloquear escrituras",
durationLabel: "Permitir escrituras durante",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minutos",
remaining: "{time} restantes",
unlockedBadge: "Escrituras desbloqueadas",
lockNow: "Volver a bloquear ahora",
unlockAction: "Desbloquear escrituras…",
sourceImport: "Importación de tabla",
sourceTransfer: "Transferencia de datos",
sourceSqlFile: "Ejecución de archivo SQL",
sourceStatus: "Control de solo lectura",
sourceDataEditor: "Editor de datos",
},
processList: {
title: "Lista de procesos",
sessionCount: "{count} sesiones",
+18
View File
@@ -8101,6 +8101,24 @@ export default withEnglishFallback({
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
sourceMultiDbSql: "Esecuzione SQL multi-DB",
},
readOnlyUnlock: {
confirmTitle: "Sblocca scritture",
confirmMessage: "Questa connessione è in sola lettura. Le scritture restano bloccate a meno che non le sblocchi per un breve intervallo. L'impostazione di sola lettura salvata non verrà disattivata.",
confirmDetails: "Connessione: {connection}\nOrigine: {source}\nLe scritture saranno consentite per la durata selezionata, poi la sola lettura riprenderà automaticamente.",
confirmAction: "Sblocca scritture",
durationLabel: "Consenti scritture per",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minuti",
remaining: "{time} rimanenti",
unlockedBadge: "Scritture sbloccate",
lockNow: "Blocca scritture ora",
unlockAction: "Sblocca scritture…",
sourceImport: "Importazione tabella",
sourceTransfer: "Trasferimento dati",
sourceSqlFile: "Esecuzione file SQL",
sourceStatus: "Controllo sola lettura",
sourceDataEditor: "Editor dati",
},
processList: {
title: "Elenco processi",
sessionCount: "{count} sessioni",
+18
View File
@@ -8152,6 +8152,24 @@ export default withEnglishFallback({
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
sourceMultiDbSql: "複数DB SQL実行",
},
readOnlyUnlock: {
confirmTitle: "書き込みのロックを解除",
confirmMessage: "この接続は読み取り専用です。短い時間だけロックを解除しない限り、書き込みはブロックされたままです。保存済みの読み取り専用設定はオフになりません。",
confirmDetails: "接続:{connection}\nソース:{source}\n選択した時間だけ書き込みが許可され、その後自動的に読み取り専用に戻ります。",
confirmAction: "書き込みのロックを解除",
durationLabel: "書き込みを許可する時間",
durationOneMinute: "1 分",
durationFiveMinutes: "5 分",
remaining: "残り {time}",
unlockedBadge: "書き込み解除中",
lockNow: "今すぐ再ロック",
unlockAction: "書き込みのロックを解除…",
sourceImport: "テーブルのインポート",
sourceTransfer: "データ転送",
sourceSqlFile: "SQL ファイル実行",
sourceStatus: "読み取り専用コントロール",
sourceDataEditor: "データエディター",
},
processList: {
title: "プロセス一覧",
sessionCount: "{count} セッション",
+18
View File
@@ -4599,6 +4599,24 @@ export default withEnglishFallback({
sourceAdmin: "데이터베이스 관리",
aiReviewRequired: "프로덕션 SQL이 편집기에 추가되었습니다. 확인하려면 검토 후 수동으로 실행하세요.",
},
readOnlyUnlock: {
confirmTitle: "쓰기 잠금 해제",
confirmMessage: "이 연결은 읽기 전용입니다. 짧은 시간 동안 잠금을 해제하지 않으면 쓰기가 계속 차단됩니다. 저장된 읽기 전용 설정은 꺼지지 않습니다.",
confirmDetails: "연결: {connection}\n출처: {source}\n선택한 시간 동안 쓰기가 허용된 뒤 자동으로 읽기 전용으로 돌아갑니다.",
confirmAction: "쓰기 잠금 해제",
durationLabel: "쓰기 허용 시간",
durationOneMinute: "1분",
durationFiveMinutes: "5분",
remaining: "{time} 남음",
unlockedBadge: "쓰기 잠금 해제됨",
lockNow: "지금 다시 잠금",
unlockAction: "쓰기 잠금 해제…",
sourceImport: "테이블 가져오기",
sourceTransfer: "데이터 이전",
sourceSqlFile: "SQL 파일 실행",
sourceStatus: "읽기 전용 컨트롤",
sourceDataEditor: "데이터 편집기",
},
sqlParameters: {
title: "SQL 매개변수",
description: "SQL 템플릿 자리표시자의 값을 입력하세요. DBX가 SQL을 실행하기 전에 대체합니다.",
+18
View File
@@ -8102,6 +8102,24 @@ export default withEnglishFallback({
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
sourceMultiDbSql: "Execução de SQL em múltiplos bancos de dados",
},
readOnlyUnlock: {
confirmTitle: "Desbloquear gravações",
confirmMessage: "Esta conexão é somente leitura. As gravações continuam bloqueadas a menos que você as desbloqueie por um curto intervalo. A configuração de somente leitura salva não será desativada.",
confirmDetails: "Conexão: {connection}\nOrigem: {source}\nAs gravações serão permitidas pela duração selecionada e, em seguida, o modo somente leitura será retomado automaticamente.",
confirmAction: "Desbloquear gravações",
durationLabel: "Permitir gravações por",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minutos",
remaining: "{time} restantes",
unlockedBadge: "Gravações desbloqueadas",
lockNow: "Bloquear gravações agora",
unlockAction: "Desbloquear gravações…",
sourceImport: "Importação de tabela",
sourceTransfer: "Transferência de dados",
sourceSqlFile: "Execução de arquivo SQL",
sourceStatus: "Controle de somente leitura",
sourceDataEditor: "Editor de dados",
},
processList: {
title: "Lista de Processos",
sessionCount: "{count} sessões",
+18
View File
@@ -5155,6 +5155,24 @@ export default withEnglishFallback({
sourceAdmin: "数据库管理",
aiReviewRequired: "生产 SQL 已放入编辑器。请先检查,再手动执行以确认。",
},
readOnlyUnlock: {
confirmTitle: "临时解锁写入",
confirmMessage: "当前连接处于只读模式。写入会被阻止,除非你确认解锁一小段时间。已保存的只读设置不会被关闭。",
confirmDetails: "连接:{connection}\n入口:{source}\n写入仅在所选时长内允许,到期后自动恢复只读。",
confirmAction: "解锁写入",
durationLabel: "允许写入时长",
durationOneMinute: "1 分钟",
durationFiveMinutes: "5 分钟",
remaining: "剩余 {time}",
unlockedBadge: "写入已解锁",
lockNow: "立即重新锁定",
unlockAction: "解锁写入…",
sourceImport: "表导入",
sourceTransfer: "数据传输",
sourceSqlFile: "SQL 文件执行",
sourceStatus: "只读控制",
sourceDataEditor: "数据编辑器",
},
sqlParameters: {
title: "SQL 参数",
description: "为 SQL 模板占位符填写参数值,DBX 会在执行前替换为 SQL 字面量。",
+18
View File
@@ -8092,6 +8092,24 @@ export default withEnglishFallback({
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
sourceMultiDbSql: "多庫 SQL 執行",
},
readOnlyUnlock: {
confirmTitle: "暫時解鎖寫入",
confirmMessage: "目前連線處於唯讀模式。寫入會被阻擋,除非你確認解鎖一小段時間。已儲存的唯讀設定不會被關閉。",
confirmDetails: "連線:{connection}\n入口:{source}\n寫入僅在所選時長內允許,到期後自動恢復唯讀。",
confirmAction: "解鎖寫入",
durationLabel: "允許寫入時長",
durationOneMinute: "1 分鐘",
durationFiveMinutes: "5 分鐘",
remaining: "剩餘 {time}",
unlockedBadge: "寫入已解鎖",
lockNow: "立即重新鎖定",
unlockAction: "解鎖寫入…",
sourceImport: "資料表匯入",
sourceTransfer: "資料傳輸",
sourceSqlFile: "SQL 檔案執行",
sourceStatus: "唯讀控制",
sourceDataEditor: "資料編輯器",
},
processList: {
title: "處理程序清單",
sessionCount: "{count} 個會話",
+3
View File
@@ -56,6 +56,9 @@ export const testConnectionWithInfo = forward("testConnectionWithInfo");
export const connectDb = forward("connectDb");
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
export const unlockConnectionWrites = forward("unlockConnectionWrites");
export const lockConnectionWrites = forward("lockConnectionWrites");
export const connectionWriteUnlockState = forward("connectionWriteUnlockState");
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
export const disconnectDb = forward("disconnectDb");
export const sessionCredentialStatus = forward("sessionCredentialStatus");
+14
View File
@@ -399,6 +399,20 @@ export async function saveConnectionDatabaseInfo(connectionId: string, databaseI
});
}
export async function unlockConnectionWrites(connectionId: string, durationSecs: number): Promise<number> {
const state = await post<{ remainingMs: number }>("/api/connection/write-unlock", { connectionId, durationSecs });
return state.remainingMs;
}
export async function lockConnectionWrites(connectionId: string): Promise<void> {
return post("/api/connection/write-unlock/lock", { connectionId });
}
export async function connectionWriteUnlockState(connectionId: string): Promise<number> {
const state = await post<{ remainingMs: number }>("/api/connection/write-unlock/state", { connectionId });
return state.remainingMs;
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return post("/api/connection/final-proxy-port", { config });
}
+18
View File
@@ -1023,6 +1023,24 @@ export async function saveConnectionDatabaseInfo(connectionId: string, databaseI
});
}
export interface WriteUnlockState {
remainingMs: number;
}
export async function unlockConnectionWrites(connectionId: string, durationSecs: number): Promise<number> {
const state = await invokeBackend<WriteUnlockState>("unlock_connection_writes", { connectionId, durationSecs });
return state.remainingMs;
}
export async function lockConnectionWrites(connectionId: string): Promise<void> {
return invokeBackend("lock_connection_writes", { connectionId });
}
export async function connectionWriteUnlockState(connectionId: string): Promise<number> {
const state = await invokeBackend<WriteUnlockState>("connection_write_unlock_state", { connectionId });
return state.remainingMs;
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return invokeBackend("connection_final_proxy_port", { config });
}
+4 -1
View File
@@ -1,4 +1,7 @@
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type AgentTargetConnection = {
id?: string;
read_only?: boolean;
transport_layers?: Array<{ enabled?: boolean }>;
external_config?: unknown;
@@ -32,7 +35,7 @@ export function consulAgentAddressesMatch(left: string, right: string): boolean
}
export function consulAgentWriteTargetSafe(connection: AgentTargetConnection | undefined, identityNode: string | undefined): boolean {
if (!connection || connection.read_only || connection.transport_layers?.some((layer) => layer.enabled !== false)) return false;
if (!connection || connectionIsEffectivelyReadOnly(connection) || connection.transport_layers?.some((layer) => layer.enabled !== false)) return false;
const external = connection.external_config;
if (!external || typeof external !== "object" || Array.isArray(external)) return false;
const config = external as Record<string, unknown>;
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess, formatWriteUnlockRemaining, sqlLooksLikeMutation } from "../readOnlyWriteAccess";
const unlockConnectionWrites = vi.fn();
const lockConnectionWrites = vi.fn();
const connectionWriteUnlockState = vi.fn();
vi.mock("@/lib/backend/api", () => ({
unlockConnectionWrites: (...args: unknown[]) => unlockConnectionWrites(...args),
lockConnectionWrites: (...args: unknown[]) => lockConnectionWrites(...args),
connectionWriteUnlockState: (...args: unknown[]) => connectionWriteUnlockState(...args),
}));
async function waitForPending(connectionId: string) {
const store = useReadOnlyUnlockStore();
await vi.waitFor(() => {
expect(store.pending?.connectionId).toBe(connectionId);
});
return store;
}
describe("read-only write unlock", () => {
beforeEach(() => {
setActivePinia(createPinia());
unlockConnectionWrites.mockReset().mockResolvedValue(60_000);
lockConnectionWrites.mockReset().mockResolvedValue(undefined);
connectionWriteUnlockState.mockReset().mockResolvedValue(0);
vi.useRealTimers();
});
it("formats remaining time as m:ss", () => {
expect(formatWriteUnlockRemaining(60_000)).toBe("1:00");
expect(formatWriteUnlockRemaining(5_000)).toBe("0:05");
expect(formatWriteUnlockRemaining(0)).toBe("0:00");
});
it("classifies SQL and Redis writes without treating reads as mutations", () => {
expect(sqlLooksLikeMutation("SELECT 1", "mysql")).toBe(false);
expect(sqlLooksLikeMutation("INSERT INTO t VALUES (1)", "mysql")).toBe(true);
expect(sqlLooksLikeMutation("UPDATE t SET a = 1", "postgres")).toBe(true);
expect(sqlLooksLikeMutation("DELETE FROM t", "mysql")).toBe(true);
expect(sqlLooksLikeMutation("CREATE TABLE t (id INT)", "mysql")).toBe(true);
expect(sqlLooksLikeMutation("GET k", "redis")).toBe(false);
expect(sqlLooksLikeMutation("SET k v", "redis")).toBe(true);
});
it("keeps the persistent read-only flag on while a window is active", async () => {
const connection = { id: "prod", name: "prod-db", read_only: true, db_type: "mysql" as const };
expect(connectionIsEffectivelyReadOnly(connection)).toBe(true);
const pending = ensureReadOnlyWriteAccess({ connection, sql: "INSERT INTO t VALUES (1)", source: "SQL editor" });
const store = await waitForPending("prod");
expect(unlockConnectionWrites).not.toHaveBeenCalled();
await store.confirm(60);
await expect(pending).resolves.toBe(true);
expect(unlockConnectionWrites).toHaveBeenCalledWith("prod", 60);
expect(connection.read_only).toBe(true);
expect(connectionIsEffectivelyReadOnly(connection)).toBe(false);
expect(store.isUnlocked("prod")).toBe(true);
expect(store.isUnlocked("other")).toBe(false);
});
it("cancels without unlocking and forgets the window when locked or expired", async () => {
const connection = { id: "prod", name: "prod-db", read_only: true, db_type: "mysql" as const };
const cancelled = ensureReadOnlyWriteAccess({ connection, sql: "DELETE FROM t", source: "SQL editor" });
const store = await waitForPending("prod");
store.cancel();
await expect(cancelled).resolves.toBe(false);
expect(unlockConnectionWrites).not.toHaveBeenCalled();
expect(connectionIsEffectivelyReadOnly(connection)).toBe(true);
const unlocked = ensureReadOnlyWriteAccess({ connection, sql: "DELETE FROM t", source: "SQL editor" });
await waitForPending("prod");
await store.confirm(60);
await expect(unlocked).resolves.toBe(true);
expect(await ensureReadOnlyWriteAccess({ connection, sql: "UPDATE t SET a = 1" })).toBe(true);
expect(unlockConnectionWrites).toHaveBeenCalledTimes(1);
await store.lockNow("prod");
expect(lockConnectionWrites).toHaveBeenCalledWith("prod");
expect(store.isUnlocked("prod")).toBe(false);
expect(connectionIsEffectivelyReadOnly(connection)).toBe(true);
});
it("does not prompt for reads or writable connections", async () => {
const store = useReadOnlyUnlockStore();
await expect(ensureReadOnlyWriteAccess({ connection: { id: "w", read_only: false, db_type: "mysql" }, sql: "INSERT INTO t VALUES (1)" })).resolves.toBe(true);
await expect(ensureReadOnlyWriteAccess({ connection: { id: "r", read_only: true, db_type: "mysql" }, sql: "SELECT 1" })).resolves.toBe(true);
expect(store.pending).toBeUndefined();
expect(connectionWriteUnlockState).not.toHaveBeenCalled();
});
it("reuses an existing backend window without prompting again", async () => {
connectionWriteUnlockState.mockResolvedValue(45_000);
const connection = { id: "prod", name: "prod-db", read_only: true, db_type: "mysql" as const };
await expect(ensureReadOnlyWriteAccess({ connection, sql: "INSERT INTO t VALUES (1)" })).resolves.toBe(true);
expect(unlockConnectionWrites).not.toHaveBeenCalled();
expect(useReadOnlyUnlockStore().pending).toBeUndefined();
expect(connectionIsEffectivelyReadOnly(connection)).toBe(false);
});
it("returns to effectively read-only when the local timer expires", async () => {
vi.useFakeTimers();
const store = useReadOnlyUnlockStore();
const connection = { id: "prod", name: "prod-db", read_only: true, db_type: "mysql" as const };
store.rememberWindow("prod", 60_000);
expect(connectionIsEffectivelyReadOnly(connection)).toBe(false);
await vi.advanceTimersByTimeAsync(60_000);
expect(store.isUnlocked("prod")).toBe(false);
expect(connectionIsEffectivelyReadOnly(connection)).toBe(true);
expect(connection.read_only).toBe(true);
});
});
@@ -1,5 +1,6 @@
import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
export type DatabaseNamespaceCreationTarget = "database" | "schema" | "attach" | "special";
@@ -12,7 +13,7 @@ export interface DatabaseNamespaceCreationMatrixEntry {
deferred?: string;
}
type CreationConnection = (Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> & Partial<Pick<ConnectionConfig, "host" | "password">>) | undefined;
type CreationConnection = (Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> & Partial<Pick<ConnectionConfig, "host" | "password" | "id">>) | undefined;
// Keep creation target-specific: many products expose schemas, files, or provider-managed namespaces instead of a top-level database.
export const DATABASE_NAMESPACE_CREATION_MATRIX = {
@@ -97,7 +98,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
} satisfies Record<DatabaseType, DatabaseNamespaceCreationMatrixEntry>;
export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null {
if (!connection || connection.read_only) return null;
if (!connection || connectionIsEffectivelyReadOnly(connection)) return null;
if (connection.db_type === "mongodb" && isMongoLegacyDriverProfile(connection.driver_profile)) return null;
if (connection.db_type === "sqlite" && (connection.host?.trim().toLowerCase() === ":memory:" || Boolean(connection.password))) {
return null;
@@ -107,7 +108,7 @@ export function connectionNamespaceCreationTarget(connection: CreationConnection
}
export function databaseNodeNamespaceCreationTarget(connection: CreationConnection, node: Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">): DatabaseNodeCreationTarget | null {
if (!connection || connection.read_only || node.type !== "database" || !node.database) return null;
if (!connection || connectionIsEffectivelyReadOnly(connection) || node.type !== "database" || !node.database) return null;
const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type];
return entry.database ?? null;
}
@@ -1,5 +1,6 @@
import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/database";
import { supportsCreateDatabaseCharset } from "@/lib/database/createDatabaseSql";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
export type DatabasePropertyEditGroup = "charsetCollation" | "databaseComment" | "schemaComment";
@@ -9,7 +10,7 @@ export interface DatabasePropertyEditingEntry {
deferred?: string;
}
type PropertyEditConnection = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> | undefined;
type PropertyEditConnection = (Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> & Partial<Pick<ConnectionConfig, "id">>) | undefined;
type DatabaseNode = Pick<{ type: TreeNodeType; database?: string | null }, "type" | "database">;
type SchemaNode = Pick<{ type: TreeNodeType; database?: string | null; schema?: string | null }, "type" | "database" | "schema">;
@@ -97,7 +98,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
} satisfies Record<DatabaseType, DatabasePropertyEditingEntry>;
function entryFor(connection: PropertyEditConnection): DatabasePropertyEditingEntry | null {
if (!connection || connection.read_only) return null;
if (!connection || connectionIsEffectivelyReadOnly(connection)) return null;
return DATABASE_PROPERTY_EDITING_MATRIX[connection.db_type] ?? null;
}
@@ -1,5 +1,6 @@
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import type { ConnectionConfig } from "@/types/database";
export interface ProductionSqlExecutionGuardOptions<T> {
@@ -11,6 +12,9 @@ export interface ProductionSqlExecutionGuardOptions<T> {
}
export async function executeWithProductionSqlGuard<T>(options: ProductionSqlExecutionGuardOptions<T>): Promise<T | undefined> {
if (!(await ensureReadOnlyWriteAccess({ connection: options.connection, sql: options.sql, source: options.source }))) {
return undefined;
}
const assessment = assessProductionSql(options.sql, options.connection, options.database);
if (assessment.active && assessment.isMutation) {
// Centralize production write confirmation so secondary tool surfaces cannot
@@ -41,6 +45,9 @@ export interface ProductionContextExecutionGuardOptions<T> {
* connection/database production scope rather than SQL risk classification.
*/
export async function executeWithProductionContextGuard<T>(options: ProductionContextExecutionGuardOptions<T>): Promise<T | undefined> {
if (!(await ensureReadOnlyWriteAccess({ connection: options.connection, sql: options.reviewText, source: options.source, treatAsMutation: true }))) {
return undefined;
}
const productionContext = productionContextForDatabase(options.connection, options.database);
if (productionContext.active) {
const confirmed = await useProductionSafetyStore().requestConfirmation({
@@ -0,0 +1,70 @@
import { getActivePinia } from "pinia";
import { useReadOnlyUnlockStore, type WriteUnlockDurationSecs } from "@/stores/readOnlyUnlockStore";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { classifySqlRisk, isSqlRiskMutation } from "@/lib/sql/sqlRisk";
import type { DatabaseType } from "@/types/database";
export type { WriteUnlockDurationSecs };
export const WRITE_UNLOCK_DURATIONS: WriteUnlockDurationSecs[] = [60, 300];
export function formatWriteUnlockRemaining(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function unlockStoreOrNull() {
try {
if (!getActivePinia()) return null;
return useReadOnlyUnlockStore();
} catch {
return null;
}
}
export function isWriteUnlockActive(connectionId: string | undefined | null): boolean {
if (!connectionId) return false;
return unlockStoreOrNull()?.isUnlocked(connectionId) === true;
}
export function connectionIsEffectivelyReadOnly(connection?: { id?: string; read_only?: boolean } | null): boolean {
if (!connection?.read_only) return false;
return !isWriteUnlockActive(connection.id);
}
export function sqlLooksLikeMutation(sql: string | undefined, databaseType?: DatabaseType): boolean {
if (!sql?.trim()) return true;
if (databaseType === "redis") {
return sql.split("\n").some((line) => {
const command = line.trim();
return command.length > 0 && classifyRedisCommandSafety(command) !== "allowed";
});
}
return isSqlRiskMutation(classifySqlRisk(sql, { dialect: databaseType }).risk);
}
export interface ReadOnlyWriteAccessOptions {
connection?: { id?: string; name?: string; read_only?: boolean; db_type?: DatabaseType } | null;
sql?: string;
source?: string;
treatAsMutation?: boolean;
}
export async function ensureReadOnlyWriteAccess(options: ReadOnlyWriteAccessOptions): Promise<boolean> {
const connection = options.connection;
if (!connection?.read_only) return true;
if (!connection.id) return false;
const store = unlockStoreOrNull();
if (store?.isUnlocked(connection.id)) return true;
const isMutation = options.treatAsMutation === true || sqlLooksLikeMutation(options.sql, connection.db_type);
if (!isMutation) return true;
if (!store) return false;
return store.requestUnlock({
connectionId: connection.id,
connectionName: connection.name,
sql: options.sql,
source: options.source,
});
}
@@ -1,6 +1,7 @@
import type { ConnectionConfig } from "@/types/database";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type MongoConnectionProfile = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only">;
type MongoConnectionProfile = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> & Partial<Pick<ConnectionConfig, "id">>;
/** Accept historical Legacy profile spellings while persisted configs are normalized. */
export function isMongoLegacyDriverProfile(driverProfile?: string): boolean {
@@ -10,7 +11,7 @@ export function isMongoLegacyDriverProfile(driverProfile?: string): boolean {
/** Capabilities implemented by both the native driver and the Legacy Agent. */
export function supportsMongoAllDriverMutations(connection?: MongoConnectionProfile): boolean {
return connection?.db_type === "mongodb" && !connection.read_only;
return connection?.db_type === "mongodb" && !connectionIsEffectivelyReadOnly(connection);
}
/** MongoDB views do not own indexes; collections and time-series collections do. */
@@ -0,0 +1,178 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/lib/backend/api";
export const WRITE_UNLOCK_ONE_MINUTE_SECS = 60;
export const WRITE_UNLOCK_FIVE_MINUTES_SECS = 300;
export type WriteUnlockDurationSecs = typeof WRITE_UNLOCK_ONE_MINUTE_SECS | typeof WRITE_UNLOCK_FIVE_MINUTES_SECS;
export interface ReadOnlyUnlockRequest {
connectionId: string;
connectionName?: string;
sql?: string;
source?: string;
}
interface QueuedUnlockRequest {
request: ReadOnlyUnlockRequest;
resolve: (confirmed: boolean) => void;
}
function applyRemaining(windows: Record<string, number>, connectionId: string, remainingMs: number, now = Date.now()): Record<string, number> {
const next = { ...windows };
if (remainingMs > 0) next[connectionId] = now + remainingMs;
else delete next[connectionId];
return next;
}
/**
* Coordinates the read-only write-unlock dialog and per-connection countdown.
* Expiry is compared against wall-clock `Date.now()` so backgrounded tabs still
* expire; the backend Instant gate remains authoritative for actual writes.
*/
export const useReadOnlyUnlockStore = defineStore("readOnlyUnlock", () => {
const pending = ref<ReadOnlyUnlockRequest>();
const windows = ref<Record<string, number>>({});
const nowMs = ref(Date.now());
const queue: QueuedUnlockRequest[] = [];
let resolvePending: ((confirmed: boolean) => void) | undefined;
let ticker: ReturnType<typeof setInterval> | undefined;
let unlockInFlight = false;
function touchNow() {
nowMs.value = Date.now();
}
function pruneExpired(now = Date.now()) {
const next: Record<string, number> = {};
for (const [connectionId, expiresAt] of Object.entries(windows.value)) {
if (expiresAt > now) next[connectionId] = expiresAt;
}
windows.value = next;
if (!Object.keys(next).length) stopTicker();
}
function startTicker() {
if (ticker !== undefined) return;
ticker = setInterval(() => {
touchNow();
pruneExpired();
}, 1000);
}
function stopTicker() {
if (ticker === undefined) return;
clearInterval(ticker);
ticker = undefined;
}
// Always compare against the wall clock at call time: the ticker-fed `nowMs`
// goes stale in throttled background tabs and would keep expired windows live.
function remainingMs(connectionId: string, now = Date.now()): number {
return Math.max(0, (windows.value[connectionId] ?? 0) - now);
}
function isUnlocked(connectionId: string, now = Date.now()): boolean {
return remainingMs(connectionId, now) > 0;
}
function rememberWindow(connectionId: string, remaining: number) {
windows.value = applyRemaining(windows.value, connectionId, remaining);
touchNow();
if (Object.keys(windows.value).length) startTicker();
else stopTicker();
}
async function refreshFromBackend(connectionId: string): Promise<number> {
const remaining = await api.connectionWriteUnlockState(connectionId);
rememberWindow(connectionId, remaining);
return remaining;
}
async function requestUnlock(request: ReadOnlyUnlockRequest): Promise<boolean> {
if (isUnlocked(request.connectionId)) return true;
try {
const remaining = await refreshFromBackend(request.connectionId);
if (remaining > 0) return true;
} catch {
// Backend remains the execution gate; continue to the confirm dialog.
}
return new Promise<boolean>((resolve) => {
if (pending.value) {
queue.push({ request, resolve });
return;
}
beginRequest(request, resolve);
});
}
function beginRequest(request: ReadOnlyUnlockRequest, resolve: (confirmed: boolean) => void) {
pending.value = request;
resolvePending = resolve;
}
function settle(confirmed: boolean) {
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
resolve?.(confirmed);
const next = queue.shift();
if (next) {
if (confirmed && isUnlocked(next.request.connectionId)) next.resolve(true);
else beginRequest(next.request, next.resolve);
}
}
async function confirm(durationSecs: WriteUnlockDurationSecs) {
const request = pending.value;
if (!request || unlockInFlight) return;
unlockInFlight = true;
try {
const remaining = await api.unlockConnectionWrites(request.connectionId, durationSecs);
rememberWindow(request.connectionId, remaining);
settle(true);
} catch {
// Keep the dialog open so the user can retry or cancel.
} finally {
unlockInFlight = false;
}
}
function cancel() {
settle(false);
}
async function lockNow(connectionId: string) {
await api.lockConnectionWrites(connectionId);
rememberWindow(connectionId, 0);
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
touchNow();
pruneExpired();
if (document.visibilityState !== "visible") return;
for (const connectionId of Object.keys(windows.value)) {
void refreshFromBackend(connectionId);
}
});
}
const unlockedConnectionIds = computed(() => Object.keys(windows.value).filter((id) => isUnlocked(id)));
return {
pending,
windows,
nowMs,
unlockedConnectionIds,
remainingMs,
isUnlocked,
requestUnlock,
confirm,
cancel,
lockNow,
refreshFromBackend,
rememberWindow,
};
});