Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
---
|
|
|
|
|
id: KEY-WP-0023
|
|
|
|
|
type: workplan
|
|
|
|
|
title: "Prove the migration against live directories and fix what that surfaced"
|
|
|
|
|
domain: infotech
|
|
|
|
|
repo: key-cape
|
|
|
|
|
status: finished
|
|
|
|
|
owner: claude
|
|
|
|
|
topic_slug: live-migration-proof
|
|
|
|
|
created: "2026-09-08"
|
|
|
|
|
updated: "2026-09-08"
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_workstream_id: "9f19bfa0-78d1-5b5c-9b64-e251fa4838d6"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
---
|
|
|
|
|
|
|
|
|
|
Finishes the unproven half of gap G04. KEY-WP-0022 made the harness runnable and
|
|
|
|
|
targeted conformance at a live issuer; what remained was directory migration
|
|
|
|
|
against a real directory, both sides of the swap measured, and the explicit
|
|
|
|
|
limits recorded.
|
|
|
|
|
|
|
|
|
|
Running it found four defects that no amount of reading would have. That is the
|
|
|
|
|
result worth keeping: every one of them passed the full unit suite.
|
|
|
|
|
|
|
|
|
|
## Run the export against a live LLDAP
|
|
|
|
|
|
|
|
|
|
```task
|
|
|
|
|
id: KEY-WP-0023-T01
|
|
|
|
|
status: done
|
|
|
|
|
priority: high
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_task_id: "b7b2ea88-8457-5fae-a500-a2d0f75754de"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Defect: every LLDAP user search pointed at a branch that does not exist.**
|
|
|
|
|
`Config.userOU()` defaulted to `ou=users`; LLDAP stores users under `ou=people`,
|
|
|
|
|
and nothing in this repository set `UserOU` to correct it. `LookupUser`,
|
|
|
|
|
`ListUsers` and `ValidatePassword` all derive their search base from it, so all
|
|
|
|
|
three silently found nothing against a stock LLDAP — human login included, not
|
|
|
|
|
just the export.
|
|
|
|
|
|
|
|
|
|
Symptom that exposed it: exporting from a live LLDAP returned **zero users while
|
|
|
|
|
still emitting a membership referencing `uid=admin`**, a snapshot the repository's
|
|
|
|
|
own validator rejects with `referenced_users_exist`. Default corrected to
|
|
|
|
|
`ou=people` and pinned by a test; the re-run exports one user and validates
|
|
|
|
|
clean.
|
|
|
|
|
|
|
|
|
|
## Correct the rule I broke in KEY-WP-0021
|
|
|
|
|
|
|
|
|
|
```task
|
|
|
|
|
id: KEY-WP-0023-T02
|
|
|
|
|
status: done
|
|
|
|
|
priority: high
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_task_id: "2f988626-ed08-52ce-919a-cd45d48e712d"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Defect of my own making.** The LLDAP adapter records `_validation_warning`
|
|
|
|
|
inside `LDAPAttributes`. The `raw_attributes_well_formed` rule added in
|
|
|
|
|
KEY-WP-0021 rejects underscores as invalid attribute descriptors, so the
|
|
|
|
|
exporter's own annotation made its own output fail validation — a false positive
|
|
|
|
|
shipped one workplan earlier and caught only by validating a real export.
|
|
|
|
|
|
|
|
|
|
Keys prefixed with `_` are now recognised as tooling annotations and skipped,
|
|
|
|
|
documented as such. A test proves the exemption does not weaken the rule: a
|
|
|
|
|
malformed real attribute alongside an annotation still fails.
|
|
|
|
|
|
|
|
|
|
## Prove the LDIF loads into a real OpenLDAP
|
|
|
|
|
|
|
|
|
|
```task
|
|
|
|
|
id: KEY-WP-0023-T03
|
|
|
|
|
status: done
|
|
|
|
|
priority: high
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_task_id: "0f503342-0ca8-55ad-b7e4-e2316d4d7bad"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Defect: migrated group memberships were dangling.** `resolveMemberDN` returned
|
|
|
|
|
a member that already looked like a DN unchanged, so members kept their *source*
|
|
|
|
|
DNs (`uid=admin,ou=people,…`) while `writeUser` wrote entries to the *target*
|
|
|
|
|
branch (`ou=users`). Groups named entries the migrated directory does not
|
|
|
|
|
contain. Only the naming value is carried across now; branch and naming
|
|
|
|
|
attribute come from the target.
|
|
|
|
|
|
|
|
|
|
**Defect: empty groups could not be loaded at all.** `groupOfNames` makes
|
|
|
|
|
`member` a MUST, so OpenLDAP rejected every memberless group with an object class
|
|
|
|
|
violation and aborted the load. Since KEY-WP-0018 the export deliberately
|
|
|
|
|
preserves empty groups, so this was reachable by any real migration. Empty groups
|
|
|
|
|
now reference a placeholder entry the same LDIF creates — an `organizationalRole`,
|
|
|
|
|
not a person, so it cannot be mistaken for a migrated user — emitted only when
|
|
|
|
|
some group needs it.
|
|
|
|
|
|
|
|
|
|
**Defect: the compose file could not start.** `bitnami/openldap:2.6` does not
|
|
|
|
|
exist in the registry. It passed `docker compose config` in KEY-WP-0022, which is
|
|
|
|
|
exactly the gap between a file that parses and a stack that runs. Pinned to
|
|
|
|
|
`osixia/openldap:1.5.0`, the image the scenario was actually proved against.
|
|
|
|
|
|
|
|
|
|
Proof: LLDAP → export → validate → LDIF → `ldapadd` into OpenLDAP 1.5.0, all
|
|
|
|
|
seven entries added with no errors, and every `member` value in the loaded
|
|
|
|
|
directory resolves to an entry that exists.
|
|
|
|
|
|
|
|
|
|
## Measure both sides of the swap
|
|
|
|
|
|
|
|
|
|
```task
|
|
|
|
|
id: KEY-WP-0023-T04
|
|
|
|
|
status: done
|
|
|
|
|
priority: high
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_task_id: "8a7fe95e-d463-5e74-81fa-5fa60a879774"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
KeyCape was run locally against a generated key and targeted with the
|
|
|
|
|
conformance suite: all five checks pass, including the excluded-grant check
|
|
|
|
|
Keycloak fails. The Keycloak side was exercised twice — the stock realm in
|
|
|
|
|
KEY-WP-0022, and here the *migrated* realm produced from the live export, which
|
|
|
|
|
imported cleanly and served discovery. The migrated user arrived carrying
|
|
|
|
|
`keycape.canonicalId`.
|
|
|
|
|
|
|
|
|
|
The divergence is now measured rather than asserted: KeyCape passes 5/5, a
|
|
|
|
|
migrated Keycloak passes 4/5 and fails on advertising the excluded `implicit` and
|
|
|
|
|
`password` grants.
|
|
|
|
|
|
|
|
|
|
## Record what is proved and what is not
|
|
|
|
|
|
|
|
|
|
```task
|
|
|
|
|
id: KEY-WP-0023-T05
|
|
|
|
|
status: done
|
|
|
|
|
priority: medium
|
2026-09-08 08:55:52 +02:00
|
|
|
state_hub_task_id: "acef6ceb-6dea-56e1-94bc-e5d589ef3b6f"
|
Prove the migration against live directories and fix what that surfaced
Finishes the unproven half of gap G04. Running the harness against real LLDAP,
OpenLDAP and Keycloak found four defects that the full unit suite passed over.
Every LLDAP user search pointed at a branch that does not exist. Config.userOU()
defaulted to ou=users while LLDAP stores users under ou=people, and nothing set
UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all
three silently found nothing against a stock LLDAP -- human login included, not
only the export. Exposed by an export returning zero users while still emitting a
membership referencing uid=admin, a snapshot the repo's own validator rejects.
raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP
adapter's own _validation_warning annotation, so the exporter's output failed its
own validation. Tooling annotations are exempt now, and a test proves the
exemption does not weaken the rule.
Migrated group memberships were dangling: resolveMemberDN passed a source DN
through unchanged while entries were written to the target branch, so groups
named entries the migrated directory does not contain. And empty groups could
not load at all, since groupOfNames makes member a MUST -- they reference a
placeholder entry the LDIF creates, an organizationalRole rather than a person,
emitted only when some group needs it.
The scenario-c compose file could not start: bitnami/openldap:2.6 does not
exist, though it passed docker compose config. Pinned to the image the scenario
was proved against.
Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every
entry added and every member resolving; a realm from the same export imports into
Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated
Keycloak 4/5.
Relying-party behaviour and MFA against a migrated realm remain unexercised, and
credential/MFA migration is not supplied at all, so no harness can establish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
G04 is substantially closed: stacks are reproducible and were run, conformance is
|
|
|
|
|
externally targeted and was run against three issuers, and directory migration is
|
|
|
|
|
proved end to end into a real directory.
|
|
|
|
|
|
|
|
|
|
Not closed, and stated as such: no browser login was performed against the
|
|
|
|
|
migrated realm, so unchanged relying-party behaviour is not established, and MFA
|
|
|
|
|
was not exercised. Credential and MFA migration is a **scope exclusion** rather
|
|
|
|
|
than a test gap — SCOPE says it is not supplied, so no harness can demonstrate
|
|
|
|
|
it. `keycape-to-keycloak` already reports that subject continuity is not
|
|
|
|
|
preserved: the canonical ID survives as an attribute while Keycloak mints its own
|
|
|
|
|
`sub`, so relying parties keyed on `sub` will not recognise migrated users.
|