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
5 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | state_hub_workstream_id |
|---|---|---|---|---|---|---|---|---|---|---|
| KEY-WP-0020 | workplan | Make the Keycloak transform preserve or name every policy field | infotech | key-cape | finished | claude | migration-contract-preservation | 2026-09-07 | 2026-09-07 | 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
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
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
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.