Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ returns a number).
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.
3. **SSH-Aware Access**: Always use `ConnectionService.getJdbcTemplate(connectionId, request)` — handles SSH tunneling transparently.
4. **SQL Rule**: All generated SQL MUST use table-qualified column names (`table.column_name`).
5. **RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
6. **Virtual Threads**: Enabled for concurrency (JDK 25).
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.
6. **RAG Caching**: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
7. **Virtual Threads**: Enabled for concurrency (JDK 25).

### Frontend Rules
1. **API Centralization**: ALL API calls through `src/lib/api/client.js`. Never create direct axios instances.
Expand All @@ -210,7 +211,13 @@ returns a number).
5. **Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.

### Admin profile switch
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav. The admin JWT stays on the session; `ImpersonationService` sets an httpOnly `impersonate_user` cookie and `JwtAuthenticationFilter` overlays the target principal. `POST|DELETE|GET /api/admin/impersonate` are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. `/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername`.
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav.

The admin JWT **subject** stays the administrator so logout, refresh, and `/admin/impersonate` still own the real session. Policy identity is the target: an httpOnly `impersonate_user` cookie plus an `impUid` claim on the access token. `JwtAuthenticationFilter` overlays that principal onto the SecurityContext for every request except the impersonation control plane, logout, and session refresh. Chat, Editor, schema listing, and Agent MCP calls then run `AccessControlService` / `ConnectionChatAccessPolicyService` as the target (`actorIsAdmin` is false, so policies apply).

The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints an MCP token for the effective user and never falls back to the admin session JWT while View as is active. nginx `auth_request` on `/agent-api` forwards `/api/auth/me`'s `X-Remote-User` (the overlaid username) instead of hardcoding `admin`.

`POST|DELETE|GET /api/admin/impersonate` are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. `/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername`.

### Git Rules
- Do NOT commit automatically — wait for explicit user instruction.
Expand Down Expand Up @@ -238,7 +245,16 @@ Admins can **View as** a sub-user from the top-right of the home layout (`Profil
`hermes_requires <0.20.0` on a "verified" 401 that came from a hand-rolled
`hermes serve` run rather than `hermes webui`. 0.20.0 works. Verify against the
real start path before writing a version constraint.
5. **The agent image build clones two third-party repos over the public internet,
5. **Hermes MCP is process-global.** One `deepsql-phase1-server.js` stdio server
is started from the first loaded profile (usually `u-admin`) and lives until
the Agent API process dies. `POST /api/profile/switch` is `process_wide=False`
on purpose (per-browser cookie), so a View as / new-thread Agent chat tagged
`profile: u-marts-editor` still sends the admin MCP bearer. Chat-access policy
then `resolveEffectivePolicy(..., actorIsAdmin=true)` → `none()`. The
provisioner mirrors the target user's token onto every `deepsql.token` the
live process might re-read (`DEEPSQL_TOKEN_FILE` mtime cache in
`mcp/deepsql-phase1-lib.js`).
6. **The agent image build clones two third-party repos over the public internet,
unauthenticated.** `agent/Dockerfile` fetches `NousResearch/hermes-agent` and
`nesquena/hermes-webui` at build time. GitHub rate-limits unauthenticated
requests *per source IP*, and Actions runners share pooled egress addresses, so
Expand Down Expand Up @@ -276,6 +292,16 @@ broken. Assert the *outcome*, never the attempt:
- **`set -e` + `read` at EOF aborts silently.** Prompts in `install.sh` use
`read … || true` so the explicit emptiness checks report the problem. Without it the
installer exited 1 with no message, after writing generated secrets to `.env`.
- **Minting an Agent MCP token ≠ Hermes using it.** `/api/agent/session` can mint
`u-marts-editor`'s token and `POST /api/profile/switch` can 200 while
`execute_sql` still authenticates as admin. Hermes keeps one DeepSQL MCP
stdio process (started from the first profile that loaded `mcp_servers`) and
`profile/switch` is `process_wide=False`. A new chat thread does not respawn
MCP. `probeMcpAuth` only proves the *minted* token works against Spring, not
that the live MCP process will send it. The provisioner must mirror the
token onto every `deepsql.token` the live process might be watching
(`scripts/local-agent-provisioner.py`). Audit: `security_event.user_id` on
`EDITOR_QUERY_EXECUTED` with `clientType=mcp`.
- **Silent-failure rule, concretely:** the CLI rendered an unreachable server as
`No databases connected yet` because one `catch` covered both the connection fetch
and decorative extras. An unreachable host must never look like an empty account.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,21 @@ public ResponseEntity<?> refreshSession(HttpServletRequest httpRequest, HttpServ
User effectiveUser = impersonationService.resolveFromCookie(httpRequest, user)
.map(ImpersonationContext.State::target)
.orElse(user);
// Keep the user's agent token alive for as long as the UI session lives.
// The SPA refreshes on access-token expiry (~every 15 min of activity), so
// this slides the agent token forward on each active interval — a logged-in
// UI never ends up with a dead agent.
if (effectiveUser != user && effectiveUser.getId() != null) {
authSessionService.reissueAccessToken(
httpResponse,
session.getId(),
user,
effectiveUser.getId()
);
}
// Keep agent tokens alive for as long as the UI session lives.
// During View as the SPA still refreshes the *admin* session; also
// slide the target user's minted MCP token or their Agent tab dies.
agentBridgeService.extendAgentTokens(user.getUsername());
if (!effectiveUser.getUsername().equals(user.getUsername())) {
agentBridgeService.extendAgentTokens(effectiveUser.getUsername());
}
Map<String, Object> payload = toAuthPayload(
effectiveUser,
effectiveUser.getRoleEnum(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
import com.dbaagent.service.brain.query.PlanPatternLibraryService;
import com.dbaagent.service.brain.BrainInsightEmbeddingService;
import com.dbaagent.service.SlowQueryHistoryService;
import com.dbaagent.service.UserDataAccessPolicyService;
import com.dbaagent.service.security.AccessControlService;
import com.dbaagent.model.SlowQueryAnalysis;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -139,6 +140,7 @@ public class BrainController {
private final SlowQueryHistoryService slowQueryHistoryService;
private final BrainInsightEmbeddingService brainInsightEmbeddingService;
private final AccessControlService accessControlService;
private final UserDataAccessPolicyService userDataAccessPolicyService;

@GetMapping("/understanding/{connectionId}")
public ResponseEntity<BrainUnderstandingResponse> getUnderstanding(
Expand Down Expand Up @@ -531,7 +533,12 @@ public ResponseEntity<List<InferredTableRelationship>> getInferredRelationships(
) {
try {
accessControlService.assertCanReadConnectionContent(connectionId);
return ResponseEntity.ok(joinRelationshipInferenceService.getRelationships(connectionId));
return ResponseEntity.ok(userDataAccessPolicyService.filterInferredRelationships(
connectionId,
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin(),
joinRelationshipInferenceService.getRelationships(connectionId)
));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ public ResponseEntity<?> query(@RequestBody DashboardQueryRequest request) {
qr.setExecutionOrigin(QueryExecutionOrigin.API);
QueryResult result = queryExecutorService.executeQuery(
request.connectionId(), qr,
QueryExecutionContext.api(accessControlService.getCurrentUsername()));
QueryExecutionContext.api(
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin()
));
return ResponseEntity.ok(Map.of(
"success", true,
"columns", result.getColumns() == null ? java.util.List.of() : result.getColumns(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import com.dbaagent.model.QueryResult;
import com.dbaagent.service.ExplainPlanService;
import com.dbaagent.service.McpSqlGuardService;
import com.dbaagent.service.QueryActorContextHolder;
import com.dbaagent.service.QueryExecutionContext;
import com.dbaagent.service.QueryExecutionPolicyException;
import com.dbaagent.service.QueryExecutorService;
Expand Down Expand Up @@ -77,7 +76,10 @@ public ResponseEntity<?> executeReadOnlyQuery(@RequestBody McpReadOnlyQueryReque
QueryResult result = queryExecutorService.executeQuery(
request.getConnectionId(),
queryRequest,
QueryExecutionContext.mcp(QueryActorContextHolder.currentUsername())
QueryExecutionContext.mcp(
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin()
)
);
return ResponseEntity.ok(Map.of(
"success", true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.dbaagent.model.DashboardVersion;
import com.dbaagent.model.SavedDashboard;
import com.dbaagent.service.ConnectionChatAccessPolicyService;
import com.dbaagent.service.SavedDashboardService;
import com.dbaagent.service.security.AccessControlService;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,6 +28,9 @@ public class SavedDashboardController {
@Autowired
private AccessControlService accessControlService;

@Autowired
private ConnectionChatAccessPolicyService connectionChatAccessPolicyService;

// Every write method below is load-then-save on a row a background generation
// turn (SavedDashboardService.beginGenerationTurn etc.) may be writing at the
// same time. Without this helper, the loser's raw Hibernate message
Expand All @@ -47,6 +51,13 @@ public ResponseEntity<Map<String, Object>> enableShare(@PathVariable UUID id) {
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
if (connectionChatAccessPolicyService.hasActivePolicy(existing.getConnectionId())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"success", false,
"errorCode", "POLICY_PUBLIC_SHARE_FORBIDDEN",
"message", "This connection has an active chat access policy, so the dashboard cannot be shared publicly."
));
}
SavedDashboard d = savedDashboardService.enablePublicShare(id);
return ResponseEntity.ok(Map.of("success", true,
"shareToken", d.getShareToken(), "isPublic", true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.dbaagent.service.QueryExecutionContext;
import com.dbaagent.service.QueryExecutionPolicyException;
import com.dbaagent.service.ActiveQueryService;
import com.dbaagent.service.McpTokenService;
import com.dbaagent.service.QueryExecutorService;
import com.dbaagent.service.RunningQueryRegistry;
import com.dbaagent.service.SqlExecutionAuditService;
Expand All @@ -15,6 +16,7 @@
import com.dbaagent.service.SchemaScannerService;
import com.dbaagent.service.VisualizationService;
import com.dbaagent.service.security.AccessControlService;
import org.springframework.http.HttpHeaders;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -195,11 +197,7 @@ public ResponseEntity<Map<String, Object>> executeQuery(
QueryResult result = queryExecutorService.executeQuery(
connectionId,
queryRequest,
QueryExecutionContext.editor(
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin(),
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
)
queryExecutionContext(queryRequest, httpRequest)
);
sqlExecutionAuditService.record(SqlExecutionAuditService.AuditRecord.executed()
.connectionId(connectionId)
Expand Down Expand Up @@ -413,6 +411,26 @@ public ResponseEntity<Map<String, Object>> getTableStats(
}
}

private QueryExecutionContext queryExecutionContext(QueryRequest queryRequest, HttpServletRequest httpRequest) {
String username = accessControlService.getCurrentUsername();
boolean admin = accessControlService.isCurrentUserAdmin();
if (isMcpBearer(httpRequest)) {
return QueryExecutionContext.mcp(username, admin);
}
return QueryExecutionContext.editor(
username,
admin,
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
);
}

private boolean isMcpBearer(HttpServletRequest httpRequest) {
String authorization = httpRequest.getHeader(HttpHeaders.AUTHORIZATION);
return authorization != null
&& authorization.startsWith("Bearer ")
&& authorization.substring(7).startsWith(McpTokenService.TOKEN_PREFIX);
}

private SchemaMetadata scopedSchema(String connectionId, SchemaMetadata schema) {
return userDataAccessPolicyService.filterSchemaMetadata(
connectionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.dbaagent.service.SemanticModelService;
import com.dbaagent.service.TrainingJobService;
import com.dbaagent.service.TrainingService;
import com.dbaagent.service.UserDataAccessPolicyService;
import com.dbaagent.service.security.AccessControlService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -37,6 +38,7 @@ public class TrainingController {
private final CredentialRepository credentialRepository;
private final SemanticModelService semanticModelService;
private final AccessControlService accessControlService;
private final UserDataAccessPolicyService userDataAccessPolicyService;

/**
* Train with schema DDL
Expand Down Expand Up @@ -186,7 +188,9 @@ public ResponseEntity<Map<String, Object>> debugRetrieve(
if (question == null || question.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Query parameter 'q' is required"));
}
return ResponseEntity.ok(trainingService.debugRetrieve(connectionId, question, topK));
Map<String, Object> payload = trainingService.debugRetrieve(connectionId, question, topK);
filterDebugRetrieval(connectionId, payload);
return ResponseEntity.ok(payload);
} catch (org.springframework.web.server.ResponseStatusException e) {
throw e;
} catch (Exception e) {
Expand Down Expand Up @@ -346,6 +350,34 @@ private void rebuildAndReindexConnection(String connectionId) {
trainingService.reindexConnection(connectionId);
}

@SuppressWarnings("unchecked")
private void filterDebugRetrieval(String connectionId, Map<String, Object> payload) {
if (payload == null) {
return;
}
Object results = payload.get("results");
if (!(results instanceof List<?> rows)) {
return;
}
List<Map<String, Object>> filtered = new java.util.ArrayList<>();
for (Object row : rows) {
if (!(row instanceof Map<?, ?> map)) {
continue;
}
Object metadata = map.get("metadata");
if (userDataAccessPolicyService.isRagMetadataInScope(
connectionId,
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin(),
metadata == null ? null : String.valueOf(metadata)
)) {
filtered.add((Map<String, Object>) map);
}
}
payload.put("results", filtered);
payload.put("resultCount", filtered.size());
}

@Data
public static class DocumentationRequest {
private String connectionId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class ConnectionChatAccessPolicyResponse {
List<String> blockedSensitivityCategories;
List<String> deniedTables;
List<String> deniedColumns;
List<String> allowedSchemas;
boolean allowAggregates;
boolean blockMode;
boolean redactMode;
boolean active;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public class PolicyPreviewResponse {
List<String> blockedSensitivityCategories;
List<String> deniedTables;
List<String> deniedColumns;
List<String> allowedSchemas;
boolean allowAggregates;
List<String> impactedTables;
List<String> impactedColumns;
boolean blockMode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public class ConnectionChatAccessPolicy {
@Column(name = "denied_columns", columnDefinition = "jsonb")
private List<String> deniedColumns;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "allowed_schemas", columnDefinition = "jsonb")
private List<String> allowedSchemas;

@Column(name = "allow_aggregates", nullable = false)
private boolean allowAggregates = false;

@Column(name = "block_mode", nullable = false)
private boolean blockMode = true;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
public interface ConnectionChatAccessPolicyRepository extends JpaRepository<ConnectionChatAccessPolicy, Long> {
Optional<ConnectionChatAccessPolicy> findByConnectionIdAndUsernameIgnoreCase(String connectionId, String username);
List<ConnectionChatAccessPolicy> findAllByUsernameIgnoreCaseOrderByUpdatedAtDesc(String username);
boolean existsByConnectionIdAndActiveTrue(String connectionId);
void deleteByConnectionId(String connectionId);
}
Loading
Loading