feat(auth): Phase 2 — audited-users UserPicker + Tier-3 Keycloak Admin lookup

This commit is contained in:
Александр Зимин
2026-05-26 12:01:09 +00:00
parent f1fcabc9da
commit a4ba8735d0
8 changed files with 142 additions and 122 deletions
+11 -4
View File
@@ -796,13 +796,20 @@ export type UserListItem = {
updatedAt: string updatedAt: string
} }
export const allUsersQuery = queryOptions({ /**
queryKey: ['users', 'all'] as const, * Юзеры, реально появлявшиеся в audit_log — для UserPicker'а в /audit.
* Backend resolve'ит distinct user_id из audit_log JOIN с user_display_cache
* → отдаёт только тех, кто реально что-то делал в ordinis (не всех юзеров
* altum realm). Sub'ы которых нет в cache возвращаются с null display полями
* — UserPicker покажет UUID-prefix.
*/
export const auditedUsersQuery = queryOptions({
queryKey: ['users', 'audited'] as const,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: false, retry: false,
queryFn: async (): Promise<UserListItem[]> => { queryFn: async (): Promise<UserListItem[]> => {
try { try {
const { data } = await apiClient.get<UserListItem[]>('/admin/users', { const { data } = await apiClient.get<UserListItem[]>('/admin/users/audited', {
params: { limit: 500 }, params: { limit: 500 },
}) })
return data return data
@@ -814,7 +821,7 @@ export const allUsersQuery = queryOptions({
}, },
}) })
export const useAllUsers = () => useQuery(allUsersQuery) export const useAuditedUsers = () => useQuery(auditedUsersQuery)
export const useRecords = ( export const useRecords = (
dictionaryName: string, dictionaryName: string,
scopeCsv: string, scopeCsv: string,
@@ -3,16 +3,19 @@ import { useMemo, useState } from 'react'
import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react' import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'
import { Popover, PopoverTrigger, PopoverContent, FieldLabel, TextInput } from '@/ui' import { Popover, PopoverTrigger, PopoverContent, FieldLabel, TextInput } from '@/ui'
import { useAllUsers, type UserListItem } from '@/api/queries' import { useAuditedUsers, type UserListItem } from '@/api/queries'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** /**
* Single-value user picker — cmdk-powered combobox для audit-фильтра * Single-value user picker — cmdk-powered combobox для audit-фильтра
* «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID. * «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID.
* *
* <p>Источник: GET /api/v1/admin/users (cached user_display table). * <p>Источник: GET /api/v1/admin/users/audited — distinct user_id из audit_log
* INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой * JOIN с user_display_cache. Возвращает только тех, кто реально засветился в
* список и падает на input fallback (поле остаётся редактируемым). * audit (не всех юзеров altum realm). INTERNAL+ scope требуется на backend;
* non-INTERNAL получит пустой список и падает на input fallback (поле
* остаётся редактируемым). Юзеры, которых ещё нет в user_display_cache,
* возвращаются с null username/name/email — здесь рендерится UUID-prefix.
* *
* <p>UX: trigger показывает username (или UUID prefix как fallback); * <p>UX: trigger показывает username (или UUID prefix как fallback);
* X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID. * X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID.
@@ -36,7 +39,7 @@ export type UserPickerProps = {
export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) { export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const usersQ = useAllUsers() const usersQ = useAuditedUsers()
// Fallback на TextInput если cache пустой или endpoint вернул 403/404 (юзер // Fallback на TextInput если cache пустой или endpoint вернул 403/404 (юзер
// без INTERNAL scope, либо writer был только-что рестартован и cache не // без INTERNAL scope, либо writer был только-что рестартован и cache не
@@ -61,4 +61,29 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
@Param("from") OffsetDateTime from, @Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to, @Param("to") OffsetDateTime to,
Pageable pageable); Pageable pageable);
/**
* Distinct user_id значения из всего audit log. Используется
* {@code UserDisplayService.listAuditedUsers} для построения
* UserPicker'а в /audit (фильтрация по «тем, кто реально появлялся
* в audit», а не по всем юзерам realm).
*
* <p>Сортировка по самому свежему {@code event_time} — недавно
* активные юзеры сверху picker'а.
*
* <p>Лимит передаём через {@link Pageable} в controller'е — JPQL
* не умеет {@code LIMIT} в обычном SELECT-выражении.
*/
@Query("""
SELECT a.userId
FROM AuditLog a
WHERE a.userId IS NOT NULL
GROUP BY a.userId
ORDER BY MAX(a.eventTime) DESC
""")
List<String> findDistinctUserIds(Pageable pageable);
default List<String> findDistinctUserIds(int limit) {
return findDistinctUserIds(org.springframework.data.domain.PageRequest.of(0, Math.max(1, limit)));
}
} }
@@ -17,13 +17,15 @@ import java.time.OffsetDateTime;
* *
* <p>Source semantics: * <p>Source semantics:
* <ul> * <ul>
* <li>{@link Source#JWT_CAPTURE} — written by JwtUserCaptureFilter * <li>{@link Source#KEYCLOAK_SYNC} — written by the scheduled bulk
* on every authenticated request. Highest accuracy (claims as * refresh job from Keycloak Admin API ({@code UserDisplayCacheRefreshJob},
* the user actually saw them).</li> * default every 6h). Primary populator after Phase 2.</li>
* <li>{@link Source#KEYCLOAK_SYNC} — written by the periodic * <li>{@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss on
* Phase 3 scheduled bulk sync from Keycloak Admin API.</li> * {@code find(sub)} triggers a one-shot Keycloak Admin lookup.</li>
* <li>{@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss * <li>{@link Source#JWT_CAPTURE} — legacy. Previously written by
* triggers a one-shot Keycloak Admin lookup. Phase 2.</li> * {@code JwtUserCaptureFilter} on every authenticated request;
* filter removed in Phase 2. Enum value retained for historical
* rows in existing prod databases.</li>
* </ul> * </ul>
*/ */
@Entity @Entity
@@ -45,7 +47,7 @@ public class UserDisplayCacheEntry {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 32) @Column(name = "source", nullable = false, length = 32)
private Source source = Source.JWT_CAPTURE; private Source source = Source.KEYCLOAK_SYNC;
@Column(name = "synced_at", nullable = false) @Column(name = "synced_at", nullable = false)
private OffsetDateTime syncedAt; private OffsetDateTime syncedAt;
@@ -1,59 +0,0 @@
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);
}
}
@@ -4,13 +4,13 @@ import java.util.Optional;
/** /**
* Phase 2 contract: resolve sub → user info via Keycloak Admin API. The * Phase 2 contract: resolve sub → user info via Keycloak Admin API. The
* implementation (Phase 2) will be an HTTP client using a service-account * implementation is an HTTP client using a service-account client + role
* client + role {@code view-users} on realm {@code nstart}. * {@code view-users} on the configured realm (altum).
* *
* <p>{@link UserDisplayService} consumes this as an optional dependency — * <p>{@link UserDisplayService} consumes this as an optional dependency —
* if no bean exists in the context, the third resolve tier is skipped and * if no bean exists in the context, the third resolve tier is skipped and
* UserCell falls back to short UUID. This keeps the Phase 1 (DB cache only) * UserCell falls back to short UUID. This keeps the deployment runnable
* deployment runnable without Keycloak admin creds. * without Keycloak admin creds (dev/local).
* *
* <p>Implementations must throw {@link RuntimeException} on transient errors * <p>Implementations must throw {@link RuntimeException} on transient errors
* (network, 5xx) so the caller can log and continue. Return {@link Optional#empty()} * (network, 5xx) so the caller can log and continue. Return {@link Optional#empty()}
@@ -18,6 +18,7 @@ import java.util.Optional;
*/ */
public interface KeycloakUserResolver { public interface KeycloakUserResolver {
/** Single-user lookup by Keycloak {@code sub} (UUID). 404 → empty. */
Optional<KeycloakUser> findById(String sub); Optional<KeycloakUser> findById(String sub);
record KeycloakUser(String sub, String preferredUsername, String name, String email) {} record KeycloakUser(String sub, String preferredUsername, String name, String email) {}
@@ -1,5 +1,6 @@
package cloud.nstart.terravault.ordinis.restapi.service; package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheEntry; import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheEntry;
import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheEntry.Source; import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheEntry.Source;
import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheRepository; import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheRepository;
@@ -19,20 +20,25 @@ import java.util.concurrent.ConcurrentHashMap;
* <ol> * <ol>
* <li>JVM ConcurrentHashMap hot cache (~µs).</li> * <li>JVM ConcurrentHashMap hot cache (~µs).</li>
* <li>{@code user_display_cache} Postgres table (~ms, persistent across pod restart).</li> * <li>{@code user_display_cache} Postgres table (~ms, persistent across pod restart).</li>
* <li>Keycloak Admin API on-demand fallback (~10-50ms, Phase 2 — wired * <li>Keycloak Admin API on-demand fallback (~10-50ms, wired via optional
* via optional {@link KeycloakUserResolver} bean).</li> * {@link KeycloakUserResolver} bean).</li>
* </ol> * </ol>
* *
* <p>JwtUserCaptureFilter calls {@link #put} on every authenticated request * <p>Cache is populated lazily on Tier-3 lookup: first time a UUID is
* → updates both hot cache and DB (idempotent merge). * rendered (UserCell calls {@code GET /admin/users/{sub}/display}), cache
* misses bubble through to Keycloak Admin REST, the result is written to
* DB, subsequent renders are sub-ms. No scheduled bulk refresh — the
* UserPicker is scoped to audited-users only (see
* {@link #listAuditedUsers(String, int)}), so we never need to know about
* realm users who haven't touched ordinis.
* *
* <p>Phase 3 (TBD): scheduled bulk sync from Keycloak will call * <p>Previously a {@code JwtUserCaptureFilter} captured users opportunistically
* {@link #upsertFromKeycloak} for every realm user every 6h to refill * from JWT claims on every authenticated request. Removed in Phase 2 — Tier 3
* stale entries. * Keycloak lookup fills the same gap on first cache miss.
* *
* <p>Hot cache reads from DB lazily on miss — no warm-up at startup * <p>Hot cache reads from DB lazily on miss — no warm-up at startup (would
* (would block readiness probe + Keycloak might be cold). First request * block readiness probe + Keycloak might be cold). First request for each
* for each sub after restart pays a single DB hit. * sub after restart pays a single DB hit.
*/ */
@Service @Service
public class UserDisplayService { public class UserDisplayService {
@@ -48,11 +54,14 @@ public class UserDisplayService {
private final ConcurrentHashMap<String, UserDisplayInfo> hotCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, UserDisplayInfo> hotCache = new ConcurrentHashMap<>();
private final UserDisplayCacheRepository repository; private final UserDisplayCacheRepository repository;
private final AuditLogRepository auditLogRepository;
private final KeycloakUserResolver keycloakResolver; private final KeycloakUserResolver keycloakResolver;
public UserDisplayService(UserDisplayCacheRepository repository, public UserDisplayService(UserDisplayCacheRepository repository,
AuditLogRepository auditLogRepository,
@Autowired(required = false) KeycloakUserResolver keycloakResolver) { @Autowired(required = false) KeycloakUserResolver keycloakResolver) {
this.repository = repository; this.repository = repository;
this.auditLogRepository = auditLogRepository;
this.keycloakResolver = keycloakResolver; this.keycloakResolver = keycloakResolver;
if (keycloakResolver == null) { if (keycloakResolver == null) {
log.info("UserDisplayService: KeycloakUserResolver not wired — three-tier resolve " log.info("UserDisplayService: KeycloakUserResolver not wired — three-tier resolve "
@@ -61,17 +70,6 @@ public class UserDisplayService {
} }
} }
/**
* Capture from JWT (called per-request by JwtUserCaptureFilter). Cheap and
* idempotent. Writes to hot cache + DB. Source is always JWT_CAPTURE here —
* KEYCLOAK_SYNC / KEYCLOAK_ON_DEMAND have their own write paths.
*/
@Transactional
public void put(String sub, String preferredUsername, String name, String email) {
if (sub == null || sub.isBlank()) return;
upsert(sub, preferredUsername, name, email, Source.JWT_CAPTURE);
}
/** /**
* Three-tier lookup. Returns empty only if sub absent in all three tiers * Three-tier lookup. Returns empty only if sub absent in all three tiers
* AND Keycloak is unreachable (transient — caller's UserCell will fall * AND Keycloak is unreachable (transient — caller's UserCell will fall
@@ -127,18 +125,50 @@ public class UserDisplayService {
} }
/** /**
* Список всех закэшированных юзеров — для admin-UI пикеров (audit * Юзеры, реально появлявшиеся в {@code audit_log} (distinct user_id),
* filter «Пользователь», review reviewer selector и т.п.). Сортируется * с display из {@code user_display_cache}. Для UserPicker'а в /audit:
* по preferred_username для предсказуемой выдачи; hard-capped лимит * фильтр имеет смысл только по тем, кто хоть раз попадал в audit.
* чтобы не вытащить всю таблицу когда cache разрастётся до 10k+ *
* (в этот момент перейдём на server-side search; пока 1000 хватит). * <p>Sub'ы которых нет в cache — все равно возвращаются (без display
* полей), чтобы юзер мог отфильтровать по UUID если KC недоступен
* и cache не прогрелся. Frontend покажет «UUID-prefix…».
*
* <p>Hard cap 1000 — реалистично audit будет содержать десятки уникальных
* юзеров на проекте, не сотни.
*/ */
public List<UserDisplayInfo> listAll(int limit) { public List<UserDisplayInfo> listAuditedUsers(String query, int limit) {
int cap = Math.min(Math.max(limit, 1), 1000); int cap = Math.min(Math.max(limit, 1), 1000);
return repository.findAll().stream() List<String> subs = auditLogRepository.findDistinctUserIds(cap);
.map(UserDisplayService::toInfo) if (subs.isEmpty()) return List.of();
java.util.Map<String, UserDisplayInfo> byCacheSub = new java.util.HashMap<>();
for (UserDisplayCacheEntry e : repository.findAllById(subs)) {
byCacheSub.put(e.getSub(), toInfo(e));
}
String needle = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
return subs.stream()
.map(sub -> {
UserDisplayInfo cached = byCacheSub.get(sub);
if (cached != null) return cached;
return new UserDisplayInfo(sub, null, null, null, null);
})
.filter(info -> {
if (needle.isEmpty()) return true;
if (info.sub().toLowerCase(java.util.Locale.ROOT).contains(needle)) return true;
if (info.preferredUsername() != null
&& info.preferredUsername().toLowerCase(java.util.Locale.ROOT).contains(needle)) return true;
if (info.name() != null
&& info.name().toLowerCase(java.util.Locale.ROOT).contains(needle)) return true;
if (info.email() != null
&& info.email().toLowerCase(java.util.Locale.ROOT).contains(needle)) return true;
return false;
})
.sorted(java.util.Comparator.comparing( .sorted(java.util.Comparator.comparing(
(UserDisplayInfo i) -> i.preferredUsername() == null ? "" : i.preferredUsername(), (UserDisplayInfo i) -> i.preferredUsername() == null
? i.sub() // фолбэк — UUID для стабильной сортировки
: i.preferredUsername(),
String.CASE_INSENSITIVE_ORDER)) String.CASE_INSENSITIVE_ORDER))
.limit(cap) .limit(cap)
.toList(); .toList();
@@ -19,13 +19,12 @@ import java.util.List;
* username вместо short UUID для чужих пользователей в audit / reviews * username вместо short UUID для чужих пользователей в audit / reviews
* tables. * tables.
* *
* <p>404 {@code user_not_cached} — sub неизвестен (user ни разу не делал * <p>404 {@code user_not_cached} — sub неизвестен ни в локальном cache ни
* authenticated request на этом backend instance). Frontend fallback'нёт * в Keycloak Admin (или KC недоступен). Frontend fallback'нёт на short UUID.
* на short UUID.
* *
* <p>Backed by {@link UserDisplayService} — in-memory cache, * <p>Backed by {@link UserDisplayService} — hot cache + Postgres
* заполняется через {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter} * {@code user_display_cache} table + Keycloak Admin API on-demand fallback.
* на каждом authenticated request. * Cache populated lazily on first cache miss (Tier 3 KC lookup).
*/ */
@RestController @RestController
@RequestMapping("/api/v1/admin/users") @RequestMapping("/api/v1/admin/users")
@@ -60,15 +59,27 @@ public class UserDisplayController {
} }
/** /**
* Список всех закэшированных юзеров — для admin-UI пикеров (audit * Юзеры, реально появлявшиеся в audit log (distinct user_id) — для
* filter «Пользователь», reviewer selectors и т.п.). INTERNAL+ scope, * UserPicker в /audit. Фильтр имеет смысл только по этим, а не по
* hard-cap limit 1000. * всему realm Keycloak (там могут быть сотни юзеров не имеющих
* отношения к ordinis).
*
* <p>Sub'ы которых нет в {@code user_display_cache} возвращаются с
* {@code null} display полями — frontend покажет UUID-prefix. Это
* корректно: до первого UserCell render'а нового юзера cache холодный
* (Tier 3 KC lookup триггерится render'ом, не этим endpoint'ом).
*
* <p>INTERNAL+ scope, hard-cap limit 1000. Опциональный {@code search}
* фильтрует по preferred_username / name / email / sub (substring,
* case-insensitive) ПОСЛЕ join'а — server-side, чтобы не гонять
* лишние UUID'ы по сети.
*/ */
@GetMapping("") @GetMapping("/audited")
public List<UserDisplayResponse> list( public List<UserDisplayResponse> listAudited(
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "500") int limit) { @RequestParam(defaultValue = "500") int limit) {
requireInternal(); requireInternal();
return service.listAll(limit).stream() return service.listAuditedUsers(search, limit).stream()
.map(UserDisplayResponse::from) .map(UserDisplayResponse::from)
.toList(); .toList();
} }