Prove the migration against live directories and fix what that surfaced
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s

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
This commit is contained in:
tegwick 2026-09-08 00:29:34 +02:00
parent 9819250851
commit e729ad4c28
11 changed files with 427 additions and 26 deletions

View file

@ -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.

View file

@ -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)
}
}