From be90077520607998078a3df5bb5fbe24f1a1bf6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Wed, 13 May 2026 16:42:46 +0000 Subject: [PATCH] feat(users): /admin/users/{sub}/display endpoint + UserCell uses it --- ordinis-admin-ui/src/lib/useUserDisplay.tsx | 88 ++++++++++++++++--- .../restapi/auth/JwtUserCaptureFilter.java | 59 +++++++++++++ .../restapi/service/UserDisplayService.java | 79 +++++++++++++++++ .../restapi/web/UserDisplayController.java | 76 ++++++++++++++++ 4 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java diff --git a/ordinis-admin-ui/src/lib/useUserDisplay.tsx b/ordinis-admin-ui/src/lib/useUserDisplay.tsx index a328d9b..a631a86 100644 --- a/ordinis-admin-ui/src/lib/useUserDisplay.tsx +++ b/ordinis-admin-ui/src/lib/useUserDisplay.tsx @@ -1,4 +1,48 @@ +import { useQuery } from '@tanstack/react-query' import { useAuth } from 'react-oidc-context' +import { apiClient } from '@/api/client' + +/** + * Lookup user display info по UUID (JWT sub). Backed by backend in-memory + * cache (заполняется на каждом authenticated request через + * JwtUserCaptureFilter). Returns null если sub не в кэше — frontend + * fallback'нёт на short UUID. + * + *

Stale-while-revalidate: TanStack Query кэширует 5 минут, refetch + * on focus. Cheap — endpoint = O(1) hash lookup, no DB hit. + * + *

404 → данных нет; не считаем error (через retry: false + handle null). + */ +type UserDisplayInfo = { + sub: string + preferredUsername?: string | null + name?: string | null + email?: string | null + updatedAt: string +} + +const useResolveUserDisplay = (uuid: string | null | undefined) => + useQuery({ + queryKey: ['user-display', uuid] as const, + enabled: Boolean(uuid), + staleTime: 5 * 60 * 1000, + retry: false, + queryFn: async (): Promise => { + if (!uuid) return null + try { + const { data } = await apiClient.get( + `/admin/users/${encodeURIComponent(uuid)}/display`, + ) + return data + } catch (e: unknown) { + const status = (e as { response?: { status?: number } })?.response?.status + // 404 = user не в кэше; 403 = у actor'а scope insufficient — оба + // не error, просто нет данных. Frontend fallback'нёт на short UUID. + if (status === 404 || status === 403) return null + throw e + } + }, + }) /** * Resolve UUID sub claim (Keycloak {@code sub} → makerId / userId) к @@ -8,15 +52,17 @@ import { useAuth } from 'react-oidc-context' *

* - *

TODO Phase 1d: для не-current-user backend endpoint - * {@code /admin/users/{sub}/display} → resolve в username. Пока best-effort: - * current user resolved через JWT profile claim, остальные — UUID prefix - * (achievable via JWT alone). + *

Backend endpoint: {@code GET /api/v1/admin/users/{sub}/display} — backed + * by in-memory cache (заполняется JwtUserCaptureFilter на каждом authenticated + * request). Если backend pod restart'нулся, кэш пуст — postepenно заполняется + * обратно. Frontend gracefully fallback'ит на short UUID. * *

Используется везде где UI показывает actor: AUTHOR в таблицах * /reviews (record + schema), USER в /audit log, AUTHOR в schema draft @@ -26,9 +72,13 @@ export function useUserDisplay(uuid: string | null | undefined): { display: string isMe: boolean fullId: string + preferredUsername?: string | null + name?: string | null + email?: string | null } { const auth = useAuth() const fullId = uuid ?? '' + const resolved = useResolveUserDisplay(fullId) if (!fullId) { return { display: 'anonymous', isMe: false, fullId: '' } } @@ -36,25 +86,41 @@ export function useUserDisplay(uuid: string | null | undefined): { const isMe = Boolean(currentSub) && currentSub === fullId if (isMe) { const me = - auth.user?.profile?.preferred_username || - auth.user?.profile?.name || - auth.user?.profile?.email + (auth.user?.profile?.preferred_username as string | undefined) || + (auth.user?.profile?.name as string | undefined) || + (auth.user?.profile?.email as string | undefined) return { display: me ? `Я • ${me}` : 'Я', isMe, fullId } } + const info = resolved.data + if (info?.preferredUsername || info?.name) { + return { + display: info.preferredUsername || info.name || fullId.substring(0, 8), + isMe, + fullId, + preferredUsername: info.preferredUsername, + name: info.name, + email: info.email, + } + } + // Fallback — кэш empty или user ещё не появлялся на этом backend'е. return { display: fullId.substring(0, 8), isMe, fullId } } /** * UserCell — convenience component для тех мест где нужен readonly displaу * с tooltip. Используется в таблицах. + * + *

Tooltip содержит full UUID + email + name (если есть в кэше) для + * disambiguation: short UUID prefix может collide между users. */ export function UserCell({ uuid }: { uuid: string | null | undefined }) { - const { display, isMe, fullId } = useUserDisplay(uuid) + const { display, isMe, fullId, name, email } = useUserDisplay(uuid) if (!fullId) return anonymous + const tooltip = [fullId, name, email].filter(Boolean).join('\n') return ( {display} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java new file mode 100644 index 0000000..ed78e8a --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java @@ -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. + * + *

Runs после Spring Security JWT processing (он создаёт + * {@code JwtAuthenticationToken} в SecurityContext). Если auth.token = null + * (anonymous request, или non-OIDC auth), filter — no-op. + * + *

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); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java new file mode 100644 index 0000000..2bac04f --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java @@ -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}. + * + *

Зачем: audit log + record drafts хранят actor как JWT + * {@code sub} UUID. Frontend (UserCell в audit / reviews) показывает short + * UUID + tooltip — плохой UX. Этот сервис позволяет резолвить sub → + * читаемое имя для display, не запрашивая Keycloak Admin API на каждое + * отображение. + * + *

Trade-offs: + *

+ * + *

Альтернатива (на будущее): миграция 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 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 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) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java new file mode 100644 index 0000000..1252ffc --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java @@ -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. + * + *

404 {@code user_not_cached} — sub неизвестен (user ни разу не делал + * authenticated request на этом backend instance). Frontend fallback'нёт + * на short UUID. + * + *

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()); + } + } +}