// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。 // // 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出 // 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正 // 执行共用同一份代码——预览即所得。 // // 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的 // 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave // 状态才会生成删除操作,且删除永远排在最后一阶段。 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" OpDeleteUser OpKind = "delete_user" OpCreateGroup OpKind = "create_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 string `json:"dn,omitempty"` // Detail 是人类可读的说明,直接展示在预览页。 Detail string `json:"detail"` // 以下字段供执行器使用,不参与 JSON 展示。 User *mapping.DesiredUser `json:"-"` Group *mapping.DesiredGroup `json:"-"` MemberUID string `json:"-"` AttrsHash string `json:"-"` // Seq 记录生成顺序,用于在同一阶段内保持稳定输出。 Seq int `json:"-"` } // Plan 是一次物化的完整计划。 type Plan struct { Ops []Op `json:"ops"` Warnings []string `json:"warnings"` Stats Stats `json:"stats"` } // Stats 是计划的分类统计。 type Stats struct { CreateUser int `json:"createUser"` UpdateUser int `json:"updateUser"` DeleteUser int `json:"deleteUser"` CreateGroup int `json:"createGroup"` AddMember int `json:"addMember"` RemoveMember int `json:"removeMember"` TotalUsers int `json:"totalUsers"` Unchanged int `json:"unchanged"` } // Input 是计算物化计划所需的全部输入。 type Input struct { // Desired 是期望状态(来自镜像 BuildFromMirror)。 Desired *mapping.DesiredState // ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。 ActualUsers map[string]*ldaplib.Entry // ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。 ActualGroups map[string]*ldaplib.Entry // OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。 OwnedUsers map[string]struct{} // ConfirmedLeave 是已人工确认离职、待删除的用户集。 ConfirmedLeave map[string]bool // AdminDN 与基础 OU DN。 AdminDN string PeopleOU string GroupRootDN string } // opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。 // // 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在, // 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。 func opPhase(k OpKind) int { switch k { case OpCreateGroup: return 0 case OpCreateUser, OpUpdateUser: return 1 case OpAddMember: return 2 case OpRemoveMember: return 3 case OpDeleteUser: return 4 default: return 5 } } // sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。 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: make([]string, 0), Stats: Stats{TotalUsers: len(in.ActualUsers)}, } planGroups(p, in) planUsers(p, in) planMembership(p, in) for i := range p.Ops { p.Ops[i].Seq = i } sortOps(p.Ops) return p } // planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。 func planGroups(p *Plan, in *Input) { desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups)) for _, g := range in.Desired.Groups { desired[strings.ToLower(g.DN)] = g } for _, g := range in.Desired.Groups { if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok { continue } p.Ops = append(p.Ops, Op{ Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g, Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN), }) p.Stats.CreateGroup++ } // 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。 for _, e := range in.ActualGroups { if _, ok := desired[strings.ToLower(e.DN)]; ok { continue } p.Warnings = append(p.Warnings, fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN)) } } // planUsers 处理用户的创建、更新与删除。 func planUsers(p *Plan, in *Input) { desiredByUID := make(map[string]*mapping.DesiredUser, len(in.Desired.Users)) for _, u := range in.Desired.Users { desiredByUID[u.UID] = u } for _, u := range in.Desired.Users { existing, ok := in.ActualUsers[strings.ToLower(u.UID)] if !ok { p.Ops = append(p.Ops, Op{ Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, User: u, AttrsHash: AttrsHash(u), Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName), }) p.Stats.CreateUser++ continue } if attrsDiffer(existing, u) { p.Ops = append(p.Ops, Op{ Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, User: u, AttrsHash: AttrsHash(u), Detail: "用户属性变化:" + describeAttrDiff(existing, u), }) p.Stats.UpdateUser++ continue } p.Stats.Unchanged++ } // 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。 // 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。 for uid := range in.ActualUsers { if _, want := desiredByUID[uid]; want { continue } if _, owned := in.OwnedUsers[uid]; !owned { continue } if !in.ConfirmedLeave[uid] { p.Warnings = append(p.Warnings, fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid)) continue } p.Ops = append(p.Ops, Op{ Kind: OpDeleteUser, Target: uid, Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName), DN: ldaplib.UserDN(uid, in.PeopleOU), Detail: fmt.Sprintf("删除已确认离职用户 %s", uid), }) p.Stats.DeleteUser++ } } // planMembership 比对组的成员,产出加组/移组操作。 func planMembership(p *Plan, in *Input) { for _, g := range in.Desired.Groups { e, ok := in.ActualGroups[strings.ToLower(g.DN)] actual := make(map[string]struct{}) if ok { actual = e.Members() } desired := lowerMembers(g.Members) for memberDN := range g.Members { if _, has := actual[strings.ToLower(memberDN)]; has { continue } uid, ok := uidFromDN(memberDN) if !ok { continue } p.Ops = append(p.Ops, Op{ Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid, Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name), }) p.Stats.AddMember++ } if !ok { continue } // 移出:实际有、期望没有,且只移受管、永不移 admin。 for memberDN := range actual { if _, want := desired[memberDN]; want { continue } if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) { continue } uid, ok := uidFromDN(memberDN) if !ok { continue } if _, managed := in.OwnedUsers[uid]; !managed { p.Warnings = append(p.Warnings, fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid)) continue } p.Ops = append(p.Ops, Op{ Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid, Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name), }) p.Stats.RemoveMember++ } } } // lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。 func lowerMembers(m map[string]struct{}) map[string]struct{} { out := make(map[string]struct{}, len(m)) for dn := range m { out[strings.ToLower(dn)] = struct{}{} } return out } // 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 } // attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。 // // LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName); // 默认不建自定义属性,因此只比对这四类可写硬字段。 func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool { checks := []struct { attr string want string }{ {ldaplib.AttrMail, want.Mail}, {ldaplib.AttrDisplayName, want.DisplayName}, {ldaplib.AttrGivenName, want.GivenName}, {ldaplib.AttrSN, want.SN}, } 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.DesiredUser) string { fields := []struct { label string attr string want string }{ {"mail", ldaplib.AttrMail, want.Mail}, {"displayName", ldaplib.AttrDisplayName, want.DisplayName}, {"givenName", ldaplib.AttrGivenName, want.GivenName}, {"sn", ldaplib.AttrSN, want.SN}, } 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, "; ") } func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。 func AttrsHash(u *mapping.DesiredUser) string { h := sha256.New() for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} { h.Write([]byte(v)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) } // Guard 是执行前的安全阈值。 type Guard struct { // MaxDeactivate 单次允许删除的最大账号数,0 表示不限制。 MaxDeactivate int // MaxDeactivatePercent 单次允许删除的账号比例(0-1),0 表示不限制。 MaxDeactivatePercent float64 // AllowBulk 显式跳过全部阈值检查。 AllowBulk bool } // GuardError 表示计划未通过安全阈值检查。 type GuardError struct{ Message string } func (e *GuardError) Error() string { return e.Message } // Check 校验计划是否满足安全阈值。 func (g Guard) Check(p *Plan) error { if g.AllowBulk { return nil } deletes := p.Stats.DeleteUser if deletes == 0 { return nil } if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate { return &GuardError{Message: fmt.Sprintf( "本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)} } if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 { ratio := float64(deletes) / float64(p.Stats.TotalUsers) if ratio > g.MaxDeactivatePercent { return &GuardError{Message: fmt.Sprintf( "本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑", deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)} } } return nil }