feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server

- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
This commit is contained in:
2026-09-29 23:48:55 +08:00
parent 979a98d502
commit 126e52a6ed
6 changed files with 835 additions and 1191 deletions
+121 -84
View File
@@ -17,6 +17,7 @@ import (
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/server"
"idsync/internal/store"
"idsync/internal/syncer"
@@ -28,9 +29,10 @@ var version = "dev"
func main() {
var (
cfgPath = flag.String("config", "config.yml", "配置文件路径")
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP")
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)")
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏")
dryRun = flag.Bool("dry-run", false, "只计算并打印物化计划,不写入 LLDAP")
applyAll = flag.Bool("apply", false, "直接执行同步(快照+物化,默认只做 dry-run)")
force = flag.Bool("force", false, "忽略删除数量阈值等安全护栏")
snapshot = flag.Bool("snapshot", false, "只执行一轮快照(飞书→镜像+离职流程)后退出")
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
@@ -38,25 +40,15 @@ func main() {
flag.Parse()
if *genSecret {
s, err := server.GenerateSecret()
if err != nil {
fatal("生成密钥失败", err)
}
fmt.Println(s)
fmt.Println(mustGenSecret())
return
}
if *hashPass != "" {
h, err := server.HashPassword(*hashPass)
if err != nil {
fatal("生成哈希失败", err)
}
fmt.Println(h)
fmt.Println(mustHashPassword(*hashPass))
return
}
// 配置自检:把解析后的有效值打出来(凭据脱敏)。
// 用于在真正连接目录前确认关键项,尤其是分组形态——
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
if *checkCfg {
cfg, err := config.Load(*cfgPath)
if err != nil {
@@ -81,16 +73,20 @@ func main() {
fatal("创建数据目录失败", err)
}
// 读通道:LLDAP 的 LDAP(连接/搜索)。
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
fatal("初始化 LDAP 客户端失败", err)
fatal("初始化 LDAP 读客户端失败", err)
}
if err := ldapClient.Ping(); err != nil {
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
} else {
log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
log.Info("LDAP 读通道正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
}
// 写通道:LLDAP 的 GraphQL。
gqlClient := lldapgql.New(cfg.Lldap.BaseURL, log)
feishuClient, err := feishu.New(feishu.Options{
AppID: cfg.Feishu.AppID,
AppSecret: cfg.Feishu.AppSecret,
@@ -104,19 +100,28 @@ func main() {
db, err := store.Open(cfg.Database.Source)
if err != nil {
fatal("打开台账数据库失败", err)
fatal("打开镜像数据库失败", err)
}
defer db.Close()
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log)
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gqlClient, db, log, syncer.SnapshotOptions{
WebhookURL: cfg.Feishu.WebhookURL,
ConfirmBaseURL: cfg.Server.ConfirmBaseURL,
ReNotifyInterval: 24 * time.Hour,
TokenTTL: 72 * time.Hour,
})
// 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。
// 命令行模式:跑一轮后退出,便于放进 crontab 或手工验证。
if *snapshot {
runSnapshotOnce(runner, log)
return
}
if *dryRun || *applyAll {
runOnce(cfg, runner, *applyAll, *force, log)
return
}
// 服务模式
// 服务模式。
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
if err != nil {
fatal("初始化服务失败", err)
@@ -135,10 +140,10 @@ func main() {
}
}()
if cfg.Sync.Enable {
scheduleSync(cfg, runner, log)
if cfg.Schedule.Enable {
scheduleAuto(cfg, runner, log)
} else {
log.Info("定时同步未启用(sync.enable=false)")
log.Info("定时任务未启用(schedule.enable=false)")
}
quit := make(chan os.Signal, 1)
@@ -170,38 +175,24 @@ func printEffectiveConfig(cfg *config.Config) {
fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
fmt.Printf("飞书 离职卡片 webhook : %s\n", orDefault(cfg.Feishu.WebhookURL, "(未配置)"))
fmt.Println()
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL)
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN)
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN)
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS)
fmt.Printf("LDAP 读 url : %s\n", cfg.Ldap.URL)
fmt.Printf("LDAP 读 base-dn : %s\n", cfg.Ldap.BaseDN)
fmt.Printf("LDAP 读 admin-dn : %s\n", cfg.Ldap.AdminDN)
fmt.Printf("LDAP 读 admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
fmt.Printf("LLDAP 写 base-url : %s\n", cfg.Lldap.BaseURL)
fmt.Printf("LLDAP 写 admin-user : %s\n", cfg.Lldap.AdminUser)
fmt.Printf("LLDAP 写 admin-pass : %s\n", mask(cfg.Lldap.AdminPass))
fmt.Println()
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN())
fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN())
fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN())
fmt.Printf("用户 OU : %s\n", cfg.UserOUDN())
fmt.Printf("部门组 OU : %s\n", cfg.GroupRootDN())
fmt.Printf("离职确认端点对外 URL : %s\n", orDefault(cfg.Server.ConfirmBaseURL, "(未配置,离职卡片将无法提供确认链接)"))
fmt.Println()
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass)
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr)
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n",
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass)
fmt.Println()
fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)"))
fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n",
cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun)
fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n",
cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100)
// 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。
userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN()
if inactiveDN == userDN {
fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效")
} else if len(inactiveDN) > len(userDN) &&
inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户")
} else {
fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)")
}
fmt.Printf("定时任务 : enable=%v snapshot=%q sync=%q sync-dry-run=%v\n",
cfg.Schedule.Enable, cfg.Schedule.Snapshot, cfg.Schedule.Sync, cfg.Schedule.SyncDryRun)
fmt.Printf("删除阈值 : max=%d percent=%.0f%%\n",
cfg.Guard.MaxDeactivate, cfg.Guard.MaxDeactivatePercent*100)
}
func orDefault(v, def string) string {
@@ -211,26 +202,47 @@ func orDefault(v, def string) string {
return v
}
// runOnce 执行一次同步并打印计划,然后退出。
// runSnapshotOnce 只执行一轮快照并退出。
func runSnapshotOnce(runner *syncer.Runner, log *slog.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
res, err := runner.RunSnapshot(ctx)
if err != nil {
fatal("快照失败", err)
}
log.Info("快照完成",
"users", res.Users, "departments", res.Departments, "skipped", res.Skipped,
"leave_candidates", res.LeaveCandidates, "pending_confirm", res.PendingConfirm,
"reinstated", res.Reinstated, "cards_sent", res.CardsSent)
for _, w := range res.Warnings {
log.Warn("快照告警", "detail", w)
}
}
// runOnce 执行一轮「快照 + 物化」并打印计划,然后退出。
func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// 先快照(飞书→镜像),再物化(镜像→LLDAP)。
if _, err := runner.RunSnapshot(ctx); err != nil {
fatal("快照失败", err)
}
plan, res, err := runner.Run(ctx, !apply, force, "manual")
if plan != nil {
printPlanTo(plan)
}
if err != nil {
fatal("同步失败", err)
fatal("物化失败", err)
}
if !apply {
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
return
}
log.Info("同步完成",
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
log.Info("物化完成",
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
for _, e := range res.Errors {
log.Error("操作失败详情", "detail", e)
@@ -248,7 +260,6 @@ func buildServer(
) (*server.Server, error) {
secret := cfg.Server.SessionSecret
if secret == "" {
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
generated, err := server.GenerateSecret()
if err != nil {
return nil, fmt.Errorf("生成会话密钥失败: %w", err)
@@ -288,25 +299,36 @@ func buildServer(
}), nil
}
// scheduleSync 注册定时同步。
//
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
// scheduleAuto 注册两套定时任务:快照低频、物化略高频。输出到 runner 的互斥锁
// 保证两者绝不同时执行。
func scheduleAuto(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
c := cron.New(
cron.WithSeconds(),
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
)
_, err := c.AddFunc(cfg.Sync.Schedule, func() {
dry := cfg.Sync.DefaultDryRun
if _, err := c.AddFunc(cfg.Schedule.Snapshot, func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
log.Info("定时快照开始")
if res, err := runner.RunSnapshot(ctx); err != nil {
log.Error("定时快照失败", "err", err)
} else {
log.Info("定时快照完成",
"users", res.Users, "pending_confirm", res.PendingConfirm, "cards_sent", res.CardsSent)
}
}); err != nil {
log.Error("注册定时快照失败", "schedule", cfg.Schedule.Snapshot, "err", err)
}
log.Info("定时同步开始", "dry_run", dry)
if _, err := c.AddFunc(cfg.Schedule.Sync, func() {
dry := cfg.Schedule.SyncDryRun
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
log.Info("定时物化开始", "dry_run", dry)
plan, res, err := runner.Run(ctx, dry, false, "schedule")
if err != nil {
log.Error("定时同步失败", "err", err)
log.Error("定时物化失败", "err", err)
return
}
if dry {
@@ -314,34 +336,33 @@ func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
if plan != nil {
planned = len(plan.Ops)
}
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned)
log.Info("定时物化为 dry-run 模式,未写入变更", "planned_ops", planned)
return
}
log.Info("定时同步完成",
"created", res.Created, "updated", res.Updated,
"moved", res.Moved, "failed", res.Failed)
})
if err != nil {
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
return
log.Info("定时物化完成",
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
}); err != nil {
log.Error("注册定时物化失败", "schedule", cfg.Schedule.Sync, "err", err)
}
c.Start()
log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun)
log.Info("定时任务已启用",
"snapshot", cfg.Schedule.Snapshot, "sync", cfg.Schedule.Sync,
"sync_dry_run", cfg.Schedule.SyncDryRun)
}
func printPlanTo(p *syncer.Plan) {
fmt.Println("===== 同步计划 =====")
fmt.Println("===== 物化计划 =====")
s := p.Stats
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n",
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate)
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
fmt.Printf("新建用户 %d · 更新用户 %d · 删除用户 %d\n", s.CreateUser, s.UpdateUser, s.DeleteUser)
fmt.Printf("新建分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
s.CreateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
if len(p.Ops) > 0 {
fmt.Println("\n----- 操作明细 -----")
for _, op := range p.Ops {
fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail)
fmt.Printf(" [%-15s] %-22s %s\n", op.Kind, op.Target, op.Detail)
}
}
if len(p.Warnings) > 0 {
@@ -353,7 +374,23 @@ func printPlanTo(p *syncer.Plan) {
fmt.Println()
}
func mustGenSecret() string {
s, err := server.GenerateSecret()
if err != nil {
fatal("生成密钥失败", err)
}
return s
}
func mustHashPassword(plain string) string {
h, err := server.HashPassword(plain)
if err != nil {
fatal("生成哈希失败", err)
}
return h
}
func fatal(msg string, err error) {
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
os.Exit(1)
}
}