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