Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions articles/RunAs_HOWTO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Run-As (Impersonation) — How It Works

This document describes the Run-As functionality in WebAPI, which allows
an authorized administrator to impersonate another user.

---

## Overview

Run-As lets an administrator log in as a different user without knowing that
user's credentials. This is useful for:

- **Troubleshooting** — reproducing issues that a specific user reports by
seeing the application through their permissions.
- **Support** — performing actions on behalf of a user who cannot access the
system themselves.
- **Testing** — verifying that role and permission assignments produce the
correct behavior for a given user.

When a run-as request succeeds, WebAPI mints a brand-new JWT for the target
user with a fresh session. The administrator effectively becomes that user
for all subsequent requests made with the new token.

---

## Permission

Run-As is gated by the `admin:run-as` permission. Only users whose roles
include this permission can invoke the endpoint. The permission is seeded by
the baseline migration:

```sql
INSERT INTO sec_permission (value, description)
VALUES ('admin:run-as', 'Run as another user');
```

Assign this permission to a role, then assign that role to the administrators
who need impersonation capability.

---

## Endpoint

```
POST /user/runas
```

### Request

| Parameter | Location | Required | Description |
|-----------|----------|----------|-------------|
| `login` | query string or form body | Yes | The login of the user to impersonate |

The request must include a valid `Authorization: Bearer <jwt>` header for the
calling user (the administrator).

### Successful Response (200 OK)

```json
{
"login": "targetuser",
"jwt": "eyJhbGciOiJIUzI1NiIs...",
"roles": [],
"message": "Run-as successful"
}
```

The `jwt` field contains a newly minted token whose `sub` claim is the
target user. The caller should replace their current token with this one.

### Error Responses

| Status | Condition | `x-auth-error` Header |
|--------|-----------|-----------------------|
| **403 Forbidden** | Caller does not have the `admin:run-as` permission | — |
| **404 Not Found** | Target user does not exist in the system | `User not found` |

---

## How It Works

```
Administrator WebAPI Database
│ │ │
│ POST /user/runas?login=X │ │
│────────────────────────────►│ │
│ │ @PreAuthorize checks │
│ │ admin:run-as permission │
│ │ │
│ │ Look up user "X" │
│ │──────────────────────────────►│
│ │◄──────────────────────────────│
│ │ │
│ │ Create session for "X" │
│ │──────────────────────────────►│
│ │◄──────────────────────────────│
│ │ │
│ │ Mint JWT (sub=X, sid=new) │
│ │ │
│ { login, jwt, roles, msg } │ │
│◄────────────────────────────│ │
│ │ │
│ (subsequent requests use │ │
│ the new JWT as user X) │ │
```

### Step by Step

1. **Authorization check** — Spring Security's `@PreAuthorize` verifies that
the caller has the `admin:run-as` permission before the endpoint method
executes. If not, a 403 is returned immediately.

2. **Target user lookup** — `AuthorizationService.getUserByLogin()` searches
for the target user. If the user does not exist, a 404 with the
`x-auth-error: User not found` header is returned.

3. **Session creation** — A new session is created for the target user via
`SessionService.createSession()`. This is a standard session entry in
`sec_session`, identical to one created during normal login.

4. **JWT minting** — A JWT is generated with `sub` = target login and
`sid` = the new session ID, using the same `JwtService.generateToken()`
used by all other login flows.

5. **Response** — The JWT is returned in a `LoginService.Result` JSON object.
The Atlas UI replaces its stored token with this new JWT and reloads the
user's permissions via `GET /user/me`.

---

## Returning to Your Own Identity

Run-As does not maintain a stack or track the original administrator identity
in the token. To return to your own account, simply log out and log back in
with your own credentials.

---

## Nested Run-As

Nested impersonation is allowed. If the target user also has the `admin:run-as`
permission, they (or the admin acting as them) can invoke `/user/runas` again
to impersonate yet another user. Each call mints a completely independent JWT
and session.

---

## Atlas UI Integration

The Atlas UI already supports Run-As. When a user with `admin:run-as`
permission is logged in, the welcome screen displays a "Run as" input field
and button. The UI:

1. Checks the permission via `isPermitted('admin:run-as')`.
2. Sends `POST /user/runas` with the `login` parameter.
3. On success, stores the returned JWT and calls `loadUserInfo()` to refresh
the displayed identity and permissions.
4. On failure, displays the error from the `x-auth-error` response header.

No UI code changes are required — the endpoint contract matches the existing
Atlas implementation.

---

## Implementation Files

| File | Role |
|------|------|
| `LoginController.java` | `POST /user/runas` endpoint with `@PreAuthorize` |
| `LoginService.java` | `runAs()` method — user lookup, session creation, JWT minting |
| `AuthorizationService.java` | `getUserByLogin()` facade to the internal `UserService` |

---

## Security Considerations

- **Audit trail** — All actions performed under a run-as session are attributed
to the target user, not the original administrator. The session ID in the JWT
can be used to correlate actions back to the run-as event in the session
table.
- **Session independence** — The administrator's original session remains valid.
Logging out of the run-as session does not affect the administrator's own
session.
- **Single-login policy** — If `security.session.single-login` is enabled,
creating a run-as session for a target user will revoke any existing sessions
for that user. Be aware of this when impersonating users who are actively
logged in.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import jakarta.annotation.PostConstruct;

import org.ohdsi.webapi.security.authz.User;
import org.ohdsi.webapi.security.authz.UserEntity;
import org.ohdsi.webapi.security.authz.UserRepository;
import org.ohdsi.webapi.security.identity.WebApiPrincipal;
Expand Down Expand Up @@ -314,7 +315,7 @@ public AbstractAuthenticationToken convert(Jwt jwt) {
UserEntity user = userRepository.findByLogin(login).orElseThrow(() -> new BadCredentialsException("User not found: %s".formatted(login)));

// Build principal and authentication token
WebApiPrincipal principal = new WebApiPrincipal(user.getId(), user.getLogin());
WebApiPrincipal principal = new WebApiPrincipal(User.fromEntity(user));

Collection<GrantedAuthority> authorities = List.of(); // populate as needed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import jakarta.servlet.http.HttpServletResponse;

/**
* The LoginController class groups the different auth controller endpoints, and
* we do it with inner classes
Expand Down Expand Up @@ -49,7 +52,26 @@ public LoginService.Result refresh(Authentication authentication) {
@GetMapping("/user/logout")
public LoginService.Result logout(Authentication authentication) {
return loginSvc.logout(authentication);
}
}

/**
* Run-as (impersonation) endpoint. Allows a user with admin:run-as permission
* to log in as another user.
*/
@PostMapping("/user/runas")
@PreAuthorize("isPermitted('admin:run-as')")
public ResponseEntity<LoginService.Result> runAs(
@RequestParam String login,
HttpServletResponse response) {
try {
LoginService.Result result = loginSvc.runAs(login);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
response.setHeader("x-auth-error", "User not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new LoginService.Result(null, null, null, "User not found"));
}
}

/**
* Windows Authentication controller which responds with JWT and login results.
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/org/ohdsi/webapi/security/authc/LoginService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.UUID;

import org.ohdsi.webapi.security.authz.AuthorizationService;
import org.ohdsi.webapi.security.authz.User;
import org.ohdsi.webapi.security.session.SessionProperties;
import org.ohdsi.webapi.security.session.SessionService;
import org.slf4j.Logger;
Expand Down Expand Up @@ -73,6 +74,29 @@ public Result mintSession(Authentication authentication) {
return new Result(login, jwt, roles, "Login successful");
}

/**
* Impersonate another user. Creates a new session for the target user
* and mints a JWT as that user.
*
* @param targetLogin the login of the user to impersonate
* @return Result with JWT for the target user
* @throws IllegalArgumentException if the target user does not exist
*/
public Result runAs(String targetLogin) {
final String login = targetLogin.toLowerCase();

User targetUser = authorizationService.getUserByLogin(login)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + login));

UUID sessionId = sessionService.createSession(login);
Instant expiresAt = Instant.now().plus(sessionProps.getExpiration());
String jwt = jwtService.generateToken(login, sessionId.toString(), Date.from(expiresAt));

log.info("LoginService: runAs: impersonating {}", login);

return new Result(login, jwt, new String[]{}, "Run-as successful");
}

/**
* extends the authenticated session by minting a new JWT, and extending the
* session in the session store.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.ohdsi.webapi.security.authc.UserOrigin;
import org.ohdsi.webapi.security.identity.WebApiPrincipal;
Expand Down Expand Up @@ -137,9 +138,17 @@ public List<String> filterToExistingRoles(List<String> roleNames) {
}

public User getCurrentUser() {
WebApiPrincipal principal = getAuthenticatedPrincipal();
UserEntity ue = this.userService.getUserById(principal.getUserId());
return User.fromEntity(ue);
return getAuthenticatedPrincipal().getUser();
}

/**
* Look up a user by login.
* @param login the login to search for
* @return the User if found, or empty
*/
@Transactional(readOnly = true)
public Optional<User> getUserByLogin(String login) {
return userService.getUserByLogin(login).map(User::fromEntity);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,37 @@

import java.security.Principal;
import java.util.Objects;
import org.ohdsi.webapi.security.authz.User;

public final class WebApiPrincipal implements Principal {

public static final long ANONYMOUS_USER_ID = -1L;
public static final String ANONYMOUS_LOGIN = "anonymous";

public static final WebApiPrincipal ANONYMOUS = new WebApiPrincipal(ANONYMOUS_USER_ID, ANONYMOUS_LOGIN);
public static final WebApiPrincipal ANONYMOUS =
new WebApiPrincipal(new User(ANONYMOUS_USER_ID, ANONYMOUS_LOGIN, "Anonymous"));

private final long userId;
private final String login;
private final User user;

public WebApiPrincipal(long userId, String login) {
this.userId = userId;
this.login = Objects.requireNonNull(login, "login");
public WebApiPrincipal(User user) {
this.user = Objects.requireNonNull(user, "user");
}

public long getUserId() {
return userId;
return user.id();
}

public User getUser() {
return user;
}

@Override
public String getName() {
return login;
return user.login();
}

public boolean isAnonymous() {
return this == ANONYMOUS || userId == ANONYMOUS_USER_ID;
return this == ANONYMOUS || user.id() == ANONYMOUS_USER_ID;
}

@Override
Expand All @@ -38,16 +42,16 @@ public boolean equals(Object o) {
if (!(o instanceof WebApiPrincipal))
return false;
WebApiPrincipal that = (WebApiPrincipal) o;
return userId == that.userId;
return user.id().equals(that.user.id());
}

@Override
public int hashCode() {
return Long.hashCode(userId);
return Long.hashCode(user.id());
}

@Override
public String toString() {
return "WebApiPrincipal[userId=" + userId + ", login=" + login + "]";
return "WebApiPrincipal[userId=" + user.id() + ", login=" + user.login() + "]";
}
}
Loading
Loading