attempts;
try {
- hasResultSet = stmt.execute(finalQuery);
- } catch (SQLException executionError) {
- if (policyDecision.mutating() && looksLikeWritePrivilegeDenied(executionError)) {
- throw QueryExecutionPolicyException.dbWritePrivilegeDenied(
- executionError.getMessage(),
- policyDecision.primaryQueryType()
- );
+ attempts = policyDecision.mutating()
+ ? executionProvider.onlineDdlAttempts(finalQuery)
+ : List.of(finalQuery);
+ } catch (IllegalArgumentException blocking) {
+ throw QueryExecutionPolicyException.unsafeMutation(
+ blocking.getMessage(), policyDecision.primaryQueryType());
+ }
+ String ddlSession = policyDecision.mutating() ? executionProvider.ddlSessionSql(finalQuery) : null;
+ if (ddlSession != null) {
+ applyGuard(connection, ddlSession);
+ }
+ boolean hasResultSet = false;
+ for (int attempt = 0; attempt < attempts.size(); attempt++) {
+ try {
+ hasResultSet = stmt.execute(tag + attempts.get(attempt));
+ break;
+ } catch (SQLException executionError) {
+ boolean anotherAttemptLeft = attempt < attempts.size() - 1;
+ if (anotherAttemptLeft && executionProvider.isOnlineDdlUnsupported(executionError)) {
+ log.info("Online DDL variant {} unsupported, trying next: {}",
+ attempt + 1, executionError.getMessage());
+ continue;
+ }
+ if (policyDecision.mutating() && looksLikeWritePrivilegeDenied(executionError)) {
+ throw QueryExecutionPolicyException.dbWritePrivilegeDenied(
+ executionError.getMessage(),
+ policyDecision.primaryQueryType()
+ );
+ }
+ String guardrail = executionProvider.guardrailMessage(executionError);
+ if (guardrail != null) {
+ log.warn("Database guardrail tripped for {} on {}: {}",
+ executionContext.actorUsername(), connectionId, executionError.getMessage());
+ throw QueryExecutionPolicyException.guardrailTripped(
+ guardrail, policyDecision.primaryQueryType());
+ }
+ throw executionError;
}
- throw executionError;
}
if (hasResultSet) {
@@ -788,6 +848,14 @@ public QueryResult executeQuery(
result.setRows(Arrays.asList(Arrays.asList(updateCount)));
}
}
+ } finally {
+ // Always for user and agent SQL, even a lone SELECT: Postgres
+ // `SELECT set_config('lock_timeout', '0', false)` is a read that changes
+ // the session. Trusted origins only when they sent preambles.
+ if (!trustedOrigin || statements.size() > 1) {
+ resetGuard(connection, executionProvider.statementGuardResetSql(serverVersion), connectionId);
+ }
+ }
} finally {
runningQueryRegistry.unregister(queryRequest.getExecutionId());
}
@@ -802,6 +870,83 @@ public QueryResult executeQuery(
return userDataAccessPolicyService.redactResult(connectionId, result, executionContext);
}
+ private static void applyGuard(Connection connection, String guardSql) throws SQLException {
+ if (guardSql == null) {
+ return;
+ }
+ try (Statement guardStmt = connection.createStatement()) {
+ guardStmt.execute(guardSql);
+ } catch (SQLException e) {
+ throw new SQLException("Could not apply DeepSQL's database safety settings, so the query was not run: "
+ + e.getMessage(), e.getSQLState(), e.getErrorCode(), e);
+ }
+ }
+
+ /**
+ * Best effort: a failed reset leaves the per-statement settings in place, which are
+ * stricter than the pool baseline, not looser — so it is logged, not thrown, rather
+ * than masking the query's own result or error.
+ */
+ private void resetGuard(Connection connection, String resetSql, String connectionId) {
+ if (resetSql == null) {
+ return;
+ }
+ try (Statement resetStmt = connection.createStatement()) {
+ resetStmt.execute(resetSql);
+ } catch (SQLException | RuntimeException e) {
+ log.warn("Could not reset session guardrails on connection {}: {}", connectionId, e.getMessage());
+ }
+ }
+
+ private static String serverVersion(Connection connection) {
+ try {
+ return connection.getMetaData().getDatabaseProductVersion();
+ } catch (SQLException | RuntimeException e) {
+ return null;
+ }
+ }
+
+ /** Rows returned to a person or agent that asked for no limit. */
+ static final int DEFAULT_RESULT_ROWS = 10_000;
+ /** Ceiling on an explicit request; matches the Editor's CSV export (EXPORT_ROW_LIMIT). */
+ static final int MAX_RESULT_ROWS = 100_000;
+
+ /**
+ * The row cap was client-controlled: a request without {@code limit} streamed the
+ * whole table into memory. People and agents now get {@link #DEFAULT_RESULT_ROWS} unless
+ * they ask, and never more than {@link #MAX_RESULT_ROWS}. DeepSQL's own background jobs
+ * keep what they ask for.
+ */
+ static int effectiveRowLimit(Integer requested, QueryExecutionContext context) {
+ int asked = requested != null && requested > 0 ? requested : 0;
+ if (isTrustedOrigin(context)) {
+ return asked;
+ }
+ return asked > 0 ? Math.min(asked, MAX_RESULT_ROWS) : DEFAULT_RESULT_ROWS;
+ }
+
+ /** DeepSQL's own jobs, not a person or an agent. */
+ private static boolean isTrustedOrigin(QueryExecutionContext context) {
+ QueryExecutionOrigin origin = context == null ? null : context.origin();
+ return origin == null || origin == QueryExecutionOrigin.INTERNAL || origin == QueryExecutionOrigin.SCHEDULED;
+ }
+
+ /**
+ * A leading comment naming who ran the statement. Every DeepSQL query reaches the
+ * database as one shared login, so without this the slow log, processlist and
+ * Performance Insights cannot say which person (or agent) caused the load.
+ * The username is reduced to a safe charset so it can never close the comment.
+ */
+ static String actorTag(QueryExecutionContext context) {
+ String origin = context == null || context.origin() == null
+ ? "internal"
+ : context.origin().name().toLowerCase(Locale.ROOT);
+ String user = context == null || context.actorUsername() == null
+ ? ""
+ : " user=" + context.actorUsername().replaceAll("[^A-Za-z0-9._@-]", "_");
+ return "/* deepsql origin=" + origin + user + " */ ";
+ }
+
/**
* Strips a trailing ORDER BY clause from a SQL query so it can be safely wrapped
* in a COUNT(*) subquery. ORDER BY in a subquery without LIMIT is invalid in some
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 23c59967..abddbdd0 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -255,7 +255,9 @@ training.history.retention.days=0
training.history.cleanup.cron=0 30 2 * * *
# Security / user-log retention (security_event, user_session, auth_login_challenge)
-security.audit.retention-days=30
+# 90 days: an incident review needed to know who ran a statement, and
+# security_event (who, origin, SQL, rows) is the only per-person record of it.
+security.audit.retention-days=90
# Growth Monitoring Configuration
growth-monitoring.enabled=true
diff --git a/backend/src/test/java/com/dbaagent/provider/mysql/MySQLGuardrailLiveTest.java b/backend/src/test/java/com/dbaagent/provider/mysql/MySQLGuardrailLiveTest.java
new file mode 100644
index 00000000..b1c0eb05
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/provider/mysql/MySQLGuardrailLiveTest.java
@@ -0,0 +1,401 @@
+package com.dbaagent.provider.mysql;
+
+import com.dbaagent.model.ConnectionRequest;
+import com.dbaagent.model.Permission;
+import com.dbaagent.model.QueryRequest;
+import com.dbaagent.model.QueryResult;
+import com.dbaagent.provider.DatabaseProviderRegistry;
+import com.dbaagent.provider.api.DatabaseDialect;
+import com.dbaagent.repository.KeyColumnAnalysisRepository;
+import com.dbaagent.service.CacheMetricsService;
+import com.dbaagent.service.ConnectionService;
+import com.dbaagent.service.CredentialService;
+import com.dbaagent.service.QueryExecutionContext;
+import com.dbaagent.service.QueryExecutionPolicyException;
+import com.dbaagent.service.QueryExecutionPolicyService;
+import com.dbaagent.service.QueryExecutorService;
+import com.dbaagent.service.QueryLineageService;
+import com.dbaagent.service.RunningQueryRegistry;
+import com.dbaagent.service.SchemaSnapshotService;
+import com.dbaagent.service.UserDataAccessPolicyService;
+import com.dbaagent.service.security.AccessControlService;
+import com.dbaagent.service.telemetry.TelemetryCounters;
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.springframework.cache.CacheManager;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The production guardrails against a real MySQL server, through the real executor and
+ * policy. Mocks stand in only for credential lookup and Spring wiring.
+ *
+ * Skipped unless {@code DEEPSQL_MYSQL_TEST_URL} points at a disposable server, e.g.
+ * {@code jdbc:mysql://127.0.0.1:33084/?user=root&allowPublicKeyRetrieval=true&useSSL=false}.
+ * It creates and drops its own schema, and toggles {@code @@global.read_only} on that
+ * server, so never point it at a shared database.
+ */
+@EnabledIfEnvironmentVariable(named = "DEEPSQL_MYSQL_TEST_URL", matches = ".+")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class MySQLGuardrailLiveTest {
+
+ private static final String SCHEMA = "deepsql_guardrail_test";
+ private static final String CONN_ID = "live-mysql";
+
+ private final MySQLQueryExecutionProvider provider = new MySQLQueryExecutionProvider();
+ private String baseUrl;
+ private HikariDataSource pool;
+ private QueryExecutorService executor;
+
+ @BeforeAll
+ void setUp() throws Exception {
+ baseUrl = System.getenv("DEEPSQL_MYSQL_TEST_URL");
+ try (Connection c = DriverManager.getConnection(baseUrl); Statement s = c.createStatement()) {
+ s.execute("DROP DATABASE IF EXISTS " + SCHEMA);
+ s.execute("CREATE DATABASE " + SCHEMA);
+ }
+
+ // One connection, so every run reuses it: what one run leaves behind, the next sees.
+ HikariConfig config = new HikariConfig();
+ config.setJdbcUrl(schemaUrl());
+ config.setMaximumPoolSize(1);
+ // The production pool config, not a copy of it: the eviction bug lived here.
+ ConnectionService.applyGuardrails(config, provider);
+ pool = new HikariDataSource(config);
+
+ ConnectionRequest request = new ConnectionRequest();
+ request.setDbType("mysql");
+ CredentialService credentials = mock(CredentialService.class);
+ when(credentials.getDecryptedConnection(CONN_ID)).thenReturn(request);
+ ConnectionService connections = mock(ConnectionService.class);
+ when(connections.getConnection(CONN_ID, request)).thenAnswer(inv -> pool.getConnection());
+
+ DatabaseDialect dialect = mock(DatabaseDialect.class);
+ when(dialect.queryExecution()).thenReturn(provider);
+ DatabaseProviderRegistry registry = mock(DatabaseProviderRegistry.class);
+ when(registry.getDialect(anyString())).thenReturn(dialect);
+ when(registry.getCanonicalName(anyString())).thenReturn("mysql");
+
+ AccessControlService access = mock(AccessControlService.class);
+ when(access.hasPermission(Permission.EXECUTE_DDL)).thenReturn(true);
+ UserDataAccessPolicyService dataPolicy = mock(UserDataAccessPolicyService.class);
+ when(dataPolicy.redactResult(any(), any(), any())).thenAnswer(inv -> inv.getArgument(1));
+
+ executor = new QueryExecutorService(
+ connections, credentials, mock(SchemaSnapshotService.class), mock(CacheManager.class),
+ mock(CacheMetricsService.class), mock(QueryLineageService.class), registry,
+ new QueryExecutionPolicyService(registry, access), dataPolicy, mock(TelemetryCounters.class),
+ mock(KeyColumnAnalysisRepository.class), new RunningQueryRegistry());
+ }
+
+ @AfterAll
+ void tearDown() throws Exception {
+ if (pool != null) {
+ pool.close();
+ }
+ try (Connection c = DriverManager.getConnection(baseUrl); Statement s = c.createStatement()) {
+ s.execute("SET GLOBAL read_only = 0");
+ s.execute("DROP DATABASE IF EXISTS " + SCHEMA);
+ }
+ }
+
+ @BeforeEach
+ void freshTable() throws Exception {
+ try (Connection c = raw(); Statement s = c.createStatement()) {
+ s.execute("DROP TABLE IF EXISTS customers");
+ s.execute("CREATE TABLE customers (id INT PRIMARY KEY, email VARCHAR(255) NULL, "
+ + "contact VARCHAR(255) NULL, note VARCHAR(50) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+ s.execute("INSERT INTO customers VALUES (1,'a@x.io','1',NULL),(2,'b@x.io','2',NULL),(3,'c@x.io','3',NULL)");
+ }
+ }
+
+ // ---- helpers ---------------------------------------------------------------------
+
+ private String schemaUrl() {
+ int q = baseUrl.indexOf('?');
+ return q < 0 ? baseUrl + SCHEMA : baseUrl.substring(0, q) + SCHEMA + baseUrl.substring(q);
+ }
+
+ private Connection raw() throws SQLException {
+ return DriverManager.getConnection(schemaUrl());
+ }
+
+ private QueryResult run(String sql, QueryExecutionContext context) throws SQLException {
+ return executor.executeQuery(CONN_ID, new QueryRequest(sql, null, 240), context);
+ }
+
+ private static QueryExecutionContext confirmedDba() {
+ return QueryExecutionContext.editor("dba", true, true);
+ }
+
+ private static String cell(QueryResult result, int col) {
+ return String.valueOf(result.getRows().get(0).get(col));
+ }
+
+ private String columnDefinition(String column) throws SQLException {
+ try (Connection c = raw(); Statement s = c.createStatement(); ResultSet rs = s.executeQuery(
+ "SELECT COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '" + SCHEMA
+ + "' AND TABLE_NAME = 'customers' AND COLUMN_NAME = '" + column + "'")) {
+ rs.next();
+ return rs.getString(1) + " " + rs.getString(2);
+ }
+ }
+
+ // ---- the incident ----------------------------------------------------------------
+
+ @Test
+ void theServerDefaultIsToWaitAYearForAMetadataLock() throws Exception {
+ // The root cause, measured rather than recalled.
+ try (Connection c = DriverManager.getConnection(baseUrl); Statement s = c.createStatement();
+ ResultSet rs = s.executeQuery("SELECT @@global.lock_wait_timeout")) {
+ rs.next();
+ assertThat(rs.getLong(1)).isEqualTo(31_536_000L);
+ }
+ }
+
+ @Test
+ void aBlockedAlterGivesUpAfterFiveSecondsAndWritersQueuedBehindItGoThrough() throws Exception {
+ // An open transaction holds a shared metadata lock, as an app session does.
+ try (Connection holder = raw()) {
+ holder.setAutoCommit(false);
+ try (Statement s = holder.createStatement()) {
+ s.executeQuery("SELECT * FROM customers WHERE id = 1").close();
+ }
+
+ long alterStart = System.nanoTime();
+ CompletableFuture alter = CompletableFuture.supplyAsync(() -> {
+ try {
+ run("ALTER TABLE customers MODIFY email VARCHAR(512) NOT NULL", confirmedDba());
+ return null;
+ } catch (Throwable t) {
+ return t;
+ }
+ });
+
+ // A writer arriving while the ALTER waits queues behind its pending exclusive
+ // lock — the pile-up. It has no guard of its own (an app session).
+ TimeUnit.SECONDS.sleep(1);
+ long writerStart = System.nanoTime();
+ CompletableFuture writer = CompletableFuture.runAsync(() -> {
+ try (Connection w = raw(); Statement s = w.createStatement()) {
+ s.executeUpdate("INSERT INTO customers VALUES (4,'d@x.io','4',NULL)");
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ Throwable alterError = alter.get(30, TimeUnit.SECONDS);
+ double alterSeconds = (System.nanoTime() - alterStart) / 1e9;
+ writer.get(30, TimeUnit.SECONDS);
+ double writerSeconds = (System.nanoTime() - writerStart) / 1e9;
+ holder.rollback();
+
+ System.out.printf("[live] ALTER gave up after %.2fs; queued writer finished after %.2fs%n",
+ alterSeconds, writerSeconds);
+ assertThat(alterError).isInstanceOf(QueryExecutionPolicyException.class);
+ assertThat(((QueryExecutionPolicyException) alterError).getErrorCode())
+ .isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(alterError.getMessage()).contains("5 seconds");
+ assertThat(alterSeconds).isBetween(4.5, 9.0);
+ // The writer waited (it really was queued) but only until the ALTER gave up.
+ assertThat(writerSeconds).isBetween(2.0, 9.0);
+ assertThat(columnDefinition("email")).isEqualTo("varchar(255) YES");
+ }
+ }
+
+ @Test
+ void theIncidentAlterRunsOnlineWhenNothingHoldsTheTable() throws Exception {
+ // INSTANT cannot change NULL-ability: 1846, then INPLACE with LOCK=NONE succeeds.
+ run("ALTER TABLE customers MODIFY email VARCHAR(512) NOT NULL", confirmedDba());
+ assertThat(columnDefinition("email")).isEqualTo("varchar(512) NO");
+ }
+
+ @Test
+ void anInstantChangeRunsInstant() throws Exception {
+ run("ALTER TABLE customers ADD COLUMN tier TINYINT NULL", confirmedDba());
+ assertThat(columnDefinition("tier")).isEqualTo("tinyint YES");
+ }
+
+ @Test
+ void aChangeThatCannotRunOnlineChangesNothingAndSaysWhy() throws Exception {
+ // INT -> BIGINT needs a table copy: refused by INSTANT and by INPLACE.
+ QueryExecutionPolicyException e = assertThrows(QueryExecutionPolicyException.class,
+ () -> run("ALTER TABLE customers MODIFY id BIGINT", confirmedDba()));
+ System.out.println("[live] " + e.getMessage());
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(e.getMessage()).contains("gh-ost");
+ assertThat(columnDefinition("id")).isEqualTo("int NO");
+ }
+
+ // ---- session guards --------------------------------------------------------------
+
+ @Test
+ void everyPooledConnectionStartsWithTheLockGuard() throws Exception {
+ try (Connection c = pool.getConnection(); Statement s = c.createStatement();
+ ResultSet rs = s.executeQuery("SELECT @@session.lock_wait_timeout, @@session.innodb_lock_wait_timeout")) {
+ rs.next();
+ assertThat(rs.getLong(1)).isEqualTo(5);
+ assertThat(rs.getLong(2)).isEqualTo(5);
+ }
+ }
+
+ @Test
+ void aPreambleCannotLoosenTheGuardsForTheStatement() throws Exception {
+ QueryResult result = run("SET SESSION lock_wait_timeout = 31536000; "
+ + "SELECT @@session.lock_wait_timeout, @@session.sql_safe_updates, @@session.max_execution_time",
+ QueryExecutionContext.editor("analyst", false, false));
+ assertThat(cell(result, 0)).isEqualTo("5");
+ assertThat(cell(result, 1)).isEqualTo("1");
+ // A writable server: reads capped at 30s whatever the client asked for (240s).
+ assertThat(cell(result, 2)).isEqualTo("30000");
+ }
+
+ @Test
+ void nothingAUserSetLeaksToTheNextBorrower() throws Exception {
+ run("SET SESSION foreign_key_checks = 0; SET SESSION sql_mode = ''; SELECT 1",
+ QueryExecutionContext.editor("dba", true, false));
+
+ try (Connection c = pool.getConnection(); Statement s = c.createStatement(); ResultSet rs = s.executeQuery(
+ "SELECT @@session.foreign_key_checks, @@session.sql_mode = @@global.sql_mode, "
+ + "@@session.sql_safe_updates, @@session.max_execution_time, @@session.lock_wait_timeout")) {
+ rs.next();
+ assertThat(rs.getInt(1)).as("foreign_key_checks").isEqualTo(1);
+ assertThat(rs.getInt(2)).as("sql_mode restored").isEqualTo(1);
+ assertThat(rs.getInt(3)).as("sql_safe_updates").isEqualTo(0);
+ assertThat(rs.getLong(4)).as("max_execution_time").isEqualTo(0);
+ assertThat(rs.getLong(5)).as("lock_wait_timeout baseline").isEqualTo(5);
+ }
+ }
+
+ @Test
+ void aReplicaGetsTheClientTimeoutNotTheThirtySecondCap() throws Exception {
+ try (Connection c = DriverManager.getConnection(baseUrl); Statement s = c.createStatement()) {
+ s.execute("SET GLOBAL read_only = 1");
+ }
+ try {
+ QueryResult result = run("SELECT @@session.max_execution_time",
+ QueryExecutionContext.editor("analyst", false, false));
+ assertThat(cell(result, 0)).isEqualTo("240000");
+ } finally {
+ try (Connection c = DriverManager.getConnection(baseUrl); Statement s = c.createStatement()) {
+ s.execute("SET GLOBAL read_only = 0");
+ }
+ }
+ }
+
+ @Test
+ void maxExecutionTimeReallyStopsAReadAndIsExplained() throws Exception {
+ try (Connection c = raw(); Statement s = c.createStatement()) {
+ s.execute("SET SESSION max_execution_time = 300");
+ SQLException e = assertThrows(SQLException.class, () -> s.executeQuery(
+ "SELECT COUNT(*) FROM information_schema.COLUMNS a, information_schema.COLUMNS b, "
+ + "information_schema.COLUMNS c").close());
+ assertThat(e.getErrorCode()).isEqualTo(3024);
+ assertThat(provider.guardrailMessage(e)).contains("time limit");
+ }
+ }
+
+ @Test
+ void safeUpdatesRefusesAWriteThatDoesNotFilterOnAKey() throws Exception {
+ QueryExecutionPolicyException e = assertThrows(QueryExecutionPolicyException.class,
+ () -> run("UPDATE customers SET note = 'x' WHERE note IS NULL", confirmedDba()));
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(e.getMessage()).contains("indexed");
+
+ QueryResult keyed = run("UPDATE customers SET note = 'x' WHERE id = 1", confirmedDba());
+ assertThat(keyed.getRowCount()).isEqualTo(1);
+ }
+
+ @Test
+ void aRowLockWaitAlsoGivesUpAfterFiveSeconds() throws Exception {
+ try (Connection holder = raw()) {
+ holder.setAutoCommit(false);
+ try (Statement s = holder.createStatement()) {
+ s.executeUpdate("UPDATE customers SET note = 'held' WHERE id = 2");
+ }
+ long start = System.nanoTime();
+ QueryExecutionPolicyException e = assertThrows(QueryExecutionPolicyException.class,
+ () -> run("UPDATE customers SET note = 'mine' WHERE id = 2", confirmedDba()));
+ double seconds = (System.nanoTime() - start) / 1e9;
+ holder.rollback();
+ System.out.printf("[live] row lock wait gave up after %.2fs%n", seconds);
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(seconds).isBetween(4.5, 9.0);
+ }
+ }
+
+ // ---- what the policy refuses, and why it must ------------------------------------
+
+ @Test
+ void mysqlReallyExecutesTheBodyOfAnExecutableComment() throws Exception {
+ // The threat model, proven: the server runs it, while a comment-stripping check sees nothing.
+ try (Connection c = raw(); Statement s = c.createStatement()) {
+ s.execute("/*!50000 CREATE TABLE exec_comment_proof (id INT) */");
+ try (ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '"
+ + SCHEMA + "' AND TABLE_NAME = 'exec_comment_proof'")) {
+ rs.next();
+ assertThat(rs.getInt(1)).isEqualTo(1);
+ }
+ s.execute("DROP TABLE exec_comment_proof");
+ }
+ QueryExecutionPolicyException e = assertThrows(QueryExecutionPolicyException.class,
+ () -> run("/*!50000 CREATE TABLE exec_comment_proof (id INT) */", confirmedDba()));
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ }
+
+ @Test
+ void setGlobalIsRefusedEvenForAConfirmedAdmin() {
+ QueryExecutionPolicyException e = assertThrows(QueryExecutionPolicyException.class,
+ () -> run("SET GLOBAL max_connections = 10", QueryExecutionContext.editor("admin", true, true)));
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.ADMIN_STATEMENT_BLOCKED);
+ }
+
+ @Test
+ void theActorTagReachesTheServer() throws Exception {
+ // What the slow log and Performance Insights would see.
+ run("SELECT 'tag-probe' AS probe", QueryExecutionContext.editor("dba", true, false));
+ try (Connection c = raw(); Statement s = c.createStatement(); ResultSet rs = s.executeQuery(
+ // events_statements_history (last N per thread) is on by default; _long is not.
+ "SELECT SQL_TEXT FROM performance_schema.events_statements_history "
+ + "WHERE SQL_TEXT LIKE '%tag-probe%' AND SQL_TEXT NOT LIKE '%events_statements%' "
+ + "ORDER BY EVENT_ID DESC LIMIT 1")) {
+ assertThat(rs.next()).as("statement recorded").isTrue();
+ assertThat(rs.getString(1)).startsWith("/* deepsql origin=editor user=dba */ SELECT 'tag-probe'");
+ }
+ }
+
+ @Test
+ void theOnlineAttemptsAreValidSqlOnThisServer() {
+ // Guards against appended-clause syntax drift across MySQL versions.
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE customers ADD COLUMN y INT -- trailing"))
+ .allSatisfy(sql -> assertThat(sql).contains("\n, ALGORITHM="));
+ assertThat(List.of(provider.statementGuardSql(240, "8.4.11"), provider.statementGuardResetSql("8.4.11")))
+ .allSatisfy(sql -> {
+ try (Connection c = raw(); Statement s = c.createStatement()) {
+ s.execute(sql);
+ } catch (SQLException ex) {
+ throw new AssertionError(sql + " -> " + ex.getMessage(), ex);
+ }
+ });
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/provider/mysql/MySQLQueryExecutionProviderGuardTest.java b/backend/src/test/java/com/dbaagent/provider/mysql/MySQLQueryExecutionProviderGuardTest.java
new file mode 100644
index 00000000..14f296d5
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/provider/mysql/MySQLQueryExecutionProviderGuardTest.java
@@ -0,0 +1,202 @@
+package com.dbaagent.provider.mysql;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.SQLException;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * The guardrails added after a production incident: an
+ * {@code ALTER TABLE customers MODIFY ... NOT NULL} queued every write on the table for
+ * four minutes, until the Editor's 240s client timeout killed it.
+ */
+class MySQLQueryExecutionProviderGuardTest {
+
+ private final MySQLQueryExecutionProvider provider = new MySQLQueryExecutionProvider();
+
+ // ---- online DDL ----------------------------------------------------------
+
+ @Test
+ void anAlterWithNoAlgorithmTriesInstantThenInplaceWithoutLocks() {
+ List attempts = provider.onlineDdlAttempts(
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL;");
+
+ assertThat(attempts).containsExactly(
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL\n, ALGORITHM=INSTANT",
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL\n, ALGORITHM=INPLACE, LOCK=NONE");
+ }
+
+ @Test
+ void theAppendedClauseCannotBeSwallowedByATrailingLineComment() {
+ // Appended on the same line, ", ALGORITHM=INSTANT" would sit inside the
+ // comment and the ALTER would run with MySQL's default (possibly blocking)
+ // algorithm — a silent bypass of the whole guard.
+ List attempts = provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN c INT -- for reports");
+
+ assertThat(attempts.getFirst()).endsWith("-- for reports\n, ALGORITHM=INSTANT");
+ }
+
+ @Test
+ void inplaceWithoutALockClauseGetsLockNone() {
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD INDEX i (c), ALGORITHM=INPLACE"))
+ .containsExactly("ALTER TABLE t ADD INDEX i (c), ALGORITHM=INPLACE\n, LOCK=NONE");
+ }
+
+ @Test
+ void explicitlyOnlineStatementsRunAsWritten() {
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT"))
+ .containsExactly("ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INSTANT");
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD INDEX i (c), ALGORITHM=INPLACE, LOCK=NONE"))
+ .containsExactly("ALTER TABLE t ADD INDEX i (c), ALGORITHM=INPLACE, LOCK=NONE");
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD INDEX i (c), LOCK = NONE"))
+ .containsExactly("ALTER TABLE t ADD INDEX i (c), LOCK = NONE");
+ }
+
+ @Test
+ void blockingAlgorithmsAndLocksAreRefused() {
+ for (String sql : List.of(
+ "ALTER TABLE t MODIFY c varchar(512), ALGORITHM=COPY",
+ "ALTER TABLE t MODIFY c varchar(512), ALGORITHM COPY",
+ "ALTER TABLE t MODIFY c varchar(512), ALGORITHM=DEFAULT",
+ "ALTER TABLE t MODIFY c varchar(512), LOCK=SHARED",
+ "ALTER TABLE t MODIFY c varchar(512), LOCK=EXCLUSIVE",
+ "ALTER TABLE t MODIFY c varchar(512), ALGORITHM=INPLACE, LOCK=DEFAULT")) {
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> provider.onlineDdlAttempts(sql), sql);
+ assertThat(e.getMessage()).contains("gh-ost");
+ }
+ }
+
+ @Test
+ void wordsInsideLiteralsAndIdentifiersAreNotClauses() {
+ // A comment string or a column named `lock` must not read as a LOCK clause.
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t COMMENT 'LOCK=SHARED please'"))
+ .hasSize(2);
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN `lock` INT"))
+ .hasSize(2);
+ }
+
+ @Test
+ void nonAlterStatementsPassThrough() {
+ assertThat(provider.onlineDdlAttempts("CREATE TABLE t (id INT)")).containsExactly("CREATE TABLE t (id INT)");
+ assertThat(provider.onlineDdlAttempts("UPDATE t SET a = 1 WHERE id = 2"))
+ .containsExactly("UPDATE t SET a = 1 WHERE id = 2");
+ }
+
+ @Test
+ void onlyAlgorithmNotSupportedErrorsTriggerTheFallback() {
+ assertThat(provider.isOnlineDdlUnsupported(new SQLException(
+ "ALGORITHM=INSTANT is not supported. Reason: Need to rebuild the table. Try ALGORITHM=COPY/INPLACE.",
+ "0A000", 1846))).isTrue();
+ assertThat(provider.isOnlineDdlUnsupported(new SQLException("Lock wait timeout exceeded", "HY000", 1205)))
+ .isFalse();
+ }
+
+ // ---- session guards ------------------------------------------------------
+
+ @Test
+ void everyPooledConnectionGivesUpOnLocksAfterFiveSeconds() {
+ assertThat(provider.connectionGuardSql())
+ .isEqualTo("SET SESSION lock_wait_timeout = 5, innodb_lock_wait_timeout = 5");
+ }
+
+ @Test
+ void perStatementGuardCapsReadsAtThirtySecondsOnlyOnAWritableServer() {
+ // innodb_read_only too: Aurora readers report read_only=0.
+ assertThat(provider.statementGuardSql(240, "8.0.36")).isEqualTo(
+ "SET SESSION lock_wait_timeout = 5, innodb_lock_wait_timeout = 5, sql_safe_updates = 1, "
+ + "max_execution_time = IF(@@global.read_only OR @@global.innodb_read_only, 240000, LEAST(240000, 30000))");
+ assertThat(provider.statementGuardSql(0, null))
+ .contains("IF(@@global.read_only OR @@global.innodb_read_only, 30000, LEAST(30000, 30000))");
+ }
+
+ @Test
+ void clauseDetectionStaysLinearOnUnterminatedCommentsAndQuotes() {
+ // A lazy /\*.*?\*/ rescans to the end from every unterminated opener.
+ for (String tail : List.of("/*" + "a/*".repeat(32_000), "'" + "a\\'".repeat(32_000), "`" + "a".repeat(64_000),
+ // CodeQL java/polynomial-redos: ALGORITHM followed by a long run of spaces and no value.
+ ("ALGORITHM" + " ".repeat(2_000) + "x ").repeat(25), ("LOCK" + " ".repeat(2_000) + "x ").repeat(25))) {
+ long startedAt = System.currentTimeMillis();
+ provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN x INT " + tail);
+ assertThat(System.currentTimeMillis() - startedAt).as("must not backtrack").isLessThan(5_000L);
+ }
+ }
+
+ @Test
+ void aClauseInsideACommentIsNotAClause() {
+ // Read as real, this ran the MODIFY with MySQL's default (COPY) algorithm, unguarded.
+ assertThat(provider.onlineDdlAttempts(
+ "ALTER TABLE bookings MODIFY status VARCHAR(20) NOT NULL -- , ALGORITHM=INPLACE, LOCK=NONE"))
+ .hasSize(2);
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN x INT /* LOCK=SHARED */")).hasSize(2);
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN x INT # ALGORITHM=COPY")).hasSize(2);
+ // A quote inside a comment must not desynchronise literal stripping: the real
+ // COPY clause on the next line is still seen, and refused.
+ assertThrows(IllegalArgumentException.class,
+ () -> provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN x INT -- don't\n, ALGORITHM=COPY"));
+ }
+
+ @Test
+ void mariaDbGetsNoMaxExecutionTimeBecauseItDoesNotHaveOne() {
+ // The MySQL provider also serves "mariadb" connections. An unknown variable fails the
+ // SET, and the executor fails closed — every query on the connection would be refused.
+ assertThat(provider.statementGuardSql(240, "10.11.6-MariaDB-log")).isEqualTo(
+ "SET SESSION lock_wait_timeout = 5, innodb_lock_wait_timeout = 5, sql_safe_updates = 1");
+ assertThat(provider.statementGuardResetSql("10.11.6-MariaDB-log")).doesNotContain("max_execution_time");
+ }
+
+ @Test
+ void theResetRestoresThePoolBaselineSoNothingLeaksToTheNextBorrower() {
+ // Also the settings a preamble could use to weaken the next borrower's writes.
+ // `= @@global.x`, not `= DEFAULT`: on MySQL 8.4, `foreign_key_checks = DEFAULT`
+ // leaves the session at 0 (see MySQLGuardrailLiveTest).
+ assertThat(provider.statementGuardResetSql("8.0.36")).isEqualTo(
+ "SET SESSION lock_wait_timeout = 5, innodb_lock_wait_timeout = 5, "
+ + "sql_safe_updates = @@global.sql_safe_updates, sql_mode = @@global.sql_mode, "
+ + "foreign_key_checks = @@global.foreign_key_checks, unique_checks = @@global.unique_checks, "
+ + "time_zone = @@global.time_zone, max_execution_time = @@global.max_execution_time");
+ }
+
+ @Test
+ void serversWithoutInstantFallBackToInplace() {
+ // MySQL 5.7 / Aurora MySQL 2 reject INSTANT as a syntax error at the word we appended.
+ assertThat(provider.isOnlineDdlUnsupported(new SQLException(
+ "You have an error in your SQL syntax; check the manual ... near 'INSTANT' at line 2", "42000", 1064)))
+ .isTrue();
+ // A genuine syntax error elsewhere is not an excuse to retry.
+ assertThat(provider.isOnlineDdlUnsupported(new SQLException(
+ "You have an error in your SQL syntax; ... near 'COLUMNN c INT' at line 1", "42000", 1064)))
+ .isFalse();
+ }
+
+ @Test
+ void aPartitionChangeMustStateItsAlgorithmExplicitly() {
+ // Partition options come last in an ALTER, so an appended clause is a syntax error.
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> provider.onlineDdlAttempts("ALTER TABLE t ADD PARTITION (PARTITION p3 VALUES LESS THAN (2030))"));
+ assertThat(e.getMessage()).contains("ALGORITHM=INPLACE, LOCK=NONE");
+ assertThat(provider.onlineDdlAttempts(
+ "ALTER TABLE t ALGORITHM=INPLACE, LOCK=NONE, ADD PARTITION (PARTITION p3 VALUES LESS THAN (2030))"))
+ .hasSize(1);
+ // A column merely named like it is not a partition clause.
+ assertThat(provider.onlineDdlAttempts("ALTER TABLE t ADD COLUMN partition_key INT")).hasSize(2);
+ }
+
+ // ---- errors --------------------------------------------------------------
+
+ @Test
+ void guardTripsAreExplainedInPlainWords() {
+ assertThat(provider.guardrailMessage(new SQLException("Lock wait timeout exceeded", "HY000", 1205)))
+ .contains("5 seconds");
+ assertThat(provider.guardrailMessage(new SQLException("You are using safe update mode", "HY000", 1175)))
+ .contains("indexed");
+ assertThat(provider.guardrailMessage(new SQLException("maximum statement execution time exceeded", "HY000", 3024)))
+ .contains("replica");
+ assertThat(provider.guardrailMessage(new SQLException("not supported. Try ALGORITHM=COPY", "0A000", 1846)))
+ .contains("gh-ost");
+ assertThat(provider.guardrailMessage(new SQLException("Duplicate entry", "23000", 1062))).isNull();
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/provider/postgres/PostgresQueryExecutionProviderGuardTest.java b/backend/src/test/java/com/dbaagent/provider/postgres/PostgresQueryExecutionProviderGuardTest.java
new file mode 100644
index 00000000..af5be642
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/provider/postgres/PostgresQueryExecutionProviderGuardTest.java
@@ -0,0 +1,40 @@
+package com.dbaagent.provider.postgres;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.SQLException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PostgresQueryExecutionProviderGuardTest {
+
+ private final PostgresQueryExecutionProvider provider = new PostgresQueryExecutionProvider();
+
+ @Test
+ void everyPooledConnectionGivesUpOnLocksAfterFiveSeconds() {
+ assertThat(provider.connectionGuardSql()).isEqualTo("SET lock_timeout = '5s'");
+ }
+
+ @Test
+ void theResetClearsEverySessionSettingNotJustTheGuard() {
+ // A preamble, or a read like SELECT set_config(...), can change any setting
+ // (session_replication_role, search_path, ...); the pooled connection carries it on.
+ assertThat(provider.statementGuardResetSql(null)).isEqualTo("RESET ALL; SET lock_timeout = '5s'");
+ }
+
+ @Test
+ void aConcurrentIndexBuildIsNotCutOffByTheLockTimeout() {
+ // Cut off midway it leaves an INVALID index; its locks block neither reads nor writes.
+ assertThat(provider.ddlSessionSql("CREATE INDEX CONCURRENTLY idx ON orders (customer_id)"))
+ .isEqualTo("SET lock_timeout = 0");
+ assertThat(provider.ddlSessionSql("drop index concurrently idx")).isEqualTo("SET lock_timeout = 0");
+ assertThat(provider.ddlSessionSql("ALTER TABLE orders ADD COLUMN x int")).isNull();
+ }
+
+ @Test
+ void aLockTimeoutIsExplainedInPlainWords() {
+ assertThat(provider.guardrailMessage(new SQLException("canceling statement due to lock timeout", "55P03")))
+ .contains("5 seconds");
+ assertThat(provider.guardrailMessage(new SQLException("duplicate key", "23505"))).isNull();
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/McpSqlGuardServiceTest.java b/backend/src/test/java/com/dbaagent/service/McpSqlGuardServiceTest.java
index 1f327643..9c9c1db8 100644
--- a/backend/src/test/java/com/dbaagent/service/McpSqlGuardServiceTest.java
+++ b/backend/src/test/java/com/dbaagent/service/McpSqlGuardServiceTest.java
@@ -213,4 +213,24 @@ void stillAllowsOrdinaryAnalyticQueries() {
assertTrue(service.validateReadOnlySql(
"SELECT count(*), sum(o.total) FROM public.orders o WHERE o.created_at >= '2026-01-01'", true).ok());
}
+
+ @Test
+ void commentStrippingStaysLinearOnAnUnterminatedBlockComment() {
+ // The regex strip /\*[\s\S]*?\*/ rescanned to the end from every "/*": this input took
+ // minutes of CPU on a request thread, reachable from MCP and the public dashboard
+ // query endpoint.
+ String hostile = "SELECT 1 /*" + "a/*".repeat(32_000);
+ long startedAt = System.currentTimeMillis();
+ service.validateReadOnlySql(hostile, true);
+ assertTrue(System.currentTimeMillis() - startedAt < 5_000L, "comment stripping must not backtrack");
+ }
+
+ @Test
+ void terminatesAndKillsAreDangerousFunctions() {
+ assertEquals("pg_terminate_backend",
+ service.containsDangerousFunction("SELECT pg_terminate_backend(pid) FROM pg_stat_activity"));
+ assertEquals("pg_cancel_backend", service.containsDangerousFunction("select PG_CANCEL_BACKEND (42)"));
+ // A column that merely starts with the name is not a call.
+ assertNull(service.containsDangerousFunction("SELECT pg_terminate_backend_count FROM audit"));
+ }
}
diff --git a/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyProdGuardTest.java b/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyProdGuardTest.java
new file mode 100644
index 00000000..9b1045e1
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyProdGuardTest.java
@@ -0,0 +1,283 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.Permission;
+import com.dbaagent.model.QueryRequest;
+import com.dbaagent.provider.DatabaseProviderRegistry;
+import com.dbaagent.provider.api.DatabaseDialect;
+import com.dbaagent.provider.mysql.MySQLQueryExecutionProvider;
+import com.dbaagent.service.security.AccessControlService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+/**
+ * Production guardrails, exercised against the real MySQL provider. Every case runs
+ * as a confirmed admin: these are the statements that must stay blocked even for
+ * the people who can otherwise write.
+ */
+@ExtendWith(MockitoExtension.class)
+class QueryExecutionPolicyProdGuardTest {
+
+ @Mock private DatabaseProviderRegistry providerRegistry;
+ @Mock private DatabaseDialect databaseDialect;
+ @Mock private AccessControlService accessControlService;
+
+ private QueryExecutionPolicyService service;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(providerRegistry.getDialect(anyString())).thenReturn(databaseDialect);
+ lenient().when(databaseDialect.queryExecution()).thenReturn(new MySQLQueryExecutionProvider());
+ lenient().when(providerRegistry.getCanonicalName(anyString())).thenReturn("mysql");
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(true);
+ service = new QueryExecutionPolicyService(providerRegistry, accessControlService);
+ }
+
+ private QueryExecutionPolicyException blocked(String sql, QueryExecutionContext context) {
+ return assertThrows(QueryExecutionPolicyException.class,
+ () -> service.enforce(new QueryRequest(sql, null, null), context, "mysql"), sql);
+ }
+
+ private static QueryExecutionContext confirmedAdminEditor() {
+ return QueryExecutionContext.editor("admin", true, true);
+ }
+
+ @Test
+ void administrativeStatementsAreBlockedOnEverySurfaceEvenWhenConfirmed() {
+ for (String sql : List.of(
+ "SET GLOBAL max_connections = 10",
+ "SET PERSIST innodb_lock_wait_timeout = 1",
+ "SET @@global.read_only = 0",
+ "SET @x = 1, GLOBAL max_connections = 10",
+ "SET sql_log_bin = 0",
+ "GRANT ALL ON *.* TO 'x'@'%'",
+ "REVOKE SELECT ON db.* FROM 'x'@'%'",
+ "CREATE USER 'x'@'%' IDENTIFIED BY 'p'",
+ "ALTER USER 'app_user'@'%' IDENTIFIED BY 'p'",
+ "DROP USER 'x'@'%'",
+ "KILL 12345",
+ "KILL QUERY 12345",
+ "LOCK TABLES customers WRITE",
+ "FLUSH TABLES WITH READ LOCK",
+ "SHUTDOWN",
+ "RESET MASTER",
+ "PURGE BINARY LOGS BEFORE NOW()",
+ // Postgres role/identity switches: left on a pooled connection, inherited by the next borrower.
+ "SET SESSION ROLE app_owner",
+ "SET LOCAL ROLE app_owner",
+ "SET SESSION AUTHORIZATION app_owner",
+ "SET role = app_owner",
+ "SET LOCAL role TO app_owner",
+ "SET session_replication_role = replica",
+ " /* sneaky */ GRANT ALL ON *.* TO 'x'@'%'")) {
+ assertThat(blocked(sql, confirmedAdminEditor()).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.ADMIN_STATEMENT_BLOCKED);
+ assertThat(blocked(sql, QueryExecutionContext.mcp("admin", true, true)).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.ADMIN_STATEMENT_BLOCKED);
+ }
+ }
+
+ @Test
+ void executableCommentsAndOptimizerHintsAreRefused() {
+ // MySQL runs the body of /*! ... */, while every check here strips it as a comment
+ // and then sees an empty, "read-only" statement. /*+ ... */ hints such as
+ // MAX_EXECUTION_TIME or SET_VAR override the session guards for one statement.
+ for (String sql : List.of(
+ "/*!50000 GRANT ALL ON *.* TO 'x'@'%' */",
+ "/*! ALTER TABLE customers ADD COLUMN x INT */",
+ "/*!SET GLOBAL max_connections = 10*/",
+ // MariaDB's form: MySQL ignores it, MariaDB runs it.
+ "/*M!100000 GRANT ALL ON *.* TO 'x'@'%' */",
+ "/*m!100000 DROP TABLE customers */",
+ "SELECT /*+ MAX_EXECUTION_TIME(600000) */ * FROM customers",
+ "UPDATE /*+ SET_VAR(innodb_lock_wait_timeout=31536000) */ customers SET a = 1 WHERE id = 2")) {
+ assertThat(blocked(sql, QueryExecutionContext.editor("analyst", false, false)).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ assertThat(blocked(sql, confirmedAdminEditor()).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ }
+ }
+
+ @Test
+ void globalScopeCannotHideInsideMismatchedQuotes() {
+ // Two-pass quote stripping removed GLOBAL here; MySQL tokenizes it as code. And a
+ // NO_BACKSLASH_ESCAPES preamble changes MySQL's tokenizing, so no scanner on our
+ // side can be trusted to agree: the SET check reads the raw text and fails closed.
+ for (String sql : List.of(
+ "SET @a = \"'\", GLOBAL max_connections = 1, @b = \"'\"",
+ "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'; "
+ + "SET @a = '\\', GLOBAL max_connections = 1, @b = '\\'; SELECT 1")) {
+ assertThat(blocked(sql, QueryExecutionContext.editor("analyst", false, false)).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.ADMIN_STATEMENT_BLOCKED);
+ }
+ }
+
+ @Test
+ void schemaChangesThatDoNotStartWithADdlVerbStillNeedThePermission() {
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ for (String sql : List.of(
+ "CALL rebuild_customer_indexes()",
+ "SELECT * INTO customers_copy FROM customers",
+ // Postgres runs the wrapped statement under EXPLAIN ANALYZE: this creates a table.
+ "EXPLAIN ANALYZE CREATE TABLE customers_copy AS SELECT * FROM customers",
+ "DO RELEASE_LOCK('x')")) {
+ assertThat(blocked(sql, QueryExecutionContext.editor("dba", true, true)).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.DDL_PERMISSION_REQUIRED);
+ }
+ }
+
+ @Test
+ void anAdministrativeStatementHiddenAsAPreambleIsStillBlocked() {
+ // SET used to be classified as a harmless session preamble, so a SET GLOBAL in
+ // front of a SELECT ran for any role, including read-only ones.
+ assertThat(blocked("SET GLOBAL max_connections = 10; SELECT 1",
+ QueryExecutionContext.editor("analyst", false, false)).getErrorCode())
+ .isEqualTo(QueryExecutionPolicyException.ADMIN_STATEMENT_BLOCKED);
+ }
+
+ @Test
+ void functionsThatReachOutsideTheQueryAreRefusedInTheEditorToo() {
+ // The denylist used to run only for MCP and dashboards; in the Editor these were plain reads.
+ for (String sql : List.of(
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'app'",
+ "SELECT pg_cancel_backend(12345)",
+ "SELECT dblink_exec('dbname=app', 'DELETE FROM orders')")) {
+ assertThat(blocked(sql, QueryExecutionContext.editor("analyst", false, false)).getErrorCode())
+ .as(sql).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ }
+ }
+
+ @Test
+ void aUserVariableNamedRoleIsNotARoleSwitch() {
+ service.enforce(new QueryRequest("SET @role = 1; SELECT @role", null, null),
+ QueryExecutionContext.editor("analyst", false, false), "mysql");
+ }
+
+ @Test
+ void sessionVariablesStillWork() {
+ QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
+ new QueryRequest("SET @since = '2026-11-01'; SELECT * FROM bookings WHERE created_at > @since", null, null),
+ QueryExecutionContext.editor("analyst", false, false),
+ "mysql");
+ assertThat(decision.mutating()).isFalse();
+ }
+
+ @Test
+ void identifiersThatLookLikeAdminVerbsAreNotBlocked() {
+ // Same class of mistake as the old \bCOMMENT\b rule rejecting SELECT * FROM comment.
+ service.enforce(new QueryRequest("SELECT grant_id, kill_switch FROM `lock`", null, null),
+ QueryExecutionContext.editor("analyst", false, false), "mysql");
+ }
+
+ @Test
+ void truncateIsBlockedInTheEditorEvenForAConfirmedAdmin() {
+ assertThat(blocked("TRUNCATE TABLE customers", confirmedAdminEditor()).getErrorCode())
+ .isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ }
+
+ @Test
+ void ddlRequiresTheExecuteDdlPermission() {
+ when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ QueryExecutionContext dba = QueryExecutionContext.editor("dba", true, true);
+
+ for (String sql : List.of(
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL",
+ "CREATE TABLE t (id INT)",
+ "CREATE INDEX i ON t (c)",
+ "DROP INDEX i ON t",
+ "RENAME TABLE a TO b")) {
+ assertThat(blocked(sql, dba).getErrorCode()).as(sql)
+ .isEqualTo(QueryExecutionPolicyException.DDL_PERMISSION_REQUIRED);
+ // MCP refuses every DROP outright, ahead of the permission check.
+ String mcpCode = sql.startsWith("DROP")
+ ? QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED
+ : QueryExecutionPolicyException.DDL_PERMISSION_REQUIRED;
+ assertThat(blocked(sql, QueryExecutionContext.mcp("dba", true, true)).getErrorCode()).as(sql)
+ .isEqualTo(mcpCode);
+ }
+ }
+
+ @Test
+ void ddlCannotHideBehindAPreambleToSkipThePermissionCheck() {
+ // The DDL check reads the first statement. That is only sound because a run
+ // containing a mutation is limited to one statement; this pins that invariant.
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ for (String sql : List.of(
+ "SET @x = 1; ALTER TABLE customers MODIFY email varchar(512) NOT NULL",
+ "SELECT 1; ALTER TABLE customers MODIFY email varchar(512) NOT NULL",
+ "SET @x = 1; TRUNCATE TABLE customers")) {
+ QueryExecutionPolicyException e = blocked(sql, QueryExecutionContext.editor("dba", true, true));
+ assertThat(e.getErrorCode()).as(sql).isIn(
+ QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED,
+ QueryExecutionPolicyException.DDL_PERMISSION_REQUIRED);
+ }
+ }
+
+ @Test
+ void readOnlySurfacesCannotRunDdlAtAll() {
+ // API (dashboards) and chat are READ_ONLY_ONLY, so no permission check is needed there.
+ assertThat(blocked("ALTER TABLE customers ADD COLUMN x INT", QueryExecutionContext.api("admin", true))
+ .getErrorCode()).isEqualTo(QueryExecutionPolicyException.EDITOR_MUTATION_FORBIDDEN);
+ assertThat(blocked("ALTER TABLE customers ADD COLUMN x INT", QueryExecutionContext.chat())
+ .getErrorCode()).isEqualTo(QueryExecutionPolicyException.CHAT_MUTATION_BLOCKED);
+ }
+
+ @Test
+ void aDataModifyingCteIsStillRowOnly() {
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
+ new QueryRequest("WITH stale AS (SELECT id FROM customers WHERE active = 0) "
+ + "DELETE FROM customers WHERE id IN (SELECT id FROM stale)", null, null),
+ QueryExecutionContext.editor("dba", true, true), "mysql");
+ assertThat(decision.mutating()).isTrue();
+ }
+
+ @Test
+ void aWriteInsideACteIsStillRowOnly() {
+ // Classified as "DELETE (in CTE)"; the label must not read as a schema change.
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
+ new QueryRequest("WITH d AS (DELETE FROM customers WHERE id = 5 RETURNING *) SELECT * FROM d", null, null),
+ QueryExecutionContext.editor("dba", true, true), "mysql");
+ assertThat(decision.mutating()).isTrue();
+ }
+
+ @Test
+ void dmlDoesNotNeedTheDdlPermission() {
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(false);
+ QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
+ new QueryRequest("UPDATE customers SET contact = NULL WHERE id = 7", null, null),
+ QueryExecutionContext.editor("dba", true, true), "mysql");
+ assertThat(decision.mutating()).isTrue();
+ }
+
+ @Test
+ void aBlockingAlterIsRefusedBeforeTheConfirmationPrompt() {
+ // Unconfirmed: the refusal must come instead of the "are you sure?" dialog, not after it.
+ QueryExecutionPolicyException e = blocked(
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL, ALGORITHM=COPY",
+ QueryExecutionContext.editor("admin", true, false));
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
+ assertThat(e.getMessage()).contains("gh-ost");
+ }
+
+ @Test
+ void theAlterConfirmationSaysHowItWillRun() {
+ QueryExecutionPolicyException e = blocked(
+ "ALTER TABLE customers MODIFY email varchar(512) NOT NULL",
+ QueryExecutionContext.editor("admin", true, false));
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.EDITOR_MUTATION_CONFIRMATION_REQUIRED);
+ // Dialect-neutral wording: the policy no longer names MySQL algorithms.
+ assertThat(String.join(" ", e.getWarnings())).contains("does not block writes").contains("5 seconds");
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java b/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java
index a766569e..c2c11106 100644
--- a/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java
+++ b/backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java
@@ -1,6 +1,8 @@
package com.dbaagent.service;
+import com.dbaagent.model.Permission;
import com.dbaagent.model.QueryRequest;
+import com.dbaagent.service.security.AccessControlService;
import com.dbaagent.provider.DatabaseProviderRegistry;
import com.dbaagent.provider.api.DatabaseDialect;
import com.dbaagent.provider.api.QueryExecutionProvider;
@@ -24,6 +26,7 @@ class QueryExecutionPolicyServiceTest {
@Mock private DatabaseProviderRegistry providerRegistry;
@Mock private DatabaseDialect databaseDialect;
+ @Mock private AccessControlService accessControlService;
private QueryExecutionPolicyService service;
@@ -38,7 +41,10 @@ void setUp() {
when(providerRegistry.getDialect(anyString())).thenReturn(databaseDialect);
when(databaseDialect.queryExecution()).thenReturn(realProvider);
lenient().when(providerRegistry.getCanonicalName(anyString())).thenReturn("mysql");
- service = new QueryExecutionPolicyService(providerRegistry);
+ // Admins in these tests hold EXECUTE_DDL; the permission split itself is
+ // covered in QueryExecutionPolicyProdGuardTest.
+ lenient().when(accessControlService.hasPermission(Permission.EXECUTE_DDL)).thenReturn(true);
+ service = new QueryExecutionPolicyService(providerRegistry, accessControlService);
}
@Test
diff --git a/backend/src/test/java/com/dbaagent/service/QueryExecutorGuardrailTest.java b/backend/src/test/java/com/dbaagent/service/QueryExecutorGuardrailTest.java
new file mode 100644
index 00000000..518aa79f
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/QueryExecutorGuardrailTest.java
@@ -0,0 +1,256 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.ConnectionRequest;
+import com.dbaagent.model.QueryRequest;
+import com.dbaagent.provider.DatabaseProviderRegistry;
+import com.dbaagent.provider.api.DatabaseDialect;
+import com.dbaagent.provider.mysql.MySQLQueryExecutionProvider;
+import com.dbaagent.repository.KeyColumnAnalysisRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.cache.CacheManager;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Executor-side guardrails from a production incident, run against
+ * the real MySQL provider so the SQL asserted here is the SQL that ships.
+ */
+@ExtendWith(MockitoExtension.class)
+// The executor calls Statement.execute several times per run (preambles, guards, the
+// statement); strict stubbing would reject every call but the one a test stubs.
+@MockitoSettings(strictness = Strictness.LENIENT)
+class QueryExecutorGuardrailTest {
+
+ // The mocked Connection has no metadata, so the server version is unknown (null).
+ private static final String GUARD = new MySQLQueryExecutionProvider().statementGuardSql(240, null);
+ private static final String RESET = new MySQLQueryExecutionProvider().statementGuardResetSql(null);
+
+ @Mock private ConnectionService connectionService;
+ @Mock private CredentialService credentialService;
+ @Mock private SchemaSnapshotService schemaSnapshotService;
+ @Mock private CacheManager cacheManager;
+ @Mock private CacheMetricsService cacheMetricsService;
+ @Mock private QueryLineageService queryLineageService;
+ @Mock private DatabaseProviderRegistry providerRegistry;
+ @Mock private DatabaseDialect dialect;
+ @Mock private QueryExecutionPolicyService queryExecutionPolicyService;
+ @Mock private UserDataAccessPolicyService userDataAccessPolicyService;
+ @Mock private com.dbaagent.service.telemetry.TelemetryCounters telemetryCounters;
+ @Mock private KeyColumnAnalysisRepository keyColumnAnalysisRepository;
+ @Mock private Connection connection;
+ @Mock private Statement statement;
+
+ private QueryExecutorService executor;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ executor = new QueryExecutorService(
+ connectionService, credentialService, schemaSnapshotService, cacheManager, cacheMetricsService,
+ queryLineageService, providerRegistry, queryExecutionPolicyService, userDataAccessPolicyService,
+ telemetryCounters, keyColumnAnalysisRepository, new RunningQueryRegistry());
+
+ ConnectionRequest request = new ConnectionRequest();
+ request.setDbType("mysql");
+ when(credentialService.getDecryptedConnection("conn-1")).thenReturn(request);
+ lenient().when(providerRegistry.getCanonicalName("mysql")).thenReturn("mysql");
+ when(providerRegistry.getDialect("mysql")).thenReturn(dialect);
+ when(dialect.queryExecution()).thenReturn(new MySQLQueryExecutionProvider());
+ when(connectionService.getConnection("conn-1", request)).thenReturn(connection);
+ when(connection.createStatement()).thenReturn(statement);
+ // The session-pid probe is best effort; failing it here keeps it out of the way.
+ lenient().when(statement.executeQuery("SELECT CONNECTION_ID()")).thenThrow(new SQLException("no pid"));
+ lenient().when(userDataAccessPolicyService.redactResult(any(), any(), any()))
+ .thenAnswer(inv -> inv.getArgument(1));
+ }
+
+ private void policyReturns(boolean mutating, String queryType) {
+ when(queryExecutionPolicyService.enforce(any(), any(), eq("mysql")))
+ .thenReturn(new QueryExecutionPolicyService.PolicyDecision(List.of(), mutating, queryType));
+ }
+
+ private QueryExecutionPolicyException runExpectingPolicyError(String sql) {
+ return assertThrows(QueryExecutionPolicyException.class, () -> executor.executeQuery(
+ "conn-1", new QueryRequest(sql, null, 240), QueryExecutionContext.editor("dba", true, true)));
+ }
+
+ @Test
+ void guardsAreReappliedAfterTheUsersPreamblesAndBeforeTheStatement() throws Exception {
+ policyReturns(true, "UPDATE");
+
+ executor.executeQuery("conn-1",
+ new QueryRequest("SET SESSION lock_wait_timeout = 31536000; UPDATE customers SET contact = NULL WHERE id = 7",
+ null, 240),
+ QueryExecutionContext.editor("dba", true, true));
+
+ String tag = "/* deepsql origin=editor user=dba */ ";
+ InOrder order = inOrder(statement);
+ order.verify(statement).execute(GUARD);
+ order.verify(statement).execute(tag + "SET SESSION lock_wait_timeout = 31536000");
+ order.verify(statement).execute(GUARD);
+ order.verify(statement).execute(tag + "UPDATE customers SET contact = NULL WHERE id = 7");
+ order.verify(statement).execute(RESET);
+ }
+
+ @Test
+ void aHeavyReadPlacedFirstInABatchStillRunsUnderTheGuards() throws Exception {
+ // A read-only batch may hold several SELECTs; the first used to run as a preamble
+ // with no read time limit and no actor tag.
+ policyReturns(false, "SELECT");
+
+ executor.executeQuery("conn-1",
+ new QueryRequest("SELECT * FROM bookings b JOIN payments p ON p.booking_id = b.id; SELECT 1", null, 240),
+ QueryExecutionContext.editor("dba", true, false));
+
+ InOrder order = inOrder(statement);
+ order.verify(statement).execute(GUARD);
+ order.verify(statement).execute(
+ "/* deepsql origin=editor user=dba */ SELECT * FROM bookings b JOIN payments p ON p.booking_id = b.id");
+ }
+
+ @Test
+ void deepsqlsOwnJobsKeepThePoolBaselineOnly() throws Exception {
+ // INTERNAL/SCHEDULED SQL is trusted code with its own timeouts: no 30s primary cap,
+ // no sql_safe_updates, and no reset round trip when it sent no preambles.
+ policyReturns(false, "SELECT");
+
+ executor.executeQuery("conn-1", new QueryRequest("SELECT COUNT(*) FROM customers", null, 240),
+ QueryExecutionContext.internal());
+
+ verify(statement, never()).execute(GUARD);
+ verify(statement, never()).execute(RESET);
+ verify(statement).execute("/* deepsql origin=internal */ SELECT COUNT(*) FROM customers");
+ }
+
+ @Test
+ void theSessionIsResetEvenWhenTheStatementFails() throws Exception {
+ // Session variables outlive the borrow: without the reset, this run's
+ // sql_safe_updates and max_execution_time would reach the next borrower,
+ // background jobs on the same pool included.
+ policyReturns(true, "UPDATE");
+ when(statement.execute(startsWith("/* deepsql"))).thenThrow(new SQLException("Duplicate entry", "23000", 1062));
+
+ assertThrows(SQLException.class, () -> executor.executeQuery("conn-1",
+ new QueryRequest("UPDATE customers SET email = 'a@b.c' WHERE id = 7", null, 240),
+ QueryExecutionContext.editor("dba", true, true)));
+
+ verify(statement).execute(RESET);
+ }
+
+ @Test
+ void aStatementDoesNotRunWithoutItsGuards() throws Exception {
+ policyReturns(false, "SELECT");
+ when(statement.execute(GUARD)).thenThrow(new SQLException("Unknown system variable", "HY000", 1193));
+
+ SQLException e = assertThrows(SQLException.class, () -> executor.executeQuery(
+ "conn-1", new QueryRequest("SELECT 1", null, 240), QueryExecutionContext.editor("dba", true, false)));
+
+ assertThat(e.getMessage()).contains("safety settings");
+ verify(statement, never()).execute(startsWith("/* deepsql"));
+ }
+
+ @Test
+ void anAlterFallsBackFromInstantToInplaceWithoutLocks() throws Exception {
+ policyReturns(true, "ALTER");
+ String alter = "ALTER TABLE customers MODIFY email varchar(512) NOT NULL";
+ String tag = "/* deepsql origin=editor user=dba */ ";
+ when(statement.execute(tag + alter + "\n, ALGORITHM=INSTANT")).thenThrow(new SQLException(
+ "ALGORITHM=INSTANT is not supported. Reason: Need to rebuild the table. Try ALGORITHM=COPY/INPLACE.",
+ "0A000", 1846));
+
+ executor.executeQuery("conn-1", new QueryRequest(alter, null, 240),
+ QueryExecutionContext.editor("dba", true, true));
+
+ verify(statement).execute(tag + alter + "\n, ALGORITHM=INPLACE, LOCK=NONE");
+ }
+
+ @Test
+ void anAlterThatCannotRunOnlineChangesNothingAndSaysWhy() throws Exception {
+ policyReturns(true, "ALTER");
+ when(statement.execute(startsWith("/* deepsql"))).thenThrow(new SQLException(
+ "ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.",
+ "0A000", 1846));
+
+ QueryExecutionPolicyException e = runExpectingPolicyError("ALTER TABLE customers MODIFY id BIGINT");
+
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(e.getMessage()).contains("gh-ost");
+ }
+
+ @Test
+ void aLockWaitTimeoutIsReportedAsTheGuardItIs() throws Exception {
+ policyReturns(true, "ALTER");
+ when(statement.execute(startsWith("/* deepsql"))).thenThrow(new SQLException(
+ "Lock wait timeout exceeded; try restarting transaction", "HY000", 1205));
+
+ QueryExecutionPolicyException e = runExpectingPolicyError("ALTER TABLE customers ADD COLUMN x INT");
+
+ assertThat(e.getErrorCode()).isEqualTo(QueryExecutionPolicyException.DB_GUARDRAIL_TRIPPED);
+ assertThat(e.getMessage()).contains("5 seconds");
+ // Not retried as INPLACE: a busy table is busy for every algorithm.
+ verify(statement, never()).execute(org.mockito.ArgumentMatchers.endsWith("LOCK=NONE"));
+ }
+
+ @Test
+ void peopleAndAgentsGetTenThousandRowsUnlessTheyAskAndNeverMoreThanTheExportCeiling() {
+ assertThat(QueryExecutorService.effectiveRowLimit(null, QueryExecutionContext.editor("a", true, false)))
+ .isEqualTo(10_000);
+ // The Editor's CSV export asks for 100k (EXPORT_ROW_LIMIT); a 10k ceiling silently
+ // truncated exports that worked before.
+ assertThat(QueryExecutorService.effectiveRowLimit(100_000, QueryExecutionContext.editor("a", true, false)))
+ .isEqualTo(100_000);
+ assertThat(QueryExecutorService.effectiveRowLimit(50_000, QueryExecutionContext.mcp("a")))
+ .isEqualTo(50_000);
+ assertThat(QueryExecutorService.effectiveRowLimit(5_000_000, QueryExecutionContext.mcp("a")))
+ .isEqualTo(100_000);
+ assertThat(QueryExecutorService.effectiveRowLimit(1_000, QueryExecutionContext.editor("a", false, false)))
+ .isEqualTo(1_000);
+ assertThat(QueryExecutorService.effectiveRowLimit(null, QueryExecutionContext.api("a")))
+ .isEqualTo(10_000);
+ // DeepSQL's own jobs keep what they ask for.
+ assertThat(QueryExecutorService.effectiveRowLimit(null, QueryExecutionContext.internal())).isZero();
+ assertThat(QueryExecutorService.effectiveRowLimit(null, QueryExecutionContext.scheduled())).isZero();
+ }
+
+ @Test
+ void theActorTagCannotBeUsedToCloseTheComment() {
+ String tag = QueryExecutorService.actorTag(QueryExecutionContext.editor("x */ DROP TABLE t; /*", true, true));
+ assertThat(tag.indexOf("*/")).isEqualTo(tag.length() - 3);
+ assertThat(tag).isEqualTo("/* deepsql origin=editor user=x____DROP_TABLE_t____ */ ");
+ }
+
+ @Test
+ void readsAreUnaffectedBeyondTheTagAndTheCap() throws Exception {
+ policyReturns(false, "SELECT");
+ when(statement.execute(anyString())).thenReturn(false);
+
+ executor.executeQuery("conn-1", new QueryRequest("SELECT * FROM customers", null, 240),
+ QueryExecutionContext.mcp("agent"));
+
+ // The cap is setMaxRows only. Appending LIMIT text to a request that asked for no
+ // limit broke FETCH FIRST, FOR UPDATE and LOCK IN SHARE MODE.
+ verify(statement).setMaxRows(10_001);
+ verify(statement).execute("/* deepsql origin=mcp user=agent */ SELECT * FROM customers");
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/QueryExecutorServiceTest.java b/backend/src/test/java/com/dbaagent/service/QueryExecutorServiceTest.java
index a26d4790..b60a7b2d 100644
--- a/backend/src/test/java/com/dbaagent/service/QueryExecutorServiceTest.java
+++ b/backend/src/test/java/com/dbaagent/service/QueryExecutorServiceTest.java
@@ -40,6 +40,7 @@ class QueryExecutorServiceTest {
@Mock private KeyColumnAnalysisRepository keyColumnAnalysisRepository;
@Mock private Connection connection;
@Mock private Statement statement;
+ @Mock private com.dbaagent.provider.api.DatabaseDialect dialect;
private QueryExecutorService queryExecutorService;
@@ -115,9 +116,14 @@ void executeQuery_writePrivilegeDenied_mapsToPolicyException() throws Exception
when(providerRegistry.getCanonicalName("mysql")).thenReturn("mysql");
when(queryExecutionPolicyService.enforce(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("mysql")))
.thenReturn(new QueryExecutionPolicyService.PolicyDecision(List.of(), true, "UPDATE"));
+ when(providerRegistry.getDialect("mysql")).thenReturn(dialect);
+ when(dialect.queryExecution()).thenReturn(new com.dbaagent.provider.mysql.MySQLQueryExecutionProvider());
when(connectionService.getConnection("conn-1", request)).thenReturn(connection);
when(connection.createStatement()).thenReturn(statement);
- when(statement.execute("UPDATE customers SET property_status = 'ACTIVE' WHERE customer_id = 9"))
+ when(statement.executeQuery("SELECT CONNECTION_ID()")).thenThrow(new SQLException("no pid"));
+ // Every statement now carries an actor tag; see QueryExecutorGuardrailTest.
+ org.mockito.Mockito.lenient().when(statement.execute("/* deepsql origin=editor user=admin */ "
+ + "UPDATE customers SET property_status = 'ACTIVE' WHERE customer_id = 9"))
.thenThrow(new SQLException("UPDATE command denied to user", "42000"));
QueryExecutionPolicyException exception = assertThrows(
diff --git a/mcp/CLAUDE.md b/mcp/CLAUDE.md
index d6389ee1..1b441808 100644
--- a/mcp/CLAUDE.md
+++ b/mcp/CLAUDE.md
@@ -80,7 +80,7 @@ in this version; ask before mid-session admin work.
| `optimize_slow_query` | AI query REWRITE + plan diagnosis for one specific SQL. Single-query scoped, synchronous. Does NOT recommend indexes — index/pre-aggregation recs require whole-workload context (`get_index_recommendations` / Workload Analysis). Pass `avgExecutionTimeMs` to anchor the impact estimate. |
| `get_index_recommendations` | **Workload-weighted DBA-grade index advisor.** Pre-computed top-N (default 5) recommendations ranked by net benefit (`Σ calls × mean_exec_time` − write-cost). Each result carries up to 5 contributing query fingerprints, the role each column played, and optional HypoPG cost-delta on Postgres. Covers both `CREATE_INDEX` and `DROP_INDEX` (unused + redundant-prefix) candidates. |
| **`apply_index_recommendation`** | Apply (or dry-run) a recommendation against its target connection and measure the before/after benefit on contributing queries. `DRY_RUN` (default) uses HypoPG (Postgres-only) for zero-write cost-delta. `APPLY` runs real `CREATE/DROP INDEX CONCURRENTLY` (configurable via `concurrent`). `APPLY_AND_MEASURE` additionally runs `EXPLAIN ANALYZE` for wall-clock timings. Write modes require `confirm: true`. The DDL is server-generated from the recommendation row — clients never supply SQL. This is the only MCP path that can drop an index; `execute_sql` blocks `DROP`. |
-| **`execute_sql`** | **Run a SQL statement.** Policy is server-enforced: developers can run SELECT/WITH/SHOW/EXPLAIN; admins can also run DML and non-destructive DDL (`CREATE`, `ALTER`, `CREATE INDEX`) with a two-step confirmation. `DROP` and `TRUNCATE` are blocked on this surface even for confirmed admins. EXPLAIN and EXPLAIN ANALYZE are just SQL — no separate flag. |
+| **`execute_sql`** | **Run a SQL statement.** Policy is server-enforced: developers can run SELECT/WITH/SHOW/EXPLAIN; admins can also run DML and non-destructive DDL (`CREATE`, `ALTER`, `CREATE INDEX`) with a two-step confirmation. Schema changes also need the "Run schema changes" (`EXECUTE_DDL`) permission, and MySQL `ALTER` runs online only (it stops rather than block writes). `DROP` and `TRUNCATE` are blocked on this surface even for confirmed admins, as are server-admin statements (GRANT, KILL, `SET GLOBAL`, role switches), `pg_terminate_backend`/`dblink_*`-style functions, and MySQL `/*! */` / `/*+ */` comments. Every statement gives up after 5s waiting for a lock. EXPLAIN and EXPLAIN ANALYZE are just SQL — no separate flag. |
| **`analyze_query_plan`** | **AI-enriched plan analysis** for a query. Returns the parsed plan tree, performance issues, index recommendations, and a written summary that takes the connection's schema and business rules into account. Pass `useAnalyze: true` to run `EXPLAIN ANALYZE` (actually executes the query). |
| **`analyze_migration`** | **Check whether a DDL statement is safe to run, before running it.** Deterministic verdict (SAFE/CAUTION/DANGER/FAILS/UNKNOWN) verified against a real PostgreSQL — trust it over your own recollection of Postgres lock semantics. Returns the exact locks taken (per table — `ADD FOREIGN KEY` locks the referenced table too), whether the table is rewritten, a duration estimate scaled by live table size, and a safer alternative where one exists. PostgreSQL only; MySQL returns `UNKNOWN` rather than a guess. Read-only — never executes the statement. |
diff --git a/mcp/deepsql-phase1-lib.js b/mcp/deepsql-phase1-lib.js
index f988e4dc..dbe09047 100644
--- a/mcp/deepsql-phase1-lib.js
+++ b/mcp/deepsql-phase1-lib.js
@@ -41,6 +41,7 @@ const DANGEROUS_SQL_FUNCTIONS = [
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
"lo_import", "lo_export",
"load_file",
+ "pg_terminate_backend", "pg_cancel_backend",
];
// The function being *called* — name, optional whitespace, open paren. Matching the bare name
diff --git a/src/lib/permissions.js b/src/lib/permissions.js
index 649f79c1..a5f72404 100644
--- a/src/lib/permissions.js
+++ b/src/lib/permissions.js
@@ -30,6 +30,7 @@ export const PERMISSIONS = {
RUN_ANALYSIS: 'RUN_ANALYSIS',
RUN_INGESTION: 'RUN_INGESTION',
EXECUTE_PLAYBOOKS: 'EXECUTE_PLAYBOOKS',
+ EXECUTE_DDL: 'EXECUTE_DDL',
USE_INDEX_ADVISOR: 'USE_INDEX_ADVISOR',
MANAGE_ALERTS: 'MANAGE_ALERTS',