From cf0e6ba18517380dcb5db1a0072edeb10c311f86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 12:09:36 +0000 Subject: [PATCH 1/5] feat: add admin-only profile switch to verify user policies Admins can view the product as a sub-user from the top-right control. The admin JWT stays on the session; an httpOnly impersonation cookie overlays the target principal so connection ACLs, chat/editor policies, and role-gated nav apply as they would for that user. Co-authored-by: Venkat SF --- CLAUDE.md | 5 +- .../dbaagent/controller/AuthController.java | 21 +- .../controller/ImpersonationController.java | 136 ++++++++ .../com/dbaagent/model/SecurityEventType.java | 2 + .../security/ImpersonationContext.java | 50 +++ .../security/JwtAuthenticationFilter.java | 18 +- .../dbaagent/service/AuthSessionService.java | 23 ++ .../service/ImpersonationService.java | 327 ++++++++++++++++++ .../security/AccessControlService.java | 10 +- .../service/ImpersonationServiceTest.java | 281 +++++++++++++++ .../security/AccessControlServiceTest.java | 34 ++ docs/root/CLAUDE.md | 5 +- src/components/layout/AppSidebar.jsx | 9 +- src/components/layout/AppSidebar.module.css | 7 + src/components/layout/ProfileSwitch.jsx | 201 +++++++++++ .../layout/ProfileSwitch.module.css | 227 ++++++++++++ src/hooks/useAuth.jsx | 29 +- src/lib/api/client.js | 15 + src/pages/Home.jsx | 40 ++- 19 files changed, 1408 insertions(+), 32 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/controller/ImpersonationController.java create mode 100644 backend/src/main/java/com/dbaagent/security/ImpersonationContext.java create mode 100644 backend/src/main/java/com/dbaagent/service/ImpersonationService.java create mode 100644 backend/src/test/java/com/dbaagent/service/ImpersonationServiceTest.java create mode 100644 src/components/layout/ProfileSwitch.jsx create mode 100644 src/components/layout/ProfileSwitch.module.css diff --git a/CLAUDE.md b/CLAUDE.md index 652ae89..e42b874 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 @@ -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/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/src/components/layout/AppSidebar.jsx b/src/components/layout/AppSidebar.jsx index b9b5c9e..66f6523 100644 --- a/src/components/layout/AppSidebar.jsx +++ b/src/components/layout/AppSidebar.jsx @@ -29,7 +29,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)) @@ -194,7 +194,12 @@ export default function AppSidebar() {
- {username} +
+ {username} + {impersonating && ( + Viewing as this user + )} +
+ + + +
+ {open && ( + + )} + + ) + } + + return ( +
+ + + + {open && ( + + )} +
+ ) +} + +function CandidateMenu({ candidates, loading, switching, error, activeUsername, onSelect }) { + return ( +
+
Switch into a user
+ {loading &&
Loading users…
} + {!loading && error &&
{error}
} + {!loading && !error && candidates.length === 0 && ( +
No sub-users available
+ )} + {!loading && candidates.map((user) => ( + + ))} +
+ ) +} diff --git a/src/components/layout/ProfileSwitch.module.css b/src/components/layout/ProfileSwitch.module.css new file mode 100644 index 0000000..211a560 --- /dev/null +++ b/src/components/layout/ProfileSwitch.module.css @@ -0,0 +1,227 @@ +.switchWrap { + position: absolute; + top: 12px; + right: 16px; + z-index: 40; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #111827; + font-size: 12px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: background 0.12s, border-color 0.12s; +} + +.trigger:hover:not(:disabled) { + background: #f9fafb; + border-color: #d1d5db; +} + +.trigger:disabled { + opacity: 0.6; + cursor: default; +} + +.banner { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 40px; + padding: 8px 16px; + background: #111827; + color: #f9fafb; + flex-shrink: 0; + z-index: 30; +} + +.bannerCopy { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.bannerLabel { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #9ca3af; +} + +.bannerName { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.bannerMeta { + font-size: 12px; + color: #9ca3af; +} + +.bannerActions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.secondaryBtn, +.exitBtn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.secondaryBtn { + background: transparent; + border: 1px solid #4b5563; + color: #e5e7eb; +} + +.secondaryBtn:hover:not(:disabled) { + background: #1f2937; +} + +.exitBtn { + background: #fff; + border: 1px solid #fff; + color: #111827; +} + +.exitBtn:hover:not(:disabled) { + background: #f3f4f6; +} + +.secondaryBtn:disabled, +.exitBtn:disabled { + opacity: 0.6; + cursor: default; +} + +.menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 320px; + max-height: 360px; + overflow: auto; + background: #fff; + color: #111827; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 50; +} + +.banner .menu { + right: 16px; + top: calc(100% + 4px); +} + +.menuLabel { + padding: 10px 12px 6px; + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.menuItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + text-align: left; + cursor: pointer; +} + +.menuItem:hover:not(:disabled) { + background: #f9fafb; +} + +.menuItem:disabled { + cursor: default; +} + +.menuItemActive { + background: #f3f4f6; +} + +.menuItemMain { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.menuItemName { + font-size: 13px; + font-weight: 600; + color: #111827; +} + +.menuItemEmail { + font-size: 11px; + color: #6b7280; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menuEmpty, +.menuError { + padding: 12px; + font-size: 13px; + color: #6b7280; +} + +.menuError, +.error { + color: #b91c1c; + font-size: 12px; +} + +.roleBadge { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: #f3f4f6; + color: #374151; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + flex-shrink: 0; +} + +.banner .roleBadge { + background: #374151; + color: #e5e7eb; +} diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 052c733..1a02762 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, createContext, useContext, useMemo, useCallback } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { getActionPermission, getActionConfig } from '@/lib/actions' -import { authAPI, setupAPI, AUTH_CHANGE_EVENT } from '@/lib/api/client' +import { authAPI, setupAPI, adminAPI, AUTH_CHANGE_EVENT } from '@/lib/api/client' import { queryClient } from '@/lib/queryClient' import { useChatStore } from '@/lib/stores/useChatStore' import { useConnectionStore } from '@/lib/stores/useConnectionStore' @@ -88,6 +88,9 @@ export function AuthProvider({ children }) { emailTwoFactorEnabled: payload?.emailTwoFactorEnabled ?? false, mfaRequired: false, mfaEnrolled: false, + impersonating: Boolean(payload?.impersonating), + impersonatorUsername: payload?.impersonatorUsername || null, + impersonatorEmail: payload?.impersonatorEmail || null, }) setRole(normalizedRole) setPermissions(permissionSet) @@ -200,6 +203,18 @@ export function AuthProvider({ children }) { handleLoggedOut(true) }, [handleLoggedOut]) + const startImpersonation = useCallback(async (userId) => { + const payload = await adminAPI.startImpersonation(userId) + applyAuthPayload(payload, { resetSession: true }) + return payload + }, [applyAuthPayload]) + + const stopImpersonation = useCallback(async () => { + const payload = await adminAPI.stopImpersonation() + applyAuthPayload(payload, { resetSession: true }) + return payload + }, [applyAuthPayload]) + const hasPermission = useCallback((permission) => { return permissions.has(permission) }, [permissions]) @@ -255,6 +270,8 @@ export function AuthProvider({ children }) { const isAdmin = useMemo(() => role === ROLES.ADMIN, [role]) const isDeveloper = useMemo(() => role === ROLES.DEVELOPER, [role]) + const impersonating = Boolean(user?.impersonating) + const canSwitchProfile = isAdmin || impersonating const canExecute = useMemo(() => permissions.has(PERMISSIONS.EXECUTE_QUERIES), [permissions]) const canChat = useMemo(() => permissions.has(PERMISSIONS.USE_CHAT), [permissions]) @@ -268,9 +285,15 @@ export function AuthProvider({ children }) { login, logout, refreshCurrentUser, + startImpersonation, + stopImpersonation, user, username: user?.username || 'User', email: user?.email || '', + impersonating, + impersonatorUsername: user?.impersonatorUsername || null, + impersonatorEmail: user?.impersonatorEmail || null, + canSwitchProfile, role, permissions: [...permissions], hasPermission, @@ -293,9 +316,13 @@ export function AuthProvider({ children }) { login, logout, refreshCurrentUser, + startImpersonation, + stopImpersonation, user, role, permissions, + impersonating, + canSwitchProfile, hasPermission, hasAnyPermission, hasAllPermissions, diff --git a/src/lib/api/client.js b/src/lib/api/client.js index 9ab49ee..d990716 100644 --- a/src/lib/api/client.js +++ b/src/lib/api/client.js @@ -390,6 +390,21 @@ export const adminAPI = { return response.data }, + startImpersonation: async (userId) => { + const response = await apiClient.post('/api/admin/impersonate', { userId }) + return response.data + }, + + stopImpersonation: async () => { + const response = await apiClient.delete('/api/admin/impersonate') + return response.data + }, + + getImpersonationStatus: async () => { + const response = await apiClient.get('/api/admin/impersonate') + return response.data + }, + /** * Get all roles with their permissions (ADMIN only) */ diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 1ab662a..c9f9f69 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useRef } from 'react' import AppSidebar from '@/components/layout/AppSidebar' +import ProfileSwitch from '@/components/layout/ProfileSwitch' import AgentView from '@/components/Agent/AgentView' import AgentChatSection from '@/components/sections/AgentChatSection' import DigestFeedSection from '@/components/sections/DigestSection' @@ -28,7 +29,7 @@ const SECTION_MAP = { } export default function Home() { - const { role } = useAuth() + const { role, canSwitchProfile } = useAuth() const { selectedConnection } = useConnectionManager() const activeSection = useActiveSection() const setActiveSection = useSetActiveSection() @@ -79,23 +80,26 @@ export default function Home() { {!immersive && } {/* Main content — lazy-mount sections on first visit, then keep alive */} -
- {visibleSections.map(([key, Section]) => { - if (!mounted.has(key)) return null - return ( -
-
-
- ) - })} +
+ {canSwitchProfile && } +
+ {visibleSections.map(([key, Section]) => { + if (!mounted.has(key)) return null + return ( +
+
+
+ ) + })} +
) From ff55e781556004642335d5cac4a0dd7f46a94919 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 12:43:43 +0000 Subject: [PATCH 2/5] fix: keep the admin View as control out of overlapping page chrome The switch sat absolutely over the Agent header, so clicks hit the section underneath. Put it in a dedicated top-right bar instead. Co-authored-by: Venkat SF --- src/components/layout/ProfileSwitch.jsx | 2 ++ src/components/layout/ProfileSwitch.module.css | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/layout/ProfileSwitch.jsx b/src/components/layout/ProfileSwitch.jsx index 6d8ba95..ae10200 100644 --- a/src/components/layout/ProfileSwitch.jsx +++ b/src/components/layout/ProfileSwitch.jsx @@ -152,6 +152,8 @@ export default function ProfileSwitch() { className={styles.trigger} onClick={() => setOpen((value) => !value)} disabled={switching} + aria-label="View as another user" + data-testid="profile-switch-trigger" > View as diff --git a/src/components/layout/ProfileSwitch.module.css b/src/components/layout/ProfileSwitch.module.css index 211a560..e03cc49 100644 --- a/src/components/layout/ProfileSwitch.module.css +++ b/src/components/layout/ProfileSwitch.module.css @@ -1,8 +1,10 @@ .switchWrap { - position: absolute; - top: 12px; - right: 16px; + position: relative; z-index: 40; + display: flex; + justify-content: flex-end; + padding: 10px 16px 0; + flex-shrink: 0; } .trigger { From 2eaa6257b2ed0c7c9c71801b10ff32bd2932101e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 15:06:33 +0000 Subject: [PATCH 3/5] fix: drop Docs nav and View as hover tooltips The Docs sidebar tab is no longer a product surface. Persisted docs nav state now lands on Agent. Remove HelpTooltip wrappers from the profile-switch CTAs so they no longer intercept clicks. Co-authored-by: Venkat SF --- CLAUDE.md | 2 +- src/components/layout/AppSidebar.jsx | 6 ++--- src/components/layout/ProfileSwitch.jsx | 32 +++++++------------------ src/lib/features.js | 7 +++--- src/pages/Home.jsx | 2 -- 5 files changed, 16 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e42b874..71ad87a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/src/components/layout/AppSidebar.jsx b/src/components/layout/AppSidebar.jsx index 66f6523..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() { @@ -92,8 +91,7 @@ export default function AppSidebar() {
{open && ( - - {open && ( canAccessHomeSection(section, role, connection)) - return firstVisible || 'docs' + return firstVisible || 'agent-chat' } export function normalizeHomeSection(section, role, connection = null) { diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index c9f9f69..707606f 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -7,7 +7,6 @@ import DigestFeedSection from '@/components/sections/DigestSection' import BrainSection from '@/components/sections/BrainSection' import CompanyKnowledgeSection from '@/components/sections/CompanyKnowledgeSection' import DashboardsSection from '@/components/sections/DashboardsSection' -import DocsSection from '@/components/sections/DocsSection' import EditorSection from '@/components/sections/EditorSection' import SlowQueriesSection from '@/components/sections/SlowQueriesSection' import PageTransitionBar from '@/components/layout/PageTransitionBar' @@ -25,7 +24,6 @@ const SECTION_MAP = { 'company-knowledge': CompanyKnowledgeSection, performance: SlowQueriesSection, editor: EditorSection, - docs: DocsSection, } export default function Home() { From 2b204f8f54eb2ba0cc17b677b9b2fcca79864244 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 15:35:22 +0000 Subject: [PATCH 4/5] fix: remap compose agent URLs for native backend runs The Agent tab 503'd because native spring-boot:run sourced Compose DNS (deepsql-agent:8788) which does not resolve on the host. Remap those hosts to loopback in start-backend.sh, and let the agent container reach a host-side Java backend via host.docker.internal. Co-authored-by: Venkat SF --- .env.example | 3 +++ AGENTS.md | 13 ++++++++++--- docker-compose.yml | 8 ++++++-- scripts/remap-compose-hosts-for-native.sh | 22 ++++++++++++++++++++++ scripts/start-backend.sh | 7 ++++++- 5 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 scripts/remap-compose-hosts-for-native.sh 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/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/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" } From c99e72cdafe669546a362198f605b4adfa8995df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 16:03:05 +0000 Subject: [PATCH 5/5] fix: forward the effective user to the Agent API in Vite Vite has no nginx auth_request, so the Agent tab 401'd on profile/switch. Send X-Remote-User from /api/agent/session's username (including impersonation) and replay it on SSE via the proxy. Co-authored-by: Venkat SF --- src/lib/api/agentClient.js | 21 ++++++++++++++++++++- vite.config.js | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/lib/api/agentClient.js b/src/lib/api/agentClient.js index f372d8d..cde1eca 100644 --- a/src/lib/api/agentClient.js +++ b/src/lib/api/agentClient.js @@ -20,9 +20,26 @@ const CSRF_HEADER = "X-Hermes-CSRF-Token"; /** Cached CSRF token for the agent API (required once trusted-auth is on). */ let agentCsrfToken = null; +/** + * Effective DeepSQL username from the last `/api/agent/session` bootstrap. + * Vite has no nginx `auth_request` to stamp `X-Remote-User`, so the browser + * must send it. Never hardcode a user — impersonation ("View as") changes this. + */ +let agentRemoteUser = null; + +function withAgentAuthHeaders(headers = {}) { + if (agentRemoteUser) { + headers["X-Remote-User"] = agentRemoteUser; + } + return headers; +} + async function ensureAgentCsrf() { if (agentCsrfToken) return agentCsrfToken; - const res = await fetch(`${AGENT_BASE}/api/auth/status`, { credentials: "include" }); + const res = await fetch(`${AGENT_BASE}/api/auth/status`, { + credentials: "include", + headers: withAgentAuthHeaders(), + }); if (!res.ok) return null; const data = await res.json().catch(() => ({})); agentCsrfToken = data?.csrf_token || null; @@ -35,6 +52,7 @@ async function postJson(url, body, _retried = false) { // enables the agent auth gate, unsafe POSTs need the session CSRF token or // the agent answers 403 "Session expired - reload the page". if (url.startsWith(AGENT_BASE) || url.includes("/agent-api/")) { + withAgentAuthHeaders(headers); const csrf = await ensureAgentCsrf(); if (csrf) headers[CSRF_HEADER] = csrf; } @@ -99,6 +117,7 @@ export const agentChatAPI = { /** Resolve/provision the current user's agent profile (via Spring → cookie auth). */ async bootstrap(connectionId) { const data = await postJson("/api/agent/session", { connectionId }); + if (data?.username) agentRemoteUser = data.username; // Must happen before any session/new / resume path that hits /agent-api. try { await switchAgentProfile(data?.profile); diff --git a/vite.config.js b/vite.config.js index 8903b27..e20bbbf 100644 --- a/vite.config.js +++ b/vite.config.js @@ -62,6 +62,22 @@ export default defineConfig({ rewrite: (p) => p.replace(/^\/agent-api/, ''), timeout: 300000, proxyTimeout: 300000, + // Production nginx stamps X-Remote-User via auth_request. Vite has no + // equivalent, so the browser sends the effective username (including + // impersonation). EventSource cannot set headers — remember the last + // value and attach it to SSE / other proxied calls. + configure: (proxy) => { + let lastRemoteUser + proxy.on('proxyReq', (proxyReq, req) => { + const incoming = req.headers['x-remote-user'] + if (typeof incoming === 'string' && incoming.trim()) { + lastRemoteUser = incoming.trim() + } + if (lastRemoteUser) { + proxyReq.setHeader('X-Remote-User', lastRemoteUser) + } + }) + }, }, }, },