Files
idsync/internal/ldaplib/read.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

185 lines
6.0 KiB
Go

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 是读取用户条目时需要取回的属性。
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
var userReadAttrs = []string{
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// groupReadAttrs 读取分组时额外取回成员列表,以便检测成员漂移。
// groupReadAttrs 读取分组时同时请求两种成员属性与 objectClass。
// 缺省的属性不会报错,只是不返回,因此同时请求是安全的。
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
// 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 小写为键。
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)", c.group.ObjectClass), 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
}
// EnsureOU 确保某个 OU 存在,不存在则创建。用于首次同步时准备 ou=Inactive 等。
func (c *Conn) EnsureOU(dn, name string) error {
exists, err := c.ExistsDN(dn)
if err != nil {
return err
}
if exists {
return nil
}
add := ldap.NewAddRequest(dn, nil)
add.Attribute(AttrObjectClass, ObjectClassOU)
add.Attribute("ou", []string{name})
if err := c.conn.Add(add); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
return nil
}
return fmt.Errorf("创建 OU %s 失败: %w", dn, err)
}
return 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
}