feat(safety): add production environment safeguards
This commit is contained in:
parent
be66d6f46b
commit
1be66fb115
|
|
@ -73,6 +73,8 @@ import { isPreviewTab } from "@/lib/tabs/tabPresentation";
|
|||
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
|
||||
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
|
||||
import { assessProductionSql } from "@/lib/database/productionSafety";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
|
||||
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/connection/agentDriverUpdateBadge";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
|
@ -814,7 +816,22 @@ async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
|
|||
name: source.name,
|
||||
source: tab.sql,
|
||||
});
|
||||
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
|
||||
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
|
||||
if (executableSql.trim()) {
|
||||
const saved = await executeWithProductionSqlGuard({
|
||||
connection,
|
||||
database: tab.database,
|
||||
sql: executableSql,
|
||||
source: t("production.sourceObjectSource"),
|
||||
execute: async () => {
|
||||
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (!saved) return false;
|
||||
} else {
|
||||
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
|
||||
}
|
||||
queryStore.markTabClean(tab);
|
||||
toast(t("objects.sourceSaved"), 2000);
|
||||
return true;
|
||||
|
|
@ -1286,6 +1303,12 @@ function onAiRequestAutoExecuteSql(sql: string) {
|
|||
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
|
||||
selectedSql.value = "";
|
||||
|
||||
const productionAssessment = assessProductionSql(sql, activeConnection.value, activeTab.value?.database);
|
||||
if (productionAssessment.active && productionAssessment.isMutation) {
|
||||
toast(t("production.aiReviewRequired"), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = classifyAiSqlExecution(sql, activeConnection.value);
|
||||
if (decision.action === "block") {
|
||||
toast(t("ai.autoSqlBlocked"), 5000);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import {
|
||||
DAMENG_JOB_ENVIRONMENT_SQL,
|
||||
damengClearJobHistoriesSql,
|
||||
|
|
@ -169,10 +170,18 @@ async function applyPendingSql() {
|
|||
applying.value = true;
|
||||
try {
|
||||
await ensureConnection();
|
||||
await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
|
||||
maxRows: 1000,
|
||||
useTransaction: pendingUseTransaction.value,
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database: executionDatabase.value,
|
||||
sql: pendingSql.value,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: () =>
|
||||
api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
|
||||
maxRows: 1000,
|
||||
useTransaction: pendingUseTransaction.value,
|
||||
}),
|
||||
});
|
||||
if (!result) return;
|
||||
toast(t("damengJobAdmin.applySuccess"), 2500);
|
||||
previewDialogOpen.value = false;
|
||||
await (pendingAfterApply.value?.() ?? Promise.resolve());
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast";
|
|||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -165,7 +166,14 @@ async function applyPendingSql() {
|
|||
if (!pendingSql.value.trim()) return;
|
||||
applying.value = true;
|
||||
try {
|
||||
await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 });
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: props.connection,
|
||||
database: "",
|
||||
sql: pendingSql.value,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }),
|
||||
});
|
||||
if (!result) return;
|
||||
toast(t("userAdmin.applySuccess"), 2500);
|
||||
sqlDialogOpen.value = false;
|
||||
await (pendingAfterApply.value?.() ?? Promise.resolve());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ShieldAlert } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
|
||||
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
|
||||
<span>PROD</span>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -43,10 +43,10 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
|
|||
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
|
||||
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
|
||||
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
|
||||
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
|
||||
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
|
||||
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
|
||||
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
|
||||
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
|
@ -57,6 +57,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
|
|||
type DialogStep = "select" | "config";
|
||||
type DbPickerView = "icon" | "list";
|
||||
export type ConfigTab = "connection" | "advanced" | "tls" | "transport";
|
||||
type ProductionScope = "connection" | "databases";
|
||||
type MqTokenSigningMode = "none" | "hs256" | "rs256";
|
||||
type NacosAuthKind = NacosAuthConfig["kind"];
|
||||
type DremioConnectionMode = "arrow-flight-sql" | "legacy";
|
||||
|
|
@ -136,6 +137,13 @@ const visibleDatabaseSelection = ref<Set<string>>(new Set());
|
|||
const visibleDatabaseSearchText = ref("");
|
||||
const visibleDatabaseError = ref("");
|
||||
const visibleDatabaseShowSystem = ref(false);
|
||||
const showProductionDatabasesDialog = ref(false);
|
||||
const isLoadingProductionDatabases = ref(false);
|
||||
const productionDatabaseNames = ref<string[]>([]);
|
||||
const productionDatabaseSelection = ref<Set<string>>(new Set());
|
||||
const productionDatabaseSearchText = ref("");
|
||||
const productionDatabaseError = ref("");
|
||||
const productionProtectionEnabled = ref(false);
|
||||
const showVisibleSchemasDialog = ref(false);
|
||||
const isLoadingVisibleSchemas = ref(false);
|
||||
const visibleSchemaNames = ref<string[]>([]);
|
||||
|
|
@ -189,6 +197,8 @@ const defaultForm = (): ConnectionForm => ({
|
|||
informix_server: "",
|
||||
external_config: undefined,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: [],
|
||||
visible_databases: undefined,
|
||||
});
|
||||
|
||||
|
|
@ -1430,8 +1440,11 @@ watch(
|
|||
informix_server: config.informix_server || "",
|
||||
external_config: config.external_config,
|
||||
read_only: config.read_only || false,
|
||||
is_production: config.is_production || false,
|
||||
production_databases: config.production_databases || [],
|
||||
visible_databases: config.visible_databases,
|
||||
};
|
||||
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
|
||||
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
|
||||
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
|
||||
if (config.db_type === "mq") {
|
||||
|
|
@ -1471,6 +1484,7 @@ watch(
|
|||
} else {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
productionProtectionEnabled.value = false;
|
||||
selectedTransportLayerId.value = null;
|
||||
selectedType.value = "mysql";
|
||||
customDriverName.value = "";
|
||||
|
|
@ -1893,6 +1907,37 @@ const visibleDatabaseHasSystemDatabases = computed(() => {
|
|||
const connection = connectionConfigSnapshotForVisibleDatabases();
|
||||
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
|
||||
});
|
||||
const filteredProductionDatabaseNames = computed(() => {
|
||||
const query = productionDatabaseSearchText.value.trim().toLowerCase();
|
||||
if (!query) return productionDatabaseNames.value;
|
||||
return productionDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
|
||||
});
|
||||
const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size);
|
||||
const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0);
|
||||
const productionDatabaseSummary = computed(() => {
|
||||
const selected = form.value.production_databases?.length || 0;
|
||||
if (!selected) return t("production.noDatabasesSelected");
|
||||
if (!productionDatabaseNames.value.length) return t("production.databasesConfiguredCount", { count: selected });
|
||||
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
|
||||
});
|
||||
const productionScope = computed<ProductionScope>({
|
||||
get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"),
|
||||
set: (scope) => {
|
||||
form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection";
|
||||
},
|
||||
});
|
||||
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type));
|
||||
|
||||
function setProductionProtectionEnabled(enabled: boolean) {
|
||||
productionProtectionEnabled.value = enabled;
|
||||
if (!enabled) {
|
||||
form.value.is_production = false;
|
||||
form.value.production_databases = [];
|
||||
} else if (!form.value.is_production && !form.value.production_databases?.length) {
|
||||
// Enabling protection starts with the broadest scope until the user chooses a narrower one.
|
||||
form.value.is_production = true;
|
||||
}
|
||||
}
|
||||
const canChooseVisibleSchemas = computed(() => isSchemaAware(form.value.db_type));
|
||||
const visibleSchemasDatabaseKey = computed(() => form.value.database || "");
|
||||
const hasVisibleSchemaFilter = computed(() => {
|
||||
|
|
@ -2254,6 +2299,14 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
|||
}
|
||||
if (!config.one_time) config.one_time = undefined;
|
||||
if (!config.read_only) config.read_only = undefined;
|
||||
if (isSingleDatabase(config.db_type) && config.production_databases?.length) {
|
||||
// Single-database drivers expose schemas or internal names, not independently selectable databases.
|
||||
config.is_production = true;
|
||||
config.production_databases = [];
|
||||
}
|
||||
if (!config.is_production) config.is_production = undefined;
|
||||
config.production_databases = [...new Set((config.production_databases || []).map((database) => database.trim()).filter(Boolean))];
|
||||
if (!config.production_databases.length) config.production_databases = undefined;
|
||||
if (config.db_type === "mq") {
|
||||
const mqConfig = buildMqAdminConfig();
|
||||
config.external_config = mqConfig;
|
||||
|
|
@ -2685,6 +2738,16 @@ function resetVisibleDatabaseDraftState() {
|
|||
visibleDatabaseShowSystem.value = false;
|
||||
}
|
||||
|
||||
function resetProductionDatabaseDraftState() {
|
||||
showProductionDatabasesDialog.value = false;
|
||||
isLoadingProductionDatabases.value = false;
|
||||
productionDatabaseNames.value = [];
|
||||
productionDatabaseSelection.value = new Set();
|
||||
productionDatabaseSearchText.value = "";
|
||||
productionDatabaseError.value = "";
|
||||
productionProtectionEnabled.value = false;
|
||||
}
|
||||
|
||||
/** Silently load database names so the summary count shows a real total. */
|
||||
async function preloadVisibleDatabaseNames() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
|
|
@ -2755,6 +2818,88 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection
|
|||
return (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||
}
|
||||
|
||||
function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
|
||||
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
|
||||
const selected = new Set<string>();
|
||||
for (const name of selectedNames) {
|
||||
const canonicalName = available.get(name.toLowerCase());
|
||||
if (canonicalName) selected.add(canonicalName);
|
||||
}
|
||||
return [...selected];
|
||||
}
|
||||
|
||||
function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
|
||||
const configured = form.value.production_databases || [];
|
||||
// A new database-level safeguard starts broad; users can explicitly narrow it in the picker.
|
||||
return configured.length ? normalizeProductionDatabaseSelection(configured, databaseNames) : databaseNames;
|
||||
}
|
||||
|
||||
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
|
||||
if (config.db_type === "redis") {
|
||||
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
|
||||
}
|
||||
if (config.db_type === "mongodb") {
|
||||
return api.mongoListDatabases(connectionId);
|
||||
}
|
||||
return (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||
}
|
||||
|
||||
async function openProductionDatabasesPicker() {
|
||||
if (!ensureConnectionHostResolvedFromUrl() || !productionProtectionEnabled.value || form.value.is_production || isLoadingProductionDatabases.value) return;
|
||||
showProductionDatabasesDialog.value = true;
|
||||
await reloadProductionDatabases();
|
||||
}
|
||||
|
||||
async function reloadProductionDatabases() {
|
||||
if (isLoadingProductionDatabases.value) return;
|
||||
|
||||
isLoadingProductionDatabases.value = true;
|
||||
productionDatabaseError.value = "";
|
||||
productionDatabaseSearchText.value = "";
|
||||
const draftId = `__production_database_draft_${uuid()}`;
|
||||
try {
|
||||
const draftConfig = {
|
||||
...connectionConfigForSubmit(draftId),
|
||||
id: draftId,
|
||||
one_time: true,
|
||||
};
|
||||
await api.connectDb(draftConfig);
|
||||
productionDatabaseNames.value = await loadProductionDatabaseNames(draftId, draftConfig);
|
||||
productionDatabaseSelection.value = new Set(initialProductionDatabaseSelection(productionDatabaseNames.value));
|
||||
} catch (e: any) {
|
||||
productionDatabaseNames.value = [];
|
||||
productionDatabaseSelection.value = new Set();
|
||||
productionDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
|
||||
} finally {
|
||||
await api.disconnectDb(draftId).catch(() => undefined);
|
||||
isLoadingProductionDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProductionDatabase(database: string) {
|
||||
const next = new Set(productionDatabaseSelection.value);
|
||||
if (next.has(database)) next.delete(database);
|
||||
else next.add(database);
|
||||
productionDatabaseSelection.value = next;
|
||||
}
|
||||
|
||||
function selectAllProductionDatabases() {
|
||||
productionDatabaseSelection.value = new Set(productionDatabaseNames.value);
|
||||
}
|
||||
|
||||
function clearProductionDatabaseSelection() {
|
||||
productionDatabaseSelection.value = new Set();
|
||||
}
|
||||
|
||||
function saveProductionDatabaseSelection() {
|
||||
if (!productionDatabaseCanSave.value) return;
|
||||
// A database selection is always narrower than a connection-wide marker.
|
||||
productionProtectionEnabled.value = true;
|
||||
form.value.is_production = false;
|
||||
form.value.production_databases = normalizeProductionDatabaseSelection(productionDatabaseSelection.value, productionDatabaseNames.value);
|
||||
showProductionDatabasesDialog.value = false;
|
||||
}
|
||||
|
||||
function toggleVisibleDatabase(database: string) {
|
||||
const next = new Set(visibleDatabaseSelection.value);
|
||||
if (next.has(database)) next.delete(database);
|
||||
|
|
@ -2905,6 +3050,7 @@ function resetForm() {
|
|||
dbSearchQuery.value = "";
|
||||
configTab.value = "connection";
|
||||
resetVisibleDatabaseDraftState();
|
||||
resetProductionDatabaseDraftState();
|
||||
resetVisibleSchemasState();
|
||||
resetTestState();
|
||||
}
|
||||
|
|
@ -5084,6 +5230,39 @@ function openExternalUrl(url: string) {
|
|||
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
|
||||
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
|
||||
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>
|
||||
</Label>
|
||||
<div class="col-span-3 grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
|
||||
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
|
||||
</div>
|
||||
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t("production.disabledDescription") }}</p>
|
||||
<template v-else>
|
||||
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
|
||||
<Tabs v-model="productionScope" class="w-full">
|
||||
<TabsList class="grid h-8 w-full grid-cols-2">
|
||||
<TabsTrigger value="connection" class="text-xs">{{ t("production.allDatabases") }}</TabsTrigger>
|
||||
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t("production.selectedDatabases") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t("production.connectionDescription") : t("production.databaseDescription") }}</p>
|
||||
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-xs font-medium">{{ t("production.databases") }}</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
|
||||
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
<ListFilter v-else class="mr-1.5 h-4 w-4" />
|
||||
{{ t("production.selectDatabases") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyCompatibilityMode") }}</Label>
|
||||
<div class="col-span-3 flex flex-col gap-1">
|
||||
|
|
@ -5505,6 +5684,74 @@ function openExternalUrl(url: string) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showProductionDatabasesDialog">
|
||||
<DialogContent class="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("production.databasePickerTitle") }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input v-model="productionDatabaseSearchText" :placeholder="t('production.databaseSearchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="selectAllProductionDatabases">
|
||||
{{ t("visibleDatabases.selectAll") }}
|
||||
</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="clearProductionDatabaseSelection">
|
||||
{{ t("visibleDatabases.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
|
||||
{{ t("production.databaseSelectionRequired") }}
|
||||
</p>
|
||||
|
||||
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
|
||||
<div v-if="isLoadingProductionDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
|
||||
<p>{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}</p>
|
||||
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("production.retry") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
|
||||
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="database in filteredProductionDatabaseNames"
|
||||
:key="database"
|
||||
type="button"
|
||||
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
|
||||
@click="toggleProductionDatabase(database)"
|
||||
>
|
||||
<CheckSquare v-if="productionDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
|
||||
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showProductionDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="isLoadingProductionDatabases || !!productionDatabaseError || !productionDatabaseCanSave" @click="saveProductionDatabaseSelection">
|
||||
{{ t("visibleDatabases.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VisibleSchemasDialog
|
||||
v-model:open="showVisibleSchemasDialog"
|
||||
draft-mode
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
|||
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
|
||||
|
||||
|
|
@ -713,30 +714,40 @@ async function copySql() {
|
|||
|
||||
async function executeSql() {
|
||||
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
executeTotal.value = syncPlan.value.syncStatements.length;
|
||||
executedCount.value = 0;
|
||||
const targetConnection = store.getConfig(targetConnectionId.value);
|
||||
try {
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncPlan.value.syncStatements;
|
||||
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
|
||||
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
|
||||
try {
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
|
||||
executedCount.value += batch.length;
|
||||
} catch (e: any) {
|
||||
for (const stmt of batch) {
|
||||
const failed = await executeWithProductionSqlGuard({
|
||||
connection: targetConnection,
|
||||
database: targetDatabase.value,
|
||||
sql: syncPlan.value.syncSql,
|
||||
source: t("production.sourceDataCompare"),
|
||||
execute: async () => {
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
executeTotal.value = syncPlan.value.syncStatements.length;
|
||||
executedCount.value = 0;
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncPlan.value.syncStatements;
|
||||
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
|
||||
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
|
||||
try {
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
|
||||
} catch (singleError: any) {
|
||||
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
|
||||
executedCount.value += batch.length;
|
||||
} catch (e: any) {
|
||||
for (const stmt of batch) {
|
||||
try {
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
|
||||
} catch (singleError: any) {
|
||||
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
|
||||
}
|
||||
executedCount.value++;
|
||||
}
|
||||
}
|
||||
executedCount.value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const failed = syncErrors.value.length;
|
||||
return syncErrors.value.length;
|
||||
},
|
||||
});
|
||||
if (failed === undefined) return;
|
||||
if (failed === 0) {
|
||||
toast(t("dataCompare.syncSuccess"), 2000);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
||||
|
|
@ -496,7 +497,14 @@ async function handleExecuteScript() {
|
|||
|
||||
executing.value = true;
|
||||
try {
|
||||
await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: store.getConfig(targetConnectionId.value),
|
||||
database: targetDatabase.value,
|
||||
sql: deploySql.value,
|
||||
source: t("production.sourceSchemaDiff"),
|
||||
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
||||
});
|
||||
if (!result) return;
|
||||
toast(t("diff.executeSuccess"), 3000);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
|
|
@ -621,7 +629,14 @@ async function onConfirmDeploy() {
|
|||
showConfirmDialog.value = false;
|
||||
executing.value = true;
|
||||
try {
|
||||
const result = await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: store.getConfig(targetConnectionId.value),
|
||||
database: targetDatabase.value,
|
||||
sql: deploySql.value,
|
||||
source: t("production.sourceSchemaDiff"),
|
||||
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
|
||||
});
|
||||
if (!result) return;
|
||||
deployResult.value = {
|
||||
success: true,
|
||||
message: t("diff.deploySuccess"),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, d
|
|||
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
|
||||
import { extractFirstSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
|
||||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
||||
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||
|
|
@ -496,6 +499,8 @@ const proposalConfirmMessage = computed<ChatMessage | null>(() => {
|
|||
|
||||
let allowWriteSqlForNextRun = false;
|
||||
|
||||
const productionContext = computed(() => productionContextForDatabase(props.connection, props.tab?.database));
|
||||
|
||||
function proposalContainsWriteSql(content: string) {
|
||||
return /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i.test(content);
|
||||
}
|
||||
|
|
@ -505,6 +510,12 @@ function sendProposalReply(positive: boolean) {
|
|||
if (isGenerating.value) return;
|
||||
const target = proposalConfirmMessage.value;
|
||||
if (!target) return;
|
||||
if (positive && productionContext.value.active && proposalContainsWriteSql(target.content)) {
|
||||
const sql = extractFirstSqlCodeBlock(target.content);
|
||||
if (sql) emit("replaceSql", sql);
|
||||
toast(t("production.aiReviewRequired"), 5000);
|
||||
return;
|
||||
}
|
||||
const isZh = containsChinese(target.content || "");
|
||||
const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。";
|
||||
const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation.";
|
||||
|
|
@ -1396,7 +1407,8 @@ async function send() {
|
|||
|
||||
const requestedAction = activeAction.value;
|
||||
const requestedMode = assistantMode.value;
|
||||
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun;
|
||||
// Agent confirmation cannot grant autonomous writes while the active database is production.
|
||||
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active;
|
||||
allowWriteSqlForNextRun = false;
|
||||
isGenerating.value = true;
|
||||
messages.value.push({ role: "assistant", content: "" });
|
||||
|
|
@ -1483,6 +1495,7 @@ async function send() {
|
|||
instruction: modelInstruction,
|
||||
assistantContent: msg?.content || "",
|
||||
connection: props.connection,
|
||||
database: props.tab?.database,
|
||||
});
|
||||
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
|
||||
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
|
||||
|
|
@ -1739,6 +1752,7 @@ async function openExternalUrl(url: string) {
|
|||
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
|
||||
{{ chatTitle }}
|
||||
</span>
|
||||
<ProductionContextBadge v-if="productionContext.active" compact />
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
|
||||
<MessageSquarePlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { resolveHistoryActivityKind } from "@/lib/history/historyActivityKind";
|
||||
import { canRollbackHistoryEntry } from "@/lib/history/historyAiAnalysis";
|
||||
|
|
@ -16,12 +17,14 @@ import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRa
|
|||
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
|
||||
import type { HistoryEntry } from "@/lib/backend/api";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { highlight } = useSqlHighlighter();
|
||||
const store = useHistoryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [sql: string, entry: HistoryEntry];
|
||||
|
|
@ -226,7 +229,14 @@ async function rollback(entry: HistoryEntry) {
|
|||
isRollingBack.value = true;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await api.executeScript(connectionId, entry.database, rollbackSql);
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(connectionId),
|
||||
database: entry.database,
|
||||
sql: rollbackSql,
|
||||
source: t("production.sourceQueryHistory"),
|
||||
execute: () => api.executeScript(connectionId, entry.database, rollbackSql),
|
||||
});
|
||||
if (!result) return;
|
||||
await store.add({
|
||||
connection_id: connectionId,
|
||||
connection_name: entry.connection_name,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generate
|
|||
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
|
||||
import type { ColumnInfo, TableInfo } from "@/types/database";
|
||||
|
||||
|
|
@ -450,43 +451,57 @@ async function startInsert() {
|
|||
const cid = props.prefillConnectionId;
|
||||
const db = props.prefillDatabase;
|
||||
if (!cid || !db) return;
|
||||
executing.value = true;
|
||||
const perTable: TableResult[] = [];
|
||||
for (const r of generatedResults.value) {
|
||||
const stmts = sqlStatementsForTable(r);
|
||||
const rowCount = r.rows.length;
|
||||
let ok = 0;
|
||||
let lastError = "";
|
||||
for (let si = 0; si < stmts.length; si++) {
|
||||
try {
|
||||
if (generateOptions.useTransaction) {
|
||||
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
|
||||
} else {
|
||||
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
|
||||
const sql = allSqlStatements().join("\n");
|
||||
if (!sql.trim()) return;
|
||||
try {
|
||||
await executeWithProductionSqlGuard({
|
||||
connection: store.getConfig(cid),
|
||||
database: db,
|
||||
sql,
|
||||
source: t("production.sourceDataGenerate"),
|
||||
execute: async () => {
|
||||
executing.value = true;
|
||||
const perTable: TableResult[] = [];
|
||||
for (const r of generatedResults.value) {
|
||||
const stmts = sqlStatementsForTable(r);
|
||||
const rowCount = r.rows.length;
|
||||
let ok = 0;
|
||||
let lastError = "";
|
||||
for (let si = 0; si < stmts.length; si++) {
|
||||
try {
|
||||
if (generateOptions.useTransaction) {
|
||||
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
|
||||
} else {
|
||||
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
|
||||
}
|
||||
if (generateOptions.extendedInsert) {
|
||||
ok = rowCount;
|
||||
} else if (!(generateOptions.truncate && si === 0)) {
|
||||
ok++;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[startInsert] SQL error:", msg);
|
||||
if (!lastError) lastError = msg;
|
||||
if (generateOptions.extendedInsert) {
|
||||
ok = 0;
|
||||
}
|
||||
if (!generateOptions.continueOnError) break;
|
||||
}
|
||||
}
|
||||
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
|
||||
if (ok > 0) {
|
||||
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
|
||||
}
|
||||
}
|
||||
if (generateOptions.extendedInsert) {
|
||||
ok = rowCount;
|
||||
} else if (!(generateOptions.truncate && si === 0)) {
|
||||
ok++;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[startInsert] SQL error:", msg);
|
||||
if (!lastError) lastError = msg;
|
||||
if (generateOptions.extendedInsert) {
|
||||
ok = 0;
|
||||
}
|
||||
if (!generateOptions.continueOnError) break;
|
||||
}
|
||||
}
|
||||
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
|
||||
if (ok > 0) {
|
||||
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
|
||||
}
|
||||
executeResults.value = perTable;
|
||||
currentStep.value = "result";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
executeResults.value = perTable;
|
||||
currentStep.value = "result";
|
||||
executing.value = false;
|
||||
}
|
||||
|
||||
const orderDialogOpen = ref(false);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/sea
|
|||
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
|
||||
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
|
||||
|
|
@ -82,7 +83,17 @@ const emit = defineEmits<{
|
|||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const dialogs = useDialogSources();
|
||||
const productionConfirmationDetails = computed(() => {
|
||||
const request = productionSafetyStore.pending;
|
||||
if (!request) return "";
|
||||
return t("production.confirmDetails", {
|
||||
connection: request.connectionName || "-",
|
||||
database: request.productionDatabases?.join(", ") || request.database || "-",
|
||||
source: request.source || "-",
|
||||
});
|
||||
});
|
||||
|
||||
const editConfig = computed(() => {
|
||||
const id = connectionStore.editingConnectionId;
|
||||
|
|
@ -136,6 +147,18 @@ watch(
|
|||
@update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)"
|
||||
@confirm="emit('dangerConfirm')"
|
||||
/>
|
||||
<DangerConfirmDialog
|
||||
v-if="productionSafetyStore.pending"
|
||||
:open="true"
|
||||
:title="t('production.confirmTitle')"
|
||||
:message="t('production.confirmMessage')"
|
||||
:details-text="productionConfirmationDetails"
|
||||
:sql="productionSafetyStore.pending.sql"
|
||||
:confirm-label="t('production.confirmAction')"
|
||||
:close-on-confirm="false"
|
||||
@update:open="(open) => !open && productionSafetyStore.cancel()"
|
||||
@confirm="productionSafetyStore.confirm()"
|
||||
/>
|
||||
<SqlParameterDialog
|
||||
v-if="showSqlParameterDialog"
|
||||
:open="showSqlParameterDialog"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
|||
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } from "@lucide/vue";
|
||||
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -80,6 +80,7 @@ import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
|
|||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
|
||||
type DataGridHandle = {
|
||||
onToolbarRefresh: () => Promise<void> | void;
|
||||
|
|
@ -156,7 +157,7 @@ const emit = defineEmits<{
|
|||
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
|
@ -206,6 +207,13 @@ const activeTableMeta = computed(() => props.activeTab.tableMeta);
|
|||
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
|
||||
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection));
|
||||
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
|
||||
const productionSessionDetail = computed(() => {
|
||||
if (!activeProductionContext.value.active) return "";
|
||||
if (activeProductionContext.value.reason === "connection") return t("production.connection");
|
||||
return activeProductionContext.value.databases.join(", ") || t("production.databases");
|
||||
});
|
||||
|
||||
function findNodeInTree(nodes: TreeNode[], id: string): TreeNode | undefined {
|
||||
for (const node of nodes) {
|
||||
|
|
@ -732,15 +740,23 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<div class="production-session-shell flex flex-col flex-1 min-h-0" :class="{ 'production-session-shell--active': activeProductionContext.active }">
|
||||
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
|
||||
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
|
||||
<span v-if="productionSessionDetail" class="min-w-0 truncate rounded-[4px] border border-red-500/25 bg-background/65 px-1.5 py-0.5 font-medium text-red-700 dark:text-red-200">{{ productionSessionDetail }}</span>
|
||||
</div>
|
||||
<!-- Query mode: editor + results -->
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
|
||||
<Pane class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
|
||||
<div class="h-full flex flex-col relative">
|
||||
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.24] dark:text-red-200/[0.2]">{{ productionWatermarkText }}</span>
|
||||
</div>
|
||||
<QueryEditor
|
||||
ref="queryEditorRef"
|
||||
class="flex-1"
|
||||
class="relative z-0 flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
|
|
@ -1554,11 +1570,41 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
isolation: isolate;
|
||||
}
|
||||
|
||||
.production-session-shell--active {
|
||||
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--destructive) 78%, transparent);
|
||||
}
|
||||
|
||||
.production-session-strip {
|
||||
background-image: linear-gradient(90deg, color-mix(in oklch, var(--destructive) 14%, transparent), color-mix(in oklch, var(--destructive) 7%, transparent));
|
||||
}
|
||||
|
||||
.query-output-splitpanes :deep(> .splitpanes__splitter) {
|
||||
z-index: 1;
|
||||
flex: 0 0 3px;
|
||||
}
|
||||
|
||||
.production-watermark {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 3rem;
|
||||
overflow: hidden;
|
||||
padding: 3rem 2.5rem;
|
||||
}
|
||||
|
||||
.production-watermark__label {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
transform: rotate(-22deg);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.production-watermark {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.result-tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
|||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { useSchemaOptions } from "@/composables/useSchemaOptions";
|
||||
|
|
@ -15,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
|
|||
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
|
||||
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -65,6 +67,9 @@ const activeDatabaseOptions = computed(() => {
|
|||
|
||||
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
|
||||
const showDatabaseProductionBadge = computed(() => activeProductionContext.value.reason === "database");
|
||||
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
const activeSchemaValue = computed(() => props.activeTab.schema || "");
|
||||
const supportsExplain = computed(() => {
|
||||
|
|
@ -87,6 +92,10 @@ const transactionTooltip = computed(() => {
|
|||
if (isAgent) return t("toolbar.autoCommitAgent");
|
||||
return isManual ? t("toolbar.manualTransaction") : t("toolbar.autoCommit");
|
||||
});
|
||||
const executeButtonClass = computed(() => {
|
||||
if (props.activeTab.isExecuting) return "";
|
||||
return activeProductionContext.value.active ? "bg-red-500/10 text-red-700 hover:bg-red-500/20 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200" : "bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200";
|
||||
});
|
||||
|
||||
const isTransactionActive = computed(() => !!props.txnSessionId);
|
||||
|
||||
|
|
@ -144,6 +153,11 @@ function databaseDisplayName(database: string): string {
|
|||
function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
return connectionStore.getConfig(connectionId);
|
||||
}
|
||||
|
||||
function databaseOptionIsProduction(database: string): boolean {
|
||||
if (!database || props.activeConnection?.is_production) return false;
|
||||
return productionContextForDatabase(props.activeConnection, database).reason === "database";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -155,7 +169,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="activeTab.isExecuting ? '' : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'"
|
||||
:class="executeButtonClass"
|
||||
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
|
||||
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
|
||||
>
|
||||
|
|
@ -318,6 +332,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
|
||||
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<ProductionContextBadge v-if="showConnectionProductionBadge" compact />
|
||||
</div>
|
||||
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
|
||||
</template>
|
||||
|
|
@ -354,9 +369,13 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
|||
<template #trigger-label="{ label, loading }">
|
||||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
|
||||
<ProductionContextBadge v-if="showDatabaseProductionBadge" compact />
|
||||
</template>
|
||||
<template #option-label="{ label }">
|
||||
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
|
||||
<template #option-label="{ option, label }">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
|
||||
<ProductionContextBadge v-if="databaseOptionIsProduction(option)" compact />
|
||||
</div>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
<Tooltip v-if="activeDatabaseValue && !isSingleDb">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
|
|||
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
|
||||
import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue";
|
||||
import SchemaFilterDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
|
||||
|
|
@ -171,6 +172,8 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
|||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
|
||||
const { t } = useI18n();
|
||||
const labelRef = ref<HTMLElement>();
|
||||
|
|
@ -272,6 +275,11 @@ const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(props.node.type
|
|||
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
|
||||
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
|
||||
const labelWidthClass = computed(() => (usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 truncate"));
|
||||
const nodeProductionContext = computed(() => {
|
||||
const connectionId = props.node.connectionId;
|
||||
return productionContextForDatabase(connectionId ? connectionStore.getConfig(connectionId) : undefined, props.node.database);
|
||||
});
|
||||
const showProductionBadge = computed(() => nodeProductionContext.value.active && ["connection", "database", "redis-db", "mongo-db"].includes(props.node.type));
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
return props.node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined;
|
||||
|
|
@ -2337,6 +2345,18 @@ function openRenameObjectDialog() {
|
|||
showRenameObjectDialog.value = true;
|
||||
}
|
||||
|
||||
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string } = {}) {
|
||||
if (!node.connectionId) return undefined;
|
||||
const database = options.database ?? node.database ?? "";
|
||||
return executeWithProductionSqlGuard({
|
||||
connection: connectionStore.getConfig(node.connectionId),
|
||||
database,
|
||||
sql,
|
||||
source: t("production.sourceSidebar"),
|
||||
execute: () => api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema),
|
||||
});
|
||||
}
|
||||
|
||||
let renameObjectPreviewRequestId = 0;
|
||||
|
||||
async function refreshRenameObjectPreviewSql() {
|
||||
|
|
@ -2390,7 +2410,7 @@ async function confirmRenameObject() {
|
|||
source: source.source,
|
||||
});
|
||||
for (const sql of statements) {
|
||||
await api.executeQuery(node.connectionId, node.database, sql, schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema });
|
||||
}
|
||||
} else {
|
||||
const sql = await buildRenameObjectSql({
|
||||
|
|
@ -2400,7 +2420,7 @@ async function confirmRenameObject() {
|
|||
oldName: node.label,
|
||||
newName,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
}
|
||||
toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000);
|
||||
showRenameObjectDialog.value = false;
|
||||
|
|
@ -2418,7 +2438,7 @@ async function confirmDropObject() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
||||
toast(t(msgKey, { name: node.label }), 3000);
|
||||
closeDroppedTableObjectTabsForNode(node);
|
||||
|
|
@ -2440,7 +2460,7 @@ async function confirmDropTableChildObject() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropTableChildObjectPreviewSql.value || (await buildDropTableChildObjectSql(options));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.dropTableChildObjectSuccess", { name: options.name }), 3000);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
} catch (e: any) {
|
||||
|
|
@ -2484,7 +2504,7 @@ async function confirmBatchDrop() {
|
|||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
|
||||
if (!sql) continue;
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
closeDroppedTableObjectTabsForNode(target);
|
||||
connectionStore.removeTreeNode(target.id);
|
||||
}
|
||||
|
|
@ -2507,7 +2527,8 @@ async function confirmBatchTruncate() {
|
|||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await truncateSqlForTreeNode(target, { cascade: useCascade });
|
||||
if (!sql) return false;
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
const result = await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
return result === undefined ? false : undefined;
|
||||
},
|
||||
refreshMutatedTableDataTabsForNodes,
|
||||
);
|
||||
|
|
@ -2527,7 +2548,7 @@ async function confirmBatchEmpty() {
|
|||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await emptySqlForTreeNode(target);
|
||||
if (!sql) throw new Error("Empty table SQL is unavailable");
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||
});
|
||||
for (const failure of result.failed) {
|
||||
console.error(`Failed to empty table "${failure.target.label}":`, failure.error);
|
||||
|
|
@ -2719,7 +2740,7 @@ async function confirmDropTable() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = dropTablePreviewSql.value || (await buildDropTableSql(dropTableSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
|
||||
closeDroppedTableObjectTabsForNode(node);
|
||||
connectionStore.removeTreeNode(node.id);
|
||||
|
|
@ -2739,7 +2760,7 @@ async function confirmEmptyTable() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
|
||||
toast(t(messageKey, { name: node.label }), 3000);
|
||||
await refreshMutatedTableDataTabsForNode(node);
|
||||
|
|
@ -2760,7 +2781,7 @@ async function confirmTruncateTable() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(truncateTableSqlOptions()));
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
|
||||
await refreshMutatedTableDataTabsForNode(node);
|
||||
} catch (e: any) {
|
||||
|
|
@ -2895,7 +2916,7 @@ async function confirmEditDatabaseProperties() {
|
|||
const options = databasePropertyEditOptions();
|
||||
if (!options) return;
|
||||
const sql = await buildUpdateDatabasePropertiesSql(options);
|
||||
await api.executeQuery(node.connectionId, databasePropertyName(), sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: databasePropertyName() });
|
||||
toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000);
|
||||
showEditDatabasePropertiesDialog.value = false;
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
|
|
@ -2968,7 +2989,7 @@ async function confirmEditSchemaComment() {
|
|||
name: node.schema || node.label,
|
||||
comment: schemaCommentText.value,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.editSchemaCommentSuccess", { name: node.label }), 3000);
|
||||
showEditSchemaCommentDialog.value = false;
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
|
||||
|
|
@ -3143,7 +3164,8 @@ async function createDuckDbAttachedDatabaseFile() {
|
|||
duckDbAttachedDatabaseNameFromPath(path),
|
||||
existingDatabases.map((database) => database.name),
|
||||
);
|
||||
await api.executeQuery(node.connectionId, "", await buildDuckDbAttachDatabaseSql(path, name));
|
||||
const sql = await buildDuckDbAttachDatabaseSql(path, name);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config) {
|
||||
|
|
@ -3184,7 +3206,7 @@ async function confirmCreateDatabase() {
|
|||
charset: createDatabaseCharset.value,
|
||||
collation: createDatabaseCollation.value,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
toast(t("contextMenu.createDatabaseSuccess", { name }), 3000);
|
||||
await connectionStore.ensureVisibleDatabase(node.connectionId, name);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
|
|
@ -3255,7 +3277,7 @@ async function confirmDropDatabase() {
|
|||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
|
||||
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
showDropDatabaseConfirm.value = false;
|
||||
|
|
@ -3349,7 +3371,7 @@ async function confirmCreateSchema() {
|
|||
databaseType: effectiveDatabaseTypeForConnection(config),
|
||||
name,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, targetDatabase || "", sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: targetDatabase || "" });
|
||||
toast(t("contextMenu.createSchemaSuccess", { name }), 3000);
|
||||
if (isConnectionLevelSchemaCreation) {
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
|
|
@ -3379,7 +3401,7 @@ async function confirmDropSchema() {
|
|||
databaseType: currentDatabaseType(),
|
||||
name: node.label,
|
||||
}));
|
||||
await api.executeQuery(node.connectionId, node.database, sql);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database });
|
||||
toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "sqlserver") {
|
||||
|
|
@ -3417,7 +3439,7 @@ async function confirmDuplicateStructure() {
|
|||
sourceName: node.label,
|
||||
targetName: newName,
|
||||
});
|
||||
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
|
||||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
|
||||
await refreshTableList(node);
|
||||
} catch (e: any) {
|
||||
|
|
@ -3446,7 +3468,7 @@ async function confirmPasteTable() {
|
|||
sourceName: entry.sourceName,
|
||||
targetName,
|
||||
});
|
||||
await api.executeQuery(entry.connectionId, entry.database, structureSql, entry.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
|
||||
}
|
||||
if (copyData) {
|
||||
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
|
||||
|
|
@ -3461,7 +3483,7 @@ async function confirmPasteTable() {
|
|||
targetName,
|
||||
...dataCopyColumnOptions,
|
||||
});
|
||||
await api.executeQuery(entry.connectionId, entry.database, dataSql, entry.schema);
|
||||
await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
|
||||
}
|
||||
successCount++;
|
||||
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
|
||||
|
|
@ -5323,6 +5345,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
@click.stop
|
||||
/>
|
||||
<span v-else ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
|
||||
<ProductionContextBadge v-if="showProductionBadge" compact />
|
||||
<span
|
||||
v-if="
|
||||
(node.type === 'group-tables' || node.type === 'group-views' || node.type === 'group-materialized-views' || node.type === 'group-procedures' || node.type === 'group-functions' || node.type === 'group-sequences' || node.type === 'group-packages' || node.type === 'group-partitions') &&
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
|
||||
|
|
@ -31,6 +33,7 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const filePath = ref("");
|
||||
|
|
@ -280,6 +283,18 @@ async function refreshTargetAfterImport() {
|
|||
|
||||
async function startExecution() {
|
||||
if (!canStart.value || !preview.value) 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.
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql: preview.value.preview,
|
||||
connectionName: selectedConnection.value?.name,
|
||||
database: database.value,
|
||||
productionDatabases: productionContext.databases,
|
||||
source: t("production.sourceSqlFile"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
executionId.value = id;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useSettingsStore, type StructureEditorDensity } from "@/stores/settingsStore";
|
||||
|
|
@ -64,6 +66,7 @@ import * as api from "@/lib/backend/api";
|
|||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const store = useConnectionStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
const queryStore = useQueryStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
|
@ -2061,13 +2064,24 @@ function toggleSqlPreviewCollapsed() {
|
|||
|
||||
async function applyChanges() {
|
||||
if (!canApply.value || !props.connectionId || !props.database) return;
|
||||
const sql = previewSqlText.value;
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
const productionContext = productionContextForDatabase(connection, props.database);
|
||||
if (productionContext.active) {
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql,
|
||||
connectionName: connection?.name,
|
||||
database: props.database,
|
||||
productionDatabases: productionContext.databases,
|
||||
source: t("production.sourceStructure"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
const sql = previewSqlText.value;
|
||||
const refreshScope = captureStructureRefreshScope();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const connection = store.getConfig(props.connectionId);
|
||||
const result = hasSqliteTypeChange.value
|
||||
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
|
||||
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2281,6 +2281,48 @@ export default {
|
|||
cancel: "Cancel",
|
||||
confirm: "Execute",
|
||||
},
|
||||
production: {
|
||||
title: "Production environment",
|
||||
connection: "Production connection",
|
||||
enable: "Enable production safeguards",
|
||||
disabledDescription: "Enable this to protect every database or selected databases.",
|
||||
scope: "Protection scope",
|
||||
allDatabases: "All databases",
|
||||
selectedDatabases: "Selected databases",
|
||||
singleDatabaseScopeHint: "This database type supports connection-level production safeguards only.",
|
||||
databases: "Production databases",
|
||||
connectionDescription: "Every database on this connection uses production safeguards.",
|
||||
databaseDescription: "Choose the databases that need production safeguards. The first selection includes all databases.",
|
||||
selectDatabases: "Select production databases",
|
||||
noDatabasesSelected: "No databases selected",
|
||||
databasesConfiguredCount: "{count} selected",
|
||||
databasesSelectedCount: "{selected}/{total} selected",
|
||||
databasePickerTitle: "Select production databases",
|
||||
databasePickerDescription: 'Choose the databases on "{connection}" that need production safeguards.',
|
||||
databaseSearchPlaceholder: "Search databases...",
|
||||
databaseSelectionRequired: "Select at least one database.",
|
||||
databaseLoadFailed: "Could not load databases: {message}",
|
||||
noDatabasesAvailable: "No databases are available to select.",
|
||||
retry: "Retry",
|
||||
confirmTitle: "Confirm production write",
|
||||
confirmMessage: "This operation changes a production database and requires explicit confirmation.",
|
||||
confirmDetails: "Connection: {connection}\nDatabase: {database}\nSource: {source}",
|
||||
confirmAction: "Execute in production",
|
||||
sourceSqlEditor: "SQL editor",
|
||||
sourceDataGrid: "Data editor",
|
||||
sourceStructure: "Structure editor",
|
||||
sourceSqlFile: "SQL file execution",
|
||||
sourceObjectBrowser: "Object browser",
|
||||
sourceSchemaDiff: "Schema diff",
|
||||
sourceDataCompare: "Data compare",
|
||||
sourceExtension: "Extension manager",
|
||||
sourceSidebar: "Object tree",
|
||||
sourceObjectSource: "Object source editor",
|
||||
sourceDataGenerate: "Data generator",
|
||||
sourceQueryHistory: "Query history rollback",
|
||||
sourceAdmin: "Database administration",
|
||||
aiReviewRequired: "Production SQL was added to the editor. Review it and run it manually to confirm.",
|
||||
},
|
||||
sqlParameters: {
|
||||
title: "SQL Parameters",
|
||||
description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.",
|
||||
|
|
|
|||
|
|
@ -3636,4 +3636,46 @@ export default withEnglishFallback({
|
|||
partitionMustIncrease: "El nuevo número de particiones debe ser mayor que el actual.",
|
||||
confirmDelete: '¿Está seguro de que desea eliminar el tema "{name}"? Esta operación no se puede deshacer.',
|
||||
},
|
||||
production: {
|
||||
title: "Entorno de producción",
|
||||
connection: "Conexión de producción",
|
||||
enable: "Activar protección del entorno de producción",
|
||||
disabledDescription: "Al activar, puede elegir proteger todas las bases de datos o bases de datos específicas.",
|
||||
scope: "Alcance de la protección",
|
||||
allDatabases: "Todas las bases de datos",
|
||||
selectedDatabases: "Bases de datos seleccionadas",
|
||||
singleDatabaseScopeHint: "Este tipo de base de datos solo admite protección del entorno de producción a nivel de conexión.",
|
||||
databases: "Bases de datos de producción",
|
||||
connectionDescription: "Todas las bases de datos de esta conexión tendrán habilitada la protección de producción.",
|
||||
databaseDescription: "Seleccione de la conexión las bases de datos que necesiten protección de producción. La primera vez, se seleccionan todas por defecto.",
|
||||
selectDatabases: "Seleccionar bases de datos de producción",
|
||||
noDatabasesSelected: "Ninguna seleccionada",
|
||||
databasesConfiguredCount: "{count} seleccionadas",
|
||||
databasesSelectedCount: "{selected}/{total} seleccionadas",
|
||||
databasePickerTitle: "Seleccionar bases de datos de producción",
|
||||
databasePickerDescription: "Seleccione las bases de datos en {connection} que necesiten protección de producción.",
|
||||
databaseSearchPlaceholder: "Buscar bases de datos...",
|
||||
databaseSelectionRequired: "Seleccione al menos una base de datos.",
|
||||
databaseLoadFailed: "No se pudo cargar la lista de bases de datos: {message}",
|
||||
noDatabasesAvailable: "No se encontraron bases de datos para seleccionar.",
|
||||
retry: "Reintentar",
|
||||
confirmTitle: "Confirmar escritura en el entorno de producción",
|
||||
confirmMessage: "Esta operación modificará la base de datos de producción. Debe confirmar explícitamente para continuar.",
|
||||
confirmDetails: "Conexión: {connection}\nBase de datos: {database}\nOrigen: {source}",
|
||||
confirmAction: "Ejecutar en el entorno de producción",
|
||||
sourceSqlEditor: "Editor SQL",
|
||||
sourceDataGrid: "Editor de datos",
|
||||
sourceStructure: "Editor de estructura",
|
||||
sourceSqlFile: "Ejecución de archivo SQL",
|
||||
sourceObjectBrowser: "Explorador de objetos",
|
||||
sourceSchemaDiff: "Comparación de esquemas",
|
||||
sourceDataCompare: "Comparación de datos",
|
||||
sourceExtension: "Gestor de extensiones",
|
||||
sourceSidebar: "Árbol de objetos",
|
||||
sourceObjectSource: "Editor de código fuente de objetos",
|
||||
sourceDataGenerate: "Generador de datos",
|
||||
sourceQueryHistory: "Reversión del historial de consultas",
|
||||
sourceAdmin: "Administración de bases de datos",
|
||||
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3634,4 +3634,46 @@ export default withEnglishFallback({
|
|||
partitionMustIncrease: "Il nuovo numero di partizioni deve essere maggiore di quello attuale.",
|
||||
confirmDelete: 'Confermi di eliminare il topic "{name}"? Questa operazione è irreversibile.',
|
||||
},
|
||||
production: {
|
||||
title: "Ambiente di produzione",
|
||||
connection: "Connessione di produzione",
|
||||
enable: "Abilita protezione ambiente di produzione",
|
||||
disabledDescription: "Dopo l'attivazione, è possibile scegliere di proteggere tutti i database o database specifici.",
|
||||
scope: "Ambito di protezione",
|
||||
allDatabases: "Tutti i database",
|
||||
selectedDatabases: "Database selezionati",
|
||||
singleDatabaseScopeHint: "Questo tipo di database supporta solo la protezione dell'ambiente di produzione a livello di connessione.",
|
||||
databases: "Database di produzione",
|
||||
connectionDescription: "Tutti i database su questa connessione avranno la protezione di produzione abilitata.",
|
||||
databaseDescription: "Seleziona i database che necessitano di protezione di produzione dalla connessione. Alla prima apertura, sono selezionati tutti per impostazione predefinita.",
|
||||
selectDatabases: "Seleziona database di produzione",
|
||||
noDatabasesSelected: "Nessuno selezionato",
|
||||
databasesConfiguredCount: "{count} selezionati",
|
||||
databasesSelectedCount: "Selezionati {selected}/{total}",
|
||||
databasePickerTitle: "Seleziona database di produzione",
|
||||
databasePickerDescription: "Seleziona i database che necessitano di protezione di produzione in «{connection}».",
|
||||
databaseSearchPlaceholder: "Cerca database...",
|
||||
databaseSelectionRequired: "Selezionare almeno un database.",
|
||||
databaseLoadFailed: "Impossibile caricare l'elenco dei database: {message}",
|
||||
noDatabasesAvailable: "Nessun database selezionabile trovato.",
|
||||
retry: "Riprova",
|
||||
confirmTitle: "Conferma scrittura in ambiente di produzione",
|
||||
confirmMessage: "Questa operazione modificherà il database di produzione; è necessario confermare esplicitamente per procedere.",
|
||||
confirmDetails: "Connessione: {connection}\nDatabase: {database}\nOrigine: {source}",
|
||||
confirmAction: "Esegui in ambiente di produzione",
|
||||
sourceSqlEditor: "Editor SQL",
|
||||
sourceDataGrid: "Editor dati",
|
||||
sourceStructure: "Editor struttura",
|
||||
sourceSqlFile: "Esecuzione file SQL",
|
||||
sourceObjectBrowser: "Browser oggetti",
|
||||
sourceSchemaDiff: "Confronto schema",
|
||||
sourceDataCompare: "Confronto dati",
|
||||
sourceExtension: "Gestione estensioni",
|
||||
sourceSidebar: "Albero oggetti",
|
||||
sourceObjectSource: "Editor sorgente oggetto",
|
||||
sourceDataGenerate: "Generatore dati",
|
||||
sourceQueryHistory: "Rollback cronologia query",
|
||||
sourceAdmin: "Amministrazione database",
|
||||
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3635,4 +3635,46 @@ export default withEnglishFallback({
|
|||
partitionMustIncrease: "新しいパーティション数は現在のパーティション数より大きくなければなりません。",
|
||||
confirmDelete: "トピック「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
},
|
||||
production: {
|
||||
title: "本番環境",
|
||||
connection: "本番接続",
|
||||
enable: "本番環境保護を有効にする",
|
||||
disabledDescription: "有効にすると、すべてのデータベースまたは指定されたデータベースを保護するように選択できます。",
|
||||
scope: "保護範囲",
|
||||
allDatabases: "すべてのデータベース",
|
||||
selectedDatabases: "データベースを選択",
|
||||
singleDatabaseScopeHint: "このデータベースタイプでは、接続レベルの本番環境保護のみがサポートされています。",
|
||||
databases: "本番データベース",
|
||||
connectionDescription: "この接続上のすべてのデータベースで本番保護が有効になります。",
|
||||
databaseDescription: "接続から本番保護が必要なデータベースを選択します。初回起動時はデフォルトで全選択されています。",
|
||||
selectDatabases: "本番データベースを選択",
|
||||
noDatabasesSelected: "まだ選択されていません",
|
||||
databasesConfiguredCount: "{count} 個選択済み",
|
||||
databasesSelectedCount: "{selected}/{total} 選択済み",
|
||||
databasePickerTitle: "本番データベースを選択",
|
||||
databasePickerDescription: "「{connection}」で本番保護が必要なデータベースを選択してください。",
|
||||
databaseSearchPlaceholder: "データベースを検索...",
|
||||
databaseSelectionRequired: "少なくとも1つのデータベースを選択してください。",
|
||||
databaseLoadFailed: "データベースリストを読み込めませんでした:{message}",
|
||||
noDatabasesAvailable: "選択可能なデータベースが見つかりません。",
|
||||
retry: "再試行",
|
||||
confirmTitle: "本番環境への書き込みを確認",
|
||||
confirmMessage: "この操作は本番データベースを変更します。続行するには明示的な確認が必要です。",
|
||||
confirmDetails: "接続:{connection}\nデータベース:{database}\nソース:{source}",
|
||||
confirmAction: "本番環境で実行",
|
||||
sourceSqlEditor: "SQL エディター",
|
||||
sourceDataGrid: "データエディター",
|
||||
sourceStructure: "構造エディター",
|
||||
sourceSqlFile: "SQL ファイル実行",
|
||||
sourceObjectBrowser: "オブジェクトブラウザー",
|
||||
sourceSchemaDiff: "スキーマ差分",
|
||||
sourceDataCompare: "データ比較",
|
||||
sourceExtension: "拡張機能マネージャー",
|
||||
sourceSidebar: "オブジェクトツリー",
|
||||
sourceObjectSource: "オブジェクトソースエディター",
|
||||
sourceDataGenerate: "データジェネレーター",
|
||||
sourceQueryHistory: "クエリ履歴のロールバック",
|
||||
sourceAdmin: "データベース管理",
|
||||
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3636,4 +3636,46 @@ export default withEnglishFallback({
|
|||
partitionMustIncrease: "O novo número de partições deve ser maior que o atual.",
|
||||
confirmDelete: 'Tem certeza de que deseja excluir o tópico "{name}"? Esta operação é irreversível.',
|
||||
},
|
||||
production: {
|
||||
title: "Ambiente de Produção",
|
||||
connection: "Conexão de Produção",
|
||||
enable: "Ativar proteção de ambiente de produção",
|
||||
disabledDescription: "Após ativar, é possível escolher proteger todos os bancos de dados ou bancos específicos.",
|
||||
scope: "Escopo da proteção",
|
||||
allDatabases: "Todos os bancos de dados",
|
||||
selectedDatabases: "Selecionar bancos de dados",
|
||||
singleDatabaseScopeHint: "Este tipo de banco de dados suporta apenas proteção de ambiente de produção em nível de conexão.",
|
||||
databases: "Bancos de dados de produção",
|
||||
connectionDescription: "Todos os bancos de dados nesta conexão terão proteção de produção ativada.",
|
||||
databaseDescription: "Selecione os bancos de dados da conexão que precisam de proteção de produção. Na primeira abertura, todos são selecionados por padrão.",
|
||||
selectDatabases: "Selecionar bancos de dados de produção",
|
||||
noDatabasesSelected: "Nenhum selecionado ainda",
|
||||
databasesConfiguredCount: "{count} selecionado(s)",
|
||||
databasesSelectedCount: "{selected}/{total} selecionado(s)",
|
||||
databasePickerTitle: "Selecionar bancos de dados de produção",
|
||||
databasePickerDescription: "Selecione os bancos de dados em «{connection}» que precisam de proteção de produção.",
|
||||
databaseSearchPlaceholder: "Buscar bancos de dados...",
|
||||
databaseSelectionRequired: "Por favor, selecione pelo menos um banco de dados.",
|
||||
databaseLoadFailed: "Não foi possível carregar a lista de bancos de dados: {message}",
|
||||
noDatabasesAvailable: "Nenhum banco de dados disponível encontrado.",
|
||||
retry: "Tentar novamente",
|
||||
confirmTitle: "Confirmar gravação em ambiente de produção",
|
||||
confirmMessage: "Esta operação irá modificar o banco de dados de produção. É necessário confirmar explicitamente para continuar.",
|
||||
confirmDetails: "Conexão: {connection}\nBanco de dados: {database}\nOrigem: {source}",
|
||||
confirmAction: "Executar em produção",
|
||||
sourceSqlEditor: "Editor SQL",
|
||||
sourceDataGrid: "Editor de dados",
|
||||
sourceStructure: "Editor de estrutura",
|
||||
sourceSqlFile: "Execução de arquivo SQL",
|
||||
sourceObjectBrowser: "Navegador de objetos",
|
||||
sourceSchemaDiff: "Comparação de esquemas",
|
||||
sourceDataCompare: "Comparação de dados",
|
||||
sourceExtension: "Gerenciador de extensões",
|
||||
sourceSidebar: "Árvore de objetos",
|
||||
sourceObjectSource: "Editor de código-fonte de objetos",
|
||||
sourceDataGenerate: "Gerador de dados",
|
||||
sourceQueryHistory: "Reversão do histórico de consultas",
|
||||
sourceAdmin: "Administração de banco de dados",
|
||||
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2280,6 +2280,48 @@ export default withEnglishFallback({
|
|||
cancel: "取消",
|
||||
confirm: "执行",
|
||||
},
|
||||
production: {
|
||||
title: "生产环境",
|
||||
connection: "生产连接",
|
||||
enable: "启用生产环境保护",
|
||||
disabledDescription: "开启后可选择保护全部数据库或指定数据库。",
|
||||
scope: "保护范围",
|
||||
allDatabases: "全部数据库",
|
||||
selectedDatabases: "选择数据库",
|
||||
singleDatabaseScopeHint: "此数据库类型仅支持连接级生产环境保护。",
|
||||
databases: "生产数据库",
|
||||
connectionDescription: "此连接上的所有数据库都会启用生产保护。",
|
||||
databaseDescription: "从连接中选择需要生产保护的数据库。首次打开默认全选。",
|
||||
selectDatabases: "选择生产数据库",
|
||||
noDatabasesSelected: "尚未选择",
|
||||
databasesConfiguredCount: "已选择 {count} 个",
|
||||
databasesSelectedCount: "已选择 {selected}/{total}",
|
||||
databasePickerTitle: "选择生产数据库",
|
||||
databasePickerDescription: "选择「{connection}」中需要生产保护的数据库。",
|
||||
databaseSearchPlaceholder: "搜索数据库...",
|
||||
databaseSelectionRequired: "请至少选择一个数据库。",
|
||||
databaseLoadFailed: "无法加载数据库列表:{message}",
|
||||
noDatabasesAvailable: "未找到可选择的数据库。",
|
||||
retry: "重试",
|
||||
confirmTitle: "确认生产环境写入",
|
||||
confirmMessage: "该操作将变更生产数据库,必须明确确认后才能继续。",
|
||||
confirmDetails: "连接:{connection}\n数据库:{database}\n入口:{source}",
|
||||
confirmAction: "在生产环境执行",
|
||||
sourceSqlEditor: "SQL 编辑器",
|
||||
sourceDataGrid: "数据编辑器",
|
||||
sourceStructure: "结构编辑器",
|
||||
sourceSqlFile: "SQL 文件执行",
|
||||
sourceObjectBrowser: "对象浏览器",
|
||||
sourceSchemaDiff: "结构对比",
|
||||
sourceDataCompare: "数据对比",
|
||||
sourceExtension: "扩展管理",
|
||||
sourceSidebar: "对象树",
|
||||
sourceObjectSource: "对象源码编辑器",
|
||||
sourceDataGenerate: "数据生成器",
|
||||
sourceQueryHistory: "查询历史回滚",
|
||||
sourceAdmin: "数据库管理",
|
||||
aiReviewRequired: "生产 SQL 已放入编辑器。请先检查,再手动执行以确认。",
|
||||
},
|
||||
sqlParameters: {
|
||||
title: "SQL 参数",
|
||||
description: "为 SQL 模板占位符填写参数值,DBX 会在执行前替换为 SQL 字面量。",
|
||||
|
|
|
|||
|
|
@ -3635,4 +3635,46 @@ export default withEnglishFallback({
|
|||
partitionMustIncrease: "新分區數必須大於目前分區數。",
|
||||
confirmDelete: "確定要刪除主題「{name}」嗎?此操作無法復原。",
|
||||
},
|
||||
production: {
|
||||
title: "生產環境",
|
||||
connection: "生產連線",
|
||||
enable: "啟用生產環境保護",
|
||||
disabledDescription: "開啟後可選擇保護全部資料庫或指定資料庫。",
|
||||
scope: "保護範圍",
|
||||
allDatabases: "全部資料庫",
|
||||
selectedDatabases: "選擇資料庫",
|
||||
singleDatabaseScopeHint: "此資料庫類型僅支援連線級生產環境保護。",
|
||||
databases: "生產資料庫",
|
||||
connectionDescription: "此連線上的所有資料庫都會啟用生產保護。",
|
||||
databaseDescription: "從連線中選擇需要生產保護的資料庫。首次開啟預設全選。",
|
||||
selectDatabases: "選擇生產資料庫",
|
||||
noDatabasesSelected: "尚未選擇",
|
||||
databasesConfiguredCount: "已選擇 {count} 個",
|
||||
databasesSelectedCount: "已選擇 {selected}/{total}",
|
||||
databasePickerTitle: "選擇生產資料庫",
|
||||
databasePickerDescription: "選擇「{connection}」中需要生產保護的資料庫。",
|
||||
databaseSearchPlaceholder: "搜尋資料庫...",
|
||||
databaseSelectionRequired: "請至少選擇一個資料庫。",
|
||||
databaseLoadFailed: "無法載入資料庫清單:{message}",
|
||||
noDatabasesAvailable: "未找到可選擇的資料庫。",
|
||||
retry: "重試",
|
||||
confirmTitle: "確認生產環境寫入",
|
||||
confirmMessage: "該操作將變更生產資料庫,必須明確確認後才能繼續。",
|
||||
confirmDetails: "連線:{connection}\n資料庫:{database}\n入口:{source}",
|
||||
confirmAction: "在生產環境執行",
|
||||
sourceSqlEditor: "SQL 編輯器",
|
||||
sourceDataGrid: "資料編輯器",
|
||||
sourceStructure: "結構編輯器",
|
||||
sourceSqlFile: "SQL 檔案執行",
|
||||
sourceObjectBrowser: "物件瀏覽器",
|
||||
sourceSchemaDiff: "結構比對",
|
||||
sourceDataCompare: "資料比對",
|
||||
sourceExtension: "擴充功能管理",
|
||||
sourceSidebar: "物件樹",
|
||||
sourceObjectSource: "物件原始碼編輯器",
|
||||
sourceDataGenerate: "資料產生器",
|
||||
sourceQueryHistory: "查詢歷史回復",
|
||||
sourceAdmin: "資料庫管理",
|
||||
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildAiAgentPlan } from "../aiAgentPlan";
|
||||
import { classifyAiSqlExecution } from "../aiSqlExecutionPolicy";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const productionConnection: ConnectionConfig = {
|
||||
id: "conn-1",
|
||||
name: "Operations",
|
||||
db_type: "mysql",
|
||||
host: "db.internal",
|
||||
port: 3306,
|
||||
username: "readonly",
|
||||
password: "",
|
||||
production_databases: ["prod_app"],
|
||||
};
|
||||
|
||||
describe("AI production SQL policy", () => {
|
||||
it("requires confirmation for a scoped production write", () => {
|
||||
expect(classifyAiSqlExecution("UPDATE users SET active = 1 WHERE id = 7", productionConnection, "prod_app")).toMatchObject({
|
||||
action: "confirm",
|
||||
environment: "production",
|
||||
reasons: ["production_write"],
|
||||
});
|
||||
});
|
||||
|
||||
it("hands production write SQL back to the operator instead of auto-executing", () => {
|
||||
const plan = buildAiAgentPlan({
|
||||
mode: "agent",
|
||||
action: "generate",
|
||||
instruction: "execute the update",
|
||||
assistantContent: "```sql\nUPDATE users SET active = 1 WHERE id = 7\n```",
|
||||
connection: productionConnection,
|
||||
database: "prod_app",
|
||||
});
|
||||
|
||||
expect(plan.executableSql).toBeUndefined();
|
||||
expect(plan.handoffSql).toContain("UPDATE users");
|
||||
expect(plan.steps).toContainEqual({ kind: "execute_sql", status: "skipped", reason: "requires_confirmation" });
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ export interface AiAgentPlanInput {
|
|||
instruction: string;
|
||||
assistantContent: string;
|
||||
connection?: ConnectionConfig;
|
||||
database?: string;
|
||||
}
|
||||
|
||||
export interface AiAgentPlan {
|
||||
|
|
@ -61,7 +62,7 @@ export function buildAiAgentPlan(input: AiAgentPlanInput): AiAgentPlan {
|
|||
return { steps };
|
||||
}
|
||||
|
||||
const decision = classifyAiSqlExecution(sql, input.connection);
|
||||
const decision = classifyAiSqlExecution(sql, input.connection, input.database);
|
||||
steps.push({ kind: "risk_check", status: "done", ...decision });
|
||||
|
||||
if (decision.action === "auto_execute") {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText } from "@/lib/sql/sqlRisk";
|
||||
|
||||
export type ConnectionEnvironment = "production" | "non_production" | "unknown";
|
||||
export type AiSqlExecutionAction = "auto_execute" | "confirm" | "block";
|
||||
|
|
@ -11,40 +13,29 @@ export interface AiSqlExecutionDecision {
|
|||
reasons: string[];
|
||||
}
|
||||
|
||||
const READ_RE = /^(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i;
|
||||
const INSERT_RE = /^INSERT\b/i;
|
||||
const UPDATE_RE = /^UPDATE\b/i;
|
||||
const DELETE_RE = /^DELETE\b/i;
|
||||
const CONFIRM_WRITE_RE = /^(MERGE|REPLACE)\b/i;
|
||||
const BLOCK_RE = /^(DROP|TRUNCATE|ALTER|RENAME)\b/i;
|
||||
const SCHEMA_RE = /^(CREATE)\b/i;
|
||||
|
||||
const PRODUCTION_RE = /\b(prod|prd|production)\b|生产|正式/i;
|
||||
const NON_PRODUCTION_RE = /\b(local|localhost|dev|develop|development|test|testing|stage|staging|sandbox|demo)\b|本地|开发|测试|预发/i;
|
||||
const LOCAL_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|::1)$/i;
|
||||
const NEGATIVE_EXECUTION_RE = /(不要|别|不用|禁止|只生成|仅生成|只写|仅写).{0,12}(执行|运行|跑)|do\s+not\s+execute|don't\s+execute|dont\s+execute|without\s+executing|only\s+(generate|write|return)/i;
|
||||
|
||||
export function stripAiSqlComments(sql: string): string {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/#.*$/gm, " ");
|
||||
return sqlSafetyText(sql);
|
||||
}
|
||||
|
||||
function sqlStatements(sql: string): string[] {
|
||||
return stripAiSqlComments(sql)
|
||||
.split(";")
|
||||
.map((stmt) => stmt.trim())
|
||||
.filter(Boolean);
|
||||
return splitSqlStatementsForSafety(sql);
|
||||
}
|
||||
|
||||
function classifyStatement(statement: string): AiSqlExecutionCategory {
|
||||
if (READ_RE.test(statement)) return "read";
|
||||
if (BLOCK_RE.test(statement)) return "dangerous";
|
||||
if (SCHEMA_RE.test(statement)) return "schema_change";
|
||||
if (INSERT_RE.test(statement)) return "low_risk_write";
|
||||
if (UPDATE_RE.test(statement)) return isScopedUpdate(statement) ? "low_risk_write" : "dangerous";
|
||||
if (DELETE_RE.test(statement) || CONFIRM_WRITE_RE.test(statement)) return "write";
|
||||
function classifyStatement(statement: string, connection?: ConnectionConfig): AiSqlExecutionCategory {
|
||||
const risk = classifySqlStatementRisk(statement, { dialect: connection?.db_type });
|
||||
if (risk.risk === "read") return "read";
|
||||
if (risk.risk === "unknown") return "unknown";
|
||||
if (risk.risk === "transaction" || risk.risk === "ddl") {
|
||||
return risk.firstKeyword === "create" ? "schema_change" : "dangerous";
|
||||
}
|
||||
if (risk.firstKeyword === "insert") return "low_risk_write";
|
||||
if (risk.firstKeyword === "update") return isScopedUpdate(statement) ? "low_risk_write" : "dangerous";
|
||||
if (risk.firstKeyword === "delete" || risk.firstKeyword === "merge" || risk.firstKeyword === "replace") return "write";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
|
|
@ -56,8 +47,9 @@ function isScopedUpdate(statement: string): boolean {
|
|||
return /\b[\w"`.[\]]*(?:id|_id|uuid|key)[\w"`.[\]]*\s*=\s*(?:'[^']+'|"[^"]+"|`[^`]+`|[\w.-]+)/i.test(where);
|
||||
}
|
||||
|
||||
export function classifyConnectionEnvironment(connection?: ConnectionConfig): ConnectionEnvironment {
|
||||
export function classifyConnectionEnvironment(connection?: ConnectionConfig, database?: string): ConnectionEnvironment {
|
||||
if (!connection) return "unknown";
|
||||
if (productionContextForDatabase(connection, database).active) return "production";
|
||||
|
||||
const parts = [connection.name, connection.host, connection.database, connection.connection_string].filter(Boolean);
|
||||
const signal = parts.join(" ");
|
||||
|
|
@ -66,8 +58,8 @@ export function classifyConnectionEnvironment(connection?: ConnectionConfig): Co
|
|||
return "unknown";
|
||||
}
|
||||
|
||||
export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfig): AiSqlExecutionDecision {
|
||||
const environment = classifyConnectionEnvironment(connection);
|
||||
export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfig, database?: string): AiSqlExecutionDecision {
|
||||
const environment = classifyConnectionEnvironment(connection, database);
|
||||
const statements = sqlStatements(sql);
|
||||
const reasons: string[] = [];
|
||||
|
||||
|
|
@ -75,9 +67,10 @@ export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfi
|
|||
return { action: "block", environment, category: "unknown", reasons: ["empty_sql"] };
|
||||
}
|
||||
|
||||
const categories = statements.map(classifyStatement);
|
||||
const categories = statements.map((statement) => classifyStatement(statement, connection));
|
||||
const hasMultipleStatements = statements.length > 1;
|
||||
if (hasMultipleStatements) reasons.push("multi_statement");
|
||||
const productionAssessment = assessProductionSql(sql, connection, database);
|
||||
|
||||
if (categories.includes("dangerous")) {
|
||||
return { action: "block", environment, category: "dangerous", reasons };
|
||||
|
|
@ -91,6 +84,11 @@ export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfi
|
|||
return { action: "auto_execute", environment, category: "read", reasons };
|
||||
}
|
||||
|
||||
if (productionAssessment.active && productionAssessment.isMutation) {
|
||||
reasons.push("production_write");
|
||||
return { action: "confirm", environment: "production", category: categories[0] ?? "unknown", reasons };
|
||||
}
|
||||
|
||||
if (hasMultipleStatements) {
|
||||
return { action: "confirm", environment, category: "write", reasons };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { executeWithProductionSqlGuard } from "../productionExecutionGuard";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "conn-1",
|
||||
name: "Operations",
|
||||
db_type: "mysql",
|
||||
host: "db.internal",
|
||||
port: 3306,
|
||||
username: "operator",
|
||||
password: "",
|
||||
production_databases: ["prod_app"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("production SQL execution guard", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("waits for confirmation before executing production SQL", async () => {
|
||||
const execute = vi.fn().mockResolvedValue("done");
|
||||
const pendingExecution = executeWithProductionSqlGuard({
|
||||
connection: connection(),
|
||||
database: "prod_app",
|
||||
sql: "DELETE FROM users WHERE id = 1",
|
||||
source: "Schema diff",
|
||||
execute,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const store = useProductionSafetyStore();
|
||||
expect(store.pending).toMatchObject({
|
||||
sql: "DELETE FROM users WHERE id = 1",
|
||||
database: "prod_app",
|
||||
productionDatabases: ["prod_app"],
|
||||
source: "Schema diff",
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
|
||||
store.confirm();
|
||||
await expect(pendingExecution).resolves.toBe("done");
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels production SQL without executing the callback", async () => {
|
||||
const execute = vi.fn().mockResolvedValue("done");
|
||||
const pendingExecution = executeWithProductionSqlGuard({
|
||||
connection: connection(),
|
||||
database: "prod_app",
|
||||
sql: "TRUNCATE TABLE users",
|
||||
source: "Object tree",
|
||||
execute,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
useProductionSafetyStore().cancel();
|
||||
|
||||
await expect(pendingExecution).resolves.toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues concurrent production confirmations", async () => {
|
||||
const firstExecute = vi.fn().mockResolvedValue("first");
|
||||
const secondExecute = vi.fn().mockResolvedValue("second");
|
||||
const firstSql = "DELETE FROM prod_app.users WHERE id = 1";
|
||||
const secondSql = "DELETE FROM prod_app.audit_log WHERE id = 2";
|
||||
const firstExecution = executeWithProductionSqlGuard({
|
||||
connection: connection(),
|
||||
database: "staging",
|
||||
sql: firstSql,
|
||||
source: "Schema diff",
|
||||
execute: firstExecute,
|
||||
});
|
||||
const secondExecution = executeWithProductionSqlGuard({
|
||||
connection: connection(),
|
||||
database: "staging",
|
||||
sql: secondSql,
|
||||
source: "Data compare",
|
||||
execute: secondExecute,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const store = useProductionSafetyStore();
|
||||
expect(store.pending?.sql).toBe(firstSql);
|
||||
expect(firstExecute).not.toHaveBeenCalled();
|
||||
expect(secondExecute).not.toHaveBeenCalled();
|
||||
|
||||
store.confirm();
|
||||
await expect(firstExecution).resolves.toBe("first");
|
||||
expect(firstExecute).toHaveBeenCalledTimes(1);
|
||||
expect(store.pending?.sql).toBe(secondSql);
|
||||
|
||||
store.cancel();
|
||||
await expect(secondExecution).resolves.toBeUndefined();
|
||||
expect(secondExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes non-production SQL immediately", async () => {
|
||||
const execute = vi.fn().mockResolvedValue("done");
|
||||
await expect(
|
||||
executeWithProductionSqlGuard({
|
||||
connection: connection(),
|
||||
database: "staging",
|
||||
sql: "UPDATE staging.users SET active = 1 WHERE id = 1",
|
||||
source: "Data compare",
|
||||
execute,
|
||||
}),
|
||||
).resolves.toBe("done");
|
||||
|
||||
expect(useProductionSafetyStore().pending).toBeUndefined();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { assessProductionSql, isProductionMutation, productionContextForDatabase } from "../productionSafety";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
interface ProductionSafetyCorpusCase {
|
||||
name: string;
|
||||
dialect: DatabaseType;
|
||||
productionDatabases: string[];
|
||||
activeDatabase: string;
|
||||
sql: string;
|
||||
active: boolean;
|
||||
isMutation: boolean;
|
||||
databases: string[];
|
||||
}
|
||||
|
||||
const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[];
|
||||
|
||||
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "conn-1",
|
||||
name: "Operations",
|
||||
db_type: "mysql",
|
||||
host: "db.internal",
|
||||
port: 3306,
|
||||
username: "readonly",
|
||||
password: "",
|
||||
production_databases: ["prod_app"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("production SQL safety", () => {
|
||||
it("marks an explicitly production connection regardless of database", () => {
|
||||
expect(productionContextForDatabase(connection({ is_production: true }), "scratch").active).toBe(true);
|
||||
});
|
||||
|
||||
it("marks only configured production databases for multi-database connections", () => {
|
||||
expect(productionContextForDatabase(connection(), "PROD_APP").active).toBe(true);
|
||||
expect(productionContextForDatabase(connection(), "staging").active).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a write after a USE production switch despite comments", () => {
|
||||
const assessment = assessProductionSql("-- install\nUSE `prod_app`; /* migration */ DELETE FROM users", connection(), "staging");
|
||||
expect(assessment).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
});
|
||||
|
||||
it("detects qualified production targets in multi-statement SQL", () => {
|
||||
const assessment = assessProductionSql("SELECT ';' AS literal; DELETE FROM `prod_app`.`orders`; UPDATE staging.users SET active = 1", connection(), "staging");
|
||||
expect(assessment).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
});
|
||||
|
||||
it("detects production database DDL without a selected production database", () => {
|
||||
expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
});
|
||||
|
||||
it("detects production writes hidden behind parser-sensitive SQL forms", () => {
|
||||
for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the shared SQL target safety corpus", () => {
|
||||
for (const corpusCase of productionSafetyCorpus) {
|
||||
const assessment = assessProductionSql(
|
||||
corpusCase.sql,
|
||||
connection({
|
||||
db_type: corpusCase.dialect,
|
||||
production_databases: corpusCase.productionDatabases,
|
||||
}),
|
||||
corpusCase.activeDatabase,
|
||||
);
|
||||
expect(
|
||||
{
|
||||
active: assessment.active,
|
||||
isMutation: assessment.isMutation,
|
||||
databases: assessment.databases,
|
||||
},
|
||||
corpusCase.name,
|
||||
).toEqual({
|
||||
active: corpusCase.active,
|
||||
isMutation: corpusCase.isMutation,
|
||||
databases: corpusCase.databases,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("detects qualified procedure calls and privilege targets", () => {
|
||||
for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows resolved non-production procedure and privilege targets", () => {
|
||||
expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
});
|
||||
|
||||
it("conservatively confirms ambiguous production targets", () => {
|
||||
for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat read-only qualified references as write targets", () => {
|
||||
expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
});
|
||||
|
||||
it("treats unrecognized SQL as a production mutation until proven read-only", () => {
|
||||
expect(isProductionMutation("MAINTAIN UNKNOWN THING")).toBe(true);
|
||||
expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true });
|
||||
});
|
||||
|
||||
it("does not require a production confirmation for reads", () => {
|
||||
expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { assessProductionSql } from "@/lib/database/productionSafety";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
export interface ProductionSqlExecutionGuardOptions<T> {
|
||||
connection?: ConnectionConfig;
|
||||
database?: string | null;
|
||||
sql: string;
|
||||
source?: string;
|
||||
execute: () => Promise<T>;
|
||||
}
|
||||
|
||||
export async function executeWithProductionSqlGuard<T>(options: ProductionSqlExecutionGuardOptions<T>): Promise<T | undefined> {
|
||||
const assessment = assessProductionSql(options.sql, options.connection, options.database);
|
||||
if (assessment.active && assessment.isMutation) {
|
||||
// Centralize production write confirmation so secondary tool surfaces cannot
|
||||
// bypass the same explicit review step used by the SQL editor.
|
||||
const confirmed = await useProductionSafetyStore().requestConfirmation({
|
||||
sql: options.sql,
|
||||
connectionName: options.connection?.name,
|
||||
database: options.database ?? undefined,
|
||||
productionDatabases: assessment.databases,
|
||||
source: options.source,
|
||||
});
|
||||
if (!confirmed) return undefined;
|
||||
}
|
||||
return options.execute();
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { classifySqlRisk, isSqlRiskMutation } from "@/lib/sql/sqlRisk";
|
||||
|
||||
export type ProductionContextReason = "connection" | "database" | "sql_target";
|
||||
|
||||
export interface ProductionContext {
|
||||
active: boolean;
|
||||
reason?: ProductionContextReason;
|
||||
databases: string[];
|
||||
}
|
||||
|
||||
export interface ProductionSqlAssessment extends ProductionContext {
|
||||
isMutation: boolean;
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`;
|
||||
const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`;
|
||||
const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`;
|
||||
const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i");
|
||||
const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi");
|
||||
const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i");
|
||||
const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi");
|
||||
const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi");
|
||||
const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi");
|
||||
const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i;
|
||||
const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i;
|
||||
const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i;
|
||||
const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set<DatabaseType>(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]);
|
||||
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
|
||||
const SCHEMA_FIRST_QUALIFIER_TYPES = new Set<DatabaseType>([
|
||||
"postgres",
|
||||
"redshift",
|
||||
"gaussdb",
|
||||
"kwdb",
|
||||
"opengauss",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"yashandb",
|
||||
"oracle",
|
||||
"oceanbase-oracle",
|
||||
"dameng",
|
||||
"firebird",
|
||||
"exasol",
|
||||
"teradata",
|
||||
"vertica",
|
||||
"db2",
|
||||
"informix",
|
||||
"h2",
|
||||
"iris",
|
||||
"xugu",
|
||||
"oscar",
|
||||
"gbase",
|
||||
"saphana",
|
||||
"sqlserver",
|
||||
"snowflake",
|
||||
"trino",
|
||||
"prestosql",
|
||||
"databricks",
|
||||
"bigquery",
|
||||
]);
|
||||
|
||||
interface ReferencedDatabaseAssessment {
|
||||
databases: string[];
|
||||
uncertain: boolean;
|
||||
}
|
||||
|
||||
interface SqlTargetSafetyText {
|
||||
text: string;
|
||||
quotedIdentifiers: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize database identifiers for safety matching while retaining the
|
||||
* original display value separately. Safety comparisons intentionally ignore
|
||||
* identifier quoting and case so a quoted MySQL production database cannot
|
||||
* bypass its marker.
|
||||
*/
|
||||
export function normalizeProductionDatabase(value: string | undefined | null): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.replace(/^[`"[]|[`"\]]$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function productionDatabases(connection: ConnectionConfig | undefined): string[] {
|
||||
if (!connection?.production_databases?.length) return [];
|
||||
return [...new Set(connection.production_databases.map(normalizeProductionDatabase).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function productionContextForDatabase(connection: ConnectionConfig | undefined, database: string | undefined | null): ProductionContext {
|
||||
if (!connection) return { active: false, databases: [] };
|
||||
if (connection.is_production) return { active: true, reason: "connection", databases: [] };
|
||||
|
||||
const normalizedDatabase = normalizeProductionDatabase(database);
|
||||
const marked = productionDatabases(connection);
|
||||
if (normalizedDatabase && marked.includes(normalizedDatabase)) {
|
||||
return { active: true, reason: "database", databases: [String(database)] };
|
||||
}
|
||||
return { active: false, databases: [] };
|
||||
}
|
||||
|
||||
export function isProductionMutation(sql: string): boolean {
|
||||
return isSqlRiskMutation(classifySqlRisk(sql).risk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves production scope for the SQL about to run. The active database is
|
||||
* authoritative for ordinary statements; MySQL-style USE and qualified names
|
||||
* additionally protect production databases referenced from another context.
|
||||
*/
|
||||
export function assessProductionSql(sql: string, connection: ConnectionConfig | undefined, activeDatabase: string | undefined | null): ProductionSqlAssessment {
|
||||
const activeContext = productionContextForDatabase(connection, activeDatabase);
|
||||
const targetText = sqlTargetSafetyText(sql);
|
||||
const statements = splitTargetStatements(targetText.text);
|
||||
const risk = classifySqlRisk(sql, { dialect: connection?.db_type });
|
||||
const isMutation = isSqlRiskMutation(risk.risk);
|
||||
if (!isMutation || !connection) return { ...activeContext, isMutation };
|
||||
if (connection.is_production) return { active: true, reason: "connection", databases: [], isMutation };
|
||||
if (activeContext.active) return { ...activeContext, isMutation };
|
||||
|
||||
const marked = productionDatabases(connection);
|
||||
if (!marked.length) return { active: false, databases: [], isMutation };
|
||||
|
||||
const targets = referencedDatabases(statements, connection.db_type, activeDatabase, targetText.quotedIdentifiers);
|
||||
const matched = targets.databases.filter((database) => marked.includes(normalizeProductionDatabase(database)));
|
||||
if (matched.length) return { active: true, reason: "sql_target", databases: matched, isMutation };
|
||||
if (targets.uncertain) return { active: true, reason: "sql_target", databases: marked, isMutation };
|
||||
return { active: false, databases: [], isMutation };
|
||||
}
|
||||
|
||||
function referencedDatabases(statements: string[], dbType: DatabaseType, activeDatabase: string | undefined | null, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
|
||||
const databases = new Set<string>();
|
||||
let uncertain = false;
|
||||
let useDatabase = "";
|
||||
const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase);
|
||||
|
||||
for (const statement of statements) {
|
||||
const statementDatabases = new Set<string>();
|
||||
const statementAssessment = classifySqlRisk(statement, { dialect: dbType });
|
||||
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
|
||||
const useMatch = statement.match(USE_RE);
|
||||
if (useMatch?.[1]) {
|
||||
useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers);
|
||||
continue;
|
||||
}
|
||||
if (!statementIsMutation) continue;
|
||||
const currentDatabase = useDatabase || normalizedActiveDatabase;
|
||||
|
||||
collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE);
|
||||
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]);
|
||||
for (const match of statement.matchAll(DATABASE_TARGET_RE)) {
|
||||
const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : "";
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) {
|
||||
const database = normalizeTargetDatabase(match[1], quotedIdentifiers);
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
const copyTarget = statement.match(COPY_TARGET_RE);
|
||||
if (copyTarget?.[1]) {
|
||||
const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase);
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
for (const database of statementDatabases) databases.add(database);
|
||||
// The target regexes intentionally extract one object at a time. Until all
|
||||
// list forms are parsed, never let a resolved first target disable fallback.
|
||||
uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0);
|
||||
}
|
||||
return { databases: [...databases], uncertain };
|
||||
}
|
||||
|
||||
function collectQualifiedTargetDatabases(statement: string, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, ...patterns: RegExp[]): void {
|
||||
for (const pattern of patterns) {
|
||||
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]);
|
||||
}
|
||||
}
|
||||
|
||||
function collectQualifiedTargetDatabaseGroups(statement: string, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, pattern: RegExp, captureIndexes: number[]): void {
|
||||
pattern.lastIndex = 0;
|
||||
for (const match of statement.matchAll(pattern)) {
|
||||
for (const captureIndex of captureIndexes) {
|
||||
const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase);
|
||||
if (database) databases.add(database);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string): string {
|
||||
const parts = String(qualifiedName ?? "")
|
||||
.split(".")
|
||||
.map((part) => normalizeTargetDatabase(part, quotedIdentifiers))
|
||||
.filter(Boolean);
|
||||
if (parts.length < 2) return currentDatabase;
|
||||
if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? "";
|
||||
return currentDatabase;
|
||||
}
|
||||
|
||||
function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map<string, string>): string {
|
||||
const normalized = normalizeProductionDatabase(value);
|
||||
const quoted = quotedIdentifiers.get(normalized);
|
||||
return quoted === undefined ? normalized : normalizeProductionDatabase(quoted);
|
||||
}
|
||||
|
||||
function qualifiedFirstPartIsDatabase(dbType: DatabaseType, partCount: number): boolean {
|
||||
if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(dbType)) return true;
|
||||
if (SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType)) return false;
|
||||
return partCount >= 2;
|
||||
}
|
||||
|
||||
function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: DatabaseType): boolean {
|
||||
const normalizedKind = String(kind ?? "").toLowerCase();
|
||||
if (normalizedKind === "database" || normalizedKind === "catalog") return true;
|
||||
if (normalizedKind !== "schema") return false;
|
||||
return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType<typeof classifySqlRisk>, hasResolvedTarget: boolean): boolean {
|
||||
if (!isSqlRiskMutation(assessment.risk)) return false;
|
||||
if (assessment.risk === "transaction") return false;
|
||||
const firstKeyword = assessment.firstKeyword;
|
||||
if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false;
|
||||
return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget;
|
||||
}
|
||||
|
||||
function splitTargetStatements(sql: string): string[] {
|
||||
return sql
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < sql.length) {
|
||||
const char = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
index += 2;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "#") {
|
||||
index += 1;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
if (close < 0) return { text: output, quotedIdentifiers };
|
||||
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
|
||||
if (executablePrefixLength > 0) {
|
||||
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
|
||||
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dollarQuote = dollarQuoteTagAt(sql, index);
|
||||
if (dollarQuote) {
|
||||
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
|
||||
index = close < 0 ? sql.length : close + dollarQuote.length;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'") {
|
||||
index = readQuotedEnd(sql, index, "'", "'");
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "`" || char === "[") {
|
||||
const close = char === "[" ? "]" : char;
|
||||
const end = readQuotedEnd(sql, index, char, close);
|
||||
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
|
||||
const token = `__dbxq${quotedIdentifiers.size}__`;
|
||||
quotedIdentifiers.set(token.toLowerCase(), identifier);
|
||||
output += ` ${token} `;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { text: output, quotedIdentifiers };
|
||||
}
|
||||
|
||||
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
|
||||
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
|
||||
if (sql[index + 2] === "!") return 3;
|
||||
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function skipExecutableCommentVersion(sql: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
|
||||
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
|
||||
}
|
||||
|
||||
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
|
||||
let index = start + open.length;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === "\\" && (open === "'" || open === '"')) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql.startsWith(close, index)) {
|
||||
if (sql.startsWith(close + close, index)) {
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function unquoteIdentifier(value: string, open: string, close: string): string {
|
||||
if (!value.startsWith(open) || !value.endsWith(close)) return value;
|
||||
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
|
||||
|
||||
export interface SqlRiskStatementAssessment {
|
||||
risk: SqlRiskLevel;
|
||||
firstKeyword?: string;
|
||||
}
|
||||
|
||||
export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
|
||||
statements: SqlRiskStatementAssessment[];
|
||||
}
|
||||
|
||||
interface SqlRiskOptions {
|
||||
dialect?: DatabaseType | string;
|
||||
}
|
||||
|
||||
interface SqlRiskToken {
|
||||
text: string;
|
||||
normalized: string;
|
||||
}
|
||||
|
||||
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
|
||||
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
|
||||
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
|
||||
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
|
||||
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
|
||||
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
|
||||
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
|
||||
|
||||
const RISK_ORDER: Record<SqlRiskLevel, number> = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
ddl: 2,
|
||||
transaction: 3,
|
||||
unknown: 4,
|
||||
};
|
||||
|
||||
export function splitSqlStatementsForSafety(sql: string): string[] {
|
||||
return sqlSafetyText(sql)
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskAssessment {
|
||||
const statements = splitSqlStatementsForSafety(sql).map((statement) => classifySqlStatementRisk(statement, options));
|
||||
if (!statements.length) return { risk: "unknown", statements: [] };
|
||||
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
|
||||
return { ...highest, statements };
|
||||
}
|
||||
|
||||
export function classifySqlStatementRisk(sql: string, _options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
|
||||
return classifyTokens(tokenizeSqlForRisk(sql));
|
||||
}
|
||||
|
||||
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
|
||||
return risk !== "read";
|
||||
}
|
||||
|
||||
export function sqlSafetyText(sql: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < sql.length) {
|
||||
const char = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
index += 2;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "#") {
|
||||
index += 1;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
if (close < 0) return output;
|
||||
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
|
||||
if (executablePrefixLength > 0) {
|
||||
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
|
||||
output += ` ${sqlSafetyText(sql.slice(bodyStart, close))} `;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dollarQuote = dollarQuoteTagAt(sql, index);
|
||||
if (dollarQuote) {
|
||||
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
|
||||
index = close < 0 ? sql.length : close + dollarQuote.length;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'") {
|
||||
index = readQuotedEnd(sql, index, "'", "'");
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "`" || char === "[") {
|
||||
const close = char === "[" ? "]" : char;
|
||||
const end = readQuotedEnd(sql, index, char, close);
|
||||
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
|
||||
const tokens: SqlRiskToken[] = [];
|
||||
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
|
||||
for (const match of sql.matchAll(re)) {
|
||||
const text = match[0];
|
||||
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
|
||||
const useful = trimWrappingParentheses(tokens);
|
||||
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
|
||||
if (!firstKeyword) return { risk: "unknown" };
|
||||
|
||||
if (READ_KEYWORDS.has(firstKeyword)) {
|
||||
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
|
||||
}
|
||||
|
||||
if (firstKeyword === "with") {
|
||||
return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
|
||||
}
|
||||
|
||||
if (firstKeyword === "explain") {
|
||||
return classifyExplainTokens(useful);
|
||||
}
|
||||
|
||||
if (firstKeyword === "copy") {
|
||||
return { risk: classifyCopyTokens(useful), firstKeyword };
|
||||
}
|
||||
|
||||
if (firstKeyword === "pragma") {
|
||||
return { risk: classifyPragmaTokens(useful), firstKeyword };
|
||||
}
|
||||
|
||||
if (firstKeyword === "use") return { risk: "read", firstKeyword };
|
||||
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
|
||||
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
|
||||
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
|
||||
|
||||
// Unknown statements are treated as unsafe until a dialect-aware parser can
|
||||
// prove they are read-only.
|
||||
return { risk: "unknown", firstKeyword };
|
||||
}
|
||||
|
||||
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
|
||||
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
|
||||
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
|
||||
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
|
||||
const inner = classifyTokens(tokens.slice(innerIndex));
|
||||
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
|
||||
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
|
||||
}
|
||||
|
||||
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
|
||||
if (tokens.some((token) => token.normalized === "from")) return "write";
|
||||
if (tokens.some((token) => token.normalized === "to")) return "read";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
|
||||
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
|
||||
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
|
||||
return "write";
|
||||
}
|
||||
|
||||
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
|
||||
let result: SqlRiskLevel | undefined;
|
||||
for (const token of tokens) {
|
||||
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
|
||||
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
|
||||
let depth = 0;
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) continue;
|
||||
if (token.text === "(") depth += 1;
|
||||
if (token.text === ")") depth = Math.max(0, depth - 1);
|
||||
if (depth !== 0) continue;
|
||||
if (token.normalized === "into") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
|
||||
let start = 0;
|
||||
let end = tokens.length;
|
||||
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
|
||||
start += 1;
|
||||
end -= 1;
|
||||
}
|
||||
return tokens.slice(start, end);
|
||||
}
|
||||
|
||||
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openIndex; index < tokens.length; index += 1) {
|
||||
if (tokens[index]?.text === "(") depth += 1;
|
||||
if (tokens[index]?.text === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
|
||||
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
|
||||
if (sql[index + 2] === "!") return 3;
|
||||
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function skipExecutableCommentVersion(sql: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
|
||||
const match = sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
|
||||
return match?.[0];
|
||||
}
|
||||
|
||||
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
|
||||
let index = start + open.length;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === "\\" && (open === "'" || open === '"')) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql.startsWith(close, index)) {
|
||||
if (sql.startsWith(close + close, index)) {
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function unquoteIdentifier(value: string, open: string, close: string): string {
|
||||
if (!value.startsWith(open) || !value.endsWith(close)) return value;
|
||||
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export interface ProductionConfirmationRequest {
|
||||
sql: string;
|
||||
connectionName?: string;
|
||||
database?: string;
|
||||
productionDatabases?: string[];
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface QueuedConfirmationRequest {
|
||||
request: ProductionConfirmationRequest;
|
||||
resolve: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the single production-write confirmation dialog shared by all
|
||||
* workbench surfaces. The request is intentionally transient and is never
|
||||
* persisted, so every production write requires a fresh user decision.
|
||||
*/
|
||||
export const useProductionSafetyStore = defineStore("productionSafety", () => {
|
||||
const pending = ref<ProductionConfirmationRequest>();
|
||||
const queue: QueuedConfirmationRequest[] = [];
|
||||
let resolvePending: ((confirmed: boolean) => void) | undefined;
|
||||
|
||||
function requestConfirmation(request: ProductionConfirmationRequest): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (pending.value) {
|
||||
// Keep concurrent production writes visible instead of silently denying
|
||||
// the later operation while the user is reviewing the current SQL.
|
||||
queue.push({ request, resolve });
|
||||
return;
|
||||
}
|
||||
beginRequest(request, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function beginRequest(request: ProductionConfirmationRequest, 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) beginRequest(next.request, next.resolve);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
settle(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
settle(false);
|
||||
}
|
||||
|
||||
return { pending, requestConfirmation, confirm, cancel };
|
||||
});
|
||||
|
|
@ -157,6 +157,10 @@ export interface ConnectionConfig {
|
|||
external_config?: unknown;
|
||||
one_time?: boolean;
|
||||
read_only?: boolean;
|
||||
/** Explicit production marker for every database reachable through this connection. */
|
||||
is_production?: boolean;
|
||||
/** Database-level production markers for multi-database connections. */
|
||||
production_databases?: string[];
|
||||
}
|
||||
|
||||
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
|
||||
|
|
|
|||
|
|
@ -620,6 +620,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -456,6 +456,12 @@ async fn execute_execute_query(
|
|||
// Classify SQL risk using sqlparser AST
|
||||
let db_type_str = format!("{:?}", db_type).to_lowercase();
|
||||
let risk = crate::sql_risk::classify_sql_risk(sql, &db_type_str)?;
|
||||
let connection_config = state.configs.read().await.get(connection_id).cloned();
|
||||
if let Some(config) = connection_config {
|
||||
if risk != SqlRisk::ReadOnly && crate::production_safety::targets_production_database(&config, database, sql) {
|
||||
return Err("Blocked: AI agents cannot execute writes or DDL on a production database. Return the SQL for the user to review and execute manually in DBX.".to_string());
|
||||
}
|
||||
}
|
||||
if !sql_risk_allowed(risk, sql_permissions) {
|
||||
if risk == SqlRisk::Transaction {
|
||||
return Err("Blocked: transaction control statements are not available to the AI agent.".to_string());
|
||||
|
|
|
|||
|
|
@ -1058,6 +1058,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1116,6 +1118,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1220,6 +1224,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
};
|
||||
scrub_connection_secrets(&mut config);
|
||||
assert!(config.password.is_empty());
|
||||
|
|
|
|||
|
|
@ -3144,6 +3144,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -763,6 +763,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3108,6 +3108,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
};
|
||||
|
||||
assert_eq!(redis_database_index(&config), 4);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub mod object_source_sql;
|
|||
pub mod path_utils;
|
||||
pub mod plugins;
|
||||
pub mod process;
|
||||
pub mod production_safety;
|
||||
pub mod query;
|
||||
pub mod query_cancel;
|
||||
pub mod query_execution_sql;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ pub struct ConnectionConfig {
|
|||
pub one_time: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub read_only: bool,
|
||||
/// Explicitly marks every database reachable through this connection as production.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_production: bool,
|
||||
/// Database-level production markers for multi-database connections.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub production_databases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -457,6 +463,10 @@ struct ConnectionConfigData {
|
|||
pub one_time: bool,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
#[serde(default)]
|
||||
pub is_production: bool,
|
||||
#[serde(default)]
|
||||
pub production_databases: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ConnectionConfigData> for ConnectionConfig {
|
||||
|
|
@ -507,6 +517,8 @@ impl From<ConnectionConfigData> for ConnectionConfig {
|
|||
jdbc_driver_paths: data.jdbc_driver_paths,
|
||||
one_time: data.one_time,
|
||||
read_only: data.read_only,
|
||||
is_production: data.is_production,
|
||||
production_databases: data.production_databases,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1834,6 +1846,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
};
|
||||
cfg.redis_key_separator = ":".to_string();
|
||||
cfg
|
||||
|
|
|
|||
|
|
@ -596,6 +596,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only,
|
||||
is_production: false,
|
||||
production_databases: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: true,
|
||||
is_production: false,
|
||||
production_databases: Vec::new(),
|
||||
};
|
||||
cfg.read_only = true;
|
||||
state.configs.write().await.insert(cfg.id.clone(), cfg);
|
||||
|
|
@ -278,6 +280,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: true,
|
||||
is_production: false,
|
||||
production_databases: Vec::new(),
|
||||
};
|
||||
state.configs.write().await.insert(cfg.id.clone(), cfg);
|
||||
let err = nacos_rollback_config_core(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,687 @@
|
|||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use regex::Regex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const IDENTIFIER_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*";
|
||||
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,2}";
|
||||
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*))?";
|
||||
|
||||
static DML_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid DML target regex")
|
||||
});
|
||||
static DDL_OBJECT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?({TARGET_NAME_PATTERN})"
|
||||
))
|
||||
.expect("valid DDL object target regex")
|
||||
});
|
||||
static INDEX_ON_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b.*?\bON\s+({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid index target regex")
|
||||
});
|
||||
static DATABASE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?({IDENTIFIER_PATTERN})"
|
||||
))
|
||||
.expect("valid database target regex")
|
||||
});
|
||||
static USE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(&format!(r"(?is)^\s*USE\s+({IDENTIFIER_PATTERN})")).expect("valid USE regex"));
|
||||
static COPY_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)^\s*COPY\s+({TARGET_NAME_PATTERN})\s+FROM\b")).expect("valid COPY target regex")
|
||||
});
|
||||
static TRUNCATE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\bTRUNCATE\s+(?:TABLE\s+)?({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid truncate target regex")
|
||||
});
|
||||
static RENAME_TABLE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\bRENAME\s+TABLE\s+({TARGET_NAME_PATTERN})\s+TO\s+({TARGET_NAME_PATTERN})"))
|
||||
.expect("valid rename table target regex")
|
||||
});
|
||||
static MAINTENANCE_TABLE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+({TARGET_NAME_PATTERN})"
|
||||
))
|
||||
.expect("valid maintenance table target regex")
|
||||
});
|
||||
static COMMENT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+({TARGET_NAME_PATTERN})"
|
||||
))
|
||||
.expect("valid comment target regex")
|
||||
});
|
||||
static ROUTINE_CALL_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(r"(?is)\b(?:CALL|EXEC|EXECUTE)\s+({QUALIFIED_NAME_PATTERN})"))
|
||||
.expect("valid routine call target regex")
|
||||
});
|
||||
static PRIVILEGE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?({QUALIFIED_NAME_PATTERN})"
|
||||
))
|
||||
.expect("valid privilege target regex")
|
||||
});
|
||||
static PRIVILEGE_DATABASE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*({IDENTIFIER_PATTERN})"
|
||||
))
|
||||
.expect("valid privilege database target regex")
|
||||
});
|
||||
static GLOBAL_PRIVILEGE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+\*\s*\.\s*\*").expect("valid global privilege target regex")
|
||||
});
|
||||
static GLOBAL_DDL_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b")
|
||||
.expect("valid global DDL target regex")
|
||||
});
|
||||
static MULTI_TARGET_MUTATION_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b.*,|RENAME\s+TABLE\b.*,)")
|
||||
.expect("valid multi-target mutation regex")
|
||||
});
|
||||
static FIRST_KEYWORD_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(IDENTIFIER_PATTERN).expect("valid first keyword regex"));
|
||||
|
||||
#[derive(Default)]
|
||||
struct ReferencedDatabaseAssessment {
|
||||
databases: HashSet<String>,
|
||||
uncertain: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SqlTargetSafetyText {
|
||||
text: String,
|
||||
quoted_identifiers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Returns whether the selected database inherits an explicit production marker.
|
||||
pub fn is_production_database(config: &ConnectionConfig, database: &str) -> bool {
|
||||
config.is_production
|
||||
|| (!database.trim().is_empty()
|
||||
&& config
|
||||
.production_databases
|
||||
.iter()
|
||||
.any(|name| normalize_database_name(name) == normalize_database_name(database)))
|
||||
}
|
||||
|
||||
/// Returns whether a non-read SQL statement targets production scope.
|
||||
///
|
||||
/// Agent execution already classifies SQL risk with `sql_risk`; this function
|
||||
/// focuses only on production scope, including qualified cross-database writes
|
||||
/// such as `DELETE FROM prod_app.users` while the selected database is staging.
|
||||
pub fn targets_production_database(config: &ConnectionConfig, active_database: &str, sql: &str) -> bool {
|
||||
if is_production_database(config, active_database) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let marked: HashSet<String> =
|
||||
config.production_databases.iter().map(|name| normalize_database_name(name)).collect();
|
||||
if marked.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let assessment = referenced_databases(sql, &config.db_type, active_database);
|
||||
assessment.databases.into_iter().any(|database| marked.contains(&database)) || assessment.uncertain
|
||||
}
|
||||
|
||||
fn normalize_database_name(value: &str) -> String {
|
||||
value.trim().trim_matches(|ch| matches!(ch, '`' | '"' | '[' | ']')).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str) -> ReferencedDatabaseAssessment {
|
||||
let mut assessment = ReferencedDatabaseAssessment::default();
|
||||
let cleaned = sql_target_safety_text(sql);
|
||||
let mut use_database = String::new();
|
||||
let normalized_active_database = normalize_database_name(active_database);
|
||||
|
||||
for statement in cleaned.text.split(';').map(str::trim).filter(|statement| !statement.is_empty()) {
|
||||
let mut statement_databases = HashSet::new();
|
||||
let statement_is_mutation = crate::query_execution_sql::is_write_sql(statement);
|
||||
if let Some(database) = USE_RE
|
||||
.captures(statement)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
use_database = database;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !statement_is_mutation {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_database =
|
||||
if use_database.is_empty() { normalized_active_database.as_str() } else { use_database.as_str() };
|
||||
|
||||
collect_qualified_target_databases(
|
||||
statement,
|
||||
db_type,
|
||||
&cleaned.quoted_identifiers,
|
||||
current_database,
|
||||
&mut statement_databases,
|
||||
&[
|
||||
&DML_TARGET_RE,
|
||||
&DDL_OBJECT_TARGET_RE,
|
||||
&INDEX_ON_TARGET_RE,
|
||||
&TRUNCATE_TARGET_RE,
|
||||
&MAINTENANCE_TABLE_TARGET_RE,
|
||||
&COMMENT_TARGET_RE,
|
||||
&ROUTINE_CALL_TARGET_RE,
|
||||
&PRIVILEGE_TARGET_RE,
|
||||
],
|
||||
);
|
||||
collect_qualified_target_database_groups(
|
||||
statement,
|
||||
db_type,
|
||||
&cleaned.quoted_identifiers,
|
||||
current_database,
|
||||
&mut statement_databases,
|
||||
&RENAME_TABLE_TARGET_RE,
|
||||
&[1, 2],
|
||||
);
|
||||
for capture in DATABASE_TARGET_RE.captures_iter(statement) {
|
||||
if let Some(database) = capture
|
||||
.get(1)
|
||||
.filter(|kind| database_target_kind_means_database(kind.as_str(), db_type))
|
||||
.and_then(|_| capture.get(2))
|
||||
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
statement_databases.insert(database);
|
||||
}
|
||||
}
|
||||
for capture in PRIVILEGE_DATABASE_TARGET_RE.captures_iter(statement) {
|
||||
if let Some(database) = capture
|
||||
.get(1)
|
||||
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
statement_databases.insert(database);
|
||||
}
|
||||
}
|
||||
if let Some(database) = COPY_TARGET_RE
|
||||
.captures(statement)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.and_then(|target| {
|
||||
database_from_qualified_name(target.as_str(), db_type, &cleaned.quoted_identifiers, current_database)
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
statement_databases.insert(database);
|
||||
}
|
||||
let has_resolved_target = !statement_databases.is_empty();
|
||||
assessment.databases.extend(statement_databases);
|
||||
// The target regexes intentionally extract one object at a time. Until all
|
||||
// list forms are parsed, never let a resolved first target disable fallback.
|
||||
assessment.uncertain = assessment.uncertain
|
||||
|| GLOBAL_PRIVILEGE_TARGET_RE.is_match(statement)
|
||||
|| MULTI_TARGET_MUTATION_RE.is_match(statement)
|
||||
|| is_ambiguous_production_target_statement(statement, has_resolved_target);
|
||||
}
|
||||
assessment
|
||||
}
|
||||
|
||||
fn collect_qualified_target_databases(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
quoted_identifiers: &HashMap<String, String>,
|
||||
current_database: &str,
|
||||
databases: &mut HashSet<String>,
|
||||
patterns: &[&LazyLock<Regex>],
|
||||
) {
|
||||
for pattern in patterns {
|
||||
collect_qualified_target_database_groups(
|
||||
statement,
|
||||
db_type,
|
||||
quoted_identifiers,
|
||||
current_database,
|
||||
databases,
|
||||
pattern,
|
||||
&[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_qualified_target_database_groups(
|
||||
statement: &str,
|
||||
db_type: &DatabaseType,
|
||||
quoted_identifiers: &HashMap<String, String>,
|
||||
current_database: &str,
|
||||
databases: &mut HashSet<String>,
|
||||
pattern: &LazyLock<Regex>,
|
||||
capture_indexes: &[usize],
|
||||
) {
|
||||
for capture in pattern.captures_iter(statement) {
|
||||
for capture_index in capture_indexes {
|
||||
if let Some(database) = capture
|
||||
.get(*capture_index)
|
||||
.and_then(|target| {
|
||||
database_from_qualified_name(target.as_str(), db_type, quoted_identifiers, current_database)
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
databases.insert(database);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn database_from_qualified_name(
|
||||
qualified_name: &str,
|
||||
db_type: &DatabaseType,
|
||||
quoted_identifiers: &HashMap<String, String>,
|
||||
current_database: &str,
|
||||
) -> Option<String> {
|
||||
let parts: Vec<String> = qualified_name
|
||||
.split('.')
|
||||
.map(|part| normalize_target_database_name(part, quoted_identifiers))
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
if parts.len() < 2 {
|
||||
return (!current_database.is_empty()).then(|| current_database.to_string());
|
||||
}
|
||||
if qualified_first_part_is_database(db_type, parts.len()) {
|
||||
return parts.first().cloned();
|
||||
}
|
||||
(!current_database.is_empty()).then(|| current_database.to_string())
|
||||
}
|
||||
|
||||
fn normalize_target_database_name(value: &str, quoted_identifiers: &HashMap<String, String>) -> String {
|
||||
let normalized = normalize_database_name(value);
|
||||
quoted_identifiers.get(&normalized).map(|quoted| normalize_database_name(quoted)).unwrap_or(normalized)
|
||||
}
|
||||
|
||||
fn qualified_first_part_is_database(db_type: &DatabaseType, part_count: usize) -> bool {
|
||||
if part_count >= 3
|
||||
&& matches!(
|
||||
db_type,
|
||||
DatabaseType::SqlServer
|
||||
| DatabaseType::Snowflake
|
||||
| DatabaseType::Trino
|
||||
| DatabaseType::PrestoSql
|
||||
| DatabaseType::Databricks
|
||||
| DatabaseType::Bigquery
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if schema_first_qualifier_type(db_type) {
|
||||
return false;
|
||||
}
|
||||
part_count >= 2
|
||||
}
|
||||
|
||||
fn database_target_kind_means_database(kind: &str, db_type: &DatabaseType) -> bool {
|
||||
if kind.eq_ignore_ascii_case("database") || kind.eq_ignore_ascii_case("catalog") {
|
||||
return true;
|
||||
}
|
||||
kind.eq_ignore_ascii_case("schema") && !schema_first_qualifier_type(db_type)
|
||||
}
|
||||
|
||||
fn schema_first_qualifier_type(db_type: &DatabaseType) -> bool {
|
||||
matches!(
|
||||
db_type,
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Redshift
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::Yashandb
|
||||
| DatabaseType::Oracle
|
||||
| DatabaseType::OceanbaseOracle
|
||||
| DatabaseType::Dameng
|
||||
| DatabaseType::Firebird
|
||||
| DatabaseType::Exasol
|
||||
| DatabaseType::Teradata
|
||||
| DatabaseType::Vertica
|
||||
| DatabaseType::Db2
|
||||
| DatabaseType::Informix
|
||||
| DatabaseType::H2
|
||||
| DatabaseType::Iris
|
||||
| DatabaseType::Xugu
|
||||
| DatabaseType::Oscar
|
||||
| DatabaseType::Gbase
|
||||
| DatabaseType::SapHana
|
||||
| DatabaseType::SqlServer
|
||||
| DatabaseType::Snowflake
|
||||
| DatabaseType::Trino
|
||||
| DatabaseType::PrestoSql
|
||||
| DatabaseType::Databricks
|
||||
| DatabaseType::Bigquery
|
||||
)
|
||||
}
|
||||
|
||||
fn is_ambiguous_production_target_statement(statement: &str, has_resolved_target: bool) -> bool {
|
||||
if !crate::query_execution_sql::is_write_sql(statement) {
|
||||
return false;
|
||||
}
|
||||
let Some(first_keyword) = first_keyword(statement) else {
|
||||
return true;
|
||||
};
|
||||
if is_transaction_keyword(&first_keyword) {
|
||||
return false;
|
||||
}
|
||||
GLOBAL_DDL_TARGET_RE.is_match(statement) || !has_resolved_target
|
||||
}
|
||||
|
||||
fn first_keyword(statement: &str) -> Option<String> {
|
||||
FIRST_KEYWORD_RE.find(statement).map(|value| value.as_str().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_transaction_keyword(keyword: &str) -> bool {
|
||||
matches!(keyword, "begin" | "start" | "commit" | "rollback" | "abort" | "savepoint" | "release")
|
||||
}
|
||||
|
||||
fn sql_target_safety_text(sql: &str) -> SqlTargetSafetyText {
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut result = SqlTargetSafetyText { text: String::with_capacity(sql.len()), quoted_identifiers: HashMap::new() };
|
||||
append_sql_target_safety_text(&chars, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn append_sql_target_safety_text(chars: &[char], result: &mut SqlTargetSafetyText) {
|
||||
let mut index = 0usize;
|
||||
|
||||
while index < chars.len() {
|
||||
let ch = chars[index];
|
||||
let next = chars.get(index + 1).copied();
|
||||
|
||||
if ch == '-' && next == Some('-') {
|
||||
index += 2;
|
||||
while index < chars.len() && chars[index] != '\n' && chars[index] != '\r' {
|
||||
index += 1;
|
||||
}
|
||||
result.text.push(' ');
|
||||
continue;
|
||||
}
|
||||
if ch == '#' {
|
||||
index += 1;
|
||||
while index < chars.len() && chars[index] != '\n' && chars[index] != '\r' {
|
||||
index += 1;
|
||||
}
|
||||
result.text.push(' ');
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && next == Some('*') {
|
||||
if let Some((body, close_index)) = mysql_executable_comment_body(&chars, index) {
|
||||
result.text.push(' ');
|
||||
let body_chars: Vec<char> = body.chars().collect();
|
||||
append_sql_target_safety_text(&body_chars, result);
|
||||
result.text.push(' ');
|
||||
index = close_index;
|
||||
} else {
|
||||
index += 2;
|
||||
while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') {
|
||||
index += 1;
|
||||
}
|
||||
index = (index + 2).min(chars.len());
|
||||
result.text.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((tag, tag_len)) = dollar_quote_tag_at(&chars, index) {
|
||||
index += tag_len;
|
||||
while index + tag_len <= chars.len() && !chars[index..index + tag_len].iter().collect::<String>().eq(&tag) {
|
||||
index += 1;
|
||||
}
|
||||
index = (index + tag_len).min(chars.len());
|
||||
result.text.push(' ');
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' {
|
||||
index = skip_string_literal(&chars, index, '\'', '\'');
|
||||
result.text.push(' ');
|
||||
continue;
|
||||
}
|
||||
if ch == '"' || ch == '`' || ch == '[' {
|
||||
let close = if ch == '[' { ']' } else { ch };
|
||||
index = append_quoted_identifier_token(chars, index, close, result);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.text.push(ch);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn mysql_executable_comment_body(chars: &[char], start: usize) -> Option<(String, usize)> {
|
||||
if chars.get(start) != Some(&'/') || chars.get(start + 1) != Some(&'*') {
|
||||
return None;
|
||||
}
|
||||
let mut index = start + 2;
|
||||
match chars.get(index).copied() {
|
||||
Some('!') => index += 1,
|
||||
Some('M') if chars.get(index + 1) == Some(&'!') => index += 2,
|
||||
_ => return None,
|
||||
}
|
||||
while matches!(chars.get(index), Some(ch) if ch.is_ascii_digit() || ch.is_whitespace()) {
|
||||
index += 1;
|
||||
}
|
||||
let body_start = index;
|
||||
while index + 1 < chars.len() {
|
||||
if chars[index] == '*' && chars[index + 1] == '/' {
|
||||
return Some((chars[body_start..index].iter().collect(), index + 2));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Some((chars[body_start..].iter().collect(), chars.len()))
|
||||
}
|
||||
|
||||
fn dollar_quote_tag_at(chars: &[char], start: usize) -> Option<(String, usize)> {
|
||||
if chars.get(start) != Some(&'$') {
|
||||
return None;
|
||||
}
|
||||
let mut index = start + 1;
|
||||
if chars.get(index) == Some(&'$') {
|
||||
return Some(("$$".to_string(), 2));
|
||||
}
|
||||
if !matches!(chars.get(index), Some(ch) if ch.is_ascii_alphabetic() || *ch == '_') {
|
||||
return None;
|
||||
}
|
||||
index += 1;
|
||||
while matches!(chars.get(index), Some(ch) if ch.is_ascii_alphanumeric() || *ch == '_') {
|
||||
index += 1;
|
||||
}
|
||||
if chars.get(index) != Some(&'$') {
|
||||
return None;
|
||||
}
|
||||
let tag: String = chars[start..=index].iter().collect();
|
||||
Some((tag, index - start + 1))
|
||||
}
|
||||
|
||||
fn skip_string_literal(chars: &[char], start: usize, open: char, close: char) -> usize {
|
||||
let mut index = start + 1;
|
||||
while index < chars.len() {
|
||||
if chars[index] == '\\' && matches!(open, '\'' | '"') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if chars[index] == close {
|
||||
if chars.get(index + 1) == Some(&close) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
chars.len()
|
||||
}
|
||||
|
||||
fn append_quoted_identifier_token(
|
||||
chars: &[char],
|
||||
start: usize,
|
||||
close: char,
|
||||
result: &mut SqlTargetSafetyText,
|
||||
) -> usize {
|
||||
let mut index = start + 1;
|
||||
let mut identifier = String::new();
|
||||
while index < chars.len() {
|
||||
if chars[index] == close {
|
||||
if chars.get(index + 1) == Some(&close) {
|
||||
identifier.push(close);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
let token = format!("__dbxq{}__", result.quoted_identifiers.len());
|
||||
result.quoted_identifiers.insert(token.to_ascii_lowercase(), identifier);
|
||||
result.text.push(' ');
|
||||
result.text.push_str(&token);
|
||||
result.text.push(' ');
|
||||
return index + 1;
|
||||
}
|
||||
identifier.push(if chars[index] == ';' { ' ' } else { chars[index] });
|
||||
index += 1;
|
||||
}
|
||||
let token = format!("__dbxq{}__", result.quoted_identifiers.len());
|
||||
result.quoted_identifiers.insert(token.to_ascii_lowercase(), identifier);
|
||||
result.text.push(' ');
|
||||
result.text.push_str(&token);
|
||||
result.text.push(' ');
|
||||
chars.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_production_database, targets_production_database};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProductionSafetyCorpusCase {
|
||||
name: String,
|
||||
dialect: DatabaseType,
|
||||
production_databases: Vec<String>,
|
||||
active_database: String,
|
||||
sql: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn config() -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "test".to_string(),
|
||||
db_type: DatabaseType::Mysql,
|
||||
driver_profile: None,
|
||||
driver_label: None,
|
||||
url_params: None,
|
||||
agent_java_options: vec![],
|
||||
host: "localhost".to_string(),
|
||||
port: 3306,
|
||||
username: "root".to_string(),
|
||||
password: String::new(),
|
||||
database: None,
|
||||
visible_databases: None,
|
||||
visible_schemas: None,
|
||||
attached_databases: vec![],
|
||||
color: None,
|
||||
transport_layers: vec![],
|
||||
connect_timeout_secs: 10,
|
||||
query_timeout_secs: 30,
|
||||
idle_timeout_secs: 60,
|
||||
keepalive_interval_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: vec![],
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
redis_key_separator: ":".to_string(),
|
||||
redis_scan_page_size: Some(1000),
|
||||
etcd_endpoints: String::new(),
|
||||
gbase_server: String::new(),
|
||||
informix_server: String::new(),
|
||||
external_config: None,
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec!["prod_app".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_marked_database_case_insensitively() {
|
||||
assert!(is_production_database(&config(), "`PROD_APP`"));
|
||||
assert!(!is_production_database(&config(), "staging"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cross_database_production_targets() {
|
||||
assert!(targets_production_database(&config(), "staging", "DELETE FROM prod_app.users WHERE id = 1"));
|
||||
assert!(targets_production_database(&config(), "staging", "USE prod_app; DELETE FROM users WHERE id = 1"));
|
||||
assert!(targets_production_database(&config(), "staging", "COPY prod_app.users FROM '/tmp/users.csv'"));
|
||||
assert!(targets_production_database(&config(), "staging", "DROP DATABASE IF EXISTS `prod_app`"));
|
||||
assert!(targets_production_database(&config(), "staging", "CALL prod_app.purge_users()"));
|
||||
assert!(targets_production_database(&config(), "staging", "CALL `prod_app`.`purge_users`()"));
|
||||
assert!(targets_production_database(&config(), "staging", "GRANT ALL ON prod_app.* TO 'u'@'%'"));
|
||||
assert!(targets_production_database(
|
||||
&config(),
|
||||
"staging",
|
||||
"GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"
|
||||
));
|
||||
assert!(!targets_production_database(&config(), "staging", "DELETE FROM staging.users WHERE id = 1"));
|
||||
assert!(!targets_production_database(&config(), "staging", "CALL staging.purge_users()"));
|
||||
assert!(!targets_production_database(&config(), "staging", "GRANT ALL ON staging.* TO 'u'@'%'"));
|
||||
assert!(!targets_production_database(
|
||||
&config(),
|
||||
"staging",
|
||||
"DELETE FROM staging.users WHERE note = 'FROM prod_app.users'"
|
||||
));
|
||||
assert!(!targets_production_database(
|
||||
&config(),
|
||||
"staging",
|
||||
"SELECT * FROM prod_app.users; DELETE FROM staging.users WHERE id = 1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_sql_target_safety_corpus() {
|
||||
let corpus: Vec<ProductionSafetyCorpusCase> =
|
||||
serde_json::from_str(include_str!("../../../tests/fixtures/production-safety-corpus.json"))
|
||||
.expect("production safety corpus is valid JSON");
|
||||
|
||||
for corpus_case in corpus {
|
||||
let mut config = config();
|
||||
config.db_type = corpus_case.dialect;
|
||||
config.production_databases = corpus_case.production_databases;
|
||||
assert_eq!(
|
||||
targets_production_database(&config, &corpus_case.active_database, &corpus_case.sql),
|
||||
corpus_case.active,
|
||||
"{}",
|
||||
corpus_case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conservatively_blocks_ambiguous_production_targets() {
|
||||
assert!(targets_production_database(&config(), "staging", "CALL purge_users()"));
|
||||
assert!(targets_production_database(&config(), "staging", "GRANT PROCESS ON *.* TO 'u'@'%'"));
|
||||
assert!(targets_production_database(&config(), "staging", "GRANT ALL ON users TO 'u'@'%'"));
|
||||
assert!(targets_production_database(&config(), "staging", "CREATE USER 'u'@'%'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_sqlserver_database_qualifiers_dialect_aware() {
|
||||
let mut sqlserver = config();
|
||||
sqlserver.db_type = DatabaseType::SqlServer;
|
||||
|
||||
assert!(targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.dbo.users WHERE id = 1"));
|
||||
assert!(!targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.users WHERE id = 1"));
|
||||
}
|
||||
}
|
||||
|
|
@ -3049,6 +3049,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3856,6 +3858,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
};
|
||||
|
||||
let params = external_driver_query_params(
|
||||
|
|
|
|||
|
|
@ -201,11 +201,47 @@ pub fn is_write_sql(sql: &str) -> bool {
|
|||
return !is_safe_read_pragma(&upper);
|
||||
}
|
||||
|
||||
if starts_with_keyword(&upper, "SELECT") && select_contains_top_level_into(&upper) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A statement is a write if it doesn't start with a read keyword,
|
||||
// or if it contains embedded dangerous keywords (e.g. CTE-wrapped writes like WITH ... AS (DELETE FROM ...))
|
||||
!starts_with_read || contains_dangerous_sql_keyword(sql)
|
||||
}
|
||||
|
||||
fn select_contains_top_level_into(upper: &str) -> bool {
|
||||
let mut token = String::new();
|
||||
let mut depth = 0usize;
|
||||
let mut saw_select = false;
|
||||
|
||||
for ch in upper.chars().chain(std::iter::once(' ')) {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !token.is_empty() {
|
||||
if depth == 0 {
|
||||
if token == "SELECT" {
|
||||
saw_select = true;
|
||||
} else if saw_select && token == "INTO" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
|
||||
if ch == '(' {
|
||||
depth += 1;
|
||||
} else if ch == ')' {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a PRAGMA statement is a safe read-only form.
|
||||
/// Allows: PRAGMA table_info(...), PRAGMA index_list(...), etc.
|
||||
/// Blocks: PRAGMA name = value, PRAGMA name(value), or unknown PRAGMA names.
|
||||
|
|
@ -321,7 +357,13 @@ fn strip_sql_comments(sql: &str) -> String {
|
|||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_block_comment = true;
|
||||
if let Some(body) = read_mysql_executable_comment_body(&mut chars) {
|
||||
output.push(' ');
|
||||
output.push_str(&strip_sql_comments(&body));
|
||||
output.push(' ');
|
||||
} else {
|
||||
in_block_comment = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +434,13 @@ pub fn strip_sql_comments_and_literals(sql: &str) -> String {
|
|||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_block_comment = true;
|
||||
if let Some(body) = read_mysql_executable_comment_body(&mut chars) {
|
||||
output.push(' ');
|
||||
output.push_str(&strip_sql_comments_and_literals(&body));
|
||||
output.push(' ');
|
||||
} else {
|
||||
in_block_comment = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' {
|
||||
|
|
@ -412,6 +460,39 @@ pub fn strip_sql_comments_and_literals(sql: &str) -> String {
|
|||
output
|
||||
}
|
||||
|
||||
fn read_mysql_executable_comment_body<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
|
||||
where
|
||||
I: Iterator<Item = char>,
|
||||
{
|
||||
let marker = chars.peek().copied()?;
|
||||
if marker == '!' {
|
||||
chars.next();
|
||||
} else if marker == 'M' {
|
||||
chars.next();
|
||||
if chars.peek() != Some(&'!') {
|
||||
return None;
|
||||
}
|
||||
chars.next();
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut body = String::new();
|
||||
let mut skipping_version = true;
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
return Some(body);
|
||||
}
|
||||
if skipping_version && (ch.is_ascii_digit() || ch.is_whitespace()) {
|
||||
continue;
|
||||
}
|
||||
skipping_version = false;
|
||||
body.push(ch);
|
||||
}
|
||||
Some(body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -609,6 +690,11 @@ mod tests {
|
|||
assert!(is_write_sql("CREATE TABLE users (id INT)"));
|
||||
assert!(is_write_sql("ALTER TABLE users ADD COLUMN age INT"));
|
||||
assert!(is_write_sql("TRUNCATE TABLE users"));
|
||||
assert!(is_write_sql("EXPLAIN ANALYZE DELETE FROM users"));
|
||||
assert!(is_write_sql("SELECT * INTO backup_users FROM users"));
|
||||
assert!(is_write_sql("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'"));
|
||||
assert!(is_write_sql("COPY users FROM '/tmp/users.csv'"));
|
||||
assert!(is_write_sql("/*! DELETE FROM users */"));
|
||||
assert!(is_write_sql(
|
||||
"MERGE INTO target USING source ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name"
|
||||
));
|
||||
|
|
|
|||
|
|
@ -2352,6 +2352,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlparser::ast::Statement;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlparser::ast::{Query, SetExpr, Statement};
|
||||
use sqlparser::dialect::{
|
||||
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
|
||||
};
|
||||
|
|
@ -60,8 +60,20 @@ fn resolve_dialect(dialect: &str) -> Box<dyn sqlparser::dialect::Dialect> {
|
|||
fn classify_statement(stmt: &Statement) -> SqlRisk {
|
||||
match stmt {
|
||||
// Pure reads
|
||||
Statement::Query(_) => SqlRisk::ReadOnly,
|
||||
Statement::Explain { .. } => SqlRisk::ReadOnly,
|
||||
Statement::Query(query) => {
|
||||
if query_contains_select_into(query) {
|
||||
SqlRisk::Write
|
||||
} else {
|
||||
SqlRisk::ReadOnly
|
||||
}
|
||||
}
|
||||
Statement::Explain { analyze, statement, .. } => {
|
||||
if *analyze {
|
||||
classify_statement(statement)
|
||||
} else {
|
||||
SqlRisk::ReadOnly
|
||||
}
|
||||
}
|
||||
Statement::ExplainTable { .. } => SqlRisk::ReadOnly,
|
||||
|
||||
// Show/Describe variants
|
||||
|
|
@ -101,10 +113,11 @@ fn classify_statement(stmt: &Statement) -> SqlRisk {
|
|||
SqlRisk::Transaction
|
||||
}
|
||||
|
||||
// Copy (PostgreSQL) 鈥?treat as write
|
||||
// COPY FROM mutates data; keep COPY conservative because sqlparser does
|
||||
// not expose enough dialect-specific direction detail here.
|
||||
Statement::Copy { .. } => SqlRisk::Write,
|
||||
|
||||
// Pragma (SQLite/DuckDB) 鈥?conservative: treat as write unless known-safe
|
||||
// SQLite/DuckDB PRAGMA statements can mutate database/session state.
|
||||
Statement::Pragma { .. } => SqlRisk::Write,
|
||||
|
||||
// Catch-all: conservative write classification
|
||||
|
|
@ -112,6 +125,21 @@ fn classify_statement(stmt: &Statement) -> SqlRisk {
|
|||
}
|
||||
}
|
||||
|
||||
fn query_contains_select_into(query: &Query) -> bool {
|
||||
set_expr_contains_select_into(&query.body)
|
||||
}
|
||||
|
||||
fn set_expr_contains_select_into(expr: &SetExpr) -> bool {
|
||||
match expr {
|
||||
SetExpr::Select(select) => select.into.is_some(),
|
||||
SetExpr::Query(query) => query_contains_select_into(query),
|
||||
SetExpr::SetOperation { left, right, .. } => {
|
||||
set_expr_contains_select_into(left) || set_expr_contains_select_into(right)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify SQL risk using sqlparser AST analysis.
|
||||
///
|
||||
/// If parsing fails (non-standard SQL, non-SQL databases), falls back to
|
||||
|
|
@ -173,6 +201,13 @@ mod tests {
|
|||
assert_eq!(classify_sql_risk("INSERT INTO users VALUES (1)", "postgres").unwrap(), SqlRisk::Write);
|
||||
assert_eq!(classify_sql_risk("UPDATE users SET name = 'x'", "postgres").unwrap(), SqlRisk::Write);
|
||||
assert_eq!(classify_sql_risk("DELETE FROM users", "postgres").unwrap(), SqlRisk::Write);
|
||||
assert_eq!(classify_sql_risk("EXPLAIN ANALYZE DELETE FROM users", "postgres").unwrap(), SqlRisk::Write);
|
||||
assert_eq!(classify_sql_risk("SELECT * INTO backup_users FROM users", "postgres").unwrap(), SqlRisk::Write);
|
||||
assert_eq!(
|
||||
classify_sql_risk("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'", "mysql").unwrap(),
|
||||
SqlRisk::Write
|
||||
);
|
||||
assert_eq!(classify_sql_risk("/*! DELETE FROM users */", "mysql").unwrap(), SqlRisk::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2461,6 +2461,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2519,6 +2521,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4680,6 +4680,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ fn live_postgres_config(
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,13 @@ pub async fn ai_agent_stream(
|
|||
|
||||
let parsed_db_type: DatabaseType = serde_json::from_str(&format!("\"{}\"", body.db_type))
|
||||
.map_err(|_| AppError(format!("Unknown database type: {}", body.db_type)))?;
|
||||
let production_database = state
|
||||
.app
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get(&body.connection_id)
|
||||
.is_some_and(|config| dbx_core::production_safety::is_production_database(config, &body.database));
|
||||
|
||||
let agent_ctx = AgentLoopContext {
|
||||
state: state.app.clone(),
|
||||
|
|
@ -268,8 +275,8 @@ pub async fn ai_agent_stream(
|
|||
db_type: parsed_db_type,
|
||||
cli_mcp_server_command: None,
|
||||
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
|
||||
allow_writes: body.allow_write_sql,
|
||||
allow_dangerous: body.allow_write_sql,
|
||||
allow_writes: !production_database && body.allow_write_sql,
|
||||
allow_dangerous: !production_database && body.allow_write_sql,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -306,6 +306,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"dev": "vite --config apps/desktop/vite.config.ts",
|
||||
"dev:tauri": "tauri dev",
|
||||
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
|
||||
"dev:backend": "RUST_LOG=${RUST_LOG:-info} sh -c 'if cargo watch --version >/dev/null 2>&1; then cargo watch -x \"run -p dbx-web\"; else echo \"cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch\"; cargo run -p dbx-web; fi'",
|
||||
"dev:backend": "node scripts/dev-backend.mjs",
|
||||
"build:packages": "pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build",
|
||||
"test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test",
|
||||
"pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ test("scoped single update auto-executes only on non-production targets", () =>
|
|||
assert.equal(classifyAiSqlExecution(sql, conn({ name: "prod-db" })).action, "confirm");
|
||||
});
|
||||
|
||||
test("production target databases require confirmation even from staging", () => {
|
||||
const decision = classifyAiSqlExecution(
|
||||
"DELETE FROM prod_app.users WHERE id = 1",
|
||||
conn({ db_type: "mysql", name: "staging-db", host: "10.0.0.8", database: "staging", production_databases: ["prod_app"] }),
|
||||
"staging",
|
||||
);
|
||||
|
||||
assert.equal(decision.action, "confirm");
|
||||
assert.equal(decision.environment, "production");
|
||||
assert.deepEqual(decision.reasons, ["production_write"]);
|
||||
});
|
||||
|
||||
test("broad or destructive writes do not auto-execute", () => {
|
||||
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a'", conn()).action, "block");
|
||||
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a' WHERE 1=1", conn()).action, "block");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
|
||||
function readSource(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
test("secondary write entrypoints use the shared production SQL guard", () => {
|
||||
const entrypoints = [
|
||||
{
|
||||
path: "apps/desktop/src/components/diff/SchemaDiffDialog.vue",
|
||||
executor: "api.executeScript",
|
||||
sourceKey: "production.sourceSchemaDiff",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/diff/DataCompareDialog.vue",
|
||||
executor: "api.executeBatch",
|
||||
sourceKey: "production.sourceDataCompare",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/objects/InstallExtensionDialog.vue",
|
||||
executor: "api.executeQuery",
|
||||
sourceKey: "production.sourceExtension",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/sidebar/TreeItem.vue",
|
||||
executor: "api.executeQuery",
|
||||
sourceKey: "production.sourceSidebar",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/App.vue",
|
||||
executor: "executeObjectSourceSave",
|
||||
sourceKey: "production.sourceObjectSource",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/objects/ObjectSourceDialog.vue",
|
||||
executor: "executeObjectSourceSave",
|
||||
sourceKey: "production.sourceObjectSource",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
executor: "executeObjectSourceSave",
|
||||
sourceKey: "production.sourceObjectSource",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/objects/ObjectBrowser.vue",
|
||||
executor: "api.executeQuery",
|
||||
sourceKey: "production.sourceObjectBrowser",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/generate/DataGenerateDialog.vue",
|
||||
executor: "api.executeQuery",
|
||||
sourceKey: "production.sourceDataGenerate",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/editor/QueryHistory.vue",
|
||||
executor: "api.executeScript",
|
||||
sourceKey: "production.sourceQueryHistory",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/admin/DatabaseUserAdmin.vue",
|
||||
executor: "api.executeMulti",
|
||||
sourceKey: "production.sourceAdmin",
|
||||
},
|
||||
{
|
||||
path: "apps/desktop/src/components/admin/DamengJobAdmin.vue",
|
||||
executor: "api.executeMulti",
|
||||
sourceKey: "production.sourceAdmin",
|
||||
},
|
||||
];
|
||||
|
||||
for (const entrypoint of entrypoints) {
|
||||
const source = readSource(entrypoint.path);
|
||||
assert.match(source, /executeWithProductionSqlGuard/, entrypoint.path);
|
||||
assert.ok(source.includes(entrypoint.executor), `${entrypoint.path} should still execute SQL through its original backend API`);
|
||||
assert.ok(source.includes(entrypoint.sourceKey), `${entrypoint.path} should label the confirmation source`);
|
||||
}
|
||||
});
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
mdTable,
|
||||
notifyReload,
|
||||
parseMongoAggregateCommand,
|
||||
assessProductionSql,
|
||||
isLikelyMongoMutation,
|
||||
isProductionDatabase,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
splitSqlStatements,
|
||||
|
|
@ -245,6 +248,12 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
if (scopedConfig.db_type !== "mongodb") {
|
||||
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
const production = assessProductionSql(sql, scopedConfig, database ?? scope.database ?? scopedConfig.database);
|
||||
if (production.active && production.isMutation) {
|
||||
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the SQL for a user to review and run in DBX.");
|
||||
}
|
||||
} else if (isProductionDatabase(scopedConfig, database ?? scope.database ?? scopedConfig.database) && isLikelyMongoMutation(sql)) {
|
||||
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the command for a user to review and run in DBX.");
|
||||
}
|
||||
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
|
||||
// (node-core executeQuery) applies command-aware read/write gating.
|
||||
|
|
@ -284,6 +293,9 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
}
|
||||
const safety = evaluateRedisCommandSafety(command, sqlSafetyFromEnv());
|
||||
if (!safety.allowed) return toolError("REDIS_COMMAND_BLOCKED", safety.reason ?? "Redis command blocked.");
|
||||
if (isProductionDatabase(scopedConfig, String(defaultRedisDb(scopedConfig, scope, db))) && safety.safety !== "allowed") {
|
||||
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute write or dangerous Redis commands against a production database.");
|
||||
}
|
||||
try {
|
||||
const result = await backend.executeRedisCommand(scopedConfig, defaultRedisDb(scopedConfig, scope, db), command, {
|
||||
skipSafetyCheck: safety.skipSafetyCheck,
|
||||
|
|
@ -477,6 +489,16 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
|||
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
if (config?.db_type === "mongodb") {
|
||||
if (isProductionDatabase(config, database ?? scope.database ?? config.database) && isLikelyMongoMutation(sql)) {
|
||||
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
|
||||
}
|
||||
} else {
|
||||
const production = assessProductionSql(sql, config, database ?? scope.database ?? config.database);
|
||||
if (production.active && production.isMutation) {
|
||||
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
|
||||
}
|
||||
}
|
||||
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
|
||||
// safety flags to the desktop executor for command-aware gating.
|
||||
return bridgeRequest(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
"./entrypoint": "./dist/entrypoint.js",
|
||||
"./format": "./dist/format.js",
|
||||
"./paths": "./dist/paths.js",
|
||||
"./production-safety": "./dist/production-safety.js",
|
||||
"./redis-command": "./dist/redis-command.js",
|
||||
"./schema-context": "./dist/schema-context.js",
|
||||
"./sql-risk": "./dist/sql-risk.js",
|
||||
"./sql-safety": "./dist/sql-safety.js"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export interface ConnectionConfig {
|
|||
redis_cluster_nodes?: string;
|
||||
redis_key_separator?: string;
|
||||
read_only?: boolean;
|
||||
is_production?: boolean;
|
||||
production_databases?: string[];
|
||||
}
|
||||
|
||||
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export * from "./diagnostics.js";
|
|||
export * from "./entrypoint.js";
|
||||
export * from "./format.js";
|
||||
export * from "./paths.js";
|
||||
export * from "./production-safety.js";
|
||||
export * from "./redis-command.js";
|
||||
export * from "./schema-context.js";
|
||||
export * from "./sql-risk.js";
|
||||
export * from "./sql-safety.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
import type { ConnectionConfig } from "./connections.js";
|
||||
import { classifySqlRisk, isSqlRiskMutation } from "./sql-risk.js";
|
||||
|
||||
export interface ProductionSqlAssessment {
|
||||
active: boolean;
|
||||
isMutation: boolean;
|
||||
databases: string[];
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`;
|
||||
const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`;
|
||||
const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`;
|
||||
const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i");
|
||||
const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi");
|
||||
const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i");
|
||||
const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi");
|
||||
const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi");
|
||||
const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi");
|
||||
const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi");
|
||||
const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i;
|
||||
const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i;
|
||||
const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i;
|
||||
const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]);
|
||||
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
|
||||
const SCHEMA_FIRST_QUALIFIER_TYPES = new Set([
|
||||
"postgres",
|
||||
"redshift",
|
||||
"gaussdb",
|
||||
"kwdb",
|
||||
"opengauss",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"yashandb",
|
||||
"oracle",
|
||||
"oceanbase-oracle",
|
||||
"dameng",
|
||||
"firebird",
|
||||
"exasol",
|
||||
"teradata",
|
||||
"vertica",
|
||||
"db2",
|
||||
"informix",
|
||||
"h2",
|
||||
"iris",
|
||||
"xugu",
|
||||
"oscar",
|
||||
"gbase",
|
||||
"saphana",
|
||||
"sqlserver",
|
||||
"snowflake",
|
||||
"trino",
|
||||
"prestosql",
|
||||
"databricks",
|
||||
"bigquery",
|
||||
]);
|
||||
|
||||
interface ReferencedDatabaseAssessment {
|
||||
databases: string[];
|
||||
uncertain: boolean;
|
||||
}
|
||||
|
||||
interface SqlTargetSafetyText {
|
||||
text: string;
|
||||
quotedIdentifiers: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Normalizes quoted database names before production scope comparison. */
|
||||
export function normalizeProductionDatabase(value: string | undefined | null): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.replace(/^[`"[]|[`"\]]$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isProductionDatabase(config: ConnectionConfig | undefined, database?: string): boolean {
|
||||
if (!config) return false;
|
||||
if (config.is_production) return true;
|
||||
const selected = normalizeProductionDatabase(database);
|
||||
return !!selected && (config.production_databases ?? []).some((name) => normalizeProductionDatabase(name) === selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds writes that target a marked production database, including a MySQL
|
||||
* USE switch or a qualified database.table reference in a statement batch.
|
||||
*/
|
||||
export function assessProductionSql(sql: string, config: ConnectionConfig | undefined, activeDatabase?: string): ProductionSqlAssessment {
|
||||
const targetText = sqlTargetSafetyText(sql);
|
||||
const statements = splitTargetStatements(targetText.text);
|
||||
const isMutation = isSqlRiskMutation(classifySqlRisk(sql).risk);
|
||||
if (!isMutation || !config) return { active: isProductionDatabase(config, activeDatabase), isMutation, databases: [] };
|
||||
if (config.is_production) return { active: true, isMutation, databases: [] };
|
||||
if (isProductionDatabase(config, activeDatabase)) return { active: true, isMutation, databases: activeDatabase ? [activeDatabase] : [] };
|
||||
|
||||
const marked = new Set((config.production_databases ?? []).map(normalizeProductionDatabase).filter(Boolean));
|
||||
if (!marked.size) return { active: false, isMutation, databases: [] };
|
||||
|
||||
const targets = referencedDatabases(statements, config.db_type, activeDatabase, targetText.quotedIdentifiers);
|
||||
const databases = targets.databases.filter((database) => marked.has(normalizeProductionDatabase(database)));
|
||||
return { active: databases.length > 0 || targets.uncertain, isMutation, databases: databases.length > 0 ? databases : targets.uncertain ? [...marked] : [] };
|
||||
}
|
||||
|
||||
function referencedDatabases(statements: string[], dbType: string, activeDatabase: string | undefined, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
|
||||
const databases = new Set<string>();
|
||||
let uncertain = false;
|
||||
let useDatabase = "";
|
||||
const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase);
|
||||
|
||||
for (const statement of statements) {
|
||||
const statementDatabases = new Set<string>();
|
||||
const statementAssessment = classifySqlRisk(statement);
|
||||
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
|
||||
const useMatch = statement.match(USE_RE);
|
||||
if (useMatch?.[1]) {
|
||||
useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers);
|
||||
continue;
|
||||
}
|
||||
if (!statementIsMutation) continue;
|
||||
const currentDatabase = useDatabase || normalizedActiveDatabase;
|
||||
|
||||
collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE);
|
||||
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]);
|
||||
for (const match of statement.matchAll(DATABASE_TARGET_RE)) {
|
||||
const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : "";
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) {
|
||||
const database = normalizeTargetDatabase(match[1], quotedIdentifiers);
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
const copyTarget = statement.match(COPY_TARGET_RE);
|
||||
if (copyTarget?.[1]) {
|
||||
const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase);
|
||||
if (database) statementDatabases.add(database);
|
||||
}
|
||||
for (const database of statementDatabases) databases.add(database);
|
||||
// The target regexes intentionally extract one object at a time. Until all
|
||||
// list forms are parsed, never let a resolved first target disable fallback.
|
||||
uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0);
|
||||
}
|
||||
return { databases: [...databases], uncertain };
|
||||
}
|
||||
|
||||
function collectQualifiedTargetDatabases(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, ...patterns: RegExp[]): void {
|
||||
for (const pattern of patterns) {
|
||||
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]);
|
||||
}
|
||||
}
|
||||
|
||||
function collectQualifiedTargetDatabaseGroups(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, pattern: RegExp, captureIndexes: number[]): void {
|
||||
pattern.lastIndex = 0;
|
||||
for (const match of statement.matchAll(pattern)) {
|
||||
for (const captureIndex of captureIndexes) {
|
||||
const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase);
|
||||
if (database) databases.add(database);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string): string {
|
||||
const parts = String(qualifiedName ?? "")
|
||||
.split(".")
|
||||
.map((part) => normalizeTargetDatabase(part, quotedIdentifiers))
|
||||
.filter(Boolean);
|
||||
if (parts.length < 2) return currentDatabase;
|
||||
if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? "";
|
||||
return currentDatabase;
|
||||
}
|
||||
|
||||
function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map<string, string>): string {
|
||||
const normalized = normalizeProductionDatabase(value);
|
||||
const quoted = quotedIdentifiers.get(normalized);
|
||||
return quoted === undefined ? normalized : normalizeProductionDatabase(quoted);
|
||||
}
|
||||
|
||||
function qualifiedFirstPartIsDatabase(dbType: string, partCount: number): boolean {
|
||||
const normalizedType = dbType.toLowerCase();
|
||||
if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(normalizedType)) return true;
|
||||
if (SCHEMA_FIRST_QUALIFIER_TYPES.has(normalizedType)) return false;
|
||||
return partCount >= 2;
|
||||
}
|
||||
|
||||
function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: string): boolean {
|
||||
const normalizedKind = String(kind ?? "").toLowerCase();
|
||||
if (normalizedKind === "database" || normalizedKind === "catalog") return true;
|
||||
if (normalizedKind !== "schema") return false;
|
||||
return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType.toLowerCase());
|
||||
}
|
||||
|
||||
function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType<typeof classifySqlRisk>, hasResolvedTarget: boolean): boolean {
|
||||
if (!isSqlRiskMutation(assessment.risk)) return false;
|
||||
if (assessment.risk === "transaction") return false;
|
||||
const firstKeyword = assessment.firstKeyword;
|
||||
if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false;
|
||||
return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget;
|
||||
}
|
||||
|
||||
function splitTargetStatements(sql: string): string[] {
|
||||
return sql
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const char = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
if (char === "-" && next === "-") {
|
||||
index += 2;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "#") {
|
||||
index += 1;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
if (close < 0) return { text: output, quotedIdentifiers };
|
||||
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
|
||||
if (executablePrefixLength > 0) {
|
||||
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
|
||||
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
const dollarQuote = dollarQuoteTagAt(sql, index);
|
||||
if (dollarQuote) {
|
||||
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
|
||||
index = close < 0 ? sql.length : close + dollarQuote.length;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
index = readQuotedEnd(sql, index, "'", "'");
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "`" || char === "[") {
|
||||
const close = char === "[" ? "]" : char;
|
||||
const end = readQuotedEnd(sql, index, char, close);
|
||||
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
|
||||
const token = `__dbxq${quotedIdentifiers.size}__`;
|
||||
quotedIdentifiers.set(token.toLowerCase(), identifier);
|
||||
output += ` ${token} `;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return { text: output, quotedIdentifiers };
|
||||
}
|
||||
|
||||
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
|
||||
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
|
||||
if (sql[index + 2] === "!") return 3;
|
||||
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function skipExecutableCommentVersion(sql: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
|
||||
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
|
||||
}
|
||||
|
||||
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
|
||||
let index = start + open.length;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === "\\" && (open === "'" || open === '"')) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql.startsWith(close, index)) {
|
||||
if (sql.startsWith(close + close, index)) {
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function unquoteIdentifier(value: string, open: string, close: string): string {
|
||||
if (!value.startsWith(open) || !value.endsWith(close)) return value;
|
||||
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
|
||||
}
|
||||
|
||||
/** MCP receives Mongo shell text rather than SQL, so use a conservative write detector. */
|
||||
export function isLikelyMongoMutation(command: string): boolean {
|
||||
return /\.(?:insert(?:One|Many)?|update(?:One|Many)?|replaceOne|delete(?:One|Many)?|findOneAnd(?:Update|Replace|Delete)|drop(?:Index|Indexes)?|renameCollection|createIndex)\s*\(|\bdb\.createCollection\s*\(/i.test(command);
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
|
||||
|
||||
export interface SqlRiskStatementAssessment {
|
||||
risk: SqlRiskLevel;
|
||||
firstKeyword?: string;
|
||||
}
|
||||
|
||||
export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
|
||||
statements: SqlRiskStatementAssessment[];
|
||||
}
|
||||
|
||||
interface SqlRiskToken {
|
||||
text: string;
|
||||
normalized: string;
|
||||
}
|
||||
|
||||
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
|
||||
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
|
||||
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
|
||||
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
|
||||
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
|
||||
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
|
||||
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
|
||||
const RISK_ORDER: Record<SqlRiskLevel, number> = { read: 0, write: 1, ddl: 2, transaction: 3, unknown: 4 };
|
||||
|
||||
export function splitSqlStatementsForSafety(sql: string): string[] {
|
||||
return sqlSafetyText(sql)
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function classifySqlRisk(sql: string): SqlRiskAssessment {
|
||||
const statements = splitSqlStatementsForSafety(sql).map(classifySqlStatementRisk);
|
||||
if (!statements.length) return { risk: "unknown", statements: [] };
|
||||
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
|
||||
return { ...highest, statements };
|
||||
}
|
||||
|
||||
export function classifySqlStatementRisk(sql: string): SqlRiskStatementAssessment {
|
||||
return classifyTokens(tokenizeSqlForRisk(sql));
|
||||
}
|
||||
|
||||
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
|
||||
return risk !== "read";
|
||||
}
|
||||
|
||||
export function sqlSafetyText(sql: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
while (index < sql.length) {
|
||||
const char = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
if (char === "-" && next === "-") {
|
||||
index += 2;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "#") {
|
||||
index += 1;
|
||||
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
const close = sql.indexOf("*/", index + 2);
|
||||
if (close < 0) return output;
|
||||
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
|
||||
if (executablePrefixLength > 0) {
|
||||
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
|
||||
output += ` ${sqlSafetyText(sql.slice(bodyStart, close))} `;
|
||||
} else {
|
||||
output += " ";
|
||||
}
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
const dollarQuote = dollarQuoteTagAt(sql, index);
|
||||
if (dollarQuote) {
|
||||
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
|
||||
index = close < 0 ? sql.length : close + dollarQuote.length;
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
index = readQuotedEnd(sql, index, "'", "'");
|
||||
output += " ";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "`" || char === "[") {
|
||||
const close = char === "[" ? "]" : char;
|
||||
const end = readQuotedEnd(sql, index, char, close);
|
||||
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
|
||||
const tokens: SqlRiskToken[] = [];
|
||||
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
|
||||
for (const match of sql.matchAll(re)) {
|
||||
const text = match[0] ?? "";
|
||||
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
|
||||
const useful = trimWrappingParentheses(tokens);
|
||||
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
|
||||
if (!firstKeyword) return { risk: "unknown" };
|
||||
if (READ_KEYWORDS.has(firstKeyword)) {
|
||||
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
|
||||
}
|
||||
if (firstKeyword === "with") return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
|
||||
if (firstKeyword === "explain") return classifyExplainTokens(useful);
|
||||
if (firstKeyword === "copy") return { risk: classifyCopyTokens(useful), firstKeyword };
|
||||
if (firstKeyword === "pragma") return { risk: classifyPragmaTokens(useful), firstKeyword };
|
||||
if (firstKeyword === "use") return { risk: "read", firstKeyword };
|
||||
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
|
||||
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
|
||||
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
|
||||
return { risk: "unknown", firstKeyword };
|
||||
}
|
||||
|
||||
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
|
||||
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
|
||||
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
|
||||
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
|
||||
const inner = classifyTokens(tokens.slice(innerIndex));
|
||||
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
|
||||
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
|
||||
}
|
||||
|
||||
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
|
||||
if (tokens.some((token) => token.normalized === "from")) return "write";
|
||||
if (tokens.some((token) => token.normalized === "to")) return "read";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
|
||||
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
|
||||
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
|
||||
return "write";
|
||||
}
|
||||
|
||||
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
|
||||
let result: SqlRiskLevel | undefined;
|
||||
for (const token of tokens) {
|
||||
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
|
||||
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
|
||||
let depth = 0;
|
||||
for (const token of tokens) {
|
||||
if (token.text === "(") depth += 1;
|
||||
if (token.text === ")") depth = Math.max(0, depth - 1);
|
||||
if (depth !== 0) continue;
|
||||
if (token.normalized === "into") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
|
||||
let start = 0;
|
||||
let end = tokens.length;
|
||||
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
|
||||
start += 1;
|
||||
end -= 1;
|
||||
}
|
||||
return tokens.slice(start, end);
|
||||
}
|
||||
|
||||
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openIndex; index < tokens.length; index += 1) {
|
||||
if (tokens[index]?.text === "(") depth += 1;
|
||||
if (tokens[index]?.text === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
|
||||
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
|
||||
if (sql[index + 2] === "!") return 3;
|
||||
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function skipExecutableCommentVersion(sql: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
|
||||
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
|
||||
}
|
||||
|
||||
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
|
||||
let index = start + open.length;
|
||||
while (index < sql.length) {
|
||||
if (sql[index] === "\\" && (open === "'" || open === '"')) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql.startsWith(close, index)) {
|
||||
if (sql.startsWith(close + close, index)) {
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function unquoteIdentifier(value: string, open: string, close: string): string {
|
||||
if (!value.startsWith(open) || !value.endsWith(close)) return value;
|
||||
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText } from "./sql-risk.js";
|
||||
|
||||
export interface SqlSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
allowDangerous?: boolean;
|
||||
|
|
@ -9,8 +11,7 @@ export interface SqlSafetyDecision {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
const READ_KEYWORDS = new Set(["select", "with", "show", "describe", "desc", "explain"]);
|
||||
const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
|
||||
const DANGEROUS_RISKS = new Set(["ddl", "transaction", "unknown"]);
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
|
|
@ -21,7 +22,7 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
|||
}
|
||||
|
||||
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const statements = splitSqlStatements(sql);
|
||||
const statements = splitSqlStatementsForSafety(sql);
|
||||
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
|
||||
if (statements.length > 1 && !options.allowMultipleStatements) {
|
||||
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
|
||||
|
|
@ -42,17 +43,15 @@ export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}):
|
|||
}
|
||||
|
||||
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const normalized = stripSqlCommentsAndStrings(sql).trim();
|
||||
const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
|
||||
const assessment = classifySqlStatementRisk(sql);
|
||||
const firstKeyword = assessment.firstKeyword;
|
||||
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
|
||||
|
||||
const tokens: string[] = normalized.toLowerCase().match(/[a-z_]+/g) ?? [];
|
||||
const dangerous = tokens.find((token) => DANGEROUS_KEYWORDS.has(token));
|
||||
if (dangerous && !options.allowDangerous) {
|
||||
return { allowed: false, reason: `Dangerous SQL keyword "${dangerous.toUpperCase()}" is blocked.` };
|
||||
if (DANGEROUS_RISKS.has(assessment.risk) && !options.allowDangerous) {
|
||||
return { allowed: false, reason: `Dangerous SQL or unrecognized SQL statement "${firstKeyword.toUpperCase()}" is blocked.` };
|
||||
}
|
||||
|
||||
if (!options.allowWrites && !READ_KEYWORDS.has(firstKeyword)) {
|
||||
if (!options.allowWrites && assessment.risk !== "read") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "MCP SQL execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
|
||||
|
|
@ -60,6 +59,7 @@ function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions
|
|||
}
|
||||
|
||||
if (options.allowWrites && !options.allowDangerous) {
|
||||
const tokens: string[] = sqlSafetyText(sql).toLowerCase().match(/[a-z_]+/g) ?? [];
|
||||
if (firstKeyword === "update" && !tokens.includes("where")) {
|
||||
return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
|
||||
}
|
||||
|
|
@ -81,63 +81,5 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet
|
|||
}
|
||||
|
||||
export function splitSqlStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i];
|
||||
const next = sql[i + 1];
|
||||
|
||||
if (inLineComment) {
|
||||
current += char;
|
||||
if (char === "\n") inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
current += char;
|
||||
if (char === "*" && next === "/") {
|
||||
current += next;
|
||||
i++;
|
||||
inBlockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) {
|
||||
if (next === quote) {
|
||||
current += next;
|
||||
i++;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "-" && next === "-") inLineComment = true;
|
||||
if (char === "/" && next === "*") inBlockComment = true;
|
||||
if (char === "'" || char === '"' || char === "`") quote = char;
|
||||
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
function stripSqlCommentsAndStrings(sql: string): string {
|
||||
return sql
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/'([^']|'')*'/g, "''")
|
||||
.replace(/"([^"]|"")*"/g, '""')
|
||||
.replace(/`([^`]|``)*`/g, "``");
|
||||
return splitSqlStatementsForSafety(sql);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { assessProductionSql, isLikelyMongoMutation, isProductionDatabase } from "../src/production-safety.js";
|
||||
import type { ConnectionConfig } from "../src/connections.js";
|
||||
|
||||
interface ProductionSafetyCorpusCase {
|
||||
name: string;
|
||||
dialect: ConnectionConfig["db_type"];
|
||||
productionDatabases: string[];
|
||||
activeDatabase: string;
|
||||
sql: string;
|
||||
active: boolean;
|
||||
isMutation: boolean;
|
||||
databases: string[];
|
||||
}
|
||||
|
||||
const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[];
|
||||
|
||||
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "conn-1",
|
||||
name: "Operations",
|
||||
db_type: "mysql",
|
||||
host: "db.internal",
|
||||
port: 3306,
|
||||
username: "readonly",
|
||||
password: "",
|
||||
production_databases: ["prod_app"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("production safety", () => {
|
||||
it("keeps an explicit production connection in scope", () => {
|
||||
expect(isProductionDatabase(connection({ is_production: true }), "scratch")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a production write through USE and a qualified table name", () => {
|
||||
expect(assessProductionSql("-- migrate\nUSE prod_app; /* delete old rows */ DELETE FROM users", connection(), "staging")).toMatchObject({ active: true, isMutation: true });
|
||||
expect(assessProductionSql("DELETE FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
});
|
||||
|
||||
it("detects production writes hidden behind parser-sensitive SQL forms", () => {
|
||||
for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the shared SQL target safety corpus", () => {
|
||||
for (const corpusCase of productionSafetyCorpus) {
|
||||
const assessment = assessProductionSql(
|
||||
corpusCase.sql,
|
||||
connection({
|
||||
db_type: corpusCase.dialect,
|
||||
production_databases: corpusCase.productionDatabases,
|
||||
}),
|
||||
corpusCase.activeDatabase,
|
||||
);
|
||||
expect(
|
||||
{
|
||||
active: assessment.active,
|
||||
isMutation: assessment.isMutation,
|
||||
databases: assessment.databases,
|
||||
},
|
||||
corpusCase.name,
|
||||
).toEqual({
|
||||
active: corpusCase.active,
|
||||
isMutation: corpusCase.isMutation,
|
||||
databases: corpusCase.databases,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("detects qualified procedure calls and privilege targets", () => {
|
||||
for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows resolved non-production procedure and privilege targets", () => {
|
||||
expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
});
|
||||
|
||||
it("conservatively confirms ambiguous production targets", () => {
|
||||
for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) {
|
||||
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat read-only qualified references as write targets", () => {
|
||||
expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
|
||||
});
|
||||
|
||||
it("treats unrecognized SQL as a mutation when production is selected", () => {
|
||||
expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true });
|
||||
});
|
||||
|
||||
it("does not treat a read as a production write", () => {
|
||||
expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false });
|
||||
});
|
||||
|
||||
it("recognizes Mongo write commands before MCP forwards them", () => {
|
||||
expect(isLikelyMongoMutation("db.orders.updateOne({_id: 1}, {$set: {status: 'paid'}})")).toBe(true);
|
||||
expect(isLikelyMongoMutation("db.orders.find({status: 'paid'})")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,12 @@ test("allows read-only SQL by default", () => {
|
|||
assert.equal(decision.allowed, true);
|
||||
});
|
||||
|
||||
test("allows read-only EXPLAIN without ANALYZE", () => {
|
||||
const decision = evaluateSqlSafety("EXPLAIN SELECT * FROM users");
|
||||
|
||||
assert.equal(decision.allowed, true);
|
||||
});
|
||||
|
||||
test("allows non-dangerous write SQL by default when scoped", () => {
|
||||
const decision = evaluateSqlSafety("update users set role = 'admin' where id = 1", sqlSafetyFromEnv({}));
|
||||
|
||||
|
|
@ -28,6 +34,27 @@ test("blocks update without where when writes are enabled", () => {
|
|||
assert.match(decision.reason ?? "", /WHERE/i);
|
||||
});
|
||||
|
||||
test("blocks writes that do not start with a write keyword in read-only mode", () => {
|
||||
for (const sql of [
|
||||
"EXPLAIN ANALYZE DELETE FROM users WHERE id = 1",
|
||||
"/*! DELETE FROM users WHERE id = 1 */",
|
||||
"COPY users FROM '/tmp/users.csv'",
|
||||
"SELECT * INTO backup_users FROM users",
|
||||
"SELECT * FROM users INTO OUTFILE '/tmp/users.csv'",
|
||||
]) {
|
||||
const decision = evaluateSqlSafety(sql);
|
||||
assert.equal(decision.allowed, false, sql);
|
||||
assert.match(decision.reason ?? "", /read-only|blocked/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks unrecognized SQL unless dangerous SQL is explicitly enabled", () => {
|
||||
const decision = evaluateSqlSafety("MAINTAIN UNKNOWN THING", { allowWrites: true });
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.match(decision.reason ?? "", /unrecognized/i);
|
||||
});
|
||||
|
||||
test("blocks multiple SQL statements unless explicitly allowed", () => {
|
||||
const decision = evaluateSqlSafety("select 1; select 2");
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
RUST_LOG: process.env.RUST_LOG || "info",
|
||||
};
|
||||
|
||||
const hasCargoWatch = await commandSucceeds("cargo", ["watch", "--version"]);
|
||||
|
||||
if (!hasCargoWatch) {
|
||||
console.warn(
|
||||
"cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch",
|
||||
);
|
||||
}
|
||||
|
||||
const args = hasCargoWatch ? ["watch", "-x", "run -p dbx-web"] : ["run", "-p", "dbx-web"];
|
||||
const child = spawn("cargo", args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Keep signal handling in Node so Windows does not have to parse nested shell quotes.
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
child.kill(signal);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error.stack ?? String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
function commandSucceeds(command, args) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
child.on("error", () => resolve(false));
|
||||
child.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
|
@ -99,16 +99,22 @@ pub async fn ai_agent_stream(
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let production_database = state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get(&connection_id)
|
||||
.is_some_and(|config| dbx_core::production_safety::is_production_database(config, &database));
|
||||
let agent_ctx = AgentLoopContext {
|
||||
state: state.inner().clone(),
|
||||
connection_id,
|
||||
database,
|
||||
db_type: parsed_db_type,
|
||||
cli_mcp_server_command,
|
||||
// Explicit confirmation grants write access only to this agent run.
|
||||
// Explicit confirmation grants write access only to this agent run, never to production.
|
||||
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
|
||||
allow_writes: allow_write_sql.unwrap_or(false),
|
||||
allow_dangerous: allow_write_sql.unwrap_or(false),
|
||||
allow_writes: !production_database && allow_write_sql.unwrap_or(false),
|
||||
allow_dangerous: !production_database && allow_write_sql.unwrap_or(false),
|
||||
},
|
||||
};
|
||||
let is_agent_mode = mode.as_deref() == Some("agent");
|
||||
|
|
|
|||
|
|
@ -204,6 +204,8 @@ mod tests {
|
|||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
is_production: false,
|
||||
production_databases: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,312 @@
|
|||
[
|
||||
{
|
||||
"name": "mysql qualified delete targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DELETE FROM prod_app.users WHERE id = 1",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql use switch carries production target to unqualified mutation",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "USE `prod_app`; DELETE FROM users WHERE id = 1",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql executable comment can contain a production delete",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "/*! DELETE FROM prod_app.users WHERE id = 1 */",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql qualified procedure call targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "CALL prod_app.purge_users()",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql qualified grant targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "GRANT ALL ON prod_app.* TO 'u'@'%'",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql unqualified procedure call is ambiguous with production markers",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "CALL purge_users()",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql global privilege statement is ambiguous with production markers",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "GRANT PROCESS ON *.* TO 'u'@'%'",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql non-production procedure target does not trip production marker",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "CALL staging.purge_users()",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql non-production privilege target does not trip production marker",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "GRANT ALL ON staging.* TO 'u'@'%'",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql unqualified table DML resolves to the selected non-production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "UPDATE users SET active = 1 WHERE id = 1",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql repeated unqualified table DML resolves per statement",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "UPDATE users SET active = 1 WHERE id = 1; DELETE FROM users WHERE id = 2",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql unknown mutation without a resolved target is conservatively confirmed",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "MAINTAIN UNKNOWN THING",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql transaction statement does not target a production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "BEGIN",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql qualified rename table source and destination target production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "RENAME TABLE prod_app.old_name TO prod_app.new_name",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql rename table into production database is detected",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "RENAME TABLE staging.old_name TO prod_app.new_name",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql resolved non-production rename table target stays non-production",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "RENAME TABLE staging.old_name TO staging.new_name",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql multi-target rename conservatively protects unresolved production target",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "RENAME TABLE staging.a TO staging.b, prod_app.c TO prod_app.d",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql multi-target drop conservatively protects unresolved production target",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DROP TABLE staging.a, prod_app.b",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql qualified truncate table targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "TRUNCATE TABLE prod_app.users",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql resolved non-production truncate table target stays non-production",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "TRUNCATE TABLE staging.users",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql optimize table targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "OPTIMIZE TABLE prod_app.users",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql analyze table targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "ANALYZE TABLE prod_app.users",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql repair table targets production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "REPAIR TABLE prod_app.users",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql check table resolves to selected non-production database",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "CHECK TABLE users",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql read-only production reference before staging write stays non-production",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
},
|
||||
{
|
||||
"name": "mysql quoted unicode database target is preserved",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["生产库"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DELETE FROM `生产库`.users WHERE id = 1",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["生产库"]
|
||||
},
|
||||
{
|
||||
"name": "mysql quoted database containing spaces is preserved in privilege target",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "GRANT ALL ON `prod app`.* TO 'u'@'%'",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod app"]
|
||||
},
|
||||
{
|
||||
"name": "mysql quoted routine target with unicode database is preserved",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["生产库"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "CALL `生产库`.`purge users`()",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["生产库"]
|
||||
},
|
||||
{
|
||||
"name": "mysql quoted database containing dot is not split as a qualifier",
|
||||
"dialect": "mysql",
|
||||
"productionDatabases": ["prod.app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DROP DATABASE IF EXISTS `prod.app`",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod.app"]
|
||||
},
|
||||
{
|
||||
"name": "sqlserver three-part table name resolves production database",
|
||||
"dialect": "sqlserver",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DELETE FROM prod_app.dbo.users WHERE id = 1",
|
||||
"active": true,
|
||||
"isMutation": true,
|
||||
"databases": ["prod_app"]
|
||||
},
|
||||
{
|
||||
"name": "sqlserver two-part table name is schema-qualified, not database-qualified",
|
||||
"dialect": "sqlserver",
|
||||
"productionDatabases": ["prod_app"],
|
||||
"activeDatabase": "staging",
|
||||
"sql": "DELETE FROM prod_app.users WHERE id = 1",
|
||||
"active": false,
|
||||
"isMutation": true,
|
||||
"databases": []
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue