飞书通讯录 ↔ 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/)不入库。
525 lines
16 KiB
Go
525 lines
16 KiB
Go
// 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))
|
||
}
|