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/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+524
View File
@@ -0,0 +1,524 @@
// 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))
}