diff --git a/SCOPE.md b/SCOPE.md index 70b860a..279f3e7 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -83,9 +83,13 @@ Keycloak interchangeability are not established. (KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the authorization surface, the published keys and a real token exchange — and showed that stock Keycloak advertises the excluded `implicit` and `password` - grants, so it is not a drop-in for this profile. Directory migration, - credential and MFA preservation and relying-party behaviour remain unexercised; - Scenario C has not been run end to end. + grants, so it is not a drop-in for this profile. Directory migration is proved + end to end into a real OpenLDAP, with every migrated membership resolving, and a + realm built from a live export imports into Keycloak and serves discovery + (KEY-WP-0023). 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. Subject continuity is explicitly not preserved: the canonical + ID survives as an attribute while Keycloak mints its own `sub`. - The server listens on HTTP; HTTPS termination is deployment-owned. `/healthz` reports process status without probing dependencies. Development Compose needs configuration/key material absent from the checkout. Production diff --git a/docker-compose.scenario-c.yml b/docker-compose.scenario-c.yml index 8335417..5743fa3 100644 --- a/docker-compose.scenario-c.yml +++ b/docker-compose.scenario-c.yml @@ -4,17 +4,18 @@ # stack starts. OpenLDAP imports any LDIF mounted into its bootstrap directory. services: openldap: - image: bitnami/openldap:2.6 + # osixia/openldap imports every .ldif mounted into its bootstrap directory + # at first start. Pinned: this is the image the scenario was proved against. + image: osixia/openldap:1.5.0 + command: ["--copy-service"] environment: - - LDAP_ROOT=dc=netkingdom,dc=local - - LDAP_ADMIN_USERNAME=admin + - LDAP_ORGANISATION=NetKingdom + - LDAP_DOMAIN=netkingdom.local - LDAP_ADMIN_PASSWORD=adminpassword - - LDAP_SKIP_DEFAULT_TREE=yes - - LDAP_CUSTOM_LDIF_DIR=/ldifs volumes: - - ./build/scenario-c/ldif:/ldifs:ro + - ./build/scenario-c/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom:ro ports: - - "1389:1389" + - "1389:389" keycloak: image: quay.io/keycloak/keycloak:26.0 diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index c7f3602..ef75bd3 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -239,9 +239,41 @@ A migrated Keycloak therefore presents a wider grant surface than KeyCape does. This substantiates with evidence what SCOPE already said was unestablished, and means Scenario B legitimately reports a failure today rather than a green run. -Still open: directory migration against a live OpenLDAP, credential and MFA -preservation, and unchanged relying-party behaviour are not exercised. Scenario C -has never been run end to end. G04 does not fully close. +**Status 2026-09-08 (KEY-WP-0023): substantially closed.** Directory migration is +now proved end to end into a real directory: LLDAP -> export -> validate -> LDIF +-> ldapadd into OpenLDAP 1.5.0, every entry added without error and every member +value in the loaded directory resolving to an entry that exists. The migrated +Keycloak realm built from that same live export imported cleanly and served +discovery, with the migrated user carrying keycape.canonicalId. Both sides of the +swap are measured: KeyCape passes 5/5 conformance checks, a migrated Keycloak +passes 4/5, failing only the excluded-grant check. + +Running it found four defects that the full unit suite passed over: + +1. `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 every user search against a stock + LLDAP silently found nothing — human login included. Exposed by an export that + returned zero users while emitting a membership referencing uid=admin. +2. `raw_attributes_well_formed`, added in KEY-WP-0021, rejected the adapter's own + `_validation_warning` annotation, so the exporter's output failed its own + validation. Tooling annotations are now exempt and documented. +3. LDIF members kept their source DNs while entries were written to the target + branch, so migrated groups referenced entries the target directory does not + contain. +4. `groupOfNames` requires a member, so every empty group aborted the load. Empty + groups — preserved deliberately since KEY-WP-0018 — now reference a placeholder + entry the LDIF creates. + +A fifth was a harness defect: `bitnami/openldap:2.6` does not exist, so the +compose file added in KEY-WP-0022 passed `docker compose config` and could not +start. That is the distance between a file that parses and a stack that runs. + +Still open: 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 — it is +not supplied, so no harness can demonstrate it. Subject continuity is explicitly +not preserved, which keycape-to-keycloak already reports. ### G05 — Directory export can omit data without reporting it diff --git a/scripts/test-scenario-b.sh b/scripts/test-scenario-b.sh index 1624845..9d110b2 100755 --- a/scripts/test-scenario-b.sh +++ b/scripts/test-scenario-b.sh @@ -4,7 +4,7 @@ # Exports the canonical directory, transforms it into a Keycloak realm, starts # Keycloak over that realm, and runs the external conformance suite against it. # -# Prerequisites: docker, docker compose, and `make build` (binaries land in bin/). +# Prerequisites: docker, docker compose, and `make -C src build` (root `make build` only compiles; the src target installs into bin/). set -euo pipefail @@ -20,7 +20,7 @@ die() { echo "scenario-b: $*" >&2; exit 1; } # Check every prerequisite up front: failing half way through leaves a running # stack and a half-written realm, which is harder to diagnose than not starting. for binary in lldap-export keycape-to-keycloak; do - [ -x "bin/$binary" ] || die "bin/$binary is missing; run 'make build' first" + [ -x "bin/$binary" ] || die "bin/$binary is missing; run 'make -C src build' first" done command -v docker >/dev/null || die "docker is not installed" command -v go >/dev/null || die "go is not installed" diff --git a/src/internal/adapters/lldap/config.go b/src/internal/adapters/lldap/config.go index 8e91fe3..fce760a 100644 --- a/src/internal/adapters/lldap/config.go +++ b/src/internal/adapters/lldap/config.go @@ -18,7 +18,8 @@ type Config struct { // BaseDN is the search base, e.g. "dc=netkingdom,dc=local". BaseDN string `yaml:"baseDN"` - // UserOU is the organisational unit for users. Defaults to "ou=users" when empty. + // UserOU is the organisational unit for users. Defaults to "ou=people" when + // empty, which is where LLDAP stores users -- see userOU. UserOU string `yaml:"userOU,omitempty"` // GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty. @@ -33,7 +34,14 @@ func (c Config) userOU() string { if c.UserOU != "" { return c.UserOU } - return "ou=users" + // LLDAP places users under ou=people, not ou=users. The previous "ou=users" + // default pointed every user search at a branch LLDAP does not create, so + // LookupUser, ListUsers and ValidatePassword all silently found nothing + // against a stock LLDAP, and no config in this repository set UserOU to + // correct it (KEY-WP-0023). Found by exporting from a live LLDAP: the export + // returned zero users while still emitting a membership referencing + // uid=admin, a snapshot the repository's own validator rejects. + return "ou=people" } // groupOU returns the effective GroupOU, falling back to the default. diff --git a/src/internal/adapters/lldap/config_internal_test.go b/src/internal/adapters/lldap/config_internal_test.go new file mode 100644 index 0000000..d9e10ff --- /dev/null +++ b/src/internal/adapters/lldap/config_internal_test.go @@ -0,0 +1,28 @@ +package lldap + +import "testing" + +// KEY-WP-0023. The user search base is not cosmetic: LookupUser, ListUsers and +// ValidatePassword all derive from it, so a default pointing at a branch LLDAP +// does not create makes every one of them silently find nothing. That is what +// "ou=users" did, and no configuration in this repository overrode it. +func TestUserBaseDNDefaultsToLLDAPsPeopleBranch(t *testing.T) { + cfg := Config{BaseDN: "dc=netkingdom,dc=local"} + if got, want := cfg.userBaseDN(), "ou=people,dc=netkingdom,dc=local"; got != want { + t.Errorf("userBaseDN() = %q, want %q", got, want) + } + if got, want := cfg.groupBaseDN(), "ou=groups,dc=netkingdom,dc=local"; got != want { + t.Errorf("groupBaseDN() = %q, want %q", got, want) + } +} + +// An explicit OU still wins, for directories laid out differently. +func TestExplicitOUOverridesDefaults(t *testing.T) { + cfg := Config{BaseDN: "dc=example,dc=com", UserOU: "ou=staff", GroupOU: "ou=teams"} + if got, want := cfg.userBaseDN(), "ou=staff,dc=example,dc=com"; got != want { + t.Errorf("userBaseDN() = %q, want %q", got, want) + } + if got, want := cfg.groupBaseDN(), "ou=teams,dc=example,dc=com"; got != want { + t.Errorf("groupBaseDN() = %q, want %q", got, want) + } +} diff --git a/src/internal/migration/toldap/generator.go b/src/internal/migration/toldap/generator.go index 98963f3..fd21830 100644 --- a/src/internal/migration/toldap/generator.go +++ b/src/internal/migration/toldap/generator.go @@ -71,6 +71,19 @@ func (g *Generator) Generate(export *lldapexport.ExportResult) (string, error) { "ou: groups", }) + // The placeholder must exist before any group references it: an LDIF is + // applied in order, and a forward reference fails on directories that check. + needsPlaceholder := false + for _, grp := range export.Groups { + if len(grp.Members) == 0 { + needsPlaceholder = true + break + } + } + if needsPlaceholder { + g.writePlaceholderMember(&sb) + } + // Write user entries. for _, u := range export.Users { if err := g.writeUser(&sb, u); err != nil { @@ -155,6 +168,27 @@ func (g *Generator) writeUser(sb *strings.Builder, u domain.User) error { return nil } +// placeholderMemberRDN names the entry empty groups point at. See +// placeholderMemberDN. +const placeholderMemberRDN = "cn=empty-group-placeholder" + +// placeholderMemberDN is the member an otherwise-empty group carries. +// +// RFC 4519 makes member a MUST for groupOfNames, so a group with no members is +// not a valid entry: a real OpenLDAP rejects it with an object class violation +// and the whole migration stops there. Since KEY-WP-0018 the export deliberately +// preserves empty groups, so the generator must emit something loadable for them +// (KEY-WP-0023, found by loading generated LDIF into OpenLDAP 1.5.0). +// +// The placeholder is a real entry the same LDIF creates, as an organizationalRole +// rather than a person: it keeps every member value resolvable, and it cannot be +// mistaken for a migrated user. Its presence is a deliberate difference from the +// source directory, not a preserved fact -- see the generator's package +// documentation. +func placeholderMemberDN(baseDN string) string { + return placeholderMemberRDN + "," + baseDN +} + func (g *Generator) writeGroup(sb *strings.Builder, grp domain.Group) { dn := "dn: cn=" + grp.Name + ",ou=groups," + g.cfg.BaseDN @@ -166,15 +200,31 @@ func (g *Generator) writeGroup(sb *strings.Builder, grp domain.Group) { } for _, memberID := range grp.Members { - // If the member ID is already a full DN, use it directly. - // Otherwise build a uid=,ou=users, DN. memberDN := resolveMemberDN(memberID, g.cfg.BaseDN, g.cfg.Target) attrs = append(attrs, "member: "+memberDN) } + if len(grp.Members) == 0 { + attrs = append(attrs, "member: "+placeholderMemberDN(g.cfg.BaseDN)) + } writeEntry(sb, attrs) } +// writePlaceholderMember emits the entry empty groups reference. It is written +// only when some group needs it, so a directory with no empty groups migrates +// without any synthetic entry at all. +func (g *Generator) writePlaceholderMember(sb *strings.Builder) { + writeEntry(sb, []string{ + "dn: " + placeholderMemberDN(g.cfg.BaseDN), + "objectClass: top", + "objectClass: organizationalRole", + "cn: empty-group-placeholder", + "description: Placeholder member for groups that have no members. " + + "groupOfNames requires at least one member; this entry is created by " + + "the migration and is not present in the source directory.", + }) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -188,22 +238,46 @@ func writeEntry(sb *strings.Builder, lines []string) { sb.WriteByte('\n') } -// resolveMemberDN returns the full LDAP DN for a member. -// If the memberID already contains a comma (i.e. is a DN), it is returned as-is. -// Otherwise a DN is constructed from the username. +// resolveMemberDN returns the member's DN in the *target* directory's layout. +// +// A member arriving from a real export is already a DN in the source layout +// (LLDAP writes uid=alice,ou=people,...), while writeUser places the entry at +// ou=users in the target. Passing the source DN through unchanged, as this did, +// produced groups whose member: values named entries the migrated directory does +// not contain — the migration completed and the memberships were dangling +// (KEY-WP-0023). Found by generating LDIF from a live LLDAP export. +// +// Only the naming value is carried across; the branch and naming attribute come +// from the target. Members are user IDs in the canonical model — no_cyclic_groups +// rejects group IDs among them — so every member is resolved as a user. func resolveMemberDN(memberID, baseDN string, target Target) string { + name := memberID if strings.Contains(memberID, ",") { - // Already a full DN — return as-is. - return memberID + name = firstRDNValue(memberID) } switch target { case TargetAD: - return "cn=" + memberID + ",ou=users," + baseDN + return "cn=" + name + ",ou=users," + baseDN default: - return "uid=" + memberID + ",ou=users," + baseDN + return "uid=" + name + ",ou=users," + baseDN } } +// firstRDNValue returns the value of a DN's leftmost RDN ("uid=alice,ou=people" +// yields "alice"). A DN whose first component has no "=" is returned unchanged, +// so a malformed member is carried through visibly rather than silently dropped. +func firstRDNValue(dn string) string { + first := dn + if comma := strings.Index(dn, ","); comma >= 0 { + first = dn[:comma] + } + equals := strings.Index(first, "=") + if equals < 0 { + return dn + } + return strings.TrimSpace(first[equals+1:]) +} + // splitDisplayName splits a display name at the first space. func splitDisplayName(displayName string) (first, last string) { idx := strings.Index(displayName, " ") diff --git a/src/internal/migration/toldap/generator_test.go b/src/internal/migration/toldap/generator_test.go index be8b63b..5ec399d 100644 --- a/src/internal/migration/toldap/generator_test.go +++ b/src/internal/migration/toldap/generator_test.go @@ -344,3 +344,98 @@ func TestGenerator_ValidationFailsForInvalidData(t *testing.T) { t.Error("Generate should return error for user with empty username (invalid LDIF)") } } + +// A member arriving as a source-layout DN must be rewritten into the target +// layout, or the migrated directory carries groups whose members name entries it +// does not contain. Found by generating LDIF from a live LLDAP export, where +// members are uid=...,ou=people,... while entries are written to ou=users +// (KEY-WP-0023). +func TestMemberDNsAreRewrittenIntoTheTargetLayout(t *testing.T) { + export := &lldapexport.ExportResult{ + Users: []domain.User{{ID: "uid=admin,ou=people,dc=netkingdom,dc=local", Username: "admin", DisplayName: "Administrator"}}, + Groups: []domain.Group{{ID: "cn=admins,ou=groups,dc=netkingdom,dc=local", Name: "admins", Members: []string{"uid=admin,ou=people,dc=netkingdom,dc=local"}}}, + } + ldif, err := toldap.New(toldap.Config{BaseDN: "dc=netkingdom,dc=local", Target: toldap.TargetOpenLDAP}, telemetry.NoopEmitter{}).Generate(export) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ldif, "member: uid=admin,ou=users,dc=netkingdom,dc=local") { + t.Errorf("member not rewritten into the target layout:\n%s", ldif) + } + if strings.Contains(ldif, "ou=people") { + t.Errorf("source layout leaked into the generated LDIF:\n%s", ldif) + } + // Every member must name an entry the same LDIF creates. + for _, line := range strings.Split(ldif, "\n") { + if !strings.HasPrefix(line, "member: ") { + continue + } + memberDN := strings.TrimPrefix(line, "member: ") + if !strings.Contains(ldif, "dn: "+memberDN) { + t.Errorf("member %q has no entry in the generated LDIF", memberDN) + } + } +} + +// An Active Directory target uses cn= naming, and the rewrite must follow it. +func TestMemberDNRewriteFollowsTargetNamingAttribute(t *testing.T) { + export := &lldapexport.ExportResult{ + Users: []domain.User{{ID: "uid=admin,ou=people,dc=x,dc=y", Username: "admin", DisplayName: "Administrator"}}, + Groups: []domain.Group{{ID: "cn=admins,ou=groups,dc=x,dc=y", Name: "admins", Members: []string{"uid=admin,ou=people,dc=x,dc=y"}}}, + } + ldif, err := toldap.New(toldap.Config{BaseDN: "dc=x,dc=y", Target: toldap.TargetAD}, telemetry.NoopEmitter{}).Generate(export) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ldif, "member: cn=admin,ou=users,dc=x,dc=y") { + t.Errorf("AD member naming not applied:\n%s", ldif) + } +} + +// groupOfNames makes member a MUST, so an empty group is not a loadable entry. +// The export preserves empty groups, so the generator must emit one that loads +// (KEY-WP-0023, found by loading generated LDIF into a real OpenLDAP). +func TestEmptyGroupsGetALoadablePlaceholderMember(t *testing.T) { + export := &lldapexport.ExportResult{ + Users: []domain.User{{ID: "uid=admin,ou=people,dc=x,dc=y", Username: "admin", DisplayName: "Administrator"}}, + Groups: []domain.Group{{ID: "cn=empty,ou=groups,dc=x,dc=y", Name: "empty"}}, + } + ldif, err := toldap.New(toldap.Config{BaseDN: "dc=x,dc=y", Target: toldap.TargetOpenLDAP}, telemetry.NoopEmitter{}).Generate(export) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ldif, "dn: cn=empty-group-placeholder,dc=x,dc=y") { + t.Errorf("placeholder entry not created:\n%s", ldif) + } + if !strings.Contains(ldif, "member: cn=empty-group-placeholder,dc=x,dc=y") { + t.Errorf("empty group has no member:\n%s", ldif) + } + // Every group entry must carry at least one member attribute. + for _, block := range strings.Split(ldif, "\n\n") { + if !strings.Contains(block, "objectClass: groupOfNames") { + continue + } + if !strings.Contains(block, "member: ") { + t.Errorf("group entry without a member attribute:\n%s", block) + } + } + // The placeholder must be defined before the group that references it. + if strings.Index(ldif, "dn: cn=empty-group-placeholder") > strings.Index(ldif, "dn: cn=empty,ou=groups") { + t.Error("placeholder is defined after the group referencing it") + } +} + +// A directory with no empty groups migrates without any synthetic entry. +func TestNoPlaceholderWhenEveryGroupHasMembers(t *testing.T) { + export := &lldapexport.ExportResult{ + Users: []domain.User{{ID: "uid=admin,ou=people,dc=x,dc=y", Username: "admin", DisplayName: "Administrator"}}, + Groups: []domain.Group{{ID: "cn=admins,ou=groups,dc=x,dc=y", Name: "admins", Members: []string{"uid=admin,ou=people,dc=x,dc=y"}}}, + } + ldif, err := toldap.New(toldap.Config{BaseDN: "dc=x,dc=y", Target: toldap.TargetOpenLDAP}, telemetry.NoopEmitter{}).Generate(export) + if err != nil { + t.Fatal(err) + } + if strings.Contains(ldif, "empty-group-placeholder") { + t.Errorf("synthetic entry emitted unnecessarily:\n%s", ldif) + } +} diff --git a/src/internal/validator/validator.go b/src/internal/validator/validator.go index 0d41105..78eb866 100644 --- a/src/internal/validator/validator.go +++ b/src/internal/validator/validator.go @@ -117,6 +117,14 @@ func checkRawAttributesWellFormed(snap Snapshot) RuleResult { // only by case are one attribute supplied twice, not two attributes. seen := make(map[string]string, len(u.LDAPAttributes)) for key := range u.LDAPAttributes { + // Keys beginning with "_" are annotations this tooling writes into + // the snapshot itself -- the LLDAP adapter records + // "_validation_warning" here, for one. They are not directory + // attributes and must not be judged as though they were, or the + // exporter's own output fails validation (KEY-WP-0023). + if strings.HasPrefix(key, "_") { + continue + } switch { case strings.TrimSpace(key) == "": r.Passed = false diff --git a/src/internal/validator/validator_test.go b/src/internal/validator/validator_test.go index afb55dd..a86eff3 100644 --- a/src/internal/validator/validator_test.go +++ b/src/internal/validator/validator_test.go @@ -371,3 +371,24 @@ func findRule(results []validator.RuleResult, name string) validator.RuleResult } return validator.RuleResult{Rule: name, Passed: false, Message: "rule not found in report"} } + +// The LLDAP adapter records "_validation_warning" in LDAPAttributes, so the rule +// must not judge the tooling's own annotations as directory attributes. Found by +// validating a snapshot exported from a live LLDAP (KEY-WP-0023). +func TestRawAttributes_IgnoresToolingAnnotations(t *testing.T) { + result := attributeRule(t, map[string]string{ + "_validation_warning": "required_attributes_present: missing displayName", + "sn": "Example", + }) + if !result.Passed { + t.Errorf("tooling annotation rejected: %s", result.Message) + } +} + +// The exemption is for annotations only: a real attribute is still judged. +func TestRawAttributes_ExemptionDoesNotWeakenTheRule(t *testing.T) { + result := attributeRule(t, map[string]string{"_note": "fine", "employee_id": "42"}) + if result.Passed { + t.Error("expected the malformed real attribute to still fail") + } +} diff --git a/workplans/KEY-WP-0023-live-migration-proof.md b/workplans/KEY-WP-0023-live-migration-proof.md new file mode 100644 index 0000000..0b2a8d7 --- /dev/null +++ b/workplans/KEY-WP-0023-live-migration-proof.md @@ -0,0 +1,130 @@ +--- +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" +--- + +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 +``` + +**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 +``` + +**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 +``` + +**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 +``` + +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 +``` + +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.