// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。 // // 设计要点: // - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。 // 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。 // - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的 // 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。 package syncer import ( "crypto/sha256" "encoding/hex" "fmt" "sort" "strings" "idsync/internal/ldaplib" "idsync/internal/mapping" ) // OpKind 是操作类型。 type OpKind string const ( OpCreateUser OpKind = "create_user" OpUpdateUser OpKind = "update_user" OpDeactivate OpKind = "deactivate_user" OpReactivate OpKind = "reactivate_user" OpCreateGroup OpKind = "create_group" OpUpdateGroup OpKind = "update_group" OpAddMember OpKind = "add_member" OpRemoveMember OpKind = "remove_member" ) // Op 是一次待执行(或仅预览)的操作。 type Op struct { Kind OpKind `json:"kind"` // Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。 Target string `json:"target"` // Name 是展示名,方便界面阅读。 Name string `json:"name"` // DN 是操作涉及的条目 DN(新建时为目标 DN)。 DN string `json:"dn,omitempty"` // Detail 是人类可读的说明,直接展示在预览页。 Detail string `json:"detail"` // 以下字段供执行器使用,不参与 JSON 展示。 User *mapping.User `json:"-"` Group *mapping.Group `json:"-"` MemberDN string `json:"-"` GroupDN string `json:"-"` // TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。 TargetDN string `json:"-"` AttrsHash string `json:"-"` // Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序 // (例如父分组必须先于子分组创建)。 Seq int `json:"-"` } // Plan 是一次同步的完整计划。 type Plan struct { Ops []Op `json:"ops"` // Warnings 是不阻塞执行、但需要人工留意的信息。 Warnings []string `json:"warnings"` Stats Stats `json:"stats"` } // Stats 是计划的分类统计。 type Stats struct { CreateUser int `json:"createUser"` UpdateUser int `json:"updateUser"` Deactivate int `json:"deactivateUser"` Reactivate int `json:"reactivateUser"` CreateGroup int `json:"createGroup"` UpdateGroup int `json:"updateGroup"` AddMember int `json:"addMember"` RemoveMember int `json:"removeMember"` TotalAccounts int `json:"totalAccounts"` Unchanged int `json:"unchanged"` } // Input 是计算计划所需的全部输入。 type Input struct { // Desired 是期望状态(来自飞书) Desired *mapping.State // Active 是 ou=people 下现有用户:uid(小写)→ 条目 Active map[string]*ldaplib.Entry // Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目 Inactive map[string]*ldaplib.Entry // Groups 是现有分组:DN(小写)→ 条目 Groups map[string]*ldaplib.Entry // LedgerAccounts 是台账中的账号:uid → state(active/inactive) LedgerAccounts map[string]string // LedgerGroups 是台账中的分组:dept_id → dn LedgerGroups map[string]string // AdminDN 与相关基础 DN AdminDN string GroupRootDN string UserOUDN string InactiveOUDN string } // opPhase 决定操作的执行顺序。 // // 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系 // 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。 // 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前, // 集成测试立刻暴露了这个问题。 func opPhase(k OpKind) int { switch k { case OpCreateGroup, OpUpdateGroup: return 0 case OpCreateUser, OpUpdateUser: return 1 case OpReactivate: return 2 case OpAddMember: return 3 case OpRemoveMember: return 4 case OpDeactivate: return 5 default: return 6 } } // sortOps 按依赖顺序排列操作。 // // 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键: // 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在 // “od-root” 之前,导致子分组先于父分组创建而失败)。 func sortOps(ops []Op) { sort.SliceStable(ops, func(i, j int) bool { pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind) if pi != pj { return pi < pj } return ops[i].Seq < ops[j].Seq }) } // ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。 func ComputePlan(in *Input) *Plan { p := &Plan{ Ops: make([]Op, 0, 64), Warnings: append([]string{}, in.Desired.Warnings...), Stats: Stats{ TotalAccounts: len(in.LedgerAccounts), }, } planGroups(p, in) planUsers(p, in) planMembership(p, in) // 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序 // (mapping 已按父先于子的顺序产出分组)。 for i := range p.Ops { p.Ops[i].Seq = i } sortOps(p.Ops) return p } // planGroups 处理分组的创建与更新。 func planGroups(p *Plan, in *Input) { for _, g := range in.Desired.Groups { existing, exists := in.Groups[strings.ToLower(g.DN)] if !exists { p.Ops = append(p.Ops, Op{ Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN), Group: g, }) p.Stats.CreateGroup++ continue } // 分组已存在:仅当描述不一致时更新。 // 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮 // 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理, // 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。 if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name { p.Ops = append(p.Ops, Op{ Kind: OpUpdateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name), Group: g, }) p.Stats.UpdateGroup++ } } // 台账中存在、但期望状态里已消失的分组:不自动删除。 // 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。 wantedDept := make(map[string]string, len(in.Desired.Groups)) for _, g := range in.Desired.Groups { wantedDept[g.DeptID] = g.DN } for deptID, dn := range in.LedgerGroups { wantDN, stillWanted := wantedDept[deptID] switch { case !stillWanted: p.Warnings = append(p.Warnings, fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn)) case !strings.EqualFold(wantDN, dn): // 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的 // uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。 p.Warnings = append(p.Warnings, fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)", deptID, dn, wantDN)) } } } // planUsers 处理用户的创建、更新、停用与恢复。 func planUsers(p *Plan, in *Input) { desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users)) for _, u := range in.Desired.Users { desiredByUID[u.UID] = u } for _, u := range in.Desired.Users { _, inActiveOU := in.Active[u.UID] _, inInactiveOU := in.Inactive[u.UID] switch { case inActiveOU: // 已存在且在职:比对属性是否漂移。 existing := in.Active[u.UID] canonical := strings.ToLower(existing.DN) // DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。 // 用大小写无关比较:LDAP 服务端可能对 DN 做规范化, // 直接字符串比较会造成每轮都产生无意义的“更新”。 if !strings.EqualFold(existing.DN, u.DN) { p.Ops = append(p.Ops, Op{ Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN), User: u, TargetDN: canonical, }) p.Stats.UpdateUser++ continue } if attrsDiffer(existing, u) { p.Ops = append(p.Ops, Op{ Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, Detail: "用户属性发生变化:" + describeAttrDiff(existing, u), User: u, TargetDN: canonical, }) p.Stats.UpdateUser++ continue } p.Stats.Unchanged++ case inInactiveOU: // 存在于 ou=Inactive 但期望是在职:恢复。 // 仅当台账确认该账号由我们管理时才动它。 if _, managed := in.LedgerAccounts[u.UID]; !managed { p.Warnings = append(p.Warnings, fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN)) continue } p.Ops = append(p.Ops, Op{ Kind: OpReactivate, Target: u.UID, Name: u.DisplayName, DN: u.DN, Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN), User: u, TargetDN: strings.ToLower(in.Inactive[u.UID].DN), }) p.Stats.Reactivate++ default: // 两处都没有:新建。 p.Ops = append(p.Ops, Op{ Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN), User: u, }) p.Stats.CreateUser++ } } // 台账中在职、但期望状态里已消失:候选停用。 // 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。 for uid, state := range in.LedgerAccounts { if state != "active" { continue } if _, ok := desiredByUID[uid]; ok { continue } entry, ok := in.Active[uid] if !ok { // 台账说在职但 LDAP 里找不到:数据不一致,仅告警。 p.Warnings = append(p.Warnings, fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN)) continue } p.Ops = append(p.Ops, Op{ Kind: OpDeactivate, Target: uid, Name: entry.Get(ldaplib.AttrDisplayName), DN: ldaplib.UserDN(uid, in.InactiveOUDN), Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN), TargetDN: strings.ToLower(entry.DN), }) p.Stats.Deactivate++ } } // planMembership 比对分组内的 uniqueMember,产出加组/移组操作。 func planMembership(p *Plan, in *Input) { for _, g := range in.Desired.Groups { existing, ok := in.Groups[strings.ToLower(g.DN)] if !ok { // 分组是新建的,成员关系会在创建后由执行器处理; // 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。 for memberDN := range g.Members { p.Ops = append(p.Ops, Op{ Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name), Group: g, GroupDN: g.DN, MemberDN: memberDN, }) p.Stats.AddMember++ } continue } actual := existing.Members() // 期望有、实际没有 → 加入 for memberDN := range g.Members { if _, has := actual[strings.ToLower(memberDN)]; has { continue } p.Ops = append(p.Ops, Op{ Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name), Group: g, GroupDN: existing.DN, MemberDN: memberDN, }) p.Stats.AddMember++ } // 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。 for memberDN := range actual { if _, want := g.Members[memberDN]; want { continue } if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) { continue } // 只移除我们管理的用户,避免把外部手工加入的成员踢掉。 if !isManagedMember(memberDN, in) { p.Warnings = append(p.Warnings, fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN))) continue } p.Ops = append(p.Ops, Op{ Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name), Group: g, GroupDN: existing.DN, MemberDN: memberDN, }) p.Stats.RemoveMember++ } } } // isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。 // // 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源, // 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。 func isManagedMember(memberDN string, in *Input) bool { uid, ok := uidFromDN(memberDN) if !ok { return false } if _, managed := in.LedgerAccounts[uid]; managed { return true } // 本轮新建的用户此时尚未进入台账,也应当被视为受管。 for _, u := range in.Desired.Users { if u.UID == uid { return true } } return false } // uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。 func uidFromDN(dn string) (string, bool) { first := dn if i := strings.Index(dn, ","); i >= 0 { first = dn[:i] } first = strings.TrimSpace(first) if len(first) < 4 || !strings.EqualFold(first[:4], "uid=") { return "", false } return strings.ToLower(strings.TrimSpace(first[4:])), true } // memberRDN 从成员 DN 中提取用于展示的标识。 func memberRDN(dn string) string { if uid, ok := uidFromDN(dn); ok { return uid } return dn } // attrsDiffer 判断 LDAP 现有属性与期望是否不一致。 func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool { checks := []struct { attr string want string }{ {ldaplib.AttrCN, want.CN}, {ldaplib.AttrSN, want.SN}, {ldaplib.AttrGivenName, want.GivenName}, {ldaplib.AttrDisplayName, want.DisplayName}, {ldaplib.AttrMail, want.Mail}, {ldaplib.AttrMobile, want.Mobile}, {ldaplib.AttrEmployeeNumber, want.EmployeeNumber}, {ldaplib.AttrDepartmentNumber, want.DepartmentNumber}, {ldaplib.AttrPostalAddress, want.PostalAddress}, {ldaplib.AttrBusinessCategory, want.BusinessCategory}, {ldaplib.AttrDescription, want.Description}, // employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。 {ldaplib.AttrEmployeeType, want.EmployeeType}, } for _, c := range checks { if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) { return true } } return false } // describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。 func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string { fields := []struct { label string attr string want string }{ {"mail", ldaplib.AttrMail, want.Mail}, {"displayName", ldaplib.AttrDisplayName, want.DisplayName}, {"mobile", ldaplib.AttrMobile, want.Mobile}, {"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber}, {"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber}, {"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress}, {"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory}, {"givenName", ldaplib.AttrGivenName, want.GivenName}, {"sn", ldaplib.AttrSN, want.SN}, {"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType}, } var diffs []string for _, f := range fields { old := existing.Get(f.attr) if normalizeAttr(old) == normalizeAttr(f.want) { continue } diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want)) } if len(diffs) == 0 { return "属性无变化" } return strings.Join(diffs, "; ") } // normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异, // 避免因为无关紧要的格式差异产生大量无意义的更新操作。 func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。 func AttrsHash(u *mapping.User) string { h := sha256.New() for _, v := range []string{ u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile, u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress, u.BusinessCategory, u.Description, } { h.Write([]byte(v)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) }