Skip to content

Commit ab4ab8f

Browse files
authored
fix(editor): bound CSV export, guard concurrent runs, audit query can… (#76)
1 parent f90f60f commit ab4ab8f

7 files changed

Lines changed: 205 additions & 15 deletions

File tree

backend/src/main/java/com/dbaagent/controller/SchemaController.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,10 @@ public ResponseEntity<Map<String, Object>> executeQuery(
275275
@PostMapping("/query/{executionId}/cancel")
276276
public ResponseEntity<Map<String, Object>> cancelQuery(
277277
@PathVariable String connectionId,
278-
@PathVariable String executionId) {
278+
@PathVariable String executionId,
279+
HttpServletRequest httpRequest) {
279280
Map<String, Object> response = new HashMap<>();
281+
ClientContext client = ClientContext.fromRequest(httpRequest);
280282
try {
281283
if (!credentialService.connectionExists(connectionId)) {
282284
response.put("success", false);
@@ -287,7 +289,14 @@ public ResponseEntity<Map<String, Object>> cancelQuery(
287289

288290
var running = runningQueryRegistry.find(executionId);
289291
if (running.isEmpty()) {
290-
// Already finished, or never started. Nothing to cancel.
292+
// Already finished, or never started. Nothing to cancel. Still
293+
// audited: without this, a cancel that misses its target left
294+
// no trace at all, deliberate or not.
295+
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.cancelNoOp()
296+
.connectionId(connectionId)
297+
.executionId(executionId)
298+
.httpRequest(httpRequest)
299+
.client(client));
291300
response.put("success", true);
292301
response.put("cancelled", false);
293302
response.put("message", "Query is no longer running");
@@ -301,13 +310,24 @@ public ResponseEntity<Map<String, Object>> cancelQuery(
301310
String currentUser = accessControlService.getCurrentUsername();
302311
if (!connectionId.equals(target.connectionId())
303312
|| (target.username() != null && currentUser != null && !target.username().equals(currentUser))) {
313+
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.blocked(
314+
"cancel requested for an execution id not owned by this caller/connection")
315+
.connectionId(connectionId)
316+
.executionId(executionId)
317+
.httpRequest(httpRequest)
318+
.client(client));
304319
response.put("success", false);
305320
response.put("message", "Query not found for this connection");
306321
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
307322
}
308323

309324
activeQueryService.killQuery(connectionId, target.sessionPid());
310325
runningQueryRegistry.unregister(executionId);
326+
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.cancelled(target.sessionPid())
327+
.connectionId(connectionId)
328+
.executionId(executionId)
329+
.httpRequest(httpRequest)
330+
.client(client));
311331
response.put("success", true);
312332
response.put("cancelled", true);
313333
response.put("message", "Query cancelled");
@@ -318,6 +338,12 @@ public ResponseEntity<Map<String, Object>> cancelQuery(
318338
return ResponseEntity.status(e.getStatusCode()).body(response);
319339
} catch (Exception e) {
320340
log.warn("Failed to cancel query {} on connection {}: {}", executionId, connectionId, e.getMessage());
341+
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.failed(e.getMessage())
342+
.operation("cancel")
343+
.connectionId(connectionId)
344+
.executionId(executionId)
345+
.httpRequest(httpRequest)
346+
.client(client));
321347
response.put("success", false);
322348
response.put("message", "Failed to cancel query: " + e.getMessage());
323349
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);

backend/src/main/java/com/dbaagent/model/SecurityEventType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public enum SecurityEventType {
4141
EDITOR_QUERY_EXECUTED,
4242
EDITOR_QUERY_BLOCKED,
4343
EDITOR_QUERY_FAILED,
44+
EDITOR_QUERY_CANCELLED,
4445
SUSPICIOUS_AUTH_ACTIVITY,
4546
SMTP_CONFIG_UPDATED,
4647
SMTP_TEST_SUCCEEDED,

backend/src/main/java/com/dbaagent/service/QueryExecutionPolicyService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,19 @@ private String stripQuotedLiterals(String sql) {
466466
.replaceAll("\"([^\"\\\\]|\\\\.)*\"", "\"\"");
467467
}
468468

469+
// Word-bounded and comment-stripped: the previous literal " WHERE " match
470+
// missed a WHERE preceded by a newline (as in any multi-line formatted
471+
// UPDATE/DELETE) and was satisfied by a commented-out "-- WHERE ..." that
472+
// never reaches the database. This only runs on the keyword-fallback path —
473+
// when JSqlParser succeeds, delete.getWhere()/update.getWhere() are used
474+
// instead, which are exact.
475+
private static final Pattern WHERE_CLAUSE_PATTERN = Pattern.compile("\\bWHERE\\b", Pattern.CASE_INSENSITIVE);
476+
469477
private boolean containsWhereClause(String sql) {
470-
return sql != null && sql.toUpperCase(Locale.ROOT).contains(" WHERE ");
478+
if (sql == null) {
479+
return false;
480+
}
481+
return WHERE_CLAUSE_PATTERN.matcher(stripComments(sql)).find();
471482
}
472483

473484
private boolean looksLikeMultipleStatements(String sql) {

backend/src/main/java/com/dbaagent/service/SqlExecutionAuditService.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ public void record(AuditRecord rec) {
8080
metadata.put("connectionId", rec.connectionId);
8181
metadata.put("connectionName", rec.connectionRequest == null ? null : rec.connectionRequest.getConnectionName());
8282
metadata.put("dbType", rec.connectionRequest == null ? null : rec.connectionRequest.getDbType());
83+
if (rec.executionId != null) {
84+
metadata.put("executionId", rec.executionId);
85+
}
8386

8487
String queryText = rec.queryRequest == null ? null : rec.queryRequest.getQuery();
8588
metadata.put("queryHash", SecurityHashUtil.sha256Hex(queryText == null ? "" : queryText));
@@ -183,6 +186,7 @@ public static final class AuditRecord {
183186
private String failureReason;
184187
private HttpServletRequest httpRequest;
185188
private ClientContext client;
189+
private String executionId;
186190

187191
private AuditRecord() {}
188192

@@ -214,6 +218,33 @@ public static AuditRecord failed(String reason) {
214218
return r;
215219
}
216220

221+
/**
222+
* A deliberate cancel request against a still-running query, distinct
223+
* from {@link #cancelNoOp()}. Without a dedicated event type, the only
224+
* trace of a cancel was the killed query's own thread logging
225+
* {@code pg_terminate_backend}'s error as an ordinary EDITOR_QUERY_FAILED
226+
* — indistinguishable from any other failure, and absent entirely when
227+
* the target had already finished before the kill reached it.
228+
*/
229+
public static AuditRecord cancelled(String sessionPid) {
230+
AuditRecord r = new AuditRecord();
231+
r.eventType = SecurityEventType.EDITOR_QUERY_CANCELLED;
232+
r.outcome = SecurityEventOutcome.SUCCESS;
233+
r.operation = "cancel";
234+
r.failureReason = sessionPid == null ? null : "terminated session pid " + sessionPid;
235+
return r;
236+
}
237+
238+
/** Cancel requested for an execution id that was already finished or unknown. */
239+
public static AuditRecord cancelNoOp() {
240+
AuditRecord r = new AuditRecord();
241+
r.eventType = SecurityEventType.EDITOR_QUERY_CANCELLED;
242+
r.outcome = SecurityEventOutcome.INFO;
243+
r.operation = "cancel";
244+
r.failureReason = "query was no longer running";
245+
return r;
246+
}
247+
217248
// ── fluent setters ────────────────────────────────────────────────
218249

219250
public AuditRecord operation(String op) { this.operation = op; return this; }
@@ -225,6 +256,7 @@ public static AuditRecord failed(String reason) {
225256
public AuditRecord useAnalyze(Boolean v) { this.useAnalyze = v; return this; }
226257
public AuditRecord httpRequest(HttpServletRequest r) { this.httpRequest = r; return this; }
227258
public AuditRecord client(ClientContext c) { this.client = c; return this; }
259+
public AuditRecord executionId(String id) { this.executionId = id; return this; }
228260
public AuditRecord eventType(SecurityEventType t) { this.eventType = t; return this; }
229261
public AuditRecord outcome(SecurityEventOutcome o) { this.outcome = o; return this; }
230262
}

backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,42 @@ void editorConfirmedDeleteWithoutWhere_isBlocked() {
112112
assertThat(exception.getMessage()).contains("without a WHERE clause");
113113
}
114114

115+
// EXPLAIN UPDATE/DELETE is the one reachable path to the keyword-fallback
116+
// containsWhereClause check for a real UPDATE/DELETE: JSqlParser only models
117+
// EXPLAIN SELECT, so these fall through to detectExplainWrappedMutation
118+
// rather than Update.getWhere()/Delete.getWhere(), which every ordinary
119+
// (non-EXPLAIN) mutation test above exercises instead.
120+
@Test
121+
void editorConfirmedExplainUpdateWithMultilineWhere_isAllowed() {
122+
QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
123+
new QueryRequest(
124+
"EXPLAIN UPDATE customers\nSET property_status = 'ACTIVE'\nWHERE customer_id = 9",
125+
null,
126+
null
127+
),
128+
QueryExecutionContext.editor("admin", true, true),
129+
"mysql"
130+
);
131+
132+
assertThat(decision.mutating()).isTrue();
133+
assertThat(decision.primaryQueryType()).isEqualTo("UPDATE");
134+
}
135+
136+
@Test
137+
void editorConfirmedExplainDeleteWithOnlyCommentedOutWhere_isBlocked() {
138+
QueryExecutionPolicyException exception = assertThrows(
139+
QueryExecutionPolicyException.class,
140+
() -> service.enforce(
141+
new QueryRequest("EXPLAIN DELETE FROM customers -- WHERE customer_id = 9\n", null, null),
142+
QueryExecutionContext.editor("admin", true, true),
143+
"mysql"
144+
)
145+
);
146+
147+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
148+
assertThat(exception.getMessage()).contains("without a WHERE clause");
149+
}
150+
115151
@Test
116152
void editorMutation_multiStatementBatchIsBlocked() {
117153
QueryExecutionPolicyException exception = assertThrows(

backend/src/test/java/com/dbaagent/service/SqlExecutionAuditServiceTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,46 @@ void record_failed_capturesFailureReason() {
128128
assertThat(event.metadata()).containsEntry("clientType", "unknown");
129129
}
130130

131+
@Test
132+
void record_cancelled_logsSuccessWithSessionPidAndExecutionId() {
133+
// Before this event type existed, a successful cancel was only visible
134+
// as an accidental EDITOR_QUERY_FAILED thrown by the killed query's own
135+
// thread when pg_terminate_backend severed it — indistinguishable from
136+
// an ordinary query failure, and with no executionId to tie it back to
137+
// the run the user actually meant to stop.
138+
givenActor("alice@example.com", 1L);
139+
140+
audit.record(SqlExecutionAuditService.AuditRecord.cancelled("8642")
141+
.connectionId("conn-1")
142+
.executionId("exec-abc-123")
143+
.client(ClientContext.unknown()));
144+
145+
SecurityEventService.EventRequest event = captureLoggedEvent();
146+
assertThat(event.eventType()).isEqualTo(SecurityEventType.EDITOR_QUERY_CANCELLED);
147+
assertThat(event.outcome()).isEqualTo(SecurityEventOutcome.SUCCESS);
148+
assertThat(event.reason()).contains("8642");
149+
assertThat(event.metadata()).containsEntry("operation", "cancel");
150+
assertThat(event.metadata()).containsEntry("executionId", "exec-abc-123");
151+
}
152+
153+
@Test
154+
void record_cancelNoOp_logsInfoOutcomeNotSilence() {
155+
// A cancel that misses its target (already finished, or a guessed/
156+
// replayed executionId) must still leave a trace — previously it left
157+
// none at all, so "did anyone try to cancel this?" was unanswerable.
158+
givenActor("alice@example.com", 1L);
159+
160+
audit.record(SqlExecutionAuditService.AuditRecord.cancelNoOp()
161+
.connectionId("conn-1")
162+
.executionId("exec-already-done")
163+
.client(ClientContext.unknown()));
164+
165+
SecurityEventService.EventRequest event = captureLoggedEvent();
166+
assertThat(event.eventType()).isEqualTo(SecurityEventType.EDITOR_QUERY_CANCELLED);
167+
assertThat(event.outcome()).isEqualTo(SecurityEventOutcome.INFO);
168+
assertThat(event.metadata()).containsEntry("executionId", "exec-already-done");
169+
}
170+
131171
@Test
132172
void record_analyzePlan_carriesPlanSignalsInsteadOfRowCount() {
133173
givenActor("alice@example.com", 1L);

src/components/tabs/Core/SqlRunnerTab.js

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ import {
5454
// query fails with a real message rather than an opaque 504.
5555
const QUERY_TIMEOUT_SECONDS = 240;
5656

57+
// CSV export re-runs the query with no display cap, so it needs its own bound.
58+
// Without one, an unbounded SELECT streams every row into a JS array, a CSV
59+
// string, and a Blob, and the equivalent unbounded read on the backend loads
60+
// the whole result set into memory before responding — large enough result
61+
// sets can exhaust the backend heap for every tenant on that instance, not
62+
// just the exporter. 100k rows is generous for a CSV download while staying
63+
// well short of that failure mode.
64+
const EXPORT_ROW_LIMIT = 100000;
65+
5766
// Constants for diagram layout
5867
const DIAGRAM_NODE_WIDTH = 240;
5968
const DIAGRAM_NODE_HEIGHT = 120;
@@ -298,6 +307,12 @@ export default function SqlRunnerTab({ connectionId }) {
298307
const dbObjectsRef = useRef([]);
299308
const abortControllerRef = useRef(null);
300309
const executionIdRef = useRef(null);
310+
// Guards against a second run starting while one is already in flight (e.g. a
311+
// held-down Cmd+Enter): without it, whichever response lands last wins the
312+
// results panel regardless of which run the user actually meant to see last,
313+
// and the earlier run's abort/cancel handle gets silently discarded.
314+
const isRunningRef = useRef(false);
315+
const runSeqRef = useRef(0);
301316
// Note: savedQueriesPanelRef kept for potential future use, but panel UI is now in modal
302317
const savedQueriesPanelRef = useRef(null);
303318
const hasRowCount = (value) => value !== null && value !== undefined;
@@ -1194,6 +1209,9 @@ export default function SqlRunnerTab({ connectionId }) {
11941209
};
11951210

11961211
const handleRunQuery = async (queryToRun = null, options = {}) => {
1212+
if (isRunningRef.current) {
1213+
return;
1214+
}
11971215
const mutationConfirmed = options.mutationConfirmed === true;
11981216
let queryText = null;
11991217

@@ -1252,12 +1270,14 @@ export default function SqlRunnerTab({ connectionId }) {
12521270
return;
12531271
}
12541272

1273+
isRunningRef.current = true;
12551274
setIsRunning(true);
12561275
setError(null);
12571276
setResults(null);
12581277
setExplainResults(null); // Clear explain results when running query
12591278
setOptimizeResult(null);
12601279

1280+
const seq = ++runSeqRef.current;
12611281
const abortController = new AbortController();
12621282
abortControllerRef.current = abortController;
12631283
// Identifies this run so cancelling can terminate it on the database.
@@ -1284,6 +1304,12 @@ export default function SqlRunnerTab({ connectionId }) {
12841304
},
12851305
);
12861306

1307+
if (seq !== runSeqRef.current) {
1308+
// Superseded by a later run — drop this response rather than let a
1309+
// stale result overwrite what the user is now looking at.
1310+
return;
1311+
}
1312+
12871313
if (response.success) {
12881314
setPendingMutationConfirmation(null);
12891315
const DISPLAY_LIMIT = 1000;
@@ -1355,9 +1381,12 @@ export default function SqlRunnerTab({ connectionId }) {
13551381
setError(err.message || "Failed to execute query");
13561382
}
13571383
} finally {
1358-
abortControllerRef.current = null;
1359-
executionIdRef.current = null;
1360-
setIsRunning(false);
1384+
if (seq === runSeqRef.current) {
1385+
abortControllerRef.current = null;
1386+
executionIdRef.current = null;
1387+
isRunningRef.current = false;
1388+
setIsRunning(false);
1389+
}
13611390
}
13621391
};
13631392

@@ -1366,6 +1395,7 @@ export default function SqlRunnerTab({ connectionId }) {
13661395
if (abortControllerRef.current) {
13671396
abortControllerRef.current.abort();
13681397
}
1398+
isRunningRef.current = false;
13691399
setIsRunning(false);
13701400
setError(null);
13711401
// Aborting above only drops the HTTP response; the statement keeps running
@@ -1406,23 +1436,37 @@ export default function SqlRunnerTab({ connectionId }) {
14061436

14071437
const handleExportResults = async () => {
14081438
if (!results) return;
1409-
1410-
// If the result was limited, always re-fetch the full result set for download.
1411-
// This ensures the CSV contains all rows, not just the displayed page.
1439+
// Export re-runs the query, so it must respect the same in-flight guard as
1440+
// Run. Today `handleRunQuery` clears `results`, which hides the Export
1441+
// button for the duration of a run and makes this unreachable — but that is
1442+
// an incidental consequence of unrelated state handling, not a guarantee.
1443+
// Without this check, any change that keeps results on screen during a run
1444+
// silently reintroduces two concurrent queries from one tab.
1445+
if (isRunningRef.current || isExporting) return;
1446+
1447+
// If the result was limited, always re-fetch for download so the CSV isn't
1448+
// just the displayed page — but still capped at EXPORT_ROW_LIMIT, not
1449+
// unbounded, and on the same timeout budget as Run so it fails with a real
1450+
// error instead of an opaque 504 from the nginx proxy.
14121451
if (results.isLimited && results.query) {
14131452
setIsExporting(true);
14141453
// Strip trailing semicolon so backends don't reject the re-executed query
14151454
const queryForExport = results.query.trim().replace(/;+$/, "");
1455+
const exportAbortController = new AbortController();
1456+
const exportExecutionId =
1457+
globalThis.crypto?.randomUUID?.() ??
1458+
`exec-${Date.now()}-${Math.random().toString(16).slice(2)}`;
14161459
try {
14171460
const response = await queryAPI.executeQuery(
14181461
connectionId,
14191462
queryForExport,
1420-
null, // no limit — fetch all rows
1421-
600,
1422-
null,
1463+
EXPORT_ROW_LIMIT,
1464+
QUERY_TIMEOUT_SECONDS,
1465+
exportAbortController.signal,
14231466
{
14241467
executionOrigin: "EDITOR",
14251468
mutationConfirmed: false,
1469+
executionId: exportExecutionId,
14261470
},
14271471
);
14281472
if (response.success) {
@@ -2590,9 +2634,9 @@ export default function SqlRunnerTab({ connectionId }) {
25902634
disabled={isExporting}
25912635
title={
25922636
results?.isLimited
2593-
? results?.totalRowCount != null
2594-
? `Download all ${results.totalRowCount.toLocaleString()} rows as CSV`
2595-
: "Download full result set as CSV"
2637+
? results?.totalRowCount != null && results.totalRowCount > EXPORT_ROW_LIMIT
2638+
? `Download first ${EXPORT_ROW_LIMIT.toLocaleString()} of ${results.totalRowCount.toLocaleString()} rows as CSV`
2639+
: `Download up to ${EXPORT_ROW_LIMIT.toLocaleString()} rows as CSV`
25962640
: "Export CSV"
25972641
}
25982642
>

0 commit comments

Comments
 (0)