Skip to content

Commit 0aa2b77

Browse files
fix: schema-scoped chat access policies and acme_erp multi-schema fixture (#65)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Chat access policies are schema-aware for SQL enforcement, NL parsing, **and the SQL editor explorer**. ## Root cause Policy parsing and enforcement used bare table/column names, so schema-scoped policies leaked across the database. The editor tree (`GET /connections/{id}/objects`) also returned every schema even when the user was limited to one. ## Fix **Schema scope (enforcement + parsing)** - Parse `allowedSchemas` from policy text - Key protection by `schema.table` - Apply PII/financial and explicit table/column denials only inside allowed schemas - Block SQL that references a schema outside the allow-list (`POLICY_SCHEMA_BLOCKED`) - Block `SELECT *` only when the queried table is protected **Generic NL column constraints** Deny/allow clauses match SQL type families and live schema column names, still limited to `allowedSchemas`. No `amount`/`currency` special case. **Editor explorer** - `GET /objects`, `/schema`, `/visualization`, and table index/stats are filtered by `allowedSchemas` - Admin and unrestricted users still see the full tree - Frontend schema cache is keyed by username so an admin session cannot leak other schemas to an editor Verified live as `marts-editor` on ACME ERP: 3 `marts` tables vs 12 objects for admin. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-019fe687-99b1-76fd-80fa-dd213aecc497&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 67dcadf commit 0aa2b77

36 files changed

Lines changed: 2671 additions & 182 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ EMBEDDING_FAIL_OPEN=false
141141
# AGENT_WEBUI_URL Agent HTTP API. Compose default: http://deepsql-agent:8787
142142
# AGENT_PROVISIONER_URL Per-user profile provisioner. Compose default:
143143
# http://deepsql-agent:8788/provision
144+
# DEEPSQL_API_BASE_URL Where the agent container's MCP tools call the backend.
145+
# Compose default: http://backend:8080/api/
146+
# Native Java + Compose agent: http://host.docker.internal:8080/api/
144147
# AGENT_PROVISION_SECRET Shared secret between backend and agent (required).
145148
# DEEPSQL_AGENT_PORT / DEEPSQL_AGENT_PROVISIONER_PORT — host port mappings
146149
# (compose binds these to 127.0.0.1 only; public path is nginx /agent-api).

AGENTS.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ only covers cloud-specific, non-obvious caveats.
186186
`HERMES_WEBUI_ALLOWED_ORIGINS` (upstream env name).
187187
- A demo target DB `demo_shop` (same Postgres server, sample `customers`/`products`/`orders`)
188188
exists for exercising connection/schema features without an external database.
189+
- A multi-schema fixture DB `acme_erp` (schemas: `crm`, `sales`, `finance`, `inventory`,
190+
`hr`, `marts`) exists for chat-access-policy and multi-schema tests. Seed with:
191+
`sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then
192+
`bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth
193+
is disabled or you have an admin session cookie).
189194

190195
### Non-obvious setup caveats (each cost real debugging time)
191196

@@ -259,9 +264,16 @@ only covers cloud-specific, non-obvious caveats.
259264
non-`public` schemas (`crm`, `sales`, `finance`, `hr`, `inventory`) for Brain /
260265
MCP cross-schema checks. Prefer schema-qualified SQL (`sales.orders`); bare
261266
names follow the role’s `search_path` (usually `public`).
262-
- **`AGENT_WEBUI_URL` for native runs.** Default is `http://deepsql-agent:8787`
263-
(Compose DNS). Native local must set `AGENT_WEBUI_URL=http://127.0.0.1:8787` in
264-
`.env` or CLI/Slack `AgentChatClient` cannot reach the agent API.
267+
- **`AGENT_WEBUI_URL` / `AGENT_PROVISIONER_URL` for native runs.** Compose
268+
defaults (`http://deepsql-agent:8787` and `…:8788/provision`) do not resolve
269+
on the host. Native local must point both at loopback
270+
(`http://127.0.0.1:8787` and `http://127.0.0.1:8788/provision`) or the Agent
271+
tab returns 503 `Could not provision the DeepSQL Agent for this user`.
272+
`scripts/start-backend.sh` remaps those hostnames automatically when they
273+
don't resolve. If the agent container is used with a host-side Java backend,
274+
set `DEEPSQL_API_BASE_URL=http://host.docker.internal:8080/api/` so MCP
275+
tools can reach the native process (compose publishes `host.docker.internal`
276+
via `extra_hosts`).
265277
- **DeepSQL CLI (`deepsql`) for agent testing.** Install from the repo package:
266278
`cd mcp && DEEPSQL_SKIP_AGENT_SETUP=1 npm install -g .` (prefix
267279
`~/.npm-global`, keep that on `PATH`). Auth against local backend with an MCP

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ backend/
9393
repository/ # Spring Data repositories
9494
provider/ # Database dialect registry (PostgreSQL, MySQL)
9595
config/ # Spring configuration
96-
security/ # JWT auth, RBAC
96+
security/ # JWT auth, RBAC, admin profile switch (`ImpersonationService`)
9797
llm/ # LLM provider registry, config resolver, OpenAI-compatible provider
9898
util/ # Shared utilities
9999
src/test/ # JUnit 5 tests
@@ -110,7 +110,7 @@ src/ # Frontend (React)
110110
components/ # UI components
111111
tabs/ # 40+ specialized tabs
112112
sections/ # Top-level sidebar destinations (Agent, Dashboards, Brain,
113-
# Performance = Slow Queries + Workload, Editor, Docs)
113+
# Performance = Slow Queries + Workload, Editor)
114114
lib/
115115
api/client.js # Centralized API layer (axios, 25+ modules)
116116
stores/ # Zustand stores (dashboard, connection, chat, UI)
@@ -209,6 +209,9 @@ returns a number).
209209
4. **Tooltips**: Always use `HelpTooltip` component, never plain `title` attributes.
210210
5. **Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
211211

212+
### Admin profile switch
213+
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`.
214+
212215
### Git Rules
213216
- Do NOT commit automatically — wait for explicit user instruction.
214217
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.dbaagent.service.AuthSessionService;
1010
import com.dbaagent.service.PasswordlessAuthService;
1111
import com.dbaagent.service.PermissionService;
12+
import com.dbaagent.service.ImpersonationService;
13+
import com.dbaagent.security.ImpersonationContext;
1214
import com.dbaagent.service.SystemConfigService;
1315
import com.dbaagent.service.UserInviteService;
1416
import jakarta.servlet.http.Cookie;
@@ -47,6 +49,7 @@ public class AuthController {
4749
private final PrivateBetaRequestRepository privateBetaRequestRepository;
4850
private final SystemConfigService systemConfigService;
4951
private final AgentBridgeService agentBridgeService;
52+
private final ImpersonationService impersonationService;
5053

5154
@Value("${security.cookie.refresh-name:refresh_token}")
5255
private String refreshCookieName;
@@ -201,12 +204,21 @@ public ResponseEntity<?> refreshSession(HttpServletRequest httpRequest, HttpServ
201204
return ResponseEntity.status(401).body(Map.of("message", "Session expired"));
202205
}
203206
authSessionService.writeSessionCookies(httpResponse, refreshed.get());
207+
User effectiveUser = impersonationService.resolveFromCookie(httpRequest, user)
208+
.map(ImpersonationContext.State::target)
209+
.orElse(user);
204210
// Keep the user's agent token alive for as long as the UI session lives.
205211
// The SPA refreshes on access-token expiry (~every 15 min of activity), so
206212
// this slides the agent token forward on each active interval — a logged-in
207213
// UI never ends up with a dead agent.
208214
agentBridgeService.extendAgentTokens(user.getUsername());
209-
return ResponseEntity.ok(toAuthPayload(user, user.getRoleEnum(), permissionService.getEffectivePermissionCodes(user.getRoleEnum())));
215+
Map<String, Object> payload = toAuthPayload(
216+
effectiveUser,
217+
effectiveUser.getRoleEnum(),
218+
permissionService.getEffectivePermissionCodes(effectiveUser.getRoleEnum())
219+
);
220+
impersonationService.decorateAuthPayload(httpRequest, user, payload);
221+
return ResponseEntity.ok(payload);
210222
}
211223

212224
@PostMapping("/logout")
@@ -304,7 +316,7 @@ public ResponseEntity<?> acceptInvite(
304316
}
305317

306318
@GetMapping("/me")
307-
public ResponseEntity<?> getCurrentUser() {
319+
public ResponseEntity<?> getCurrentUser(HttpServletRequest httpRequest) {
308320
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
309321
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
310322
return ResponseEntity.status(401).body(Map.of("message", "Not authenticated"));
@@ -313,9 +325,7 @@ public ResponseEntity<?> getCurrentUser() {
313325
Role role = user.getRoleEnum();
314326
Set<String> permissions = permissionService.getEffectivePermissionCodes(role);
315327
Map<String, Object> response = toAuthPayload(user, role, permissions);
316-
response.put("emailVerified", user.isEmailVerified());
317-
response.put("accountStatus", user.getAccountStatus());
318-
response.put("emailTwoFactorEnabled", systemConfigService.getBoolean("security.workspace.email2fa.enabled"));
328+
impersonationService.decorateAuthPayload(httpRequest, user, response);
319329
return ResponseEntity.ok(response);
320330
}
321331

@@ -360,6 +370,7 @@ private ResponseEntity<?> authResponse(PasswordlessAuthService.AuthFlowResult re
360370
}
361371
if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
362372
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
373+
authSessionService.clearImpersonationCookie(httpResponse);
363374
Set<String> permissionNames = result.permissions() == null ? Set.of() : result.permissions().stream()
364375
.map(Enum::name)
365376
.collect(Collectors.toSet());
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.dbaagent.controller;
2+
3+
import com.dbaagent.model.Role;
4+
import com.dbaagent.model.User;
5+
import com.dbaagent.repository.UserRepository;
6+
import com.dbaagent.security.ImpersonationContext;
7+
import com.dbaagent.service.ImpersonationService;
8+
import com.dbaagent.service.PermissionService;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import jakarta.servlet.http.HttpServletResponse;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.access.prepost.PreAuthorize;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.core.context.SecurityContextHolder;
16+
import org.springframework.web.bind.annotation.DeleteMapping;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
import org.springframework.web.bind.annotation.PostMapping;
19+
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RestController;
22+
import org.springframework.web.server.ResponseStatusException;
23+
24+
import java.util.LinkedHashMap;
25+
import java.util.Map;
26+
import java.util.Set;
27+
28+
/**
29+
* Admin-only profile switch. These paths are excluded from the impersonation
30+
* overlay so the caller stays the real administrator while starting, listing,
31+
* or stopping a switch.
32+
*/
33+
@RestController
34+
@RequestMapping("/admin/impersonate")
35+
@PreAuthorize("hasRole('ADMIN')")
36+
@RequiredArgsConstructor
37+
public class ImpersonationController {
38+
39+
private final ImpersonationService impersonationService;
40+
private final UserRepository userRepository;
41+
private final PermissionService permissionService;
42+
43+
@GetMapping
44+
public ResponseEntity<Map<String, Object>> status(HttpServletRequest request) {
45+
User actor = currentAdmin();
46+
ImpersonationContext.State state = impersonationService.resolveFromCookie(request, actor).orElse(null);
47+
Map<String, Object> body = new LinkedHashMap<>();
48+
body.put("impersonating", state != null);
49+
body.put("impersonator", Map.of(
50+
"id", actor.getId(),
51+
"username", actor.getUsername(),
52+
"email", actor.getEmail()
53+
));
54+
body.put("target", state == null ? null : candidateView(state.target()));
55+
body.put("candidates", impersonationService.listCandidates(actor));
56+
return ResponseEntity.ok(body);
57+
}
58+
59+
@PostMapping
60+
public ResponseEntity<Map<String, Object>> start(
61+
@RequestBody Map<String, Object> requestBody,
62+
HttpServletRequest request,
63+
HttpServletResponse response
64+
) {
65+
User actor = currentAdmin();
66+
Long userId = readUserId(requestBody);
67+
ImpersonationContext.State state = impersonationService.start(actor, userId, request, response);
68+
return ResponseEntity.ok(toAuthPayload(state.target(), actor));
69+
}
70+
71+
@DeleteMapping
72+
public ResponseEntity<Map<String, Object>> stop(
73+
HttpServletRequest request,
74+
HttpServletResponse response
75+
) {
76+
User actor = currentAdmin();
77+
User restored = impersonationService.stop(actor, request, response);
78+
Map<String, Object> payload = toAuthPayload(restored, null);
79+
payload.put("impersonating", false);
80+
return ResponseEntity.ok(payload);
81+
}
82+
83+
private Map<String, Object> toAuthPayload(User user, User impersonator) {
84+
Role role = user.getRoleEnum();
85+
Set<String> permissions = permissionService.getEffectivePermissionCodes(role);
86+
Map<String, Object> payload = new LinkedHashMap<>();
87+
payload.put("username", user.getUsername());
88+
payload.put("email", user.getEmail());
89+
payload.put("role", role.name());
90+
payload.put("permissions", permissions);
91+
payload.put("emailVerified", user.isEmailVerified());
92+
payload.put("accountStatus", user.getAccountStatus());
93+
if (impersonator != null) {
94+
payload.put("impersonating", true);
95+
payload.put("impersonatorUsername", impersonator.getUsername());
96+
payload.put("impersonatorEmail", impersonator.getEmail());
97+
} else {
98+
payload.put("impersonating", false);
99+
}
100+
return payload;
101+
}
102+
103+
private Map<String, Object> candidateView(User user) {
104+
Map<String, Object> dto = new LinkedHashMap<>();
105+
dto.put("id", user.getId());
106+
dto.put("username", user.getUsername());
107+
dto.put("email", user.getEmail());
108+
dto.put("role", user.getRole());
109+
dto.put("accountStatus", user.getAccountStatus());
110+
return dto;
111+
}
112+
113+
private Long readUserId(Map<String, Object> requestBody) {
114+
if (requestBody == null || requestBody.get("userId") == null) {
115+
return null;
116+
}
117+
Object raw = requestBody.get("userId");
118+
if (raw instanceof Number number) {
119+
return number.longValue();
120+
}
121+
try {
122+
return Long.parseLong(String.valueOf(raw).trim());
123+
} catch (NumberFormatException e) {
124+
return null;
125+
}
126+
}
127+
128+
private User currentAdmin() {
129+
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
130+
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
131+
throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "Not authenticated");
132+
}
133+
return userRepository.findByUsername(auth.getName())
134+
.orElseThrow(() -> new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "User not found"));
135+
}
136+
}

0 commit comments

Comments
 (0)