feat(user-display): persistent DB cache + Keycloak resolver hook (Phase 1+2 wiring)
Closes "ток мой UUID отображается" pain reported on staging today: any
reviewer / maker / publisher / actor whose JWT sub never landed in the
JVM cache shows up as a short UUID in audit / reviews / changelog /
events / history / record drawer / webhook detail / record version
history. Restart the pod and even captured users disappear.
Three-tier resolve, replacing the in-memory-only cache that was the
"Альтернатива (на будущее)" TODO in the original UserDisplayService:
1. JVM ConcurrentHashMap hot cache (~µs, capped at 10k).
2. user_display_cache Postgres table (~ms, persistent across restart).
New migration 0023 — sub PK + preferred_username + name + email +
source enum + synced_at + updated_at, plus an index on synced_at
for the scheduled bulk sync that lands later.
3. Keycloak Admin API on-demand (Phase 2) — dispatched to optional
KeycloakUserResolver bean. When the bean isn't wired (no Keycloak
admin creds yet), the third tier is a no-op and UserCell falls
back to short UUID exactly like before.
Source enum on every row — JWT_CAPTURE / KEYCLOAK_SYNC /
KEYCLOAK_ON_DEMAND — so the eventual scheduled sync can prefer JWT-
captured rows (richest claims as the user actually saw them) and the
on-demand fallback can be distinguished from bulk sync in audits.
JwtUserCaptureFilter and UserDisplayController are unchanged — they
already used UserDisplayService.put / find with the same signatures.
The behavior change is invisible at the API layer: same /admin/users/
{sub}/display endpoint, same 404 user_not_cached on miss; persistence
just survives pod restart now.
mergeFrom on UserDisplayCacheEntry deliberately keeps the existing
non-blank field when an update brings null — Keycloak sync may have a
richer email than JWT capture; don't blank it out.
Phase 2 will land a KeycloakAdminUserResolver impl + vault secret
plumbing on this same branch. Phase 3 (scheduled bulk sync via
@Scheduled) is a separate sprint — it needs the Keycloak admin client
already in place.
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
package cloud.nstart.terravault.ordinis.domain.userdisplay;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Persistent cache entry for JWT sub → display info resolve. See
|
||||
* migration 0023-user-display-cache.xml for the table contract and
|
||||
* the multi-tier resolve strategy.
|
||||
*
|
||||
* <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>
|
||||
* </ul>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_display_cache")
|
||||
public class UserDisplayCacheEntry {
|
||||
|
||||
@Id
|
||||
@Column(name = "sub", nullable = false, length = 64)
|
||||
private String sub;
|
||||
|
||||
@Column(name = "preferred_username", length = 255)
|
||||
private String preferredUsername;
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
private String name;
|
||||
|
||||
@Column(name = "email", length = 320)
|
||||
private String email;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false, length = 32)
|
||||
private Source source = Source.JWT_CAPTURE;
|
||||
|
||||
@Column(name = "synced_at", nullable = false)
|
||||
private OffsetDateTime syncedAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
protected UserDisplayCacheEntry() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public UserDisplayCacheEntry(String sub, String preferredUsername, String name,
|
||||
String email, Source source, OffsetDateTime syncedAt) {
|
||||
this.sub = sub;
|
||||
this.preferredUsername = preferredUsername;
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.source = source;
|
||||
this.syncedAt = syncedAt;
|
||||
}
|
||||
|
||||
public String getSub() {
|
||||
return sub;
|
||||
}
|
||||
|
||||
public String getPreferredUsername() {
|
||||
return preferredUsername;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public Source getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public OffsetDateTime getSyncedAt() {
|
||||
return syncedAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent merge from a fresh capture / sync. Updates fields that
|
||||
* have a new non-null value; leaves existing data otherwise (e.g.
|
||||
* Keycloak sync may have richer email than JWT capture, don't blank).
|
||||
*/
|
||||
public void mergeFrom(String preferredUsername, String name, String email,
|
||||
Source source, OffsetDateTime syncedAt) {
|
||||
if (preferredUsername != null && !preferredUsername.isBlank()) {
|
||||
this.preferredUsername = preferredUsername;
|
||||
}
|
||||
if (name != null && !name.isBlank()) {
|
||||
this.name = name;
|
||||
}
|
||||
if (email != null && !email.isBlank()) {
|
||||
this.email = email;
|
||||
}
|
||||
this.source = source;
|
||||
this.syncedAt = syncedAt;
|
||||
}
|
||||
|
||||
public enum Source {
|
||||
JWT_CAPTURE,
|
||||
KEYCLOAK_SYNC,
|
||||
KEYCLOAK_ON_DEMAND
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package cloud.nstart.terravault.ordinis.domain.userdisplay;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserDisplayCacheRepository extends JpaRepository<UserDisplayCacheEntry, String> {
|
||||
// PK is sub (String). Built-in findById(sub), save, etc. covers Phase 1+2.
|
||||
// Phase 3 (scheduled bulk sync) will add `findAllBySyncedAtBefore(...)`
|
||||
// and `findAllByOrderBySyncedAtAsc(Pageable)` for stale-first refresh.
|
||||
}
|
||||
Reference in New Issue
Block a user