package ldaplib import ( "fmt" "strings" "github.com/go-ldap/ldap/v3" ) // Entry 是 LDAP 中一个条目的精简表示,用于与期望状态比对。 type Entry struct { DN string Attributes map[string]string // multi 保留多值属性的完整值列表(如 uniqueMember),键为小写属性名。 multi map[string][]string } // Get 返回指定属性的第一个值。 func (e *Entry) Get(attr string) string { return e.Attributes[strings.ToLower(attr)] } // NewEntry 构造一个 Entry。 // // Entry 的字段对调用方是只读的(多值属性不导出),但同步逻辑与测试都需要 // 在内存中构造它,因此这里提供一个显式构造函数,而不是让调用方猜测内部结构。 func NewEntry(dn string, attrs map[string]string, members []string) *Entry { e := &Entry{ DN: dn, Attributes: make(map[string]string, len(attrs)), multi: make(map[string][]string), } for k, v := range attrs { e.Attributes[strings.ToLower(k)] = v } if len(members) > 0 { e.multi[strings.ToLower(AttrUniqueMember)] = append([]string{}, members...) e.Attributes[strings.ToLower(AttrUniqueMember)] = members[0] } return e } // userReadAttrs / groupReadAttrs 的定义见 attrs.go。 // ListUsers 读取某个 OU 下的全部用户条目(不递归)。 func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) { sr, err := c.conn.Search(ldap.NewSearchRequest( ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=inetOrgPerson)", userReadAttrs, nil, )) if err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return map[string]*Entry{}, nil // OU 尚未创建,视为空 } return nil, fmt.Errorf("读取用户列表失败 (%s): %w", ouDN, err) } out := make(map[string]*Entry, len(sr.Entries)) for _, e := range sr.Entries { out[strings.ToLower(e.GetAttributeValue(AttrUID))] = entryFrom(e) } return out, nil } // ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。 // LLDAP 分组 objectClass 固定为 groupOfUniqueNames。 func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) { sr, err := c.conn.Search(ldap.NewSearchRequest( rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil, )) if err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return map[string]*Entry{}, nil } return nil, fmt.Errorf("读取分组列表失败 (%s): %w", rootDN, err) } out := make(map[string]*Entry, len(sr.Entries)) for _, e := range sr.Entries { out[strings.ToLower(e.DN)] = entryFrom(e) } return out, nil } // Members 返回分组条目的成员 DN 集合(全小写,便于比较)。 // // 两种成员属性都识别:目录可能用 groupOfNames/member,也可能用 // groupOfUniqueNames/uniqueMember,取决于既有约定。写操作会使用配置指定的那一种, // 但读取必须宽松,否则会漏掉另一类成员关系。 func (e *Entry) Members() map[string]struct{} { out := make(map[string]struct{}) for _, attr := range memberAttrs { for _, m := range e.multi[strings.ToLower(attr)] { out[strings.ToLower(m)] = struct{}{} } } return out } // ExistsDN 判断某个 DN 是否存在,用于幂等前置检查。 func (c *Conn) ExistsDN(dn string) (bool, error) { sr, err := c.conn.Search(ldap.NewSearchRequest( dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, "(objectClass=*)", []string{"dn"}, nil, )) if err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return false, nil } return false, fmt.Errorf("检查 DN 是否存在失败 (%s): %w", dn, err) } return len(sr.Entries) > 0, nil } // entryFrom 把 LDAP 条目转成精简表示。 // 单值属性存进 Attributes;多值属性原样保留在 multi 中。 func entryFrom(e *ldap.Entry) *Entry { ent := &Entry{ DN: e.DN, Attributes: make(map[string]string, len(e.Attributes)), multi: make(map[string][]string), } // multiValuedAttrs 记录必须按多值处理的属性。 // 不能只凭「值个数 > 1」判断:一个只含 admin 的分组其 uniqueMember // 只有一个值,若按单值属性存放,Members() 就会返回空集合,导致 // 成员比对完全失效(集成测试捕获过这个问题)。 multiValued := map[string]bool{ strings.ToLower(AttrMember): true, strings.ToLower(AttrUniqueMember): true, } for _, a := range e.Attributes { key := strings.ToLower(a.Name) if multiValued[key] { ent.multi[key] = append([]string{}, a.Values...) if len(a.Values) > 0 { ent.Attributes[key] = a.Values[0] } continue } if len(a.Values) == 1 { ent.Attributes[key] = a.Values[0] continue } ent.multi[key] = a.Values if len(a.Values) > 0 { ent.Attributes[key] = a.Values[0] } } return ent }