feat(users): /admin/users/{sub}/display endpoint + UserCell uses it
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
+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