Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
107 lines
5 KiB
Markdown
107 lines
5 KiB
Markdown
---
|
|
id: KEY-WP-0020
|
|
type: workplan
|
|
title: "Make the Keycloak transform preserve or name every policy field"
|
|
domain: infotech
|
|
repo: key-cape
|
|
status: finished
|
|
owner: claude
|
|
topic_slug: migration-contract-preservation
|
|
created: "2026-09-07"
|
|
updated: "2026-09-07"
|
|
state_hub_workstream_id: "803845de-98ad-5a5b-89ec-11bd9e0fafec"
|
|
---
|
|
|
|
Closes the first half of gap G03 of
|
|
`history/2026-09-05-011726-scope-intent-assessment.md` — semantic preservation.
|
|
The second half, proof against a running Keycloak, is G04 and stays open.
|
|
|
|
`keycape-to-keycloak` calls `Transform`, which passes no clients, so the realm it
|
|
writes has an empty `clients` array and nothing says the service-identity
|
|
contract was not migrated. `TransformWithClients` exists but no caller reaches
|
|
it. Where clients are supplied, `mapClient` always enables the standard flow
|
|
regardless of grant types, and drops `Audience`, `ServiceSubject`, `Tenant`,
|
|
`Roles`, `TokenLifetime`, `MFARequired`, `SecretRef` and the handoff URLs.
|
|
`mapUser` drops the canonical ID, tenant and roles. Realm roles and client scopes
|
|
are written as empty containers with a comment saying they "can be extended".
|
|
|
|
The failure mode is not the missing mapping — it is that a dropped field and an
|
|
inapplicable one look identical in the output. An operator diffing the realm JSON
|
|
against the KeyCape config has no way to tell what this tool decided not to carry
|
|
across. Preserve what Keycloak can express, and name the rest.
|
|
|
|
## Give the CLI the client registrations
|
|
|
|
```task
|
|
id: KEY-WP-0020-T01
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "6f6bcfd7-16c5-5311-9568-ad9aa9c3b60a"
|
|
```
|
|
|
|
Add a `-clients` flag reading the KeyCape config's client registrations and pass
|
|
them through `TransformWithClients`. When no client file is given, say so on
|
|
stderr and in the report rather than emitting an empty `clients` array that reads
|
|
like a realm with no clients.
|
|
|
|
Added `-clients`, reading registrations through a new `config.Registrations()`
|
|
that converts without resolving secrets — migration tooling needs the contract,
|
|
never the material, so `ClientSecret` is left empty by construction rather than
|
|
by remembering not to use it. An unparseable `tokenLifetime` is an error there,
|
|
since silently dropping a per-client lifetime is the defect class this closes.
|
|
Run against `config/dev-config.yaml`, all five registrations now reach the realm.
|
|
|
|
## Preserve what Keycloak can express
|
|
|
|
```task
|
|
id: KEY-WP-0020-T02
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "88c0126c-fd73-5178-af1f-a018708ff8fb"
|
|
```
|
|
|
|
Derive `standardFlowEnabled` and `serviceAccountsEnabled` from the declared grant
|
|
types instead of hardcoding the standard flow on. Carry the audience, tenant,
|
|
service subject, roles and principal type as protocol mappers, per-client
|
|
lifetime as the Keycloak client attribute that expresses it, and the handoff URLs
|
|
and secret reference as attributes — the reference, never a secret value. Derive
|
|
realm roles and client scopes from the users and clients actually present rather
|
|
than emitting empty containers. Carry the user's canonical ID, tenant and roles.
|
|
|
|
Flows now follow the grants: the old mapping set `standardFlowEnabled` on every
|
|
client, so migrating a `client_credentials` service registration silently gave it
|
|
the browser flow. Audience, tenant, service subject and roles ride as protocol
|
|
mappers, since Keycloak has no native concept for the profile's claims and would
|
|
otherwise issue tokens the profile rejects. Lifetime, handoff URLs and the secret
|
|
*reference* are attributes; a test marshals the realm and fails if a resolved
|
|
secret value appears, because a realm import file is not a custody boundary.
|
|
Realm roles and client scopes are derived and sorted.
|
|
|
|
## Name everything not preserved
|
|
|
|
```task
|
|
id: KEY-WP-0020-T03
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "5b8127b2-c01c-5d3c-afbf-adbb368941e6"
|
|
```
|
|
|
|
Report each field the transform cannot carry, at the point it is dropped, so the
|
|
output distinguishes a deliberate omission from a gap: passwords and MFA
|
|
credentials, MFA policy enforcement, and subject continuity — LLDAP's canonical
|
|
ID is a DN, and Keycloak will mint its own `sub`, so tokens after a migration
|
|
will not carry the same subject unless something downstream maps it. Fail the
|
|
realm-level validation when a supplied client's contract is not fully
|
|
represented. Test that each unpreserved field appears in the report.
|
|
|
|
`UnpreservedReport()` is separate from `ValidationReport()`. Folding them
|
|
together was the first attempt and it broke an existing test asserting a clean
|
|
export reports nothing — correctly so: consistency with the snapshot and
|
|
completeness of the migration are different questions, and one list cannot answer
|
|
both. Keeping them apart preserves the existing contract and makes the
|
|
distinction the point rather than a side effect.
|
|
|
|
Six tests cover the flows, the mappers and lifetime, the secret-value exclusion,
|
|
the derived roles and scopes, every named limit, and the separation of the two
|
|
reports. Mutation-checked: restoring the hardcoded `standardFlowEnabled` fails
|
|
the flow test. An incomplete transform now emits `partial` telemetry.
|