feat(safety): add production environment safeguards

This commit is contained in:
wuxiemian authored and GitHub committed 2026-07-12 10:02:27 +08:00
1 parent be66d6f46b
commit 1be66fb115
80 files changed
+4220 -234

No files matched your search

@@ -7,12 +7,15 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { ScrollArea } from "@/components/ui/scroll-area";
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ExtensionInfo, TreeNode } from "@/types/database";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const props = defineProps<{
node: TreeNode;
@@ -53,7 +56,14 @@ async function installExtension(name: string) {
installing.value = name;
try {
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
emit("close");
} catch (e: any) {
@@ -68,7 +78,14 @@ async function dropExtension(name: string) {
dropping.value = name;
try {
const sql = buildDropExtensionSql(name, false);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
@@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import DdlViewDialog from "./DdlViewDialog.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
@@ -1145,9 +1146,13 @@ async function confirmRename() {
newName,
source: source.source,
});
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.join(";\n"), async () => {
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
return true;
});
if (!executed) return;
} else {
const sql = await buildRenameObjectSql({
databaseType: effectiveDatabaseType.value,
@@ -1156,7 +1161,8 @@ async function confirmRename() {
oldName: row.name,
newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
}
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
showRenameDialog.value = false;
@@ -1173,7 +1179,8 @@ async function confirmDrop() {
const row = dropTarget.value;
try {
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
toast(t(successKey, { name: row.name }));
closeDroppedTableObjectTabsForRow(row);
@@ -1409,11 +1416,20 @@ async function confirmBatchDropTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
for (const row of targets) {
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
for (const { row, sql } of statements) {
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
return true;
});
if (!executed) return;
toast(t("objects.batchDropSuccess", { count: targets.length }));
clearTableSelection();
await reload();
@@ -1468,14 +1484,23 @@ async function confirmBatchTruncateTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
await runBatchTableTruncate(
targets,
async (row) => {
const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
},
refreshMutatedTableDataTabsForRows,
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
await runBatchTableTruncate(
statements,
async ({ sql }) => {
await api.executeQuery(props.connection.id, props.database, sql);
},
async (succeeded) => refreshMutatedTableDataTabsForRows(succeeded.map(({ row }) => row)),
);
return true;
});
if (!executed) return;
toast(t("objects.batchTruncateSuccess", { count: targets.length }));
clearTableSelection();
showBatchTruncateConfirm.value = false;
@@ -1696,7 +1721,8 @@ async function confirmDuplicateStructure() {
sourceName: row.name,
targetName: newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema);
@@ -1799,7 +1825,8 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(props.connection.id, props.database, structureSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(structureSql, () => api.executeQuery(props.connection.id, props.database, structureSql, schema));
if (!executed) return;
}
if (copyData) {
const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName);
@@ -1814,7 +1841,8 @@ async function confirmPasteTable() {
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(props.connection.id, props.database, dataSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
if (!executed) return;
}
successCount++;
} catch (e: any) {
@@ -1841,6 +1869,21 @@ function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boole
return result;
}
/**
* Routes Object Browser writes through the shared production gate. The SQL is
* assessed before the callback runs so generated DDL cannot bypass protection
* via executable comments, EXPLAIN ANALYZE, or qualified production targets.
*/
async function executeObjectBrowserSqlWithProductionGuard<T>(sql: string, execute: () => Promise<T>): Promise<T | undefined> {
return executeWithProductionSqlGuard({
connection: props.connection,
database: props.database,
sql,
source: t("production.sourceObjectBrowser"),
execute,
});
}
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
truncatePreviewSql.value = "";
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => "");
@@ -1858,7 +1901,8 @@ async function confirmTruncateTable() {
if (!row) return;
try {
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@@ -1883,7 +1927,8 @@ async function confirmEmptyTable() {
if (!row) return;
try {
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@@ -1949,8 +1994,23 @@ async function saveSource() {
name: row.name,
source: sourceDraft.value,
});
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: props.connection,
database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
return true;
},
});
if (!saved || sidePanelGuard.isStale(epoch)) return;
} else {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
}
toast(t("objects.sourceSaved"));
sourceEditing.value = false;
sourceDraft.value = "";
@@ -3,10 +3,12 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { Button } from "@/components/ui/button";
@@ -38,6 +40,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const content = ref("");
@@ -133,18 +136,34 @@ async function saveSource() {
return;
}
if (!draft.value.trim() || !props.databaseType) return;
const databaseType = props.databaseType;
const schema = props.schema || props.database;
saving.value = true;
saveError.value = "";
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType: props.databaseType,
databaseType,
objectType: props.objectType,
schema,
name: props.name,
source: draft.value,
});
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.connectionId),
database: props.database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
return true;
},
});
if (!saved) return;
} else {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
}
toast(t("objects.sourceSaved"));
emit("saved");
await loadSource(false);