diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 236ea34..88821f3 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -796,13 +796,20 @@ export type UserListItem = { 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, retry: false, queryFn: async (): Promise => { try { - const { data } = await apiClient.get('/admin/users', { + const { data } = await apiClient.get('/admin/users/audited', { params: { limit: 500 }, }) return data @@ -814,7 +821,7 @@ export const allUsersQuery = queryOptions({ }, }) -export const useAllUsers = () => useQuery(allUsersQuery) +export const useAuditedUsers = () => useQuery(auditedUsersQuery) export const useRecords = ( dictionaryName: string, scopeCsv: string, diff --git a/ordinis-admin-ui/src/components/audit/UserPicker.tsx b/ordinis-admin-ui/src/components/audit/UserPicker.tsx index 4b93a34..0379ef3 100644 --- a/ordinis-admin-ui/src/components/audit/UserPicker.tsx +++ b/ordinis-admin-ui/src/components/audit/UserPicker.tsx @@ -3,16 +3,19 @@ import { useMemo, useState } from 'react' import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk' 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' /** * Single-value user picker — cmdk-powered combobox для audit-фильтра * «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID. * - *

Источник: GET /api/v1/admin/users (cached user_display table). - * INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой - * список и падает на input fallback (поле остаётся редактируемым). + *

Источник: GET /api/v1/admin/users/audited — distinct user_id из audit_log + * JOIN с user_display_cache. Возвращает только тех, кто реально засветился в + * audit (не всех юзеров altum realm). INTERNAL+ scope требуется на backend; + * non-INTERNAL получит пустой список и падает на input fallback (поле + * остаётся редактируемым). Юзеры, которых ещё нет в user_display_cache, + * возвращаются с null username/name/email — здесь рендерится UUID-prefix. * *

UX: trigger показывает username (или UUID prefix как fallback); * X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID. @@ -36,7 +39,7 @@ export type UserPickerProps = { export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) { const [open, setOpen] = useState(false) - const usersQ = useAllUsers() + const usersQ = useAuditedUsers() // Fallback на TextInput если cache пустой или endpoint вернул 403/404 (юзер // без INTERNAL scope, либо writer был только-что рестартован и cache не diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java index d925af4..c5a30e4 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java @@ -61,4 +61,29 @@ public interface AuditLogRepository extends JpaRepository { @Param("from") OffsetDateTime from, @Param("to") OffsetDateTime to, Pageable pageable); + + /** + * Distinct user_id значения из всего audit log. Используется + * {@code UserDisplayService.listAuditedUsers} для построения + * UserPicker'а в /audit (фильтрация по «тем, кто реально появлялся + * в audit», а не по всем юзерам realm). + * + *

Сортировка по самому свежему {@code event_time} — недавно + * активные юзеры сверху picker'а. + * + *

Лимит передаём через {@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 findDistinctUserIds(Pageable pageable); + + default List findDistinctUserIds(int limit) { + return findDistinctUserIds(org.springframework.data.domain.PageRequest.of(0, Math.max(1, limit))); + } } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/userdisplay/UserDisplayCacheEntry.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/userdisplay/UserDisplayCacheEntry.java index a2cc86a..af94872 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/userdisplay/UserDisplayCacheEntry.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/userdisplay/UserDisplayCacheEntry.java @@ -17,13 +17,15 @@ import java.time.OffsetDateTime; * *

Source semantics: *

    - *
  • {@link Source#JWT_CAPTURE} — written by JwtUserCaptureFilter - * on every authenticated request. Highest accuracy (claims as - * the user actually saw them).
  • - *
  • {@link Source#KEYCLOAK_SYNC} — written by the periodic - * Phase 3 scheduled bulk sync from Keycloak Admin API.
  • - *
  • {@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss - * triggers a one-shot Keycloak Admin lookup. Phase 2.
  • + *
  • {@link Source#KEYCLOAK_SYNC} — written by the scheduled bulk + * refresh job from Keycloak Admin API ({@code UserDisplayCacheRefreshJob}, + * default every 6h). Primary populator after Phase 2.
  • + *
  • {@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss on + * {@code find(sub)} triggers a one-shot Keycloak Admin lookup.
  • + *
  • {@link Source#JWT_CAPTURE} — legacy. Previously written by + * {@code JwtUserCaptureFilter} on every authenticated request; + * filter removed in Phase 2. Enum value retained for historical + * rows in existing prod databases.
  • *
*/ @Entity @@ -45,7 +47,7 @@ public class UserDisplayCacheEntry { @Enumerated(EnumType.STRING) @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) private OffsetDateTime syncedAt; 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 deleted file mode 100644 index ed78e8a..0000000 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java +++ /dev/null @@ -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. - * - *

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/KeycloakUserResolver.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/KeycloakUserResolver.java index 9b7cb87..14cf100 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/KeycloakUserResolver.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/KeycloakUserResolver.java @@ -4,13 +4,13 @@ import java.util.Optional; /** * Phase 2 contract: resolve sub → user info via Keycloak Admin API. The - * implementation (Phase 2) will be an HTTP client using a service-account - * client + role {@code view-users} on realm {@code nstart}. + * implementation is an HTTP client using a service-account client + role + * {@code view-users} on the configured realm (altum). * *

{@link UserDisplayService} consumes this as an optional dependency — * 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) - * deployment runnable without Keycloak admin creds. + * UserCell falls back to short UUID. This keeps the deployment runnable + * without Keycloak admin creds (dev/local). * *

Implementations must throw {@link RuntimeException} on transient errors * (network, 5xx) so the caller can log and continue. Return {@link Optional#empty()} @@ -18,6 +18,7 @@ import java.util.Optional; */ public interface KeycloakUserResolver { + /** Single-user lookup by Keycloak {@code sub} (UUID). 404 → empty. */ Optional findById(String sub); record KeycloakUser(String sub, String preferredUsername, String name, String 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 index 38ba10d..cfb0138 100644 --- 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 @@ -1,5 +1,6 @@ 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.Source; import cloud.nstart.terravault.ordinis.domain.userdisplay.UserDisplayCacheRepository; @@ -19,20 +20,25 @@ import java.util.concurrent.ConcurrentHashMap; *

    *
  1. JVM ConcurrentHashMap hot cache (~µs).
  2. *
  3. {@code user_display_cache} Postgres table (~ms, persistent across pod restart).
  4. - *
  5. Keycloak Admin API on-demand fallback (~10-50ms, Phase 2 — wired - * via optional {@link KeycloakUserResolver} bean).
  6. + *
  7. Keycloak Admin API on-demand fallback (~10-50ms, wired via optional + * {@link KeycloakUserResolver} bean).
  8. *
* - *

JwtUserCaptureFilter calls {@link #put} on every authenticated request - * → updates both hot cache and DB (idempotent merge). + *

Cache is populated lazily on Tier-3 lookup: first time a UUID is + * 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. * - *

Phase 3 (TBD): scheduled bulk sync from Keycloak will call - * {@link #upsertFromKeycloak} for every realm user every 6h to refill - * stale entries. + *

Previously a {@code JwtUserCaptureFilter} captured users opportunistically + * from JWT claims on every authenticated request. Removed in Phase 2 — Tier 3 + * Keycloak lookup fills the same gap on first cache miss. * - *

Hot cache reads from DB lazily on miss — no warm-up at startup - * (would block readiness probe + Keycloak might be cold). First request - * for each sub after restart pays a single DB hit. + *

Hot cache reads from DB lazily on miss — no warm-up at startup (would + * block readiness probe + Keycloak might be cold). First request for each + * sub after restart pays a single DB hit. */ @Service public class UserDisplayService { @@ -48,11 +54,14 @@ public class UserDisplayService { private final ConcurrentHashMap hotCache = new ConcurrentHashMap<>(); private final UserDisplayCacheRepository repository; + private final AuditLogRepository auditLogRepository; private final KeycloakUserResolver keycloakResolver; public UserDisplayService(UserDisplayCacheRepository repository, + AuditLogRepository auditLogRepository, @Autowired(required = false) KeycloakUserResolver keycloakResolver) { this.repository = repository; + this.auditLogRepository = auditLogRepository; this.keycloakResolver = keycloakResolver; if (keycloakResolver == null) { 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 * AND Keycloak is unreachable (transient — caller's UserCell will fall @@ -127,18 +125,50 @@ public class UserDisplayService { } /** - * Список всех закэшированных юзеров — для admin-UI пикеров (audit - * filter «Пользователь», review reviewer selector и т.п.). Сортируется - * по preferred_username для предсказуемой выдачи; hard-capped лимит - * чтобы не вытащить всю таблицу когда cache разрастётся до 10k+ - * (в этот момент перейдём на server-side search; пока 1000 хватит). + * Юзеры, реально появлявшиеся в {@code audit_log} (distinct user_id), + * с display из {@code user_display_cache}. Для UserPicker'а в /audit: + * фильтр имеет смысл только по тем, кто хоть раз попадал в audit. + * + *

Sub'ы которых нет в cache — все равно возвращаются (без display + * полей), чтобы юзер мог отфильтровать по UUID если KC недоступен + * и cache не прогрелся. Frontend покажет «UUID-prefix…». + * + *

Hard cap 1000 — реалистично audit будет содержать десятки уникальных + * юзеров на проекте, не сотни. */ - public List listAll(int limit) { + public List listAuditedUsers(String query, int limit) { int cap = Math.min(Math.max(limit, 1), 1000); - return repository.findAll().stream() - .map(UserDisplayService::toInfo) + List subs = auditLogRepository.findDistinctUserIds(cap); + if (subs.isEmpty()) return List.of(); + + java.util.Map 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( - (UserDisplayInfo i) -> i.preferredUsername() == null ? "" : i.preferredUsername(), + (UserDisplayInfo i) -> i.preferredUsername() == null + ? i.sub() // фолбэк — UUID для стабильной сортировки + : i.preferredUsername(), String.CASE_INSENSITIVE_ORDER)) .limit(cap) .toList(); 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 index 17ed108..e8519f6 100644 --- 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 @@ -19,13 +19,12 @@ import java.util.List; * username вместо short UUID для чужих пользователей в audit / reviews * tables. * - *

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

404 {@code user_not_cached} — sub неизвестен ни в локальном cache ни + * в Keycloak Admin (или KC недоступен). Frontend fallback'нёт на short UUID. * - *

Backed by {@link UserDisplayService} — in-memory cache, - * заполняется через {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter} - * на каждом authenticated request. + *

Backed by {@link UserDisplayService} — hot cache + Postgres + * {@code user_display_cache} table + Keycloak Admin API on-demand fallback. + * Cache populated lazily on first cache miss (Tier 3 KC lookup). */ @RestController @RequestMapping("/api/v1/admin/users") @@ -60,15 +59,27 @@ public class UserDisplayController { } /** - * Список всех закэшированных юзеров — для admin-UI пикеров (audit - * filter «Пользователь», reviewer selectors и т.п.). INTERNAL+ scope, - * hard-cap limit 1000. + * Юзеры, реально появлявшиеся в audit log (distinct user_id) — для + * UserPicker в /audit. Фильтр имеет смысл только по этим, а не по + * всему realm Keycloak (там могут быть сотни юзеров не имеющих + * отношения к ordinis). + * + *

Sub'ы которых нет в {@code user_display_cache} возвращаются с + * {@code null} display полями — frontend покажет UUID-prefix. Это + * корректно: до первого UserCell render'а нового юзера cache холодный + * (Tier 3 KC lookup триггерится render'ом, не этим endpoint'ом). + * + *

INTERNAL+ scope, hard-cap limit 1000. Опциональный {@code search} + * фильтрует по preferred_username / name / email / sub (substring, + * case-insensitive) ПОСЛЕ join'а — server-side, чтобы не гонять + * лишние UUID'ы по сети. */ - @GetMapping("") - public List list( + @GetMapping("/audited") + public List listAudited( + @RequestParam(required = false) String search, @RequestParam(defaultValue = "500") int limit) { requireInternal(); - return service.listAll(limit).stream() + return service.listAuditedUsers(search, limit).stream() .map(UserDisplayResponse::from) .toList(); }