Skip to content

Commit 1cf7eac

Browse files
feat: admin-only profile switch to verify user policies (#66)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> Admins can switch into a sub-user profile from the top-right of the app to verify that connection ACLs, chat/editor policies, and role-gated navigation actually apply. ## How it works The admin JWT stays on the session. Starting a switch sets an httpOnly `impersonate_user` cookie. `JwtAuthenticationFilter` overlays the target user's principal so every access check (`AccessControlService`, policies, `/auth/me`) runs as that user. The impersonation control plane (`GET|POST|DELETE /api/admin/impersonate`), logout, refresh, and MCP tokens are excluded from the overlay so the real admin can stop the switch and so CLI tokens cannot ride the cookie. ## Guardrails - ADMIN only - Cannot switch into another admin, yourself, or a non-ACTIVE account - Logout clears the impersonation cookie - Audited as `IMPERSONATION_STARTED` / `IMPERSONATION_STOPPED` ## UI - Top-right **View as** control, admin-only (hover helper popups removed — they blocked clicks) - While switched: dark banner `Viewing as <user>` with **Exit** - Nav, Settings, and connection visibility follow the target role/permissions - **Docs** sidebar item removed; old `docs` nav state lands on Agent - Client session state (query cache, selected connection) resets on switch and on exit ## API - `GET /api/admin/impersonate` — candidates + current status - `POST /api/admin/impersonate` `{ userId }` — start - `DELETE /api/admin/impersonate` — stop - `GET /api/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername` Stacked on #65 so policy enforcement can be verified by switching into `marts-editor`. ## Native Agent tab (follow-up on this branch) The Agent tab 503'd on native `mvn spring-boot:run` because `.env` still used Compose DNS (`deepsql-agent:8788`) and Vite had no nginx `X-Remote-User` stamp. `scripts/start-backend.sh` now remaps unresolvable Compose hosts to loopback, the agent container can reach a host-side backend via `host.docker.internal`, and Vite forwards the effective username (including impersonation) to the Agent API. [Agent answered table count on ACME ERP](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_chat_ten_tables.webp) [Only ACME ERP Multi-Schema connection remains](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fconnections_acme_erp_only.webp) [agent_working_and_acme_connection.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fagent_working_and_acme_connection.mp4) ## Profile switch walkthrough [Sidebar without Docs](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fdocs_nav_removed.webp) [Admin View as control](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_view_as_control.webp) [View as dropdown listing marts-editor](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_view_as_dropdown.webp) [Viewing as marts-editor banner](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fviewing_as_marts_editor_banner.webp) [Editor shows only marts tables while switched](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Feditor_marts_only_while_switched.webp) [admin_profile_switch_view_as_exit.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fadmin_profile_switch_view_as_exit.mp4) <sub>To show artifacts inline, <a href="https://cursor.com/dashboard/cloud-agents#my-pull-requests">enable</a> in settings.</sub> <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-019fe687-99b1-76fd-80fa-dd213aecc497&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 94cb3b1 commit 1cf7eac

27 files changed

Lines changed: 1488 additions & 49 deletions

.env.example

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

AGENTS.md

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

CLAUDE.md

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

212+
### Admin profile switch
213+
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav. The admin JWT stays on the session; `ImpersonationService` sets an httpOnly `impersonate_user` cookie and `JwtAuthenticationFilter` overlays the target principal. `POST|DELETE|GET /api/admin/impersonate` are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. `/auth/me` returns the **effective** user plus `impersonating` / `impersonatorUsername`.
214+
212215
### Git Rules
213216
- Do NOT commit automatically — wait for explicit user instruction.
214217
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`

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

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

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

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

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

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

backend/src/main/java/com/dbaagent/model/SecurityEventType.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public enum SecurityEventType {
2525
SESSION_REFRESHED,
2626
SESSION_REVOKED,
2727
SESSION_EXPIRED,
28+
IMPERSONATION_STARTED,
29+
IMPERSONATION_STOPPED,
2830
LOGOUT,
2931
LOGOUT_ALL,
3032
ACCOUNT_LOCKED,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.dbaagent.security;
2+
3+
import com.dbaagent.model.User;
4+
5+
import java.util.Optional;
6+
7+
/**
8+
* Request-scoped impersonation overlay. The admin JWT stays on the session;
9+
* {@link JwtAuthenticationFilter} swaps the SecurityContext principal to the
10+
* target user and records both identities here so {@code /auth/me} can show a
11+
* banner and {@code AccessControlService} can honour the target even when
12+
* {@code security.auth.enabled} is false.
13+
*/
14+
public final class ImpersonationContext {
15+
16+
public record State(User impersonator, User target) {
17+
public String impersonatorUsername() {
18+
return impersonator != null ? impersonator.getUsername() : null;
19+
}
20+
21+
public String impersonatorEmail() {
22+
return impersonator != null ? impersonator.getEmail() : null;
23+
}
24+
25+
public String targetUsername() {
26+
return target != null ? target.getUsername() : null;
27+
}
28+
}
29+
30+
private static final ThreadLocal<State> CURRENT = new ThreadLocal<>();
31+
32+
private ImpersonationContext() {
33+
}
34+
35+
public static void enter(State state) {
36+
CURRENT.set(state);
37+
}
38+
39+
public static void clear() {
40+
CURRENT.remove();
41+
}
42+
43+
public static Optional<State> current() {
44+
return Optional.ofNullable(CURRENT.get());
45+
}
46+
47+
public static boolean isActive() {
48+
return CURRENT.get() != null;
49+
}
50+
}

backend/src/main/java/com/dbaagent/security/JwtAuthenticationFilter.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.springframework.web.filter.OncePerRequestFilter;
1717

1818
import com.dbaagent.service.AuthSessionService;
19+
import com.dbaagent.service.ImpersonationService;
1920

2021
import java.io.IOException;
2122
import java.util.ArrayList;
@@ -40,6 +41,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
4041
@Autowired
4142
private AuthSessionService authSessionService;
4243

44+
@Autowired
45+
private ImpersonationService impersonationService;
46+
4347
@Override
4448
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
4549
throws ServletException, IOException {
@@ -69,7 +73,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
6973
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
7074
"admin", null, devAuthorities);
7175
SecurityContextHolder.getContext().setAuthentication(auth);
72-
chain.doFilter(request, response);
76+
applyImpersonation(request, response, chain);
7377
return;
7478
}
7579

@@ -132,7 +136,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
132136
} else if (isBrainRequest) {
133137
log.warn("Brain auth missing user: path={}, username={}", requestPath, username);
134138
}
135-
chain.doFilter(request, response);
139+
applyImpersonation(request, response, chain);
140+
}
141+
142+
private void applyImpersonation(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
143+
throws ServletException, IOException {
144+
try {
145+
impersonationService.applyToRequest(request);
146+
chain.doFilter(request, response);
147+
} finally {
148+
ImpersonationContext.clear();
149+
}
136150
}
137151

138152
private String extractUsernameSafely(String token) {

0 commit comments

Comments
 (0)