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
}
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<UserListItem[]> => {
try {
const { data } = await apiClient.get<UserListItem[]>('/admin/users', {
const { data } = await apiClient.get<UserListItem[]>('/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,
@@ -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.
*
* <p>Источник: GET /api/v1/admin/users (cached user_display table).
* INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой
* список и падает на input fallback (поле остаётся редактируемым).
* <p>Источник: 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.
*
* <p>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 не
@@ -61,4 +61,29 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
@Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to,
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:
* <ul>
* <li>{@link Source#JWT_CAPTURE} — written by JwtUserCaptureFilter
* on every authenticated request. Highest accuracy (claims as
* the user actually saw them).</li>
* <li>{@link Source#KEYCLOAK_SYNC} — written by the periodic
* Phase 3 scheduled bulk sync from Keycloak Admin API.</li>
* <li>{@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss
* triggers a one-shot Keycloak Admin lookup. Phase 2.</li>
* <li>{@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.</li>
* <li>{@link Source#KEYCLOAK_ON_DEMAND} — written when a cache miss on
* {@code find(sub)} triggers a one-shot Keycloak Admin lookup.</li>
* <li>{@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.</li>
* </ul>
*/
@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;
@@ -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
* 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).
*
* <p>{@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).
*
* <p>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<KeycloakUser> findById(String sub);
record KeycloakUser(String sub, String preferredUsername, String name, String email) {}
@@ -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;
* <ol>
* <li>JVM ConcurrentHashMap hot cache (~µs).</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
* via optional {@link KeycloakUserResolver} bean).</li>
* <li>Keycloak Admin API on-demand fallback (~10-50ms, wired via optional
* {@link KeycloakUserResolver} bean).</li>
* </ol>
*
* <p>JwtUserCaptureFilter calls {@link #put} on every authenticated request
* → updates both hot cache and DB (idempotent merge).
* <p>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.
*
* <p>Phase 3 (TBD): scheduled bulk sync from Keycloak will call
* {@link #upsertFromKeycloak} for every realm user every 6h to refill
* stale entries.
* <p>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.
*
* <p>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.
* <p>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<String, UserDisplayInfo> 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.
*
* <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);
return repository.findAll().stream()
.map(UserDisplayService::toInfo)
List<String> subs = auditLogRepository.findDistinctUserIds(cap);
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(
(UserDisplayInfo i) -> i.preferredUsername() == null ? "" : i.preferredUsername(),
(UserDisplayInfo i) -> i.preferredUsername() == null
? i.sub() // фолбэк — UUID для стабильной сортировки
: i.preferredUsername(),
String.CASE_INSENSITIVE_ORDER))
.limit(cap)
.toList();
@@ -19,13 +19,12 @@ import java.util.List;
* username вместо short UUID для чужих пользователей в audit / reviews
* tables.
*
* <p>404 {@code user_not_cached} — sub неизвестен (user ни разу не делал
* authenticated request на этом backend instance). Frontend fallback'нёт
* на short UUID.
* <p>404 {@code user_not_cached} — sub неизвестен ни в локальном cache ни
* в Keycloak Admin (или KC недоступен). Frontend fallback'нёт на short UUID.
*
* <p>Backed by {@link UserDisplayService} — in-memory cache,
* заполняется через {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter}
* на каждом authenticated request.
* <p>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).
*
* <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("")
public List<UserDisplayResponse> list(
@GetMapping("/audited")
public List<UserDisplayResponse> 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();
}