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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ EMBEDDING_FAIL_OPEN=false
# AGENT_WEBUI_URL Agent HTTP API. Compose default: http://deepsql-agent:8787
# AGENT_PROVISIONER_URL Per-user profile provisioner. Compose default:
# http://deepsql-agent:8788/provision
# DEEPSQL_API_BASE_URL Where the agent container's MCP tools call the backend.
# Compose default: http://backend:8080/api/
# Native Java + Compose agent: http://host.docker.internal:8080/api/
# AGENT_PROVISION_SECRET Shared secret between backend and agent (required).
# DEEPSQL_AGENT_PORT / DEEPSQL_AGENT_PROVISIONER_PORT — host port mappings
# (compose binds these to 127.0.0.1 only; public path is nginx /agent-api).
Expand Down
18 changes: 15 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ only covers cloud-specific, non-obvious caveats.
`HERMES_WEBUI_ALLOWED_ORIGINS` (upstream env name).
- A demo target DB `demo_shop` (same Postgres server, sample `customers`/`products`/`orders`)
exists for exercising connection/schema features without an external database.
- A multi-schema fixture DB `acme_erp` (schemas: `crm`, `sales`, `finance`, `inventory`,
`hr`, `marts`) exists for chat-access-policy and multi-schema tests. Seed with:
`sudo -u postgres psql -f docker/postgres/init/11_create_acme_erp.sql` then
`bash scripts/seed-acme-erp.sh` (registers `ACME ERP (Multi-Schema)` when backend auth
is disabled or you have an admin session cookie).

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

Expand Down Expand Up @@ -259,9 +264,16 @@ only covers cloud-specific, non-obvious caveats.
non-`public` schemas (`crm`, `sales`, `finance`, `hr`, `inventory`) for Brain /
MCP cross-schema checks. Prefer schema-qualified SQL (`sales.orders`); bare
names follow the role’s `search_path` (usually `public`).
- **`AGENT_WEBUI_URL` for native runs.** Default is `http://deepsql-agent:8787`
(Compose DNS). Native local must set `AGENT_WEBUI_URL=http://127.0.0.1:8787` in
`.env` or CLI/Slack `AgentChatClient` cannot reach the agent API.
- **`AGENT_WEBUI_URL` / `AGENT_PROVISIONER_URL` for native runs.** Compose
defaults (`http://deepsql-agent:8787` and `…:8788/provision`) do not resolve
on the host. Native local must point both at loopback
(`http://127.0.0.1:8787` and `http://127.0.0.1:8788/provision`) or the Agent
tab returns 503 `Could not provision the DeepSQL Agent for this user`.
`scripts/start-backend.sh` remaps those hostnames automatically when they
don't resolve. If the agent container is used with a host-side Java backend,
set `DEEPSQL_API_BASE_URL=http://host.docker.internal:8080/api/` so MCP
tools can reach the native process (compose publishes `host.docker.internal`
via `extra_hosts`).
- **DeepSQL CLI (`deepsql`) for agent testing.** Install from the repo package:
`cd mcp && DEEPSQL_SKIP_AGENT_SETUP=1 npm install -g .` (prefix
`~/.npm-global`, keep that on `PATH`). Auth against local backend with an MCP
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ backend/
repository/ # Spring Data repositories
provider/ # Database dialect registry (PostgreSQL, MySQL)
config/ # Spring configuration
security/ # JWT auth, RBAC
security/ # JWT auth, RBAC, admin profile switch (`ImpersonationService`)
llm/ # LLM provider registry, config resolver, OpenAI-compatible provider
util/ # Shared utilities
src/test/ # JUnit 5 tests
Expand All @@ -110,7 +110,7 @@ src/ # Frontend (React)
components/ # UI components
tabs/ # 40+ specialized tabs
sections/ # Top-level sidebar destinations (Agent, Dashboards, Brain,
# Performance = Slow Queries + Workload, Editor, Docs)
# Performance = Slow Queries + Workload, Editor)
lib/
api/client.js # Centralized API layer (axios, 25+ modules)
stores/ # Zustand stores (dashboard, connection, chat, UI)
Expand Down Expand Up @@ -209,6 +209,9 @@ returns a number).
4. **Tooltips**: Always use `HelpTooltip` component, never plain `title` attributes.
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`.

### Git Rules
- Do NOT commit automatically — wait for explicit user instruction.
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.dbaagent.service.AuthSessionService;
import com.dbaagent.service.PasswordlessAuthService;
import com.dbaagent.service.PermissionService;
import com.dbaagent.service.ImpersonationService;
import com.dbaagent.security.ImpersonationContext;
import com.dbaagent.service.SystemConfigService;
import com.dbaagent.service.UserInviteService;
import jakarta.servlet.http.Cookie;
Expand Down Expand Up @@ -47,6 +49,7 @@ public class AuthController {
private final PrivateBetaRequestRepository privateBetaRequestRepository;
private final SystemConfigService systemConfigService;
private final AgentBridgeService agentBridgeService;
private final ImpersonationService impersonationService;

@Value("${security.cookie.refresh-name:refresh_token}")
private String refreshCookieName;
Expand Down Expand Up @@ -201,12 +204,21 @@ public ResponseEntity<?> refreshSession(HttpServletRequest httpRequest, HttpServ
return ResponseEntity.status(401).body(Map.of("message", "Session expired"));
}
authSessionService.writeSessionCookies(httpResponse, refreshed.get());
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.
agentBridgeService.extendAgentTokens(user.getUsername());
return ResponseEntity.ok(toAuthPayload(user, user.getRoleEnum(), permissionService.getEffectivePermissionCodes(user.getRoleEnum())));
Map<String, Object> payload = toAuthPayload(
effectiveUser,
effectiveUser.getRoleEnum(),
permissionService.getEffectivePermissionCodes(effectiveUser.getRoleEnum())
);
impersonationService.decorateAuthPayload(httpRequest, user, payload);
return ResponseEntity.ok(payload);
}

@PostMapping("/logout")
Expand Down Expand Up @@ -304,7 +316,7 @@ public ResponseEntity<?> acceptInvite(
}

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

Expand Down Expand Up @@ -360,6 +370,7 @@ private ResponseEntity<?> authResponse(PasswordlessAuthService.AuthFlowResult re
}
if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
authSessionService.clearImpersonationCookie(httpResponse);
Set<String> permissionNames = result.permissions() == null ? Set.of() : result.permissions().stream()
.map(Enum::name)
.collect(Collectors.toSet());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.dbaagent.controller;

import com.dbaagent.model.Role;
import com.dbaagent.model.User;
import com.dbaagent.repository.UserRepository;
import com.dbaagent.security.ImpersonationContext;
import com.dbaagent.service.ImpersonationService;
import com.dbaagent.service.PermissionService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

/**
* Admin-only profile switch. These paths are excluded from the impersonation
* overlay so the caller stays the real administrator while starting, listing,
* or stopping a switch.
*/
@RestController
@RequestMapping("/admin/impersonate")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
public class ImpersonationController {

private final ImpersonationService impersonationService;
private final UserRepository userRepository;
private final PermissionService permissionService;

@GetMapping
public ResponseEntity<Map<String, Object>> status(HttpServletRequest request) {
User actor = currentAdmin();
ImpersonationContext.State state = impersonationService.resolveFromCookie(request, actor).orElse(null);
Map<String, Object> body = new LinkedHashMap<>();
body.put("impersonating", state != null);
body.put("impersonator", Map.of(
"id", actor.getId(),
"username", actor.getUsername(),
"email", actor.getEmail()
));
body.put("target", state == null ? null : candidateView(state.target()));
body.put("candidates", impersonationService.listCandidates(actor));
return ResponseEntity.ok(body);
}

@PostMapping
public ResponseEntity<Map<String, Object>> start(
@RequestBody Map<String, Object> requestBody,
HttpServletRequest request,
HttpServletResponse response
) {
User actor = currentAdmin();
Long userId = readUserId(requestBody);
ImpersonationContext.State state = impersonationService.start(actor, userId, request, response);
return ResponseEntity.ok(toAuthPayload(state.target(), actor));
}

@DeleteMapping
public ResponseEntity<Map<String, Object>> stop(
HttpServletRequest request,
HttpServletResponse response
) {
User actor = currentAdmin();
User restored = impersonationService.stop(actor, request, response);
Map<String, Object> payload = toAuthPayload(restored, null);
payload.put("impersonating", false);
return ResponseEntity.ok(payload);
}

private Map<String, Object> toAuthPayload(User user, User impersonator) {
Role role = user.getRoleEnum();
Set<String> permissions = permissionService.getEffectivePermissionCodes(role);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("username", user.getUsername());
payload.put("email", user.getEmail());
payload.put("role", role.name());
payload.put("permissions", permissions);
payload.put("emailVerified", user.isEmailVerified());
payload.put("accountStatus", user.getAccountStatus());
if (impersonator != null) {
payload.put("impersonating", true);
payload.put("impersonatorUsername", impersonator.getUsername());
payload.put("impersonatorEmail", impersonator.getEmail());
} else {
payload.put("impersonating", false);
}
return payload;
}

private Map<String, Object> candidateView(User user) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("id", user.getId());
dto.put("username", user.getUsername());
dto.put("email", user.getEmail());
dto.put("role", user.getRole());
dto.put("accountStatus", user.getAccountStatus());
return dto;
}

private Long readUserId(Map<String, Object> requestBody) {
if (requestBody == null || requestBody.get("userId") == null) {
return null;
}
Object raw = requestBody.get("userId");
if (raw instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(String.valueOf(raw).trim());
} catch (NumberFormatException e) {
return null;
}
}

private User currentAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "Not authenticated");
}
return userRepository.findByUsername(auth.getName())
.orElseThrow(() -> new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "User not found"));
}
}
Loading
Loading