Make the Keycloak transform preserve or name every policy field
keycape-to-keycloak called Transform, which passes no clients, so it wrote a
realm with an empty clients array and nothing said the service-identity contract
had not been migrated. Where clients were supplied, mapClient hardcoded
standardFlowEnabled — silently giving every client_credentials registration the
browser flow — and dropped audience, service subject, tenant, roles, lifetime,
MFA policy, secret reference and handoff URLs. Realm roles and client scopes were
emitted as empty containers.
The defect was not the missing mapping but that a dropped field and an
inapplicable one looked identical in the output.
Add -clients, reading registrations through a new config.Registrations() that
converts without resolving secrets, so migration tooling cannot load material it
has no business holding. Derive flows from the declared grants. Carry the profile
claims as protocol mappers, since Keycloak has no native concept for them, and
lifetime, handoff URLs and the secret reference as attributes — the reference,
never a value. Derive realm roles and client scopes from what is present.
Report what cannot be carried, in UnpreservedReport, kept deliberately separate
from ValidationReport: consistency with the snapshot and completeness of the
migration are different questions and one list cannot answer both. It names the
unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment,
and subject continuity. An incomplete transform emits partial telemetry.
Closes the semantic-preservation half of G03; proof against a live provider is
G04 and stays open.
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
2026-09-07 13:48:48 +02:00
|
|
|
---
|
|
|
|
|
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"
|
2026-09-07 13:49:40 +02:00
|
|
|
state_hub_workstream_id: "803845de-98ad-5a5b-89ec-11bd9e0fafec"
|
Make the Keycloak transform preserve or name every policy field
keycape-to-keycloak called Transform, which passes no clients, so it wrote a
realm with an empty clients array and nothing said the service-identity contract
had not been migrated. Where clients were supplied, mapClient hardcoded
standardFlowEnabled — silently giving every client_credentials registration the
browser flow — and dropped audience, service subject, tenant, roles, lifetime,
MFA policy, secret reference and handoff URLs. Realm roles and client scopes were
emitted as empty containers.
The defect was not the missing mapping but that a dropped field and an
inapplicable one looked identical in the output.
Add -clients, reading registrations through a new config.Registrations() that
converts without resolving secrets, so migration tooling cannot load material it
has no business holding. Derive flows from the declared grants. Carry the profile
claims as protocol mappers, since Keycloak has no native concept for them, and
lifetime, handoff URLs and the secret reference as attributes — the reference,
never a value. Derive realm roles and client scopes from what is present.
Report what cannot be carried, in UnpreservedReport, kept deliberately separate
from ValidationReport: consistency with the snapshot and completeness of the
migration are different questions and one list cannot answer both. It names the
unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment,
and subject continuity. An incomplete transform emits partial telemetry.
Closes the semantic-preservation half of G03; proof against a live provider is
G04 and stays open.
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
2026-09-07 13:48:48 +02:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-07 13:49:40 +02:00
|
|
|
state_hub_task_id: "6f6bcfd7-16c5-5311-9568-ad9aa9c3b60a"
|
Make the Keycloak transform preserve or name every policy field
keycape-to-keycloak called Transform, which passes no clients, so it wrote a
realm with an empty clients array and nothing said the service-identity contract
had not been migrated. Where clients were supplied, mapClient hardcoded
standardFlowEnabled — silently giving every client_credentials registration the
browser flow — and dropped audience, service subject, tenant, roles, lifetime,
MFA policy, secret reference and handoff URLs. Realm roles and client scopes were
emitted as empty containers.
The defect was not the missing mapping but that a dropped field and an
inapplicable one looked identical in the output.
Add -clients, reading registrations through a new config.Registrations() that
converts without resolving secrets, so migration tooling cannot load material it
has no business holding. Derive flows from the declared grants. Carry the profile
claims as protocol mappers, since Keycloak has no native concept for them, and
lifetime, handoff URLs and the secret reference as attributes — the reference,
never a value. Derive realm roles and client scopes from what is present.
Report what cannot be carried, in UnpreservedReport, kept deliberately separate
from ValidationReport: consistency with the snapshot and completeness of the
migration are different questions and one list cannot answer both. It names the
unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment,
and subject continuity. An incomplete transform emits partial telemetry.
Closes the semantic-preservation half of G03; proof against a live provider is
G04 and stays open.
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
2026-09-07 13:48:48 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-07 13:49:40 +02:00
|
|
|
state_hub_task_id: "88c0126c-fd73-5178-af1f-a018708ff8fb"
|
Make the Keycloak transform preserve or name every policy field
keycape-to-keycloak called Transform, which passes no clients, so it wrote a
realm with an empty clients array and nothing said the service-identity contract
had not been migrated. Where clients were supplied, mapClient hardcoded
standardFlowEnabled — silently giving every client_credentials registration the
browser flow — and dropped audience, service subject, tenant, roles, lifetime,
MFA policy, secret reference and handoff URLs. Realm roles and client scopes were
emitted as empty containers.
The defect was not the missing mapping but that a dropped field and an
inapplicable one looked identical in the output.
Add -clients, reading registrations through a new config.Registrations() that
converts without resolving secrets, so migration tooling cannot load material it
has no business holding. Derive flows from the declared grants. Carry the profile
claims as protocol mappers, since Keycloak has no native concept for them, and
lifetime, handoff URLs and the secret reference as attributes — the reference,
never a value. Derive realm roles and client scopes from what is present.
Report what cannot be carried, in UnpreservedReport, kept deliberately separate
from ValidationReport: consistency with the snapshot and completeness of the
migration are different questions and one list cannot answer both. It names the
unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment,
and subject continuity. An incomplete transform emits partial telemetry.
Closes the semantic-preservation half of G03; proof against a live provider is
G04 and stays open.
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
2026-09-07 13:48:48 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-07 13:49:40 +02:00
|
|
|
state_hub_task_id: "5b8127b2-c01c-5d3c-afbf-adbb368941e6"
|
Make the Keycloak transform preserve or name every policy field
keycape-to-keycloak called Transform, which passes no clients, so it wrote a
realm with an empty clients array and nothing said the service-identity contract
had not been migrated. Where clients were supplied, mapClient hardcoded
standardFlowEnabled — silently giving every client_credentials registration the
browser flow — and dropped audience, service subject, tenant, roles, lifetime,
MFA policy, secret reference and handoff URLs. Realm roles and client scopes were
emitted as empty containers.
The defect was not the missing mapping but that a dropped field and an
inapplicable one looked identical in the output.
Add -clients, reading registrations through a new config.Registrations() that
converts without resolving secrets, so migration tooling cannot load material it
has no business holding. Derive flows from the declared grants. Carry the profile
claims as protocol mappers, since Keycloak has no native concept for them, and
lifetime, handoff URLs and the secret reference as attributes — the reference,
never a value. Derive realm roles and client scopes from what is present.
Report what cannot be carried, in UnpreservedReport, kept deliberately separate
from ValidationReport: consistency with the snapshot and completeness of the
migration are different questions and one list cannot answer both. It names the
unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment,
and subject continuity. An incomplete transform emits partial telemetry.
Closes the semantic-preservation half of G03; proof against a live provider is
G04 and stays open.
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
2026-09-07 13:48:48 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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.
|