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:
@@ -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.
|
||||
*
|
||||
* <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) к
|
||||
@@ -8,15 +52,17 @@ import { useAuth } from 'react-oidc-context'
|
||||
* <ul>
|
||||
* <li><b>uuid matches current user</b> → {@code "Я • <preferred_username>"}
|
||||
* (или just {@code "Я"} если profile неполный).</li>
|
||||
* <li><b>different user</b> → first 8 chars of UUID. Full ID в {@code fullId}
|
||||
* чтобы consumer мог положить в {@code title=...} для tooltip.</li>
|
||||
* <li><b>different user, cached на backend</b> → preferred_username из кэша.
|
||||
* 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>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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).
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Используется везде где 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. Используется в таблицах.
|
||||
*
|
||||
* <p>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 <span className="text-mute italic">anonymous</span>
|
||||
const tooltip = [fullId, name, email].filter(Boolean).join('\n')
|
||||
return (
|
||||
<span
|
||||
className={isMe ? 'text-ink' : 'font-mono text-ink-2'}
|
||||
title={fullId}
|
||||
title={tooltip || fullId}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
|
||||
+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