refactor: config + lldaplib 收敛到 LLDAP 形态(读只读、删 OpenLDAP 写路径与密码包)

- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class /
  group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url
  + 两套 schedule + guard
- ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password
- config.example.yml 重写成 LLDAP 形态
- 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份,
  mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
This commit is contained in:
2026-09-29 23:48:49 +08:00
parent 10ed3984dc
commit 979a98d502
16 changed files with 387 additions and 1031 deletions
+69
View File
@@ -0,0 +1,69 @@
package ldaplib
// LDAP 属性名与 DN 构造等纯工具,集中在此,供读取与映射共用。
//
// 刻意不含任何写操作:本包定位为 LLDAP 的「读」原语,一切写入走 lldapgql。
// 保留这些常量是因为 diff/映射/预览仍在比对 LDAP 属性,去掉会让比对散落各处。
import (
"github.com/go-ldap/ldap/v3"
)
// 属性名常量。
const (
AttrObjectClass = "objectClass"
AttrUID = "uid"
AttrCN = "cn"
AttrSN = "sn"
AttrGivenName = "givenName"
AttrDisplayName = "displayName"
AttrMail = "mail"
AttrMobile = "mobile"
AttrEmployeeNumber = "employeeNumber"
AttrDepartmentNumber = "departmentNumber"
AttrPostalAddress = "postalAddress"
AttrBusinessCategory = "businessCategory"
AttrEmployeeType = "employeeType"
AttrDescription = "description"
AttrMember = "member"
AttrUniqueMember = "uniqueMember"
AttrUserPassword = "userPassword"
)
// memberAttrs 列出可能承载组成员关系的属性名。
// LLDAP 组对内同时响应 member 与 uniqueMember,读取两种都认,否则会漏掉成员。
var memberAttrs = []string{AttrMember, AttrUniqueMember}
// groupObjectClass 是 LLDAP 分组的 LDAP objectClass。
var groupObjectClass = "groupOfUniqueNames"
// userReadAttrs 是读取用户条目时需要取回的属性。
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
var userReadAttrs = []string{
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// groupReadAttrs 读取分组时额外取回成员列表。
// 同时请求 member 与 uniqueMember 是安全的:缺省的属性不会报错,只是不返回。
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
// UserReadAttrs 返回读取用户条目所需的属性列表。
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
// EscapeDN 导出 DN 转义,供拼装 RDN 使用。
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeFilter 导出过滤器转义。
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
// UserDN 按 uid 与用户 OU 拼接用户 DN。
func UserDN(uid, ouDN string) string {
return "uid=" + EscapeDN(uid) + "," + ouDN
}
// GroupDN 拼接部门组 DN(cn 已含拍平拼音链)。
func GroupDN(cn, parentDN string) string {
return "cn=" + EscapeDN(cn) + "," + parentDN
}
+10 -23
View File
@@ -1,5 +1,7 @@
package ldaplib
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
import (
"crypto/tls"
"fmt"
@@ -12,19 +14,16 @@ import (
"idsync/internal/config"
)
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
// Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
type Client struct {
cfg config.Ldap
log *slog.Logger
tlsConf *tls.Config
dialOpts []ldap.DialOpt
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
group GroupSchema
}
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
// NewClient 构造 LDAP 读客户端并解析拨号参数。
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("ldap.url 未配置")
@@ -43,25 +42,13 @@ func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
}
schema := GroupSchema{
ObjectClass: cfg.GroupObjectClass,
MemberAttr: cfg.GroupMemberAttr,
}
if schema.ObjectClass == "" {
schema.ObjectClass = "groupOfUniqueNames"
}
if schema.MemberAttr == "" {
schema.MemberAttr = "uniqueMember"
}
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
}
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
type Conn struct {
conn *ldap.Conn
cfg config.Ldap
group GroupSchema
conn *ldap.Conn
cfg config.Ldap
}
// Close 释放连接。
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err)
return nil, fmt.Errorf("绑定 LDAP 失败 (%s): %w", c.cfg.AdminDN, err)
}
return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil
return &Conn{conn: conn, cfg: c.cfg}, nil
}
// Ping 验证连通性与凭据,用于健康检查。
@@ -132,4 +119,4 @@ func lastIndexByte(s string, b byte) int {
}
}
return -1
}
}
-342
View File
@@ -1,342 +0,0 @@
package ldaplib
import (
"fmt"
"strings"
"github.com/go-ldap/ldap/v3"
)
// 用户与分组的属性清单集中在此,避免散落各处导致不一致。
const (
AttrObjectClass = "objectClass"
AttrUID = "uid"
AttrCN = "cn"
AttrSN = "sn"
AttrGivenName = "givenName"
AttrDisplayName = "displayName"
AttrMail = "mail"
AttrMobile = "mobile"
AttrEmployeeNumber = "employeeNumber"
AttrDepartmentNumber = "departmentNumber"
AttrPostalAddress = "postalAddress"
AttrBusinessCategory = "businessCategory"
AttrEmployeeType = "employeeType"
AttrDescription = "description"
AttrUserPassword = "userPassword"
AttrUniqueMember = "uniqueMember"
AttrMember = "member"
)
// memberAttrs 列出可能承载组成员关系的属性名。
//
// 读取时两者都查:用哪一种取决于目录的既有约定(groupOfNames 用 member,
// groupOfUniqueNames 用 uniqueMember)。只认一种会漏掉另一种的成员关系,
// 且不会报错,只会表现为「成员比对结果不对」。
var memberAttrs = []string{AttrMember, AttrUniqueMember}
// ObjectClassUser 等是写入条目时使用的 objectClass。
// 分组的 objectClass 由 GroupSchema 在运行期决定,见下。
var (
ObjectClassUser = []string{"inetOrgPerson"}
ObjectClassOU = []string{"organizationalUnit", "top"}
)
// GroupSchema 描述分组条目的形态,必须与 Authelia 的 groups_filter 一致。
type GroupSchema struct {
// ObjectClass 形如 groupOfUniqueNames 或 groupOfNames
ObjectClass string
// MemberAttr 形如 uniqueMember 或 member
MemberAttr string
}
// ObjectClasses 返回写入时使用的 objectClass 列表。
func (g GroupSchema) ObjectClasses() []string {
return []string{g.ObjectClass, "top"}
}
// mutableUserAttrs 是更新用户时允许改写的属性。
//
// 这里刻意不包含 userPassword:Authelia 重置密码时直接写 LDAP 的 userPassword,
// 若同步的更新路径也写该属性,就会把用户刚设好的密码打回初始值。
// 密码只在创建时写入一次,此后由 Authelia 全权负责。
var mutableUserAttrs = []string{
AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, AttrMobile,
AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// escapeDN 转义 DN 中的特殊字符,避免 uid 含 . 、+ 、_ 等字符时构造出非法 DN。
func escapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeDN 导出 DN 转义,供调用方拼装 RDN 时使用。
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeFilter 导出过滤器转义。
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
// UserDN 按 uid 与所属 OU 拼接用户 DN。
func UserDN(uid, ouDN string) string {
return "uid=" + escapeDN(uid) + "," + ouDN
}
// GroupDN 拼接部门组 DN。
func GroupDN(cn, parentDN string) string {
return "cn=" + escapeDN(cn) + "," + parentDN
}
// User 是写入 LDAP 的用户条目。
type User struct {
UID string
CN string
SN string
GivenName string
DisplayName string
Mail string
Mobile string
EmployeeNumber string
DepartmentNumber string
PostalAddress string
BusinessCategory string
EmployeeType string
Description string
}
// Attributes 返回该用户对应的 LDAP 属性集合(不含 userPassword)。
func (u *User) Attributes() map[string][]string {
return map[string][]string{
AttrCN: {u.CN},
AttrSN: {u.SN},
AttrGivenName: {u.GivenName},
AttrDisplayName: {u.DisplayName},
AttrMail: {u.Mail},
AttrMobile: {u.Mobile},
AttrEmployeeNumber: {u.EmployeeNumber},
AttrDepartmentNumber: {u.DepartmentNumber},
AttrPostalAddress: {u.PostalAddress},
AttrBusinessCategory: {u.BusinessCategory},
AttrEmployeeType: {u.EmployeeType},
AttrDescription: {u.Description},
}
}
// AddUser 创建用户条目。password 仅在此时写入。
func (c *Conn) AddUser(u *User, ouDN, password string) error {
add := ldap.NewAddRequest(UserDN(u.UID, ouDN), nil)
add.Attribute(AttrObjectClass, ObjectClassUser)
add.Attribute(AttrUID, []string{u.UID})
for attr, values := range u.Attributes() {
if len(values) == 1 && values[0] == "" {
continue // 空值不写,避免产生空属性
}
add.Attribute(attr, values)
}
add.Attribute(AttrUserPassword, []string{password})
if err := c.conn.Add(add); err != nil {
return fmt.Errorf("创建用户 %s 失败: %w", u.UID, err)
}
return nil
}
// UpdateUser 更新用户属性。绝不触碰 userPassword。
//
// existing 是条目当前的内容,用于判断哪些属性需要删除。
// 这个参数不是可有可无的:对**不存在**的属性执行 Delete 会让整个 Modify
// 请求以 LDAPResultNoSuchAttribute 失败,从而使该用户的全部属性更新回滚。
// 早期版本没有传 existing,任何缺少可空字段(如工位为空)的用户都会更新失败。
func (c *Conn) UpdateUser(dn string, u *User, existing *Entry) error {
modify := ldap.NewModifyRequest(dn, nil)
attrs := u.Attributes()
for _, attr := range mutableUserAttrs {
values := attrs[attr]
if len(values) == 0 || values[0] == "" {
// 上游无此值:仅当条目当前确实存在该属性时才删除,
// 否则跳过,避免整个请求失败。
if existing != nil && existing.Get(attr) != "" {
modify.Delete(attr, nil)
}
continue
}
if existing != nil && existing.Get(attr) == values[0] {
continue // 值未变化,不产生多余的 LDAP 写操作
}
modify.Replace(attr, values)
}
if len(modify.Changes) == 0 {
return nil // 没有实际变化,避免发出空 Modify
}
// 结构上的保险:即使将来有人误把 userPassword 加进 mutableUserAttrs,这里也会拦下。
for _, ch := range modify.Changes {
if strings.EqualFold(ch.Modification.Type, AttrUserPassword) {
return fmt.Errorf("内部错误: 更新路径不允许修改 %s", AttrUserPassword)
}
}
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新用户 %s 失败: %w", dn, err)
}
return nil
}
// GetEntry 读取单个条目的属性,供更新前的差异判断使用。
func (c *Conn) GetEntry(dn string, attrs []string) (*Entry, error) {
sr, err := c.conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
"(objectClass=*)", attrs, nil,
))
if err != nil {
return nil, fmt.Errorf("读取条目 %s 失败: %w", dn, err)
}
if len(sr.Entries) == 0 {
return nil, fmt.Errorf("条目不存在: %s", dn)
}
return entryFrom(sr.Entries[0]), nil
}
// UserReadAttrs 返回读取用户条目所需的属性列表。
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
// DeleteUser 删除用户条目。
func (c *Conn) DeleteUser(dn string) error {
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
return fmt.Errorf("删除用户 %s 失败: %w", dn, err)
}
return nil
}
// MoveUser 把用户条目移动到新的父 OU,保留其全部属性(含 userPassword)。
// deleteOldRDN 置 true 表示移除旧 RDN 属性值。
func (c *Conn) MoveUser(oldDN, newRDN, newParentDN string) error {
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, newParentDN)
if err := c.conn.ModifyDN(modDN); err != nil {
return fmt.Errorf("移动用户 %s → %s,%s 失败: %w", oldDN, newRDN, newParentDN, err)
}
return nil
}
// AddGroup 创建部门分组。
//
// 无论哪种 objectClass,成员属性都是必填(groupOfNames 的 member 与
// groupOfUniqueNames 的 uniqueMember 都是 MUST),因此这里默认把 admin
// 加入其中,否则创建会因缺少必填属性而失败。
func (c *Conn) AddGroup(dn, name, description, adminDN string) error {
if _, err := c.conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
"(objectClass=*)", []string{"dn"}, nil,
)); err == nil {
return nil // 已存在,幂等
} else if !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return fmt.Errorf("检查分组 %s 是否存在失败: %w", dn, err)
}
add := ldap.NewAddRequest(dn, nil)
add.Attribute(AttrObjectClass, c.group.ObjectClasses())
add.Attribute(AttrCN, []string{name})
add.Attribute(AttrDescription, []string{description})
add.Attribute(c.group.MemberAttr, []string{adminDN})
if err := c.conn.Add(add); err != nil {
return fmt.Errorf("创建分组 %s 失败: %w", dn, err)
}
return nil
}
// UpdateGroup 更新分组的描述。
func (c *Conn) UpdateGroup(dn, description string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace(AttrDescription, []string{description})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新分组 %s 失败: %w", dn, err)
}
return nil
}
// RenameGroup 重命名分组,用于部门改名。
func (c *Conn) RenameGroup(oldDN, newRDN string) error {
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, "")
if err := c.conn.ModifyDN(modDN); err != nil {
return fmt.Errorf("重命名分组 %s → %s 失败: %w", oldDN, newRDN, err)
}
return nil
}
// DeleteGroup 删除分组条目。
func (c *Conn) DeleteGroup(dn string) error {
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
return fmt.Errorf("删除分组 %s 失败: %w", dn, err)
}
return nil
}
// SetEmployeeType 更新条目的在职状态标记(Active / Resigned)。
func (c *Conn) SetEmployeeType(dn, employeeType string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace(AttrEmployeeType, []string{employeeType})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新 %s 的 employeeType 失败: %w", dn, err)
}
return nil
}
// memberExists 判断用户是否已是分组成员。
func (c *Conn) memberExists(groupDN, userDN string) (bool, error) {
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
c.group.ObjectClass, c.group.MemberAttr, EscapeFilter(userDN))
sr, err := c.conn.Search(ldap.NewSearchRequest(
groupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
filter, []string{"dn"}, nil,
))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return false, fmt.Errorf("分组不存在: %s", groupDN)
}
return false, fmt.Errorf("查询分组 %s 成员失败: %w", groupDN, err)
}
return len(sr.Entries) > 0, nil
}
// AddMember 把用户加入分组。已存在时是幂等的。
func (c *Conn) AddMember(groupDN, userDN string) error {
found, err := c.memberExists(groupDN, userDN)
if err != nil {
return err
}
if found {
return nil
}
modify := ldap.NewModifyRequest(groupDN, nil)
modify.Add(c.group.MemberAttr, []string{userDN})
if err := c.conn.Modify(modify); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultAttributeOrValueExists) {
return nil // 并发下可能已被加入
}
return fmt.Errorf("将 %s 加入分组 %s 失败: %w", userDN, groupDN, err)
}
return nil
}
// RemoveMember 把用户移出分组。不存在时是幂等的。
func (c *Conn) RemoveMember(groupDN, userDN string) error {
found, err := c.memberExists(groupDN, userDN)
if err != nil {
return err
}
if !found {
return nil
}
modify := ldap.NewModifyRequest(groupDN, nil)
modify.Delete(c.group.MemberAttr, []string{userDN})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("将 %s 移出分组 %s 失败: %w", userDN, groupDN, err)
}
return nil
}
+3 -35
View File
@@ -40,18 +40,7 @@ func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
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}
// userReadAttrs / groupReadAttrs 的定义见 attrs.go。
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
@@ -74,10 +63,11 @@ func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
}
// 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)", c.group.ObjectClass), groupReadAttrs, nil,
fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil,
))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
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 {