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:
Александр Зимин
2026-05-13 16:42:46 +00:00
4 changed files with 291 additions and 11 deletions
+77 -11
View File
@@ -1,4 +1,48 @@
import { useQuery } from '@tanstack/react-query'
import { useAuth } from 'react-oidc-context' 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.
*
* <p>Stale-while-revalidate: TanStack Query кэширует 5 минут, refetch
* on focus. Cheap — endpoint = O(1) hash lookup, no DB hit.
*
* <p>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<UserDisplayInfo | null> => {
if (!uuid) return null
try {
const { data } = await apiClient.get<UserDisplayInfo>(
`/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) к * Resolve UUID sub claim (Keycloak {@code sub} → makerId / userId) к
@@ -8,15 +52,17 @@ import { useAuth } from 'react-oidc-context'
* <ul> * <ul>
* <li><b>uuid matches current user</b> → {@code "Я • <preferred_username>"} * <li><b>uuid matches current user</b> → {@code "Я • <preferred_username>"}
* (или just {@code "Я"} если profile неполный).</li> * (или just {@code "Я"} если profile неполный).</li>
* <li><b>different user</b> → first 8 chars of UUID. Full ID в {@code fullId} * <li><b>different user, cached на backend</b> → preferred_username из кэша.
* чтобы consumer мог положить в {@code title=...} для tooltip.</li> * Full ID + email + name доступны через extended fields.</li>
* <li><b>different user, не cached</b> → first 8 chars of UUID. Full ID в
* {@code fullId} чтобы consumer мог положить в {@code title=...}.</li>
* <li><b>null/undefined uuid</b> → {@code "anonymous"}.</li> * <li><b>null/undefined uuid</b> → {@code "anonymous"}.</li>
* </ul> * </ul>
* *
* <p>TODO Phase 1d: для не-current-user backend endpoint * <p>Backend endpoint: {@code GET /api/v1/admin/users/{sub}/display} — backed
* {@code /admin/users/{sub}/display} → resolve в username. Пока best-effort: * by in-memory cache (заполняется JwtUserCaptureFilter на каждом authenticated
* current user resolved через JWT profile claim, остальные — UUID prefix * request). Если backend pod restart'нулся, кэш пуст — postepenно заполняется
* (achievable via JWT alone). * обратно. Frontend gracefully fallback'ит на short UUID.
* *
* <p>Используется везде где UI показывает actor: AUTHOR в таблицах * <p>Используется везде где UI показывает actor: AUTHOR в таблицах
* /reviews (record + schema), USER в /audit log, AUTHOR в schema draft * /reviews (record + schema), USER в /audit log, AUTHOR в schema draft
@@ -26,9 +72,13 @@ export function useUserDisplay(uuid: string | null | undefined): {
display: string display: string
isMe: boolean isMe: boolean
fullId: string fullId: string
preferredUsername?: string | null
name?: string | null
email?: string | null
} { } {
const auth = useAuth() const auth = useAuth()
const fullId = uuid ?? '' const fullId = uuid ?? ''
const resolved = useResolveUserDisplay(fullId)
if (!fullId) { if (!fullId) {
return { display: 'anonymous', isMe: false, fullId: '' } return { display: 'anonymous', isMe: false, fullId: '' }
} }
@@ -36,25 +86,41 @@ export function useUserDisplay(uuid: string | null | undefined): {
const isMe = Boolean(currentSub) && currentSub === fullId const isMe = Boolean(currentSub) && currentSub === fullId
if (isMe) { if (isMe) {
const me = const me =
auth.user?.profile?.preferred_username || (auth.user?.profile?.preferred_username as string | undefined) ||
auth.user?.profile?.name || (auth.user?.profile?.name as string | undefined) ||
auth.user?.profile?.email (auth.user?.profile?.email as string | undefined)
return { display: me ? `Я • ${me}` : 'Я', isMe, fullId } 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 } return { display: fullId.substring(0, 8), isMe, fullId }
} }
/** /**
* UserCell — convenience component для тех мест где нужен readonly displaу * UserCell — convenience component для тех мест где нужен readonly displaу
* с tooltip. Используется в таблицах. * с tooltip. Используется в таблицах.
*
* <p>Tooltip содержит full UUID + email + name (если есть в кэше) для
* disambiguation: short UUID prefix может collide между users.
*/ */
export function UserCell({ uuid }: { uuid: string | null | undefined }) { 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 <span className="text-mute italic">anonymous</span> if (!fullId) return <span className="text-mute italic">anonymous</span>
const tooltip = [fullId, name, email].filter(Boolean).join('\n')
return ( return (
<span <span
className={isMe ? 'text-ink' : 'font-mono text-ink-2'} className={isMe ? 'text-ink' : 'font-mono text-ink-2'}
title={fullId} title={tooltip || fullId}
> >
{display} {display}
</span> </span>
@@ -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());
}
}
}