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

@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { requiresDatabaseSelection, useSqlExecution } from "../useSqlExecution";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { ConnectionConfig, QueryTab } from "@/types/database";
vi.mock("vue-i18n", () => ({
@@ -137,4 +139,34 @@ describe("useSqlExecution", () => {
expect(executedSql).toContain("set @date_start = '2026-07-04 00:00:00'");
expect(executedSql).toContain("where fp.create_at < @date_start");
});
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("prod_app"));
const activeConnection = ref<ConnectionConfig | undefined>({ ...connection("mysql"), production_databases: ["prod_app"] });
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const productionSafetyStore = useProductionSafetyStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["ok"], rows: [[1]], affected_rows: 1, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
settingsStore.editorSettings.confirmDangerousSqlExecution = false;
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "UPDATE users SET active = 1 WHERE id = 7"),
activeOutputView,
});
const pendingExecution = execution.tryExecute();
await Promise.resolve();
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
expect(executeCurrentSql).not.toHaveBeenCalled();
productionSafetyStore.confirm();
await pendingExecution;
expect(executeCurrentSql).toHaveBeenCalledTimes(1);
});
});
@@ -7,6 +7,8 @@ import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataG
import { supportsDataGridTransaction } from "@/lib/table/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "@/lib/table/tableEditing";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -168,6 +170,7 @@ export function clearDataGridPendingSnapshotsForTab(tabId: string) {
export function useDataGridEditor(options: UseDataGridEditorOptions) {
const connectionStore = useConnectionStore();
const historyStore = useHistoryStore();
const productionSafetyStore = useProductionSafetyStore();
const {
result,
@@ -1161,6 +1164,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
const customHandler = customSaveHandler?.value;
const connection = connectionStore.getConfig(connectionId.value ?? "");
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.
if (saveOptions.autoSave) {
return;
}
const confirmed = await productionSafetyStore.requestConfirmation({
sql: describeDataGridChanges(snapshot),
connectionName: connection?.name,
database: database.value,
productionDatabases: customHandlerProductionContext.databases,
source: "Data editor",
});
if (!confirmed) return;
}
if (customHandler && snapshot.newRows.length > 0 && customHandler.supportsInsert !== true && customHandler.canInsert !== true) {
saveError.value = "当前保存目标不支持新增行。";
return;
@@ -1220,6 +1239,25 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
const productionAssessment = assessProductionSql(stmts.join(";\n"), connection, database.value);
if (productionAssessment.active && productionAssessment.isMutation) {
// Autosave must never write production data without an operator reviewing the generated statements.
if (saveOptions.autoSave) {
await finishInterruptedSaveChanges(snapshot);
return;
}
const confirmed = await productionSafetyStore.requestConfirmation({
sql: stmts.join("\n"),
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
});
if (!confirmed) {
await finishInterruptedSaveChanges(snapshot);
return;
}
}
const start = Date.now();
let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", {
@@ -1473,3 +1511,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
},
};
}
function describeDataGridChanges(snapshot: { newRows: unknown[]; dirtyRows: Map<unknown, unknown>; deletedRows: Set<unknown> }): string {
const changes = [snapshot.newRows.length ? `INSERT: ${snapshot.newRows.length} row(s)` : "", snapshot.dirtyRows.size ? `UPDATE: ${snapshot.dirtyRows.size} row(s)` : "", snapshot.deletedRows.size ? `DELETE: ${snapshot.deletedRows.size} row(s)` : ""].filter(Boolean);
return changes.join("\n") || "DATA GRID WRITE";
}
@@ -14,6 +14,8 @@ import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@@ -53,6 +55,7 @@ export function useSqlExecution(deps: {
const historyStore = useHistoryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const productionSafetyStore = useProductionSafetyStore();
const { toast } = useToast();
const dangerSql = ref("");
@@ -102,6 +105,19 @@ export function useSqlExecution(deps: {
}
}
}
const productionAssessment = assessProductionSql(sql, deps.activeConnection.value, deps.activeTab.value?.database);
if (productionAssessment.active && productionAssessment.isMutation) {
// Production writes always need a new explicit decision; editor preferences cannot suppress this gate.
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: deps.activeConnection.value?.name,
database: deps.activeTab.value?.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceSqlEditor"),
});
if (confirmed) await doExecute(sql);
return;
}
if (isDangerousSql(sql) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
pendingDangerSql.value = sql;