Skip to content

Commit e120789

Browse files
fix: fail-closed chat access policy for View as and MCP
Walk the full SQL tree, deny unparseable or unhandled statements, and take the MCP/Editor actor from SecurityContext. Persist allowed schemas, scope RAG/brain retrieval, block protected-column prompt mentions, and refuse public shares on connections with an active policy. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent f8d9406 commit e120789

25 files changed

Lines changed: 1066 additions & 147 deletions

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,9 @@ returns a number).
199199
2. **LLM Provider Registry**: Use `LlmProviderRegistry` for all provider-specific LLM behavior. Do NOT add if/else or switch on provider type. Chat and embedding providers are registered and resolved independently — some providers offer only one. Providers are *factories* over credentials, not `ChatModel`s, so credentials stay resolvable per call and key rotation needs no restart.
200200
3. **SSH-Aware Access**: Always use `ConnectionService.getJdbcTemplate(connectionId, request)` — handles SSH tunneling transparently.
201201
4. **SQL Rule**: All generated SQL MUST use table-qualified column names (`table.column_name`).
202-
5. **RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
203-
6. **Virtual Threads**: Enabled for concurrency (JDK 25).
202+
5. **Chat access policy**: Fail closed. Walk the whole SQL tree (CTEs, set ops, subqueries). Deny unparseable or unhandled statements. Require an actor except `INTERNAL`/`SCHEDULED`. MCP/Editor identity comes from `SecurityContext`, not `QueryActorContextHolder`. Persist `allowed_schemas`. Do not let "how many" override a protected-column mention. Public share is refused when the connection has an active policy.
203+
6. **RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
204+
7. **Virtual Threads**: Enabled for concurrency (JDK 25).
204205

205206
### Frontend Rules
206207
1. **API Centralization**: ALL API calls through `src/lib/api/client.js`. Never create direct axios instances.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import com.dbaagent.service.brain.query.PlanPatternLibraryService;
6666
import com.dbaagent.service.brain.BrainInsightEmbeddingService;
6767
import com.dbaagent.service.SlowQueryHistoryService;
68+
import com.dbaagent.service.UserDataAccessPolicyService;
6869
import com.dbaagent.service.security.AccessControlService;
6970
import com.dbaagent.model.SlowQueryAnalysis;
7071
import lombok.RequiredArgsConstructor;
@@ -138,6 +139,7 @@ public class BrainController {
138139
private final SlowQueryHistoryService slowQueryHistoryService;
139140
private final BrainInsightEmbeddingService brainInsightEmbeddingService;
140141
private final AccessControlService accessControlService;
142+
private final UserDataAccessPolicyService userDataAccessPolicyService;
141143

142144
@GetMapping("/understanding/{connectionId}")
143145
public ResponseEntity<BrainUnderstandingResponse> getUnderstanding(
@@ -523,7 +525,12 @@ public ResponseEntity<List<InferredTableRelationship>> getInferredRelationships(
523525
) {
524526
try {
525527
accessControlService.assertCanReadConnectionContent(connectionId);
526-
return ResponseEntity.ok(joinRelationshipInferenceService.getRelationships(connectionId));
528+
return ResponseEntity.ok(userDataAccessPolicyService.filterInferredRelationships(
529+
connectionId,
530+
accessControlService.getCurrentUsername(),
531+
accessControlService.isCurrentUserAdmin(),
532+
joinRelationshipInferenceService.getRelationships(connectionId)
533+
));
527534
} catch (ResponseStatusException e) {
528535
throw e;
529536
} catch (Exception e) {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ public ResponseEntity<?> query(@RequestBody DashboardQueryRequest request) {
7777
qr.setExecutionOrigin(QueryExecutionOrigin.API);
7878
QueryResult result = queryExecutorService.executeQuery(
7979
request.connectionId(), qr,
80-
QueryExecutionContext.api(accessControlService.getCurrentUsername()));
80+
QueryExecutionContext.api(
81+
accessControlService.getCurrentUsername(),
82+
accessControlService.isCurrentUserAdmin()
83+
));
8184
return ResponseEntity.ok(Map.of(
8285
"success", true,
8386
"columns", result.getColumns() == null ? java.util.List.of() : result.getColumns(),

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import com.dbaagent.model.QueryResult;
99
import com.dbaagent.service.ExplainPlanService;
1010
import com.dbaagent.service.McpSqlGuardService;
11-
import com.dbaagent.service.QueryActorContextHolder;
1211
import com.dbaagent.service.QueryExecutionContext;
1312
import com.dbaagent.service.QueryExecutionPolicyException;
1413
import com.dbaagent.service.QueryExecutorService;
@@ -77,7 +76,10 @@ public ResponseEntity<?> executeReadOnlyQuery(@RequestBody McpReadOnlyQueryReque
7776
QueryResult result = queryExecutorService.executeQuery(
7877
request.getConnectionId(),
7978
queryRequest,
80-
QueryExecutionContext.mcp(QueryActorContextHolder.currentUsername())
79+
QueryExecutionContext.mcp(
80+
accessControlService.getCurrentUsername(),
81+
accessControlService.isCurrentUserAdmin()
82+
)
8183
);
8284
return ResponseEntity.ok(Map.of(
8385
"success", true,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.dbaagent.model.DashboardVersion;
44
import com.dbaagent.model.SavedDashboard;
5+
import com.dbaagent.service.ConnectionChatAccessPolicyService;
56
import com.dbaagent.service.SavedDashboardService;
67
import com.dbaagent.service.security.AccessControlService;
78
import lombok.extern.slf4j.Slf4j;
@@ -27,6 +28,9 @@ public class SavedDashboardController {
2728
@Autowired
2829
private AccessControlService accessControlService;
2930

31+
@Autowired
32+
private ConnectionChatAccessPolicyService connectionChatAccessPolicyService;
33+
3034
// Every write method below is load-then-save on a row a background generation
3135
// turn (SavedDashboardService.beginGenerationTurn etc.) may be writing at the
3236
// same time. Without this helper, the loser's raw Hibernate message
@@ -47,6 +51,13 @@ public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) {
4751
SavedDashboard existing = savedDashboardService.getDashboardById(id)
4852
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
4953
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
54+
if (connectionChatAccessPolicyService.hasActivePolicy(existing.getConnectionId())) {
55+
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
56+
"success", false,
57+
"errorCode", "POLICY_PUBLIC_SHARE_FORBIDDEN",
58+
"message", "This connection has an active chat access policy, so the dashboard cannot be shared publicly."
59+
));
60+
}
5061
SavedDashboard d = savedDashboardService.enablePublicShare(id);
5162
return ResponseEntity.ok(Map.of("success", true,
5263
"shareToken", d.getShareToken(), "isPublic", true));

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.dbaagent.service.QueryExecutionContext;
88
import com.dbaagent.service.QueryExecutionPolicyException;
99
import com.dbaagent.service.ActiveQueryService;
10+
import com.dbaagent.service.McpTokenService;
1011
import com.dbaagent.service.QueryExecutorService;
1112
import com.dbaagent.service.RunningQueryRegistry;
1213
import com.dbaagent.service.SqlExecutionAuditService;
@@ -15,6 +16,7 @@
1516
import com.dbaagent.service.SchemaScannerService;
1617
import com.dbaagent.service.VisualizationService;
1718
import com.dbaagent.service.security.AccessControlService;
19+
import org.springframework.http.HttpHeaders;
1820
import jakarta.servlet.http.HttpServletRequest;
1921
import lombok.RequiredArgsConstructor;
2022
import lombok.extern.slf4j.Slf4j;
@@ -195,11 +197,7 @@ public ResponseEntity<Map<String, Object>> executeQuery(
195197
QueryResult result = queryExecutorService.executeQuery(
196198
connectionId,
197199
queryRequest,
198-
QueryExecutionContext.editor(
199-
accessControlService.getCurrentUsername(),
200-
accessControlService.isCurrentUserAdmin(),
201-
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
202-
)
200+
queryExecutionContext(queryRequest, httpRequest)
203201
);
204202
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.executed()
205203
.connectionId(connectionId)
@@ -413,6 +411,26 @@ public ResponseEntity<Map<String, Object>> getTableStats(
413411
}
414412
}
415413

414+
private QueryExecutionContext queryExecutionContext(QueryRequest queryRequest, HttpServletRequest httpRequest) {
415+
String username = accessControlService.getCurrentUsername();
416+
boolean admin = accessControlService.isCurrentUserAdmin();
417+
if (isMcpBearer(httpRequest)) {
418+
return QueryExecutionContext.mcp(username, admin);
419+
}
420+
return QueryExecutionContext.editor(
421+
username,
422+
admin,
423+
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
424+
);
425+
}
426+
427+
private boolean isMcpBearer(HttpServletRequest httpRequest) {
428+
String authorization = httpRequest.getHeader(HttpHeaders.AUTHORIZATION);
429+
return authorization != null
430+
&& authorization.startsWith("Bearer ")
431+
&& authorization.substring(7).startsWith(McpTokenService.TOKEN_PREFIX);
432+
}
433+
416434
private SchemaMetadata scopedSchema(String connectionId, SchemaMetadata schema) {
417435
return userDataAccessPolicyService.filterSchemaMetadata(
418436
connectionId,

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.dbaagent.service.SemanticModelService;
1111
import com.dbaagent.service.TrainingJobService;
1212
import com.dbaagent.service.TrainingService;
13+
import com.dbaagent.service.UserDataAccessPolicyService;
1314
import com.dbaagent.service.security.AccessControlService;
1415
import lombok.Data;
1516
import lombok.RequiredArgsConstructor;
@@ -37,6 +38,7 @@ public class TrainingController {
3738
private final CredentialRepository credentialRepository;
3839
private final SemanticModelService semanticModelService;
3940
private final AccessControlService accessControlService;
41+
private final UserDataAccessPolicyService userDataAccessPolicyService;
4042

4143
/**
4244
* Train with schema DDL
@@ -186,7 +188,9 @@ public ResponseEntity<Map<String, Object>> debugRetrieve(
186188
if (question == null || question.isBlank()) {
187189
return ResponseEntity.badRequest().body(Map.of("error", "Query parameter 'q' is required"));
188190
}
189-
return ResponseEntity.ok(trainingService.debugRetrieve(connectionId, question, topK));
191+
Map<String, Object> payload = trainingService.debugRetrieve(connectionId, question, topK);
192+
filterDebugRetrieval(connectionId, payload);
193+
return ResponseEntity.ok(payload);
190194
} catch (org.springframework.web.server.ResponseStatusException e) {
191195
throw e;
192196
} catch (Exception e) {
@@ -346,6 +350,34 @@ private void rebuildAndReindexConnection(String connectionId) {
346350
trainingService.reindexConnection(connectionId);
347351
}
348352

353+
@SuppressWarnings("unchecked")
354+
private void filterDebugRetrieval(String connectionId, Map<String, Object> payload) {
355+
if (payload == null) {
356+
return;
357+
}
358+
Object results = payload.get("results");
359+
if (!(results instanceof List<?> rows)) {
360+
return;
361+
}
362+
List<Map<String, Object>> filtered = new java.util.ArrayList<>();
363+
for (Object row : rows) {
364+
if (!(row instanceof Map<?, ?> map)) {
365+
continue;
366+
}
367+
Object metadata = map.get("metadata");
368+
if (userDataAccessPolicyService.isRagMetadataInScope(
369+
connectionId,
370+
accessControlService.getCurrentUsername(),
371+
accessControlService.isCurrentUserAdmin(),
372+
metadata == null ? null : String.valueOf(metadata)
373+
)) {
374+
filtered.add((Map<String, Object>) map);
375+
}
376+
}
377+
payload.put("results", filtered);
378+
payload.put("resultCount", filtered.size());
379+
}
380+
349381
@Data
350382
public static class DocumentationRequest {
351383
private String connectionId;

backend/src/main/java/com/dbaagent/dto/ConnectionChatAccessPolicyResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class ConnectionChatAccessPolicyResponse {
1616
List<String> blockedSensitivityCategories;
1717
List<String> deniedTables;
1818
List<String> deniedColumns;
19+
List<String> allowedSchemas;
20+
boolean allowAggregates;
1921
boolean blockMode;
2022
boolean redactMode;
2123
boolean active;

backend/src/main/java/com/dbaagent/dto/PolicyPreviewResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public class PolicyPreviewResponse {
1111
List<String> blockedSensitivityCategories;
1212
List<String> deniedTables;
1313
List<String> deniedColumns;
14+
List<String> allowedSchemas;
15+
boolean allowAggregates;
1416
List<String> impactedTables;
1517
List<String> impactedColumns;
1618
boolean blockMode;

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ public class ConnectionChatAccessPolicy {
4040
@Column(name = "denied_columns", columnDefinition = "jsonb")
4141
private List<String> deniedColumns;
4242

43+
@JdbcTypeCode(SqlTypes.JSON)
44+
@Column(name = "allowed_schemas", columnDefinition = "jsonb")
45+
private List<String> allowedSchemas;
46+
47+
@Column(name = "allow_aggregates", nullable = false)
48+
private boolean allowAggregates = false;
49+
4350
@Column(name = "block_mode", nullable = false)
4451
private boolean blockMode = true;
4552

0 commit comments

Comments
 (0)