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
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:
parent
9819250851
commit
e729ad4c28
11 changed files with 427 additions and 26 deletions
10
SCOPE.md
10
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
28
src/internal/adapters/lldap/config_internal_test.go
Normal file
28
src/internal/adapters/lldap/config_internal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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=<id>,ou=users,<baseDN> 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, " ")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
workplans/KEY-WP-0023-live-migration-proof.md
Normal file
130
workplans/KEY-WP-0023-live-migration-proof.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue