飞书通讯录 ↔ 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/)不入库。
365 lines
10 KiB
Go
365 lines
10 KiB
Go
package syncer
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"idsync/internal/config"
|
||
"idsync/internal/feishu"
|
||
"idsync/internal/ldaplib"
|
||
"idsync/internal/mapping"
|
||
"idsync/internal/store"
|
||
)
|
||
|
||
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
|
||
type Runner struct {
|
||
cfg *config.Config
|
||
feishu *feishu.Client
|
||
ldap *ldaplib.Client
|
||
store *store.Store
|
||
log *slog.Logger
|
||
|
||
// guard 是执行前的安全阈值。
|
||
guard Guard
|
||
}
|
||
|
||
// NewRunner 构造编排器。
|
||
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
|
||
return &Runner{
|
||
cfg: cfg,
|
||
feishu: f,
|
||
ldap: l,
|
||
store: s,
|
||
log: log,
|
||
guard: Guard{
|
||
MaxDeactivate: cfg.Sync.MaxDeactivate,
|
||
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
|
||
// 因此预览内容与真正会发生的事情一致。
|
||
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
|
||
plan, _, err := r.plan(ctx)
|
||
return plan, err
|
||
}
|
||
|
||
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
|
||
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
|
||
run, err := r.store.StartRun(modeOf(dryRun), trigger)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("创建同步记录失败: %w", err)
|
||
}
|
||
|
||
plan, desired, err := r.prepare(ctx, run, force)
|
||
if err != nil {
|
||
return plan, nil, err
|
||
}
|
||
if dryRun {
|
||
fillRunFromPlan(run, plan)
|
||
run.Status = "success"
|
||
if err := r.store.FinishRun(run); err != nil {
|
||
r.log.Warn("写入同步记录失败", "err", err)
|
||
}
|
||
return plan, nil, nil
|
||
}
|
||
|
||
res := r.apply(ctx, plan)
|
||
fillRunFromResult(run, plan, res)
|
||
|
||
// 只有全部成功才回写台账:部分失败时保持台账不变,
|
||
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
|
||
if res.Failed == 0 {
|
||
if err := r.persist(plan, desired); err != nil {
|
||
run.Status = "failed"
|
||
run.ErrorMsg = "回写台账失败: " + err.Error()
|
||
_ = r.store.FinishRun(run)
|
||
return plan, res, fmt.Errorf("回写台账失败: %w", err)
|
||
}
|
||
} else {
|
||
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed)
|
||
}
|
||
|
||
run.Status = "success"
|
||
if res.Failed > 0 {
|
||
run.Status = "failed"
|
||
run.ErrorMsg = strings.Join(res.Errors, "; ")
|
||
}
|
||
if err := r.store.FinishRun(run); err != nil {
|
||
r.log.Warn("写入同步记录失败", "err", err)
|
||
}
|
||
|
||
return plan, res, nil
|
||
}
|
||
|
||
// prepare 计算计划并做安全检查。
|
||
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) {
|
||
plan, desired, err := r.plan(ctx)
|
||
if err != nil {
|
||
run.Status = "failed"
|
||
run.ErrorMsg = err.Error()
|
||
_ = r.store.FinishRun(run)
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
|
||
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
|
||
guard := r.guard
|
||
if force {
|
||
guard.AllowBulk = true
|
||
}
|
||
if err := guard.Check(plan); err != nil {
|
||
run.Status = "failed"
|
||
run.ErrorMsg = err.Error()
|
||
_ = r.store.FinishRun(run)
|
||
return plan, desired, err
|
||
}
|
||
return plan, desired, nil
|
||
}
|
||
|
||
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。
|
||
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
|
||
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
|
||
}
|
||
r.log.Info("已读取飞书数据",
|
||
"departments", len(snap.Departments), "users", len(snap.Users),
|
||
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
|
||
|
||
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
|
||
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
|
||
var skipWarnings []string
|
||
for _, u := range snap.SkippedNoEmail {
|
||
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
|
||
feishu.DisplayName(u), u.UnionID))
|
||
}
|
||
for _, d := range snap.SkippedDuplicate {
|
||
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
|
||
d.UID, strings.Join(d.Names, "、")))
|
||
}
|
||
|
||
desired := mapping.Build(snap, mapping.BuildOptions{
|
||
UserOUDN: r.cfg.UserOUDN(),
|
||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||
GroupRootDN: r.cfg.GroupRootDN(),
|
||
})
|
||
|
||
conn, err := r.ldap.Dial()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
defer conn.Close()
|
||
|
||
active, err := conn.ListUsers(r.cfg.UserOUDN())
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
accounts, err := r.store.AccountsByUID()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
groupLedger, err := r.store.GroupsByDeptID()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
ledgerAccounts := make(map[string]string, len(accounts))
|
||
for uid, a := range accounts {
|
||
ledgerAccounts[uid] = a.State
|
||
}
|
||
ledgerGroups := make(map[string]string, len(groupLedger))
|
||
for deptID, g := range groupLedger {
|
||
ledgerGroups[deptID] = g.DN
|
||
}
|
||
|
||
plan := ComputePlan(&Input{
|
||
Desired: desired,
|
||
Active: active,
|
||
Inactive: inactive,
|
||
Groups: groups,
|
||
LedgerAccounts: ledgerAccounts,
|
||
LedgerGroups: ledgerGroups,
|
||
AdminDN: r.cfg.Ldap.AdminDN,
|
||
GroupRootDN: r.cfg.GroupRootDN(),
|
||
UserOUDN: r.cfg.UserOUDN(),
|
||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||
})
|
||
|
||
plan.Warnings = append(skipWarnings, plan.Warnings...)
|
||
|
||
// 对“候选停用”的用户,向飞书确认是否真的离职。
|
||
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
|
||
if err := r.confirmResigned(ctx, plan, snap); err != nil {
|
||
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
|
||
}
|
||
|
||
return plan, desired, nil
|
||
}
|
||
|
||
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
|
||
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
|
||
var uids []string
|
||
|
||
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
|
||
accounts, err := r.store.AccountsByUID()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for i := range plan.Ops {
|
||
if plan.Ops[i].Kind != OpDeactivate {
|
||
continue
|
||
}
|
||
acc, ok := accounts[plan.Ops[i].Target]
|
||
if !ok || acc.UnionID == "" {
|
||
continue
|
||
}
|
||
uids = append(uids, acc.UnionID)
|
||
}
|
||
|
||
if len(uids) == 0 {
|
||
return nil
|
||
}
|
||
|
||
resigned, err := r.feishu.CheckResigned(ctx, uids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
confirmed := make(map[string]bool, len(resigned))
|
||
for _, rr := range resigned {
|
||
confirmed[rr.UnionID] = true
|
||
}
|
||
|
||
keep := make([]Op, 0, len(plan.Ops))
|
||
for _, op := range plan.Ops {
|
||
if op.Kind != OpDeactivate {
|
||
keep = append(keep, op)
|
||
continue
|
||
}
|
||
acc := accounts[op.Target]
|
||
if acc != nil && confirmed[acc.UnionID] {
|
||
keep = append(keep, op)
|
||
continue
|
||
}
|
||
// 未能确认离职:不动作,并明确告知。
|
||
plan.Stats.Deactivate--
|
||
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
|
||
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
|
||
op.Target, r.cfg.InactiveOUDN()))
|
||
}
|
||
plan.Ops = keep
|
||
|
||
return nil
|
||
}
|
||
|
||
// apply 执行计划。
|
||
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
|
||
exec := NewExecutor(r.ldap, ExecConfig{
|
||
UserOUDN: r.cfg.UserOUDN(),
|
||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||
GroupRootDN: r.cfg.GroupRootDN(),
|
||
GroupRootName: r.cfg.Ldap.GroupRootName,
|
||
AdminDN: r.cfg.Ldap.AdminDN,
|
||
InitialPassword: r.cfg.Ldap.InitialPassword,
|
||
}, r.log)
|
||
return exec.Apply(ctx, plan)
|
||
}
|
||
|
||
// persist 把本次同步的期望状态回写台账。
|
||
//
|
||
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
|
||
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
|
||
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
|
||
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
|
||
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
|
||
now := time.Now()
|
||
|
||
// 分组:凡在本次期望范围内的都登记。
|
||
for _, g := range desired.Groups {
|
||
if err := r.store.UpsertGroup(&store.Group{
|
||
DeptID: g.DeptID,
|
||
DN: g.DN,
|
||
Name: g.Name,
|
||
ParentID: g.ParentID,
|
||
State: "active",
|
||
LastSyncedAt: now,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 用户:在职的登记为 active。
|
||
for _, u := range desired.Users {
|
||
if err := r.store.UpsertAccount(&store.Account{
|
||
UID: u.UID,
|
||
DN: u.DN,
|
||
Email: u.Mail,
|
||
Name: u.DisplayName,
|
||
State: "active",
|
||
AttrsHash: AttrsHash(u),
|
||
DeptIDs: strings.Join(u.DeptIDs, ","),
|
||
LastSyncedAt: now,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 停用的账号:更新状态与,DN 指向离职 OU。
|
||
for _, op := range plan.Ops {
|
||
if op.Kind == OpDeactivate {
|
||
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// modeOf 把 dry-run 标志转成记录用的模式名。
|
||
func modeOf(dryRun bool) string {
|
||
if dryRun {
|
||
return "dry-run"
|
||
}
|
||
return "apply"
|
||
}
|
||
|
||
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。
|
||
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
|
||
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
|
||
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup +
|
||
plan.Stats.Reactivate + plan.Stats.Deactivate
|
||
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
|
||
run.Added = plan.Stats.AddMember
|
||
run.Removed = plan.Stats.RemoveMember
|
||
run.Skipped = plan.Stats.Unchanged
|
||
}
|
||
|
||
// fillRunFromResult 用实际执行结果填充同步记录。
|
||
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
|
||
run.Created = res.Created
|
||
run.Updated = res.Updated
|
||
run.Moved = res.Moved
|
||
run.Added = res.Added
|
||
run.Removed = res.Removed
|
||
run.Failed = res.Failed
|
||
run.Skipped = plan.Stats.Unchanged
|
||
}
|