feat(users): /admin/users/{sub}/display endpoint + UserCell uses it

This commit is contained in:
Александр Зимин
2026-05-13 16:42:46 +00:00
parent 0e165f69ca
commit be90077520
4 changed files with 291 additions and 11 deletions
@@ -0,0 +1,59 @@
package cloud.nstart.terravault.ordinis.restapi.auth;
import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Capture-only filter: на каждом authenticated HTTP запросе достаёт
* {@code sub} / {@code preferred_username} / {@code name} / {@code email}
* из JWT и upsert'ит в {@link UserDisplayService}. Это позволяет резолвить
* UUID → display name для UserCell на frontend без Keycloak Admin API call.
*
* <p>Runs после Spring Security JWT processing (он создаёт
* {@code JwtAuthenticationToken} в SecurityContext). Если auth.token = null
* (anonymous request, или non-OIDC auth), filter — no-op.
*
* <p>Cost: 1 hash put per authenticated request. Cache size capped в
* service до 10k entries → ~1MB max RAM.
*/
@Component
public class JwtUserCaptureFilter extends OncePerRequestFilter {
private final UserDisplayService displayService;
public JwtUserCaptureFilter(UserDisplayService displayService) {
this.displayService = displayService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
captureIfJwt();
chain.doFilter(request, response);
}
private void captureIfJwt() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) return;
Jwt token = jwt.getToken();
String sub = token.getSubject();
if (sub == null || sub.isBlank()) return;
String preferredUsername = token.getClaimAsString("preferred_username");
String name = token.getClaimAsString("name");
String email = token.getClaimAsString("email");
displayService.put(sub, preferredUsername, name, email);
}
}
@@ -0,0 +1,79 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory кэш user display info (sub → preferredUsername / name / email),
* заполняемый из JWT claims на каждом authenticated запросе через
* {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter}.
*
* <p><b>Зачем:</b> audit log + record drafts хранят actor как JWT
* {@code sub} UUID. Frontend (UserCell в audit / reviews) показывает short
* UUID + tooltip — плохой UX. Этот сервис позволяет резолвить sub →
* читаемое имя для display, не запрашивая Keycloak Admin API на каждое
* отображение.
*
* <p><b>Trade-offs:</b>
* <ul>
* <li>In-memory — пропадает на pod restart. Постепенно заполняется
* обратно по мере того как users делают запросы. Для display это OK.</li>
* <li>Без Keycloak Admin API — не видим users которые ни разу не
* логинились в Ordinis. Display fallback → short UUID.</li>
* <li>Stale: если user сменит preferred_username в Keycloak, кэш
* обновится только на следующий его login + request.</li>
* </ul>
*
* <p>Альтернатива (на будущее): миграция 0023 user_display_cache table
* + periodic Keycloak sync. Сделать когда станет реально нужно
* (пока display name никто не редактирует постоянно).
*/
@Service
public class UserDisplayService {
/** Cap чтобы не держать миллионы entries в RAM при abuse. ~100 байт per
* entry → 10k = 1MB. Eviction policy — LRU не нужен, hash без bound — fine. */
private static final int SOFT_CAP = 10_000;
private final ConcurrentHashMap<String, UserDisplayInfo> cache = new ConcurrentHashMap<>();
/**
* Обновляет / создаёт запись для {@code sub}. Idempotent. Вызывается
* из {@code JwtUserCaptureFilter} на каждом authenticated request —
* стоимость ~O(1) hash put.
*/
public void put(String sub, String preferredUsername, String name, String email) {
if (sub == null || sub.isBlank()) return;
if (cache.size() >= SOFT_CAP) return; // soft cap, don't grow unbounded
cache.put(sub, new UserDisplayInfo(
sub,
nullIfBlank(preferredUsername),
nullIfBlank(name),
nullIfBlank(email),
OffsetDateTime.now()));
}
public Optional<UserDisplayInfo> find(String sub) {
if (sub == null || sub.isBlank()) return Optional.empty();
return Optional.ofNullable(cache.get(sub));
}
/** Stats для observability — сколько users закэшировано. */
public int size() {
return cache.size();
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s;
}
public record UserDisplayInfo(
String sub,
String preferredUsername,
String name,
String email,
OffsetDateTime updatedAt) {}
}
@@ -0,0 +1,76 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
/**
* Resolve UUID (JWT sub) → human-readable display info. Frontend UserCell
* вызывает {@code GET /api/v1/admin/users/{sub}/display} чтобы показывать
* username вместо short UUID для чужих пользователей в audit / reviews
* tables.
*
* <p>404 {@code user_not_cached} — sub неизвестен (user ни разу не делал
* authenticated request на этом backend instance). Frontend fallback'нёт
* на short UUID.
*
* <p>Backed by {@link UserDisplayService} — in-memory cache,
* заполняется через {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter}
* на каждом authenticated request.
*/
@RestController
@RequestMapping("/api/v1/admin/users")
public class UserDisplayController {
private final UserDisplayService service;
private final ScopeContext scopeContext;
public UserDisplayController(UserDisplayService service, ScopeContext scopeContext) {
this.service = service;
this.scopeContext = scopeContext;
}
/** INTERNAL+ scope для лookup — admin staff feature, не для anonymous. */
private void requireInternal() {
if (!scopeContext.canAccess(DataScope.INTERNAL)) {
throw OrdinisException.forbidden(
"user_display_internal_required",
"User display lookup доступен только с INTERNAL или RESTRICTED scope.");
}
}
@GetMapping("/{sub}/display")
public UserDisplayResponse get(@PathVariable String sub) {
requireInternal();
return service.find(sub)
.map(UserDisplayResponse::from)
.orElseThrow(() -> OrdinisException.notFound(
"user_not_cached",
"User " + sub + " not cached. User должен сделать хотя бы один " +
"authenticated request на этом backend'е чтобы попасть в кэш."));
}
public record UserDisplayResponse(
String sub,
String preferredUsername,
String name,
String email,
OffsetDateTime updatedAt) {
static UserDisplayResponse from(UserDisplayService.UserDisplayInfo info) {
return new UserDisplayResponse(
info.sub(),
info.preferredUsername(),
info.name(),
info.email(),
info.updatedAt());
}
}
}