feat(auth): Phase 2 — audited-users UserPicker + Tier-3 Keycloak Admin lookup
This commit is contained in:
@@ -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 не
|
||||||
|
|||||||
+25
@@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -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;
|
||||||
|
|||||||
-59
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-4
@@ -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) {}
|
||||||
|
|||||||
+60
-30
@@ -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();
|
||||||
|
|||||||
+23
-12
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user