feat(safety): add production environment safeguards
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user