Make the LLDAP export report its own completeness
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 31s

The exporter discovered groups by walking each user's memberships, so a group
nobody belongs to never reached the snapshot, and a failed lookup was skipped by
a `continue` under a comment claiming it was recorded in the incompatibility
report. The run then emitted `result: "success"`.

Add an optional `domain.GroupLister` capability and implement `ListGroups` on
the LLDAP adapter as a direct group-subtree search, kept off `UserRepository`
because the OIDC layer never enumerates the directory. Record `groupEnumeration`
on every result and a `Complete()` predicate over it; abort rather than write a
smaller snapshot when the enumeration fails; report a failed per-user lookup on
the fallback path; emit `partial` telemetry and name the mode from the CLI.

Reading the adapter to write this surfaced a defect the assessment had not
listed: `LookupGroups` never populated `Group.Members`, and the exporter built
every membership from that field, so against a real directory the `memberships`
block was always empty while the fixture-backed tests passed. Memberships on the
fallback path now come from the user/group pair actually observed.

Sort users, groups and memberships so an unchanged directory exports
identically. Closes G05 of the scope/intent assessment.

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
This commit is contained in:
tegwick 2026-09-07 08:45:50 +02:00
parent 7fe5bccc7c
commit f7dd51b8d2
9 changed files with 538 additions and 43 deletions

View file

@ -175,6 +175,47 @@ func (a *LDAPAdapter) LookupGroups(ctx context.Context, userDN string) ([]domain
return groups, nil
}
// ListGroups returns every group in the LLDAP group subtree with its members
// populated, independently of any user's membership. Export tooling needs this
// to enumerate empty and unreferenced groups, which a (member=DN) search by
// construction cannot see.
func (a *LDAPAdapter) ListGroups(ctx context.Context) ([]domain.Group, error) {
conn, err := a.dial()
if err != nil {
return nil, err
}
defer conn.Close()
req := ldap.NewSearchRequest(
a.cfg.groupBaseDN(),
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))",
[]string{"dn", "cn", "description", "member", "uniqueMember"},
nil,
)
result, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("lldap: list groups search: %w", err)
}
groups := make([]domain.Group, 0, len(result.Entries))
for _, entry := range result.Entries {
members := entry.GetAttributeValues("member")
if len(members) == 0 {
members = entry.GetAttributeValues("uniqueMember")
}
groups = append(groups, domain.Group{
ID: entry.DN,
Name: entry.GetAttributeValue("cn"),
Description: entry.GetAttributeValue("description"),
Members: members,
})
}
return groups, nil
}
// ListUsers returns all user records from the LLDAP directory.
// It performs an LDAP search with filter (objectClass=inetOrgPerson) to list every user,
// then validates each against the canonical LDAP schema.
@ -388,3 +429,9 @@ func validationSummary(r validator.Report) string {
}
return strings.Join(msgs, "; ")
}
// Compile-time checks: the adapter satisfies the directory contracts it claims.
var (
_ domain.UserRepository = (*LDAPAdapter)(nil)
_ domain.GroupLister = (*LDAPAdapter)(nil)
)