diff --git a/docs-internal/keycloak-admin-setup.md b/docs-internal/keycloak-admin-setup.md new file mode 100644 index 0000000..a490fb6 --- /dev/null +++ b/docs-internal/keycloak-admin-setup.md @@ -0,0 +1,136 @@ +# Keycloak Admin client setup for UserDisplayService Phase 2 + +`KeycloakAdminUserResolver` resolves a JWT `sub` against the Keycloak Admin +REST API on cache miss, so users that never hit ordinis still get a +display name in audit / reviews / changelog / events / history. Without +this, only users who personally made an authenticated request show up +with a real name (the "ток мой UUID отображается" pain). + +This is a per-environment one-time setup. Disabled by default — flip on +once the Keycloak client + vault secret exist. + +## Step 1: Create service-account client in Keycloak + +Realm: `nstart`. Path: Clients → Create client. + +| Field | Value | +|---|---| +| Client type | OpenID Connect | +| Client ID | `ordinis-admin-client` | +| Client authentication | **On** (confidential) | +| Authorization | Off | +| Standard flow | Off | +| Direct access grants | Off | +| Service accounts roles | **On** | + +Save. On the created client → **Service accounts roles** tab → **Assign +role** → Filter by clients → `realm-management` → check **`view-users`** +→ Assign. + +(`view-users` is the minimum scope needed for `GET /admin/realms/{realm}/users/{id}`. +Don't assign `manage-users` — read-only is enough.) + +Then **Credentials** tab → copy the `Client secret` (long random string). + +## Step 2: Store secret in Vault + +The cluster-domain matters here — staging is `265.local`, prod is +`altum.local`. Both have separate Vault instances. + +Staging (cluster.265, namespace `config`): +```bash +kubectl -n config exec -it vault-0 -- sh -c ' + vault login + vault kv put secret/ordinis/cuod/keycloak-admin \ + client_id=ordinis-admin-client \ + client_secret= +' +``` + +Prod (altum, namespace `vault`): +```bash +ssh root@192.168.100.142 'kubectl -n vault exec -it vault-0 -- sh -c " + vault login + vault kv put secret/ordinis/cuod/keycloak-admin \ + client_id=ordinis-admin-client \ + client_secret= +"' +``` + +⚠️ Different secret per environment — staging and prod have separate +Keycloak clients (and certainly separate secrets). + +## Step 3: Wire into ordinis-app deployment + +Add to `ordinis-app` helm chart annotations (vault agent injector +template) — same pattern as `secret-postgres` / `secret-kafka` already +present in the chart: + +```yaml +vault.hashicorp.com/agent-inject-secret-keycloak-admin: secret/data/ordinis/cuod/keycloak-admin +vault.hashicorp.com/agent-inject-template-keycloak-admin: | + {{- with secret "secret/data/ordinis/cuod/keycloak-admin" -}} + export ORDINIS_KEYCLOAK_ADMIN_CLIENT_ID="{{ .Data.data.client_id }}" + export ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET="{{ .Data.data.client_secret }}" + {{- end }} +``` + +Add env vars to the deployment: + +```yaml +env: + - name: ORDINIS_KEYCLOAK_ADMIN_ENABLED + value: "true" + - name: ORDINIS_KEYCLOAK_ADMIN_URL + value: "https://auth.nstart.space" # both envs use the same Keycloak + - name: ORDINIS_KEYCLOAK_ADMIN_REALM + value: "nstart" + # CLIENT_ID + CLIENT_SECRET arrive via the vault-injected + # /vault/secrets/keycloak-admin file sourced before java starts. +``` + +## Step 4: Verify + +After redeploy, check the pod log on startup: + +``` +KeycloakAdminUserResolver active — base=https://auth.nstart.space realm=nstart client=ordinis-admin-client +``` + +End-to-end: + +```bash +# Find a sub that's NOT in the JVM cache (e.g. a user who never logged in). +# Hit the display endpoint as an admin user: +curl -s -H "Authorization: Bearer $TOKEN" \ + https://ordinis.k8s.265.nstart.cloud/api/v1/admin/users//display | jq +# Expect 200 with username/name/email even on cold cache, instead of 404. +``` + +Then check the DB — the row was persisted from the on-demand resolve: + +```sql +SELECT sub, preferred_username, source, synced_at + FROM user_display_cache + WHERE sub = ''; +-- source = KEYCLOAK_ON_DEMAND +``` + +## Step 5: Phase 3 (later — scheduled bulk sync) + +When ready to refill the table proactively (avoids the per-user +on-demand latency for fresh subs), add a Spring `@Scheduled` task that +calls `GET /admin/realms/{realm}/users` paginated and writes via +`UserDisplayService.upsertFromKeycloak`. Recommended every 6h. Not +included in this MR — depends on observed cache miss rate. + +## Failure modes + +- **Service-account client missing `view-users`** → 403 on lookup, + resolver throws RuntimeException, UserDisplayService logs warn, + caller falls back to short UUID. No outage, just no resolve. +- **Keycloak unreachable** → connect timeout / 502. Same fallback as + above. Cached entries (DB tier) keep working. +- **client_secret rotated in Keycloak but vault not updated** → 401 on + token request → resolver wipes cached token and surfaces + RuntimeException. Update vault secret and the next attempt succeeds. diff --git a/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx index 4dc8b27..b3f64b7 100644 --- a/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx +++ b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Badge, LoadingBlock, Modal, QueryErrorState } from '@/ui' import { useChangelogDiff } from '@/api/queries' +import { UserCell } from '@/lib/useUserDisplay' import { kindBadgeVariant, kindLabel } from './kind-meta' /** @@ -52,9 +53,9 @@ export function ChangelogDiffModal({ {/* Header strip — kind + author */}
{kindLabel(data.kind, t)} - + {t('changelog.diff.by', { defaultValue: 'автор' })}:{' '} - {data.publishedBy.name} +
diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx index dd7d77c..5a14ff7 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx @@ -6,6 +6,7 @@ import type { ChangelogEntry, DictionaryDetail, FlattenedRecord } from '@/api/cl import { useChangelog, useChangelogDiff, useRecords } from '@/api/queries' import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta' import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal' +import { UserCell } from '@/lib/useUserDisplay' import { cn } from '@/lib/utils' /** @@ -411,7 +412,9 @@ function ComparePanel({ · {note} )} - {author && · {author}} + {author && ( + · + )} ) @@ -683,7 +686,7 @@ function ChangesTabContent({ {new Date(e.publishedAt).toLocaleString()} - {e.publishedBy.name} +