Skip to content

Commit 8957b41

Browse files
fix: run View as policy checks as the target user
Keep the admin JWT subject for logout/refresh/control-plane, but stamp impUid on the access token so overlay (and Agent Bearer fallback) evaluate as the viewed-as user. Never provision the admin session JWT into an Agent profile while impersonating, and forward the effective username to the Agent API instead of hardcoding admin. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 088ac35 commit 8957b41

19 files changed

Lines changed: 456 additions & 25 deletions

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,13 @@ returns a number).
210210
5. **Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
211211

212212
### 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`.
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.
214+
215+
The admin JWT **subject** stays the administrator so logout, refresh, and `/admin/impersonate` still own the real session. Policy identity is the target: an httpOnly `impersonate_user` cookie plus an `impUid` claim on the access token. `JwtAuthenticationFilter` overlays that principal onto the SecurityContext for every request except the impersonation control plane, logout, and session refresh. Chat, Editor, schema listing, and Agent MCP calls then run `AccessControlService` / `ConnectionChatAccessPolicyService` as the target (`actorIsAdmin` is false, so policies apply).
216+
217+
The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints an MCP token for the effective user and never falls back to the admin session JWT while View as is active. nginx `auth_request` on `/agent-api` forwards `/api/auth/me`'s `X-Remote-User` (the overlaid username) instead of hardcoding `admin`.
218+
219+
`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`.
214220

215221
### Git Rules
216222
- Do NOT commit automatically — wait for explicit user instruction.

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,21 @@ public ResponseEntity<?> refreshSession(HttpServletRequest httpRequest, HttpServ
207207
User effectiveUser = impersonationService.resolveFromCookie(httpRequest, user)
208208
.map(ImpersonationContext.State::target)
209209
.orElse(user);
210-
// Keep the user's agent token alive for as long as the UI session lives.
211-
// The SPA refreshes on access-token expiry (~every 15 min of activity), so
212-
// this slides the agent token forward on each active interval — a logged-in
213-
// UI never ends up with a dead agent.
210+
if (effectiveUser != user && effectiveUser.getId() != null) {
211+
authSessionService.reissueAccessToken(
212+
httpResponse,
213+
session.getId(),
214+
user,
215+
effectiveUser.getId()
216+
);
217+
}
218+
// Keep agent tokens alive for as long as the UI session lives.
219+
// During View as the SPA still refreshes the *admin* session; also
220+
// slide the target user's minted MCP token or their Agent tab dies.
214221
agentBridgeService.extendAgentTokens(user.getUsername());
222+
if (!effectiveUser.getUsername().equals(user.getUsername())) {
223+
agentBridgeService.extendAgentTokens(effectiveUser.getUsername());
224+
}
215225
Map<String, Object> payload = toAuthPayload(
216226
effectiveUser,
217227
effectiveUser.getRoleEnum(),

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
import java.util.Optional;
66

77
/**
8-
* Request-scoped impersonation overlay. The admin JWT stays on the session;
8+
* Request-scoped impersonation overlay. The admin JWT subject stays on the
9+
* session so logout/refresh/control-plane still own the real administrator;
910
* {@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.
11+
* target user (from {@code impersonate_user} cookie or {@code impUid} claim)
12+
* so policy evaluation, {@code /auth/me}, and {@code AccessControlService}
13+
* honour the viewed-as user — including when {@code security.auth.enabled} is false.
1314
*/
1415
public final class ImpersonationContext {
1516

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,30 @@ private void applyImpersonation(HttpServletRequest request, HttpServletResponse
143143
throws ServletException, IOException {
144144
try {
145145
impersonationService.applyToRequest(request);
146+
stampEffectiveUser(response);
146147
chain.doFilter(request, response);
147148
} finally {
148149
ImpersonationContext.clear();
149150
}
150151
}
151152

153+
/**
154+
* nginx {@code auth_request} on {@code /agent-api} forwards this as
155+
* {@code X-Remote-User}. It must be the <em>effective</em> principal so
156+
* View as (and non-admin Agent users) do not run the shared admin profile.
157+
*/
158+
private void stampEffectiveUser(HttpServletResponse response) {
159+
var authentication = SecurityContextHolder.getContext().getAuthentication();
160+
if (authentication == null || !authentication.isAuthenticated()) {
161+
return;
162+
}
163+
String name = authentication.getName();
164+
if (name == null || name.isBlank() || "anonymousUser".equals(name)) {
165+
return;
166+
}
167+
response.setHeader("X-Remote-User", name);
168+
}
169+
152170
private String extractUsernameSafely(String token) {
153171
if (token == null || token.isBlank()) {
154172
return null;

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ public String generateAccessToken(
134134
Role role,
135135
Set<Permission> permissions,
136136
Duration ttl
137+
) {
138+
return generateAccessToken(username, sessionId, role, permissions, ttl, null);
139+
}
140+
141+
public String generateAccessToken(
142+
String username,
143+
String sessionId,
144+
Role role,
145+
Set<Permission> permissions,
146+
Duration ttl,
147+
Long impersonateUserId
137148
) {
138149
Map<String, Object> claims = new HashMap<>();
139150
claims.put("role", role.name());
@@ -145,10 +156,36 @@ public String generateAccessToken(
145156
if (sessionId != null && !sessionId.isBlank()) {
146157
claims.put("sid", sessionId);
147158
}
159+
if (impersonateUserId != null && impersonateUserId > 0) {
160+
claims.put("impUid", impersonateUserId);
161+
}
148162

149163
return createToken(claims, username, ttl);
150164
}
151165

166+
/**
167+
* Target user id stamped onto an admin access token during View as.
168+
* The JWT subject stays the administrator so logout/refresh/control-plane
169+
* still own the real session; policy evaluation overlays this user.
170+
*/
171+
public Long extractImpersonateUserId(String token) {
172+
Claims claims = extractAllClaims(token);
173+
Object raw = claims.get("impUid");
174+
if (raw instanceof Number number) {
175+
long value = number.longValue();
176+
return value > 0 ? value : null;
177+
}
178+
if (raw instanceof String text && !text.isBlank()) {
179+
try {
180+
long value = Long.parseLong(text.trim());
181+
return value > 0 ? value : null;
182+
} catch (NumberFormatException e) {
183+
return null;
184+
}
185+
}
186+
return null;
187+
}
188+
152189
/**
153190
* Generate token with role string and permissions set.
154191
*/

backend/src/main/java/com/dbaagent/service/AgentBridgeService.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dbaagent.service;
22

3+
import com.dbaagent.security.ImpersonationContext;
34
import com.fasterxml.jackson.databind.ObjectMapper;
45
import org.slf4j.Logger;
56
import org.slf4j.LoggerFactory;
@@ -142,9 +143,19 @@ public record ProfileBootstrap(String profile, String token) {}
142143
public ProfileBootstrap ensureProfile(String username, String authToken, String connectionId) {
143144
String profile = profileFor(username);
144145
if (!provisionEnabled) {
146+
if (ImpersonationContext.isActive()) {
147+
throw new ProvisioningException(
148+
"Agent provisioning is disabled; View as cannot bind a user-scoped Agent token"
149+
);
150+
}
145151
return new ProfileBootstrap(profile, authToken);
146152
}
147153
if (provisionSecret == null || provisionSecret.isBlank()) {
154+
if (ImpersonationContext.isActive()) {
155+
throw new ProvisioningException(
156+
"Agent provisioning is not configured; View as cannot bind a user-scoped Agent token"
157+
);
158+
}
148159
log.warn("agent.provision-secret is unset — skipping per-user provisioning for {}", username);
149160
return new ProfileBootstrap(profile, authToken);
150161
}
@@ -154,9 +165,15 @@ public ProfileBootstrap ensureProfile(String username, String authToken, String
154165
// every MCP call. Mint a dedicated, user-scoped, revocable MCP token
155166
// instead (authenticated by McpTokenAuthenticationFilter, not the session
156167
// filter). Fall back to the session token only if minting fails so the
157-
// tab still opens.
168+
// tab still opens — except during View as, where the session JWT is the
169+
// administrator's and would skip the target user's policy.
158170
String agentToken = mintAgentToken(username);
159171
if (agentToken == null) {
172+
if (ImpersonationContext.isActive()) {
173+
throw new ProvisioningException(
174+
"Could not mint an MCP token for " + username + " while viewing as that user"
175+
);
176+
}
160177
agentToken = authToken == null ? "" : authToken;
161178
}
162179
callProvisioner(username, profile, agentToken, connectionId);

backend/src/main/java/com/dbaagent/service/AgentChatClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ private void switchProfile(String profile) throws Exception {
136136
throw new IllegalArgumentException("agent profile is required");
137137
}
138138
// Profiles are provisioned as u-<username>; the trusted-auth header is the
139-
// bare username (nginx hard-codes X-Remote-User: admin for the browser path).
139+
// bare username (browser /agent-api gets this from /api/auth/me via nginx).
140140
remoteUser = profile.startsWith("u-") ? profile.substring(2) : profile;
141141
postJson("/api/profile/switch", Map.of("name", profile));
142142
}

backend/src/main/java/com/dbaagent/service/AuthSessionService.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,32 @@ public void writeSessionCookies(HttpServletResponse response, SessionAuthenticat
155155
response.addHeader(HttpHeaders.SET_COOKIE, buildRefreshCookie(sessionAuthentication.refreshToken()).toString());
156156
}
157157

158+
/**
159+
* Rewrite the access cookie in place (same session id) so View as can stamp
160+
* or clear {@code impUid} without rotating the refresh token. {@code impersonateUserId}
161+
* null clears the claim.
162+
*/
163+
public void reissueAccessToken(
164+
HttpServletResponse response,
165+
String sessionId,
166+
User sessionOwner,
167+
Long impersonateUserId
168+
) {
169+
if (response == null || sessionId == null || sessionId.isBlank() || sessionOwner == null) {
170+
return;
171+
}
172+
Role role = sessionOwner.getRoleEnum();
173+
String accessToken = jwtUtil.generateAccessToken(
174+
sessionOwner.getUsername(),
175+
sessionId,
176+
role,
177+
role.getPermissions(),
178+
Duration.ofMinutes(accessMinutes),
179+
impersonateUserId
180+
);
181+
response.addHeader(HttpHeaders.SET_COOKIE, buildAccessCookie(accessToken).toString());
182+
}
183+
158184
public void writeImpersonationCookie(HttpServletResponse response, String cookieName, long targetUserId) {
159185
response.addHeader(HttpHeaders.SET_COOKIE, ResponseCookie.from(cookieName, Long.toString(targetUserId))
160186
.httpOnly(true)

backend/src/main/java/com/dbaagent/service/ImpersonationService.java

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.dbaagent.repository.UserRepository;
88
import com.dbaagent.security.CustomUserDetailsService;
99
import com.dbaagent.security.ImpersonationContext;
10+
import com.dbaagent.security.JwtUtil;
1011
import jakarta.servlet.http.Cookie;
1112
import jakarta.servlet.http.HttpServletRequest;
1213
import jakarta.servlet.http.HttpServletResponse;
@@ -33,10 +34,18 @@
3334
import static org.springframework.http.HttpStatus.NOT_FOUND;
3435

3536
/**
36-
* Admin-only profile switch. The admin session (JWT cookies) is unchanged;
37-
* a separate httpOnly cookie names the user to evaluate as. The JWT filter
38-
* overlays that principal onto the SecurityContext for every request except
39-
* the impersonation control plane, logout, and session refresh.
37+
* Admin-only profile switch so an administrator can verify another user's
38+
* connection ACLs and chat/editor policies.
39+
*
40+
* <p>The admin session stays the real session (logout/refresh/control-plane).
41+
* Identity for policy is the target user: an httpOnly {@code impersonate_user}
42+
* cookie plus an {@code impUid} claim on the access JWT. The JWT filter overlays
43+
* that principal onto the SecurityContext for every request except the
44+
* impersonation control plane, logout, and session refresh.
45+
*
46+
* <p>The {@code impUid} claim matters for callers that send the access token as
47+
* a Bearer credential without cookies (Agent MCP fallback). Cookie-only overlay
48+
* left those paths running as the administrator, which skipped policy.
4049
*/
4150
@Service
4251
@RequiredArgsConstructor
@@ -49,13 +58,17 @@ public class ImpersonationService {
4958
private final CustomUserDetailsService userDetailsService;
5059
private final AuthSessionService authSessionService;
5160
private final SecurityEventService securityEventService;
61+
private final JwtUtil jwtUtil;
5262

5363
@Value("${security.auth.enabled:true}")
5464
private boolean authEnabled;
5565

5666
@Value("${security.cookie.impersonate-name:" + DEFAULT_COOKIE_NAME + "}")
5767
private String impersonateCookieName;
5868

69+
@Value("${security.cookie.name:auth_token}")
70+
private String accessCookieName;
71+
5972
public ImpersonationContext.State start(
6073
User actor,
6174
Long targetUserId,
@@ -65,6 +78,7 @@ public ImpersonationContext.State start(
6578
requireAdminActor(actor);
6679
User target = requireAllowedTarget(actor, targetUserId);
6780
authSessionService.writeImpersonationCookie(response, impersonateCookieName, target.getId());
81+
rewriteAccessToken(request, response, actor, target.getId());
6882
securityEventService.log(SecurityEventService.EventRequest.builder()
6983
.eventType(SecurityEventType.IMPERSONATION_STARTED)
7084
.outcome(SecurityEventOutcome.SUCCESS)
@@ -91,6 +105,7 @@ public User stop(
91105
requireAdminActor(actor);
92106
Optional<User> target = readTargetUser(request);
93107
authSessionService.clearImpersonationCookie(response, impersonateCookieName);
108+
rewriteAccessToken(request, response, actor, null);
94109
ImpersonationContext.clear();
95110
target.ifPresent(stopped -> securityEventService.log(SecurityEventService.EventRequest.builder()
96111
.eventType(SecurityEventType.IMPERSONATION_STOPPED)
@@ -145,6 +160,10 @@ public void decorateAuthPayload(HttpServletRequest request, User sessionUser, Ma
145160
* Overlay the target principal when the admin JWT (or the auth-disabled
146161
* synthetic admin) is already in the SecurityContext. No-ops on the
147162
* impersonation control-plane, logout/refresh, MCP tokens, and invalid cookies.
163+
*
164+
* <p>The target is taken from the {@code impersonate_user} cookie first, then
165+
* from the access token's {@code impUid} claim so Bearer callers without
166+
* cookies still evaluate policy as the viewed-as user.
148167
*/
149168
public void applyToRequest(HttpServletRequest request) {
150169
if (!shouldApply(request)) {
@@ -218,6 +237,14 @@ private Optional<User> readTargetUser(HttpServletRequest request) {
218237
}
219238

220239
private Long readTargetUserId(HttpServletRequest request) {
240+
Long fromCookie = readTargetUserIdFromCookie(request);
241+
if (fromCookie != null) {
242+
return fromCookie;
243+
}
244+
return readTargetUserIdFromJwt(request);
245+
}
246+
247+
private Long readTargetUserIdFromCookie(HttpServletRequest request) {
221248
Cookie[] cookies = request.getCookies();
222249
if (cookies == null) {
223250
return null;
@@ -230,6 +257,69 @@ private Long readTargetUserId(HttpServletRequest request) {
230257
return null;
231258
}
232259

260+
private Long readTargetUserIdFromJwt(HttpServletRequest request) {
261+
String jwt = readAccessJwt(request);
262+
if (jwt == null) {
263+
return null;
264+
}
265+
try {
266+
return jwtUtil.extractImpersonateUserId(jwt);
267+
} catch (Exception e) {
268+
return null;
269+
}
270+
}
271+
272+
private void rewriteAccessToken(
273+
HttpServletRequest request,
274+
HttpServletResponse response,
275+
User sessionOwner,
276+
Long impersonateUserId
277+
) {
278+
String sessionId = sessionIdFrom(request);
279+
if (sessionId == null) {
280+
return;
281+
}
282+
authSessionService.reissueAccessToken(response, sessionId, sessionOwner, impersonateUserId);
283+
}
284+
285+
private String sessionIdFrom(HttpServletRequest request) {
286+
Object attr = request.getAttribute("auth.sessionId");
287+
if (attr instanceof String sid && !sid.isBlank()) {
288+
return sid;
289+
}
290+
String jwt = readAccessJwt(request);
291+
if (jwt == null) {
292+
return null;
293+
}
294+
try {
295+
String sessionId = jwtUtil.extractSessionId(jwt);
296+
return sessionId == null || sessionId.isBlank() ? null : sessionId;
297+
} catch (Exception e) {
298+
return null;
299+
}
300+
}
301+
302+
private String readAccessJwt(HttpServletRequest request) {
303+
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
304+
if (authorization != null && authorization.startsWith("Bearer ")) {
305+
String token = authorization.substring(7);
306+
if (token.startsWith(McpTokenService.TOKEN_PREFIX)) {
307+
return null;
308+
}
309+
return token.isBlank() ? null : token;
310+
}
311+
Cookie[] cookies = request.getCookies();
312+
if (cookies == null) {
313+
return null;
314+
}
315+
for (Cookie cookie : cookies) {
316+
if (accessCookieName.equals(cookie.getName())) {
317+
return cookie.getValue();
318+
}
319+
}
320+
return null;
321+
}
322+
233323
private Long parseUserId(String value) {
234324
if (value == null || value.isBlank()) {
235325
return null;

0 commit comments

Comments
 (0)