Make the LLDAP export report its own completeness
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 31s
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:
parent
7fe5bccc7c
commit
f7dd51b8d2
9 changed files with 538 additions and 43 deletions
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package lldap_test
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
|
@ -427,3 +428,69 @@ func TestValidatePassword_UserNotFound(t *testing.T) {
|
|||
t.Error("expected false for non-existent user")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListGroups (KEY-WP-0018)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListGroups must search the group subtree directly rather than by membership,
|
||||
// so that groups nobody belongs to are returned, and it must populate Members —
|
||||
// LookupGroups leaves that field empty, which silently emptied every export.
|
||||
func TestLDAPAdapter_ListGroups_ReturnsAllGroupsWithMembers(t *testing.T) {
|
||||
var gotFilter, gotBase string
|
||||
conn := &mockConn{
|
||||
searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
gotFilter, gotBase = req.Filter, req.BaseDN
|
||||
return &ldap.SearchResult{Entries: []*ldap.Entry{
|
||||
{
|
||||
DN: "cn=admins,ou=groups,dc=netkingdom,dc=local",
|
||||
Attributes: []*ldap.EntryAttribute{
|
||||
{Name: "cn", Values: []string{"admins"}},
|
||||
{Name: "description", Values: []string{"Administrators"}},
|
||||
{Name: "member", Values: []string{"uid=alice,ou=people,dc=netkingdom,dc=local"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
DN: "cn=orphans,ou=groups,dc=netkingdom,dc=local",
|
||||
Attributes: []*ldap.EntryAttribute{
|
||||
{Name: "cn", Values: []string{"orphans"}},
|
||||
},
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
adapter := lldap.NewForTest(testConfig(), func(string) (lldap.LDAPConn, error) { return conn, nil })
|
||||
|
||||
groups, err := adapter.ListGroups(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups returned error: %v", err)
|
||||
}
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("want 2 groups, got %d: %+v", len(groups), groups)
|
||||
}
|
||||
if groups[0].Name != "admins" || len(groups[0].Members) != 1 {
|
||||
t.Errorf("first group not mapped with members: %+v", groups[0])
|
||||
}
|
||||
if len(groups[1].Members) != 0 {
|
||||
t.Errorf("member-less group should have no members: %+v", groups[1])
|
||||
}
|
||||
if strings.Contains(gotFilter, "member=") {
|
||||
t.Errorf("ListGroups must not filter by membership, filter was %q", gotFilter)
|
||||
}
|
||||
if !strings.Contains(gotBase, "ou=groups") {
|
||||
t.Errorf("ListGroups should search the group subtree, base was %q", gotBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAPAdapter_ListGroups_PropagatesSearchError(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
searchFn: func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return nil, errors.New("ldap: server unavailable")
|
||||
},
|
||||
}
|
||||
adapter := lldap.NewForTest(testConfig(), func(string) (lldap.LDAPConn, error) { return conn, nil })
|
||||
|
||||
if _, err := adapter.ListGroups(context.Background()); err == nil {
|
||||
t.Fatal("expected an error when the group search fails, got nil")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue