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:
@@ -0,0 +1,364 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user