Merge branch 'feat/user-display-endpoint' into 'main'
feat(users): /admin/users/{sub}/display endpoint + UserCell uses it
See merge request 2-6/2-6-4/terravault/ordinis!177
This commit is contained in:
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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) {}
|
||||
}
|
||||
+76
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user