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:
+81
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<!--
|
||||
User display cache (sub → username/email/name) — persistent backing store
|
||||
for UserDisplayService.
|
||||
|
||||
Цель: остановить bleeding "ток мой UUID отображается" в audit/reviews/
|
||||
history/changelog. Раньше UserDisplayService держал данные in-memory →
|
||||
pod restart терял всё → users которые редко логинились показывались
|
||||
как UUID.
|
||||
|
||||
Three-tier resolve chain (после wire-up в UserDisplayService):
|
||||
1. JVM ConcurrentHashMap (hot cache, ~µs)
|
||||
2. user_display_cache table (этот migration, ~ms)
|
||||
3. Keycloak Admin API on-demand (~10-50ms, only if not in DB)
|
||||
+ nightly scheduled bulk sync to refill (Phase 3).
|
||||
|
||||
Decisions:
|
||||
- PK = sub (UUID-like Keycloak user id). Match'ит JWT sub claim
|
||||
directly — нет surrogate id.
|
||||
- source ENUM текстом ('JWT_CAPTURE' | 'KEYCLOAK_SYNC' |
|
||||
'KEYCLOAK_ON_DEMAND') — Hibernate @Enumerated(STRING) уrok из 0020.
|
||||
- synced_at = когда последний раз обновили из источника. updated_at =
|
||||
row mtime (Hibernate @UpdateTimestamp). Разные потому что
|
||||
KEYCLOAK_SYNC bulk job обновляет каждые 6h даже если данные не
|
||||
изменились.
|
||||
- Soft cap on rows не нужен — Keycloak realm пока ~50 users, table
|
||||
будет ≤ 1k entries обозримо.
|
||||
-->
|
||||
|
||||
<changeSet id="0023-1-user-display-cache" author="ordinis">
|
||||
<comment>user_display_cache — persistent sub → display info, three-tier resolve</comment>
|
||||
<createTable tableName="user_display_cache">
|
||||
<column name="sub" type="VARCHAR(64)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="preferred_username" type="VARCHAR(255)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(255)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
<column name="email" type="VARCHAR(320)"><!-- RFC 5321 max -->
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
<column name="source" type="VARCHAR(32)" defaultValue="JWT_CAPTURE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="synced_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="updated_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0023-2-user-display-cache-source-check" author="ordinis">
|
||||
<comment>Enforce source enum values inline в DB чтобы insert с typo шумел</comment>
|
||||
<sql>
|
||||
ALTER TABLE user_display_cache
|
||||
ADD CONSTRAINT user_display_cache_source_check
|
||||
CHECK (source IN ('JWT_CAPTURE', 'KEYCLOAK_SYNC', 'KEYCLOAK_ON_DEMAND'));
|
||||
</sql>
|
||||
<rollback>
|
||||
ALTER TABLE user_display_cache DROP CONSTRAINT user_display_cache_source_check;
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0023-3-user-display-cache-synced-index" author="ordinis">
|
||||
<comment>Index for Phase 3 nightly sync — pull stale entries by synced_at</comment>
|
||||
<createIndex tableName="user_display_cache" indexName="idx_user_display_synced_at">
|
||||
<column name="synced_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
@@ -32,5 +32,6 @@
|
||||
<include file="changes/0020-fix-draft-status-case.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0021-notifications.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0023-user-display-cache.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
Reference in New Issue
Block a user