diff --git a/.env.example b/.env.example index be8ea9e..04e74d8 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/AGENTS.md b/AGENTS.md index 323ebf3..cff7764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,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 diff --git a/CLAUDE.md b/CLAUDE.md index 652ae89..71ad87a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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) @@ -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:` diff --git a/backend/src/main/java/com/dbaagent/controller/AuthController.java b/backend/src/main/java/com/dbaagent/controller/AuthController.java index 84a5b4b..e5d3cf7 100644 --- a/backend/src/main/java/com/dbaagent/controller/AuthController.java +++ b/backend/src/main/java/com/dbaagent/controller/AuthController.java @@ -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; @@ -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; @@ -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 payload = toAuthPayload( + effectiveUser, + effectiveUser.getRoleEnum(), + permissionService.getEffectivePermissionCodes(effectiveUser.getRoleEnum()) + ); + impersonationService.decorateAuthPayload(httpRequest, user, payload); + return ResponseEntity.ok(payload); } @PostMapping("/logout") @@ -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")); @@ -313,9 +325,7 @@ public ResponseEntity getCurrentUser() { Role role = user.getRoleEnum(); Set permissions = permissionService.getEffectivePermissionCodes(role); Map 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); } @@ -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 permissionNames = result.permissions() == null ? Set.of() : result.permissions().stream() .map(Enum::name) .collect(Collectors.toSet()); diff --git a/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java b/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java new file mode 100644 index 0000000..8810972 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java @@ -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> status(HttpServletRequest request) { + User actor = currentAdmin(); + ImpersonationContext.State state = impersonationService.resolveFromCookie(request, actor).orElse(null); + Map 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> start( + @RequestBody Map 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> stop( + HttpServletRequest request, + HttpServletResponse response + ) { + User actor = currentAdmin(); + User restored = impersonationService.stop(actor, request, response); + Map payload = toAuthPayload(restored, null); + payload.put("impersonating", false); + return ResponseEntity.ok(payload); + } + + private Map toAuthPayload(User user, User impersonator) { + Role role = user.getRoleEnum(); + Set permissions = permissionService.getEffectivePermissionCodes(role); + Map 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 candidateView(User user) { + Map 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 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")); + } +} diff --git a/backend/src/main/java/com/dbaagent/model/SecurityEventType.java b/backend/src/main/java/com/dbaagent/model/SecurityEventType.java index 8ad7962..7559ee2 100644 --- a/backend/src/main/java/com/dbaagent/model/SecurityEventType.java +++ b/backend/src/main/java/com/dbaagent/model/SecurityEventType.java @@ -25,6 +25,8 @@ public enum SecurityEventType { SESSION_REFRESHED, SESSION_REVOKED, SESSION_EXPIRED, + IMPERSONATION_STARTED, + IMPERSONATION_STOPPED, LOGOUT, LOGOUT_ALL, ACCOUNT_LOCKED, diff --git a/backend/src/main/java/com/dbaagent/security/ImpersonationContext.java b/backend/src/main/java/com/dbaagent/security/ImpersonationContext.java new file mode 100644 index 0000000..240354f --- /dev/null +++ b/backend/src/main/java/com/dbaagent/security/ImpersonationContext.java @@ -0,0 +1,50 @@ +package com.dbaagent.security; + +import com.dbaagent.model.User; + +import java.util.Optional; + +/** + * Request-scoped impersonation overlay. The admin JWT stays on the session; + * {@link JwtAuthenticationFilter} swaps the SecurityContext principal to the + * target user and records both identities here so {@code /auth/me} can show a + * banner and {@code AccessControlService} can honour the target even when + * {@code security.auth.enabled} is false. + */ +public final class ImpersonationContext { + + public record State(User impersonator, User target) { + public String impersonatorUsername() { + return impersonator != null ? impersonator.getUsername() : null; + } + + public String impersonatorEmail() { + return impersonator != null ? impersonator.getEmail() : null; + } + + public String targetUsername() { + return target != null ? target.getUsername() : null; + } + } + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private ImpersonationContext() { + } + + public static void enter(State state) { + CURRENT.set(state); + } + + public static void clear() { + CURRENT.remove(); + } + + public static Optional current() { + return Optional.ofNullable(CURRENT.get()); + } + + public static boolean isActive() { + return CURRENT.get() != null; + } +} diff --git a/backend/src/main/java/com/dbaagent/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/dbaagent/security/JwtAuthenticationFilter.java index ddecb90..9262c33 100644 --- a/backend/src/main/java/com/dbaagent/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/dbaagent/security/JwtAuthenticationFilter.java @@ -16,6 +16,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import com.dbaagent.service.AuthSessionService; +import com.dbaagent.service.ImpersonationService; import java.io.IOException; import java.util.ArrayList; @@ -40,6 +41,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private AuthSessionService authSessionService; + @Autowired + private ImpersonationService impersonationService; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { @@ -69,7 +73,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( "admin", null, devAuthorities); SecurityContextHolder.getContext().setAuthentication(auth); - chain.doFilter(request, response); + applyImpersonation(request, response, chain); return; } @@ -132,7 +136,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } else if (isBrainRequest) { log.warn("Brain auth missing user: path={}, username={}", requestPath, username); } - chain.doFilter(request, response); + applyImpersonation(request, response, chain); + } + + private void applyImpersonation(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + try { + impersonationService.applyToRequest(request); + chain.doFilter(request, response); + } finally { + ImpersonationContext.clear(); + } } private String extractUsernameSafely(String token) { diff --git a/backend/src/main/java/com/dbaagent/service/AuthSessionService.java b/backend/src/main/java/com/dbaagent/service/AuthSessionService.java index 5f85e30..1e40548 100644 --- a/backend/src/main/java/com/dbaagent/service/AuthSessionService.java +++ b/backend/src/main/java/com/dbaagent/service/AuthSessionService.java @@ -38,6 +38,9 @@ public class AuthSessionService { @Value("${security.cookie.refresh-name:refresh_token}") private String refreshCookieName; + @Value("${security.cookie.impersonate-name:impersonate_user}") + private String impersonateCookieName; + @Value("${security.cookie.secure:false}") private boolean cookieSecure; @@ -152,9 +155,29 @@ public void writeSessionCookies(HttpServletResponse response, SessionAuthenticat response.addHeader(HttpHeaders.SET_COOKIE, buildRefreshCookie(sessionAuthentication.refreshToken()).toString()); } + public void writeImpersonationCookie(HttpServletResponse response, String cookieName, long targetUserId) { + response.addHeader(HttpHeaders.SET_COOKIE, ResponseCookie.from(cookieName, Long.toString(targetUserId)) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .path("/") + .maxAge(Duration.ofDays(refreshDays)) + .build() + .toString()); + } + + public void clearImpersonationCookie(HttpServletResponse response, String cookieName) { + response.addHeader(HttpHeaders.SET_COOKIE, clearCookie(cookieName).toString()); + } + + public void clearImpersonationCookie(HttpServletResponse response) { + clearImpersonationCookie(response, impersonateCookieName); + } + public void clearSessionCookies(HttpServletResponse response) { response.addHeader(HttpHeaders.SET_COOKIE, clearCookie(accessCookieName).toString()); response.addHeader(HttpHeaders.SET_COOKIE, clearCookie(refreshCookieName).toString()); + response.addHeader(HttpHeaders.SET_COOKIE, clearCookie(impersonateCookieName).toString()); } private SessionAuthentication rotateSession( diff --git a/backend/src/main/java/com/dbaagent/service/ImpersonationService.java b/backend/src/main/java/com/dbaagent/service/ImpersonationService.java new file mode 100644 index 0000000..2d9750a --- /dev/null +++ b/backend/src/main/java/com/dbaagent/service/ImpersonationService.java @@ -0,0 +1,327 @@ +package com.dbaagent.service; + +import com.dbaagent.model.SecurityEventOutcome; +import com.dbaagent.model.SecurityEventType; +import com.dbaagent.model.User; +import com.dbaagent.model.UserAccountStatus; +import com.dbaagent.repository.UserRepository; +import com.dbaagent.security.CustomUserDetailsService; +import com.dbaagent.security.ImpersonationContext; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +/** + * Admin-only profile switch. The admin session (JWT cookies) is unchanged; + * a separate httpOnly cookie names the user to evaluate as. The JWT filter + * overlays that principal onto the SecurityContext for every request except + * the impersonation control plane, logout, and session refresh. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ImpersonationService { + + static final String DEFAULT_COOKIE_NAME = "impersonate_user"; + + private final UserRepository userRepository; + private final CustomUserDetailsService userDetailsService; + private final AuthSessionService authSessionService; + private final SecurityEventService securityEventService; + + @Value("${security.auth.enabled:true}") + private boolean authEnabled; + + @Value("${security.cookie.impersonate-name:" + DEFAULT_COOKIE_NAME + "}") + private String impersonateCookieName; + + public ImpersonationContext.State start( + User actor, + Long targetUserId, + HttpServletRequest request, + HttpServletResponse response + ) { + requireAdminActor(actor); + User target = requireAllowedTarget(actor, targetUserId); + authSessionService.writeImpersonationCookie(response, impersonateCookieName, target.getId()); + securityEventService.log(SecurityEventService.EventRequest.builder() + .eventType(SecurityEventType.IMPERSONATION_STARTED) + .outcome(SecurityEventOutcome.SUCCESS) + .userId(target.getId()) + .actorUserId(actor.getId()) + .email(actor.getEmail()) + .targetResource("user:" + target.getId()) + .clientIp(clientIp(request)) + .userAgent(userAgent(request)) + .metadata(Map.of( + "impersonatorUsername", actor.getUsername(), + "targetUsername", target.getUsername() + )) + .build()); + log.info("Admin {} started profile switch to {}", actor.getUsername(), target.getUsername()); + return new ImpersonationContext.State(actor, target); + } + + public User stop( + User actor, + HttpServletRequest request, + HttpServletResponse response + ) { + requireAdminActor(actor); + Optional target = readTargetUser(request); + authSessionService.clearImpersonationCookie(response, impersonateCookieName); + ImpersonationContext.clear(); + target.ifPresent(stopped -> securityEventService.log(SecurityEventService.EventRequest.builder() + .eventType(SecurityEventType.IMPERSONATION_STOPPED) + .outcome(SecurityEventOutcome.SUCCESS) + .userId(stopped.getId()) + .actorUserId(actor.getId()) + .email(actor.getEmail()) + .targetResource("user:" + stopped.getId()) + .clientIp(clientIp(request)) + .userAgent(userAgent(request)) + .metadata(Map.of( + "impersonatorUsername", actor.getUsername(), + "targetUsername", stopped.getUsername() + )) + .build())); + log.info("Admin {} stopped profile switch", actor.getUsername()); + return actor; + } + + public List> listCandidates(User actor) { + requireAdminActor(actor); + return userRepository.findAll().stream() + .filter(user -> isAllowedTarget(actor, user)) + .map(this::toCandidate) + .toList(); + } + + public Optional resolveFromCookie(HttpServletRequest request, User sessionUser) { + if (sessionUser == null || !sessionUser.isAdmin()) { + return Optional.empty(); + } + return readTargetUser(request) + .filter(target -> isAllowedTarget(sessionUser, target)) + .map(target -> new ImpersonationContext.State(sessionUser, target)); + } + + public void decorateAuthPayload(HttpServletRequest request, User sessionUser, Map payload) { + Optional state = ImpersonationContext.current(); + if (state.isEmpty()) { + state = resolveFromCookie(request, sessionUser); + } + if (state.isEmpty()) { + payload.put("impersonating", false); + return; + } + payload.put("impersonating", true); + payload.put("impersonatorUsername", state.get().impersonatorUsername()); + payload.put("impersonatorEmail", state.get().impersonatorEmail()); + } + + /** + * Overlay the target principal when the admin JWT (or the auth-disabled + * synthetic admin) is already in the SecurityContext. No-ops on the + * impersonation control-plane, logout/refresh, MCP tokens, and invalid cookies. + */ + public void applyToRequest(HttpServletRequest request) { + if (!shouldApply(request)) { + return; + } + Authentication current = SecurityContextHolder.getContext().getAuthentication(); + if (current == null || !current.isAuthenticated() || "anonymousUser".equals(current.getPrincipal())) { + return; + } + if (authEnabled && !hasAdminRole(current)) { + return; + } + User impersonator = userRepository.findByUsername(current.getName()) + .orElseGet(() -> syntheticAdmin(current.getName())); + if (!impersonator.isAdmin() && authEnabled) { + return; + } + Optional target = readTargetUser(request); + if (target.isEmpty() || !isAllowedTarget(impersonator, target.get())) { + return; + } + UserDetails details; + try { + details = userDetailsService.loadUserByUsername(target.get().getUsername()); + } catch (UsernameNotFoundException e) { + return; + } + UsernamePasswordAuthenticationToken swapped = new UsernamePasswordAuthenticationToken( + details, + null, + details.getAuthorities() + ); + swapped.setDetails(current.getDetails()); + SecurityContextHolder.getContext().setAuthentication(swapped); + ImpersonationContext.enter(new ImpersonationContext.State(impersonator, target.get())); + log.debug("Applied profile switch: {} -> {}", impersonator.getUsername(), target.get().getUsername()); + } + + boolean shouldApply(HttpServletRequest request) { + String path = request.getServletPath() != null ? request.getServletPath() : ""; + String uri = request.getRequestURI() != null ? request.getRequestURI() : ""; + if (isControlPlane(path) || isControlPlane(uri)) { + return false; + } + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorization != null && authorization.startsWith("Bearer ")) { + String token = authorization.substring(7); + if (token.startsWith(McpTokenService.TOKEN_PREFIX)) { + return false; + } + } + return true; + } + + private boolean isControlPlane(String path) { + if (path == null || path.isBlank()) { + return false; + } + return path.contains("/admin/impersonate") + || path.endsWith("/auth/logout") + || path.endsWith("/auth/logout-all") + || path.endsWith("/auth/refresh"); + } + + private Optional readTargetUser(HttpServletRequest request) { + Long userId = readTargetUserId(request); + if (userId == null) { + return Optional.empty(); + } + return userRepository.findById(userId); + } + + private Long readTargetUserId(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (impersonateCookieName.equals(cookie.getName())) { + return parseUserId(cookie.getValue()); + } + } + return null; + } + + private Long parseUserId(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + long parsed = Long.parseLong(value.trim()); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException e) { + return null; + } + } + + private void requireAdminActor(User actor) { + if (actor == null || !actor.isAdmin()) { + throw new ResponseStatusException(FORBIDDEN, "Only administrators can switch profiles"); + } + } + + private User requireAllowedTarget(User actor, Long targetUserId) { + if (targetUserId == null) { + throw new ResponseStatusException(BAD_REQUEST, "userId is required"); + } + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + if (!isAllowedTarget(actor, target)) { + throw new ResponseStatusException(BAD_REQUEST, denialReason(actor, target)); + } + return target; + } + + boolean isAllowedTarget(User actor, User target) { + if (actor == null || target == null || target.getId() == null) { + return false; + } + if (actor.getId() != null && actor.getId().equals(target.getId())) { + return false; + } + if (target.isAdmin()) { + return false; + } + return target.getAccountStatusEnum() == UserAccountStatus.ACTIVE; + } + + private String denialReason(User actor, User target) { + if (actor.getId() != null && actor.getId().equals(target.getId())) { + return "Cannot switch into your own profile"; + } + if (target.isAdmin()) { + return "Cannot switch into another administrator profile"; + } + if (target.getAccountStatusEnum() != UserAccountStatus.ACTIVE) { + return "Cannot switch into a locked or disabled account"; + } + return "Cannot switch into this profile"; + } + + private Map toCandidate(User user) { + Map 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 boolean hasAdminRole(Authentication authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + } + + private User syntheticAdmin(String username) { + User admin = new User(); + admin.setUsername(username != null && !username.isBlank() ? username : "admin"); + admin.setRole("ADMIN"); + admin.setAccountStatus(UserAccountStatus.ACTIVE.name()); + return admin; + } + + private String clientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private String userAgent(HttpServletRequest request) { + return request == null ? null : request.getHeader("User-Agent"); + } +} diff --git a/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java b/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java index ecefb6e..4a32265 100644 --- a/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java +++ b/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java @@ -7,6 +7,7 @@ import com.dbaagent.repository.AnalysisHistoryRepository; import com.dbaagent.repository.ChatFeedbackRepository; import com.dbaagent.repository.ChatRepository; +import com.dbaagent.security.ImpersonationContext; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; @@ -68,7 +69,7 @@ public void assertCanManageConnectionConfig(String connectionId) { } public ConnectionAccessService.ResolvedConnectionAccess resolveCurrentUserAccess(String connectionId) { - if (!authEnabled) { + if (!authEnabled && !ImpersonationContext.isActive()) { try { return connectionAccessService.resolveAccess(connectionId, null, true); } catch (RuntimeException e) { @@ -181,6 +182,11 @@ public String requireCurrentUsername() { } public boolean isCurrentUserAdmin() { + if (ImpersonationContext.isActive()) { + return ImpersonationContext.current() + .map(state -> state.target() != null && state.target().isAdmin()) + .orElse(false); + } if (!authEnabled) { return true; } @@ -199,7 +205,7 @@ private Chat findAccessibleChat(String chatId) { } private Optional findAccessibleChatIfPresent(String chatId) { - if (!authEnabled) { + if (!authEnabled && !ImpersonationContext.isActive()) { return chatRepository.findById(chatId); } String username = requireCurrentUsername(); diff --git a/backend/src/test/java/com/dbaagent/service/ImpersonationServiceTest.java b/backend/src/test/java/com/dbaagent/service/ImpersonationServiceTest.java new file mode 100644 index 0000000..029ea4b --- /dev/null +++ b/backend/src/test/java/com/dbaagent/service/ImpersonationServiceTest.java @@ -0,0 +1,281 @@ +package com.dbaagent.service; + +import com.dbaagent.model.SecurityEventOutcome; +import com.dbaagent.model.SecurityEventType; +import com.dbaagent.model.User; +import com.dbaagent.model.UserAccountStatus; +import com.dbaagent.repository.UserRepository; +import com.dbaagent.security.CustomUserDetailsService; +import com.dbaagent.security.ImpersonationContext; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ImpersonationServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private CustomUserDetailsService userDetailsService; + + @Mock + private AuthSessionService authSessionService; + + @Mock + private SecurityEventService securityEventService; + + @InjectMocks + private ImpersonationService impersonationService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(impersonationService, "authEnabled", true); + ReflectionTestUtils.setField(impersonationService, "impersonateCookieName", "impersonate_user"); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + ImpersonationContext.clear(); + } + + @Test + void startWritesCookieAndAudits() { + User admin = user(1L, "admin", "ADMIN"); + User editor = user(2L, "marts-editor", "DEVELOPER"); + when(userRepository.findById(2L)).thenReturn(Optional.of(editor)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + ImpersonationContext.State state = impersonationService.start(admin, 2L, request, response); + + assertEquals("marts-editor", state.targetUsername()); + verify(authSessionService).writeImpersonationCookie(response, "impersonate_user", 2L); + ArgumentCaptor captor = + ArgumentCaptor.forClass(SecurityEventService.EventRequest.class); + verify(securityEventService).log(captor.capture()); + assertEquals(SecurityEventType.IMPERSONATION_STARTED, captor.getValue().eventType()); + assertEquals(SecurityEventOutcome.SUCCESS, captor.getValue().outcome()); + assertEquals(1L, captor.getValue().actorUserId()); + assertEquals(2L, captor.getValue().userId()); + } + + @Test + void startRejectsSelf() { + User admin = user(1L, "admin", "ADMIN"); + when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> impersonationService.start(admin, 1L, new MockHttpServletRequest(), new MockHttpServletResponse())); + assertEquals(400, ex.getStatusCode().value()); + verify(authSessionService, never()).writeImpersonationCookie(any(), any(), eq(1L)); + } + + @Test + void startRejectsAnotherAdmin() { + User admin = user(1L, "admin", "ADMIN"); + User otherAdmin = user(3L, "ops-admin", "ADMIN"); + when(userRepository.findById(3L)).thenReturn(Optional.of(otherAdmin)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> impersonationService.start(admin, 3L, new MockHttpServletRequest(), new MockHttpServletResponse())); + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void startRejectsLockedUser() { + User admin = user(1L, "admin", "ADMIN"); + User locked = user(4L, "locked-editor", "DEVELOPER"); + locked.setAccountStatus(UserAccountStatus.LOCKED.name()); + when(userRepository.findById(4L)).thenReturn(Optional.of(locked)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> impersonationService.start(admin, 4L, new MockHttpServletRequest(), new MockHttpServletResponse())); + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void startRejectsNonAdminActor() { + User editor = user(2L, "marts-editor", "DEVELOPER"); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> impersonationService.start(editor, 5L, new MockHttpServletRequest(), new MockHttpServletResponse())); + assertEquals(403, ex.getStatusCode().value()); + } + + @Test + void applySwapsPrincipalToTargetUser() { + User admin = user(1L, "admin", "ADMIN"); + User editor = user(2L, "marts-editor", "DEVELOPER"); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin)); + when(userRepository.findById(2L)).thenReturn(Optional.of(editor)); + UserDetails editorDetails = new org.springframework.security.core.userdetails.User( + "marts-editor", + "x", + List.of(new SimpleGrantedAuthority("ROLE_DEVELOPER"), new SimpleGrantedAuthority("USE_CHAT")) + ); + when(userDetailsService.loadUserByUsername("marts-editor")).thenReturn(editorDetails); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + "admin", + null, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/schema/objects"); + request.setServletPath("/schema/objects"); + request.setCookies(new Cookie("impersonate_user", "2")); + + impersonationService.applyToRequest(request); + + assertEquals("marts-editor", SecurityContextHolder.getContext().getAuthentication().getName()); + assertTrue(ImpersonationContext.isActive()); + assertEquals("admin", ImpersonationContext.current().orElseThrow().impersonatorUsername()); + } + + @Test + void applySkipsImpersonationControlPlane() { + authenticateAdmin(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/admin/impersonate"); + request.setServletPath("/admin/impersonate"); + request.setCookies(new Cookie("impersonate_user", "2")); + + impersonationService.applyToRequest(request); + + assertEquals("admin", SecurityContextHolder.getContext().getAuthentication().getName()); + assertFalse(ImpersonationContext.isActive()); + verify(userRepository, never()).findById(2L); + } + + @Test + void applySkipsLogoutAndRefresh() { + authenticateAdmin(); + MockHttpServletRequest logout = new MockHttpServletRequest("POST", "/api/auth/logout"); + logout.setServletPath("/auth/logout"); + logout.setCookies(new Cookie("impersonate_user", "2")); + impersonationService.applyToRequest(logout); + assertEquals("admin", SecurityContextHolder.getContext().getAuthentication().getName()); + + MockHttpServletRequest refresh = new MockHttpServletRequest("POST", "/api/auth/refresh"); + refresh.setServletPath("/auth/refresh"); + refresh.setCookies(new Cookie("impersonate_user", "2")); + impersonationService.applyToRequest(refresh); + assertEquals("admin", SecurityContextHolder.getContext().getAuthentication().getName()); + verify(userDetailsService, never()).loadUserByUsername(any()); + } + + @Test + void applySkipsMcpBearerTokens() { + authenticateAdmin(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/connections"); + request.setServletPath("/connections"); + request.addHeader("Authorization", "Bearer dsql_mcp_abc.secret"); + request.setCookies(new Cookie("impersonate_user", "2")); + + impersonationService.applyToRequest(request); + + assertEquals("admin", SecurityContextHolder.getContext().getAuthentication().getName()); + assertFalse(ImpersonationContext.isActive()); + } + + @Test + void applyDoesNotSwapForNonAdminSession() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + "marts-editor", + null, + List.of(new SimpleGrantedAuthority("ROLE_DEVELOPER")) + ) + ); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/schema/objects"); + request.setServletPath("/schema/objects"); + request.setCookies(new Cookie("impersonate_user", "9")); + + impersonationService.applyToRequest(request); + + assertEquals("marts-editor", SecurityContextHolder.getContext().getAuthentication().getName()); + verify(userRepository, never()).findById(9L); + } + + @Test + void decorateAuthPayloadUsesActiveContext() { + User admin = user(1L, "admin", "ADMIN"); + User editor = user(2L, "marts-editor", "DEVELOPER"); + ImpersonationContext.enter(new ImpersonationContext.State(admin, editor)); + + Map payload = new java.util.LinkedHashMap<>(); + payload.put("username", "marts-editor"); + impersonationService.decorateAuthPayload(new MockHttpServletRequest(), editor, payload); + + assertEquals(Boolean.TRUE, payload.get("impersonating")); + assertEquals("admin", payload.get("impersonatorUsername")); + } + + @Test + void listCandidatesExcludesAdminsSelfAndInactive() { + User admin = user(1L, "admin", "ADMIN"); + User editor = user(2L, "marts-editor", "DEVELOPER"); + User otherAdmin = user(3L, "ops", "ADMIN"); + User locked = user(4L, "locked", "DEVELOPER"); + locked.setAccountStatus(UserAccountStatus.LOCKED.name()); + when(userRepository.findAll()).thenReturn(List.of(admin, editor, otherAdmin, locked)); + + List> candidates = impersonationService.listCandidates(admin); + + assertEquals(1, candidates.size()); + assertEquals("marts-editor", candidates.get(0).get("username")); + } + + private void authenticateAdmin() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + "admin", + null, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ); + } + + private static User user(Long id, String username, String role) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(username + "@demo.local"); + user.setRole(role); + user.setAccountStatus(UserAccountStatus.ACTIVE.name()); + user.setPassword("hashed"); + return user; + } +} diff --git a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java index 4673cbb..1590e3a 100644 --- a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java @@ -53,6 +53,7 @@ void setUp() { @AfterEach void tearDown() { SecurityContextHolder.clearContext(); + com.dbaagent.security.ImpersonationContext.clear(); } @Test @@ -97,6 +98,39 @@ void adminCanAccessAnyConnection() { assertDoesNotThrow(() -> accessControlService.assertCanAccessConnection("conn-1")); } + /** + * Profile switch has to punch through the auth-disabled admin bypass. + * Otherwise an admin "viewing as" an editor still sees every connection. + */ + @Test + void impersonationDisablesAdminBypassWhileAuthIsOff() { + ReflectionTestUtils.setField(accessControlService, "authEnabled", false); + + com.dbaagent.model.User impersonator = new com.dbaagent.model.User(); + impersonator.setId(1L); + impersonator.setUsername("admin"); + impersonator.setRole("ADMIN"); + com.dbaagent.model.User target = new com.dbaagent.model.User(); + target.setId(2L); + target.setUsername("marts-editor"); + target.setRole("DEVELOPER"); + com.dbaagent.security.ImpersonationContext.enter( + new com.dbaagent.security.ImpersonationContext.State(impersonator, target) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("marts-editor", null, List.of()) + ); + + when(connectionAccessService.resolveAccess("conn-1", "marts-editor", false)) + .thenReturn(resolved("conn-1", EffectiveConnectionAccess.CHAT_EDITOR, ConnectionOwnershipType.ASSIGNED)); + + assertFalse(accessControlService.isCurrentUserAdmin()); + assertEquals("marts-editor", accessControlService.requireCurrentUsername()); + assertDoesNotThrow(() -> accessControlService.assertCanUseChatEditor("conn-1")); + verify(connectionAccessService).resolveAccess("conn-1", "marts-editor", false); + verify(connectionAccessService, never()).resolveAccess(eq("conn-1"), eq(null), eq(true)); + } + /** * The dev-mode bypass has to be coherent. Every other check here honours * security.auth.enabled, so this one throwing 403 meant turning auth off turned chat diff --git a/docker-compose.yml b/docker-compose.yml index e64c3a4..c2bb83f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -140,8 +140,10 @@ services: DEEPSQL_CHAT_API_KEY: ${DEEPSQL_CHAT_API_KEY:-} DEEPSQL_CHAT_ENDPOINT: ${DEEPSQL_CHAT_ENDPOINT:-} DEEPSQL_CHAT_MODEL: ${DEEPSQL_CHAT_MODEL:-gpt-5.4} - # Reach the backend over the compose network (MCP tools + provisioner) - DEEPSQL_API_BASE_URL: http://backend:8080/api/ + # Reach the backend over the compose network (MCP tools + provisioner). + # Override to http://host.docker.internal:8080/api/ when the Java backend + # runs on the host (native `mvn spring-boot:run`) instead of Compose. + DEEPSQL_API_BASE_URL: ${DEEPSQL_API_BASE_URL:-http://backend:8080/api/} # Shared secret with backend AgentBridgeService AGENT_PROVISION_SECRET: ${AGENT_PROVISION_SECRET:-} # Origins allowed by the agent API CSRF check @@ -153,6 +155,8 @@ services: DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS: ${DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} HERMES_WEBUI_TRUSTED_PROXY_CIDRS: ${DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} HERMES_WEBUI_TRUSTED_AUTH_HEADER: X-Remote-User + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "127.0.0.1:${DEEPSQL_AGENT_PORT:-8787}:8787" - "127.0.0.1:${DEEPSQL_AGENT_PROVISIONER_PORT:-8788}:8788" diff --git a/docs/root/CLAUDE.md b/docs/root/CLAUDE.md index 9c21e97..fcc4c2f 100644 --- a/docs/root/CLAUDE.md +++ b/docs/root/CLAUDE.md @@ -821,7 +821,10 @@ though the properties themselves still sit in `application*.properties`. - `PUT /api/admin/users/{id}/role` - Update user role (ADMIN only) - `DELETE /api/admin/users/{id}` - Delete user (ADMIN only) - `GET /api/admin/roles` - Get all roles with permissions (ADMIN only) - - `GET /api/auth/me` - Get current user's profile including role/permissions + - `GET /api/admin/impersonate` - List switchable users and current profile-switch status (ADMIN only) + - `POST /api/admin/impersonate` - `{ userId }` start viewing the product as that user (ADMIN only; cannot target admins or self) + - `DELETE /api/admin/impersonate` - Stop profile switch and restore the admin session + - `GET /api/auth/me` - Get current user's profile including role/permissions; while switching, this is the **target** user plus `impersonating` / `impersonatorUsername` - **Frontend Components**: - `PermissionGuard.jsx` - Wrapper component for permission-based rendering - `UsersTab.jsx` - Admin user management tab in Workspace diff --git a/scripts/remap-compose-hosts-for-native.sh b/scripts/remap-compose-hosts-for-native.sh new file mode 100644 index 0000000..1d551ba --- /dev/null +++ b/scripts/remap-compose-hosts-for-native.sh @@ -0,0 +1,22 @@ +# Sourced by scripts/start-backend.sh (outer shell and the inner bash -lc). +# Compose service hostnames only resolve on the compose network. Native +# `mvn spring-boot:run` still sources a Compose-oriented .env, so rewrite +# those hosts to loopback when they don't resolve. +if ! getent hosts postgres >/dev/null 2>&1; then + if [ -n "${DB_URL:-}" ]; then + export DB_URL="${DB_URL//:\/\/postgres:/:\/\/127.0.0.1:}" + fi +fi +if ! getent hosts valkey >/dev/null 2>&1; then + case "${SPRING_DATA_REDIS_HOST:-}" in + valkey|"") export SPRING_DATA_REDIS_HOST=127.0.0.1 ;; + esac +fi +if ! getent hosts deepsql-agent >/dev/null 2>&1; then + if [ -n "${AGENT_WEBUI_URL:-}" ]; then + export AGENT_WEBUI_URL="${AGENT_WEBUI_URL//deepsql-agent/127.0.0.1}" + fi + if [ -n "${AGENT_PROVISIONER_URL:-}" ]; then + export AGENT_PROVISIONER_URL="${AGENT_PROVISIONER_URL//deepsql-agent/127.0.0.1}" + fi +fi diff --git a/scripts/start-backend.sh b/scripts/start-backend.sh index c67ffa7..4c87d2c 100755 --- a/scripts/start-backend.sh +++ b/scripts/start-backend.sh @@ -9,6 +9,8 @@ ENV_FILE="$PROJECT_ROOT/.env" echo "Starting DBA Agent Backend..." echo "================================" +REMAP_SCRIPT="$SCRIPT_DIR/remap-compose-hosts-for-native.sh" + if [ -f "$ENV_FILE" ]; then echo "Loading environment from .env..." set -a @@ -18,13 +20,16 @@ if [ -f "$ENV_FILE" ]; then echo "Local source-run startup ignores SPRING_PROFILES_ACTIVE=prod from .env" unset SPRING_PROFILES_ACTIVE fi + # shellcheck source=remap-compose-hosts-for-native.sh + source "$REMAP_SCRIPT" + echo "Agent provisioner: ${AGENT_PROVISIONER_URL:-unset}" fi build_backend_launch_command() { local mvn_command="$1" local env_snippet="" if [ -f "$ENV_FILE" ]; then - env_snippet="set -a && source \"$ENV_FILE\" && set +a && if [ \"\${SPRING_PROFILES_ACTIVE:-}\" = \"prod\" ]; then unset SPRING_PROFILES_ACTIVE; fi && " + env_snippet="set -a && source \"$ENV_FILE\" && set +a && if [ \"\${SPRING_PROFILES_ACTIVE:-}\" = \"prod\" ]; then unset SPRING_PROFILES_ACTIVE; fi && source \"$REMAP_SCRIPT\" && " fi printf '%s' "${env_snippet}cd \"$PROJECT_ROOT/backend\" && exec ${mvn_command} spring-boot:run" } diff --git a/src/components/layout/AppSidebar.jsx b/src/components/layout/AppSidebar.jsx index b9b5c9e..d6d12ce 100644 --- a/src/components/layout/AppSidebar.jsx +++ b/src/components/layout/AppSidebar.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { BookOpen, Brain, Code2, Database, Settings, PanelLeftClose, PanelLeftOpen, LogOut, User, ChevronDown, Check, Newspaper, Gauge, MessageSquare, LayoutDashboard } from 'lucide-react' +import { Brain, Code2, Database, Settings, PanelLeftClose, PanelLeftOpen, LogOut, User, ChevronDown, Check, Newspaper, Gauge, MessageSquare, LayoutDashboard } from 'lucide-react' import { useActiveSection, useSetActiveSection } from '@/lib/stores/useNavStore' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { AGENTS_ENABLED, canAccessHomeSection, getConnectionAccessBadge, getConnectionAccessLabel } from '@/lib/features' @@ -16,7 +16,6 @@ const NAV_ITEMS = [ { id: 'company-knowledge', label: 'Brain', icon: Brain }, { id: 'performance', label: 'Performance', icon: Gauge }, { id: 'editor', label: 'Editor', icon: Code2 }, - { id: 'docs', label: 'Docs', icon: BookOpen }, ] export default function AppSidebar() { @@ -29,7 +28,7 @@ export default function AppSidebar() { const [showConnectionDropdown, setShowConnectionDropdown] = useState(false) const userMenuRef = useRef(null) const connectionDropdownRef = useRef(null) - const { logout, role, username, isAdmin } = useAuth() + const { logout, role, username, impersonating } = useAuth() const { connections, connectionId, selectedConnection, changeConnection, isLoading, refetch } = useConnectionManager() const visibleNavItems = NAV_ITEMS.filter(({ id }) => canAccessHomeSection(id, role, selectedConnection)) @@ -92,8 +91,7 @@ export default function AppSidebar() {