diff --git a/internal/server/server.go b/internal/server/server.go index ce6a727..bd3f51f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,8 +1,16 @@ +// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。 +// +// 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点 +// **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置 +// confirmed_leave。 + package server import ( "context" "encoding/json" + "fmt" + "html" "log/slog" "net/http" "strings" @@ -57,12 +65,17 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/login", s.handleLogin) s.mux.HandleFunc("/api/logout", s.handleLogout) + // 离职确认端点:token 即凭证,不经 Auth 中间件。 + s.mux.HandleFunc(config.ResignConfirmRoute, s.handleResignConfirm) + // 以下接口需要登录。 s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus)) s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers)) s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups)) s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan)) s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync)) + s.mux.HandleFunc("/api/snapshot", s.deps.Auth.Middleware(s.handleSnapshot)) + s.mux.HandleFunc("/api/resign/pending", s.deps.Auth.Middleware(s.handleResignPending)) s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns)) } @@ -75,7 +88,6 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { webui.IndexHandler()(w, r) } -// handleLogin 处理登录。 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) @@ -88,54 +100,41 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -// handleLogout 处理登出。 func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { s.deps.Auth.Logout(w) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -// handleStatus 返回概览信息:连通性、计数、上次同步。 +// handleStatus 返回概览信息。 func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() cfg := s.deps.Cfg - ldapOK := true - ldapErr := "" + ldapOK, ldapErr := true, "" if err := s.deps.LDAP.Ping(); err != nil { - ldapOK = false - ldapErr = err.Error() + ldapOK, ldapErr = false, err.Error() } - - feishuOK := true - feishuErr := "" + feishuOK, feishuErr := true, "" if s.deps.FeishuTest != nil { if err := s.deps.FeishuTest(ctx); err != nil { - feishuOK = false - feishuErr = err.Error() + feishuOK, feishuErr = false, err.Error() } } - accounts, err := s.deps.Store.AllAccounts() + users, err := s.deps.Store.ActiveUsers() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - groups, err := s.deps.Store.AllGroups() + depts, err := s.deps.Store.ActiveDepartments() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - - active, inactive := 0, 0 - for _, a := range accounts { - if a.State == "inactive" { - inactive++ - } else { - active++ - } - } + skipped, _ := s.deps.Store.SkippedUsers() + pending, _ := s.deps.Store.PendingConfirm() lastRun, err := s.deps.Store.LastRun() if err != nil { @@ -146,103 +145,70 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "ldap": map[string]any{ "ok": ldapOK, "error": ldapErr, - "url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, - "userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(), + "url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(), }, "feishu": map[string]any{ "ok": feishuOK, "error": feishuErr, - "appId": maskSecret(cfg.Feishu.AppID), - "deptList": cfg.Feishu.DeptList, + "appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList, + }, + "lldap": map[string]any{ + "baseUrl": cfg.Lldap.BaseURL, }, "counts": map[string]any{ - "activeUsers": active, "inactiveUsers": inactive, "groups": len(groups), + "activeUsers": len(users), "departments": len(depts), + "pendingConfirm": len(pending), "skipped": len(skipped), }, "lastRun": lastRun, - "sync": map[string]any{ - "enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule, - "defaultDryRun": cfg.Sync.DefaultDryRun, + "schedule": map[string]any{ + "enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot, + "sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun, }, }) } -// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。 +// handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。 func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { - accounts, err := s.deps.Store.AllAccounts() + all, err := s.deps.Store.AllMirrorUsers() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - - // 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项, - // 这类问题在界面上一眼可见比事后翻日志有用得多。 - drift := map[string]string{} - if conn, err := s.deps.LDAP.Dial(); err == nil { - defer conn.Close() - active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN()) - inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN()) - if errA == nil && errI == nil { - for _, a := range accounts { - switch a.State { - case "inactive": - if _, ok := inactive[a.UID]; !ok { - drift[a.UID] = "台账标记离职,但 LDAP 中不存在" - } - default: - if _, ok := active[a.UID]; !ok { - if _, inInactive := inactive[a.UID]; inInactive { - drift[a.UID] = "台账标记在职,实际位于离职 OU" - } else { - drift[a.UID] = "台账存在,但 LDAP 中不存在" - } - } - } - } - } - } - type row struct { - UID string `json:"uid"` - Name string `json:"name"` - Email string `json:"email"` - State string `json:"state"` - DN string `json:"dn"` - DeptIDs string `json:"deptIds"` - LastSync string `json:"lastSyncedAt"` - Drift string `json:"drift,omitempty"` + UID string `json:"uid"` + Name string `json:"name"` + Email string `json:"email"` + State string `json:"state"` + Skipped bool `json:"skipped"` + SkipReason string `json:"skipReason,omitempty"` + DeptIDs []string `json:"deptIds"` + LastSync string `json:"lastSyncedAt"` } - - rows := make([]row, 0, len(accounts)) - for _, a := range accounts { + rows := make([]row, 0, len(all)) + for _, u := range all { + deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID) rows = append(rows, row{ - UID: a.UID, - Name: a.Name, - Email: a.Email, - State: a.State, - DN: a.DN, - DeptIDs: a.DeptIDs, - LastSync: a.LastSyncedAt.Format(time.RFC3339), - Drift: drift[a.UID], + UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State, + Skipped: u.Skipped, SkipReason: u.SkipReason, + DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt), }) } - writeJSON(w, http.StatusOK, map[string]any{"users": rows}) } -// handleGroups 返回受管分组清单。 +// handleGroups 返回部门镜像。 func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { - groups, err := s.deps.Store.AllGroups() + depts, err := s.deps.Store.AllDepartments() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) + writeJSON(w, http.StatusOK, map[string]any{"groups": depts}) } -// handlePlan 计算并返回 dry-run 计划,不产生任何写入。 +// handlePlan 计算并返回 dry-run 物化计划,不产生任何写入。 func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() - plan, err := s.deps.Runner.Preview(ctx) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -251,13 +217,12 @@ func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, plan) } -// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。 +// handleSync 执行物化。默认 dry-run,只有显式传 dryRun=false 才真正写入。 func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) return } - var req struct { DryRun *bool `json:"dryRun"` Force bool `json:"force"` @@ -265,39 +230,107 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } - - // 默认 dry-run:忘记传参数时宁可什么都不做。 dryRun := true if req.DryRun != nil { dryRun = *req.DryRun } - // 同一时刻只允许一次同步。 - select { - case s.syncMu <- struct{}{}: - defer func() { <-s.syncMu }() - default: - writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"}) + if err := s.lockSync(); err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) return } + defer s.unlockSync() ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) defer cancel() - plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual") if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]any{ - "error": err.Error(), "plan": plan, - }) + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan}) return } - - writeJSON(w, http.StatusOK, map[string]any{ - "dryRun": dryRun, "plan": plan, "result": res, - }) + writeJSON(w, http.StatusOK, map[string]any{"dryRun": dryRun, "plan": plan, "result": res}) } -// handleRuns 返回同步历史。 +// handleSnapshot 触发一轮快照。 +func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + if err := s.lockSync(); err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + defer s.unlockSync() + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) + defer cancel() + res, err := s.deps.Runner.RunSnapshot(ctx) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, res) +} + +// handleResignPending 返回待人工确认的离职候选(等价于卡片按钮,可在界面操作)。 +func (s *Server) handleResignPending(w http.ResponseWriter, r *http.Request) { + list, err := s.deps.Store.PendingConfirm() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + type row struct { + UID string `json:"uid"` + Name string `json:"name"` + Email string `json:"email"` + LastSync string `json:"lastSyncedAt"` + } + rows := make([]row, 0, len(list)) + for _, u := range list { + rows = append(rows, row{UID: u.UID, Name: u.DisplayName, Email: u.Email, LastSync: timeOrZero(u.LastSyncedAt)}) + } + writeJSON(w, http.StatusOK, map[string]any{"pending": rows}) +} + +// handleResignConfirm 处理一次性离职确认:校验 token 并通过后置 confirmed_leave。 +// +// token 即凭证,因此本项目路由不经 Auth 中间件。成功后渲染极简确认页,不回显 +// 用户敏感信息;无效/过期/已用分别给出对应提示。 +func (s *Server) handleResignConfirm(w http.ResponseWriter, r *http.Request) { + uid := r.URL.Query().Get("uid") + token := r.URL.Query().Get("token") + + rec, err := s.deps.Store.ConsumeToken(token) + if err != nil { + s.renderConfirmPage(w, "确认链接无效", "该确认链接暂不可用,可能是已失效、已使用或已过期。请向管理员重新发起离职确认。") + return + } + // token 与请求里携带的 uid 必须一致,防串用。 + if rec.UID != uid { + s.renderConfirmPage(w, "确认链接无效", "该确认链接与请求的用户不一致,已拒绝。") + return + } + if err := s.deps.Store.ConfirmLeave(uid); err != nil { + s.log().Warn("确认离职失败", "uid", uid, "err", err) + s.renderConfirmPage(w, "操作失败", "处理确认时出错,请稍后重试或联系管理员。") + return + } + s.log().Info("用户已人工确认离职", "uid", uid) + s.renderConfirmPage(w, "已确认离职", "该员工已标记为离职,将在下一轮同步中从目录移除。") +} + +// renderConfirmPage 渲染极简确认页,不展示任何用户敏感信息。 +func (s *Server) renderConfirmPage(w http.ResponseWriter, title, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = fmt.Fprintf(w, ` +
%s
`, + html.EscapeString(title), html.EscapeString(title), html.EscapeString(body)) +} + +// handleRuns 返回物化历史。 func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { runs, err := s.deps.Store.RecentRuns(100) if err != nil { @@ -307,6 +340,24 @@ func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) } +func (s *Server) lockSync() error { + select { + case s.syncMu <- struct{}{}: + return nil + default: + return fmt.Errorf("已有快照或物化任务在执行") + } +} + +func (s *Server) unlockSync() { <-s.syncMu } + +func (s *Server) log() *slog.Logger { + if s.deps.Log != nil { + return s.deps.Log + } + return slog.Default() +} + // writeJSON 输出 JSON 响应。 func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -314,7 +365,7 @@ func writeJSON(w http.ResponseWriter, code int, body any) { _ = json.NewEncoder(w).Encode(body) } -// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。 +// maskSecret 只保留标识的前后几位。 func maskSecret(s string) string { if len(s) <= 8 { if len(s) == 0 { @@ -324,3 +375,10 @@ func maskSecret(s string) string { } return s[:4] + "****" + s[len(s)-4:] } + +func timeOrZero(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} \ No newline at end of file diff --git a/internal/syncer/exec.go b/internal/syncer/exec.go index 2d2d7b4..e542ac4 100644 --- a/internal/syncer/exec.go +++ b/internal/syncer/exec.go @@ -1,274 +1,139 @@ package syncer +// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。 +// +// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的 +// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId +// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。 + import ( "context" "fmt" "log/slog" - "strings" - "idsync/internal/ldaplib" - "idsync/internal/mapping" - "idsync/internal/password" + "idsync/internal/lldapgql" ) -// Guard 是执行前的安全阈值。 -// -// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、 -// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。 -// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。 -type Guard struct { - // MaxDeactivate 单次允许停用的最大账号数,0 表示不限制。 - MaxDeactivate int - // MaxDeactivatePercent 单次允许停用的账号比例(0-1),0 表示不限制。 - MaxDeactivatePercent float64 - // AllowBulk 显式跳过全部阈值检查。 - AllowBulk bool -} - -// GuardError 表示计划未通过安全阈值检查。 -type GuardError struct { - Message string -} - -func (e *GuardError) Error() string { return e.Message } - -// Check 校验计划是否满足安全阈值。 -func (g Guard) Check(p *Plan) error { - if g.AllowBulk { - return nil - } - - deactivate := p.Stats.Deactivate - if deactivate == 0 { - return nil - } - - if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate { - return &GuardError{Message: fmt.Sprintf( - "本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", - deactivate, g.MaxDeactivate)} - } - - if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 { - ratio := float64(deactivate) / float64(p.Stats.TotalAccounts) - if ratio > g.MaxDeactivatePercent { - return &GuardError{Message: fmt.Sprintf( - "本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑", - deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)} - } - } - - return nil +// ExecConfig 是执行器所需的环境参数。 +type ExecConfig struct { + PeopleOU string } // Result 是执行结果统计。 type Result struct { Created int Updated int - Moved int + Deleted int Added int Removed int Failed int Errors []string } -// Executor 把计划应用到 LDAP。 +// Executor 把计划应用到 LLDAP(写走 GraphQL)。 type Executor struct { - client *ldaplib.Client - cfg ExecConfig - log *slog.Logger -} - -// ExecConfig 是执行器所需的环境参数。 -type ExecConfig struct { - UserOUDN string - InactiveOUDN string - GroupRootDN string - GroupRootName string - AdminDN string - // InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。 - InitialPassword string + gql *lldapgql.Client + cfg ExecConfig + log *slog.Logger } // NewExecutor 构造执行器。 -func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor { - return &Executor{client: client, cfg: cfg, log: log} +func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor { + return &Executor{gql: gql, cfg: cfg, log: log} } -// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作—— -// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。 -func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result { +// Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个 +// 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射, +// 会在创建新组时被原地扩充。 +func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result { res := &Result{} - conn, err := e.client.Dial() - if err != nil { - res.Failed++ - res.Errors = append(res.Errors, err.Error()) - return res - } - defer conn.Close() - - // 确保基础 OU 存在,否则后续写入会失败。 - // - // ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、 - // 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串 - // 难以理解的 No Such Object。 - baseOUs := []struct{ dn, name string }{ - {e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)}, - {e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)}, - {e.cfg.GroupRootDN, e.cfg.GroupRootName}, - } - for _, ou := range baseOUs { - if ou.dn == "" { - continue - } - if err := conn.EnsureOU(ou.dn, ou.name); err != nil { - res.Failed++ - res.Errors = append(res.Errors, err.Error()) - return res - } - } - for _, op := range plan.Ops { if err := ctx.Err(); err != nil { - res.Errors = append(res.Errors, "同步被取消: "+err.Error()) + res.Errors = append(res.Errors, "物化被取消: "+err.Error()) return res } - if err := e.applyOne(conn, op); err != nil { + if err := e.applyOne(ctx, op, groupIDs); err != nil { res.Failed++ msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error()) res.Errors = append(res.Errors, msg) - e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err) + e.log.Error("物化操作失败", "kind", op.Kind, "target", op.Target, "err", err) continue } switch op.Kind { - case OpCreateUser: + case OpCreateUser, OpCreateGroup: res.Created++ - case OpUpdateUser, OpReactivate: + case OpUpdateUser: res.Updated++ - if op.Kind == OpReactivate { - res.Moved++ - } - case OpDeactivate: - res.Updated++ - res.Moved++ + case OpDeleteUser: + res.Deleted++ case OpAddMember: res.Added++ case OpRemoveMember: res.Removed++ } } - return res } // applyOne 执行单个操作。 -func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error { +func (e *Executor) applyOne(ctx context.Context, op Op, groupIDs map[string]int) error { switch op.Kind { case OpCreateGroup: - // 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。 - return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN) - - case OpUpdateGroup: - return conn.UpdateGroup(op.DN, op.Group.Name) + // LLDAP 组 displayName 即部门索引;创建后把返回的 groupId 记进映射, + // 供后续成员操作用。 + name := op.Group.DeptID + id, err := e.gql.CreateGroup(ctx, name) + if err != nil { + return err + } + groupIDs[name] = id + return nil case OpCreateUser: - plain := e.cfg.InitialPassword - if plain == "" { - generated, err := password.Random() - if err != nil { - return err - } - plain = generated - } - // 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希, - // 直接写明文会让密码以明文形式留在目录里。 - encoded, err := password.EncodeSSHA(plain) - if err != nil { - return err - } - return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded) + // 创建用户不设密码:密码由 LLDAP 独占、首次登录走 Authelia 忘记密码。 + return e.gql.CreateUser(ctx, &lldapgql.CreateUserInput{ + ID: op.User.UID, + Email: sp(op.User.Mail), + DisplayName: sp(op.User.DisplayName), + FirstName: sp(op.User.GivenName), + LastName: sp(op.User.SN), + }) case OpUpdateUser: - // 若 DN 因 OU 配置变化而不同,先移动再更新属性。 - if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) { - uid := op.User.UID - if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil { - return err - } - } - // 读取现状:UpdateUser 需要据此决定哪些属性应当删除, - // 对不存在的属性执行 Delete 会导致整个 Modify 失败。 - existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs()) - if err != nil { - return err - } - return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing) - - case OpDeactivate: - // 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录; - // 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留, - // 便于日后重新入职时原样恢复。 - uid := op.Target - if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil { - return err - } - return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned) - - case OpReactivate: - uid := op.User.UID - if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil { - return err - } - if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil { - return err - } - existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs()) - if err != nil { - return err - } - return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing) + // 省略即不改:这里显式带全四个可写硬字段,与 attrsDiffer 判定的差异一致。 + return e.gql.UpdateUser(ctx, &lldapgql.UpdateUserInput{ + ID: op.User.UID, + Email: sp(op.User.Mail), + DisplayName: sp(op.User.DisplayName), + FirstName: sp(op.User.GivenName), + LastName: sp(op.User.SN), + }) case OpAddMember: - return conn.AddMember(op.GroupDN, op.MemberDN) + gid, ok := groupIDs[op.Group.DeptID] + if !ok { + return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID) + } + return e.gql.AddUserToGroup(ctx, op.MemberUID, gid) case OpRemoveMember: - return conn.RemoveMember(op.GroupDN, op.MemberDN) + gid, ok := groupIDs[op.Group.DeptID] + if !ok { + return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID) + } + return e.gql.RemoveUserFromGroup(ctx, op.MemberUID, gid) + + case OpDeleteUser: + // LLDAP deleteUser 会级联清理成员关系(外键 CASCADE),无需先移组再删。 + return e.gql.DeleteUser(ctx, op.Target) default: return fmt.Errorf("未知操作类型: %s", op.Kind) } } -// toLDAPUser 把期望状态转成 LDAP 写入结构。 -func toLDAPUser(u *mapping.User) *ldaplib.User { - return &ldaplib.User{ - UID: u.UID, - CN: u.CN, - SN: u.SN, - GivenName: u.GivenName, - DisplayName: u.DisplayName, - Mail: u.Mail, - Mobile: u.Mobile, - EmployeeNumber: u.EmployeeNumber, - DepartmentNumber: u.DepartmentNumber, - PostalAddress: u.PostalAddress, - BusinessCategory: u.BusinessCategory, - EmployeeType: u.EmployeeType, - Description: u.Description, - } -} - -// ouName 从 OU 的 DN 中取出 ou 的值。 -func ouName(dn string) string { - first := dn - if i := strings.Index(dn, ","); i >= 0 { - first = dn[:i] - } - if i := strings.Index(first, "="); i >= 0 { - return first[i+1:] - } - return first -} +// sp 生成 *string。 +func sp(s string) *string { return &s } \ No newline at end of file diff --git a/internal/syncer/plan.go b/internal/syncer/plan.go index e75b798..e1bbd10 100644 --- a/internal/syncer/plan.go +++ b/internal/syncer/plan.go @@ -1,10 +1,13 @@ -// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。 +// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。 // -// 设计要点: -// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。 -// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。 -// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的 -// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。 +// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出 +// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正 +// 执行共用同一份代码——预览即所得。 +// +// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的 +// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave +// 状态才会生成删除操作,且删除永远排在最后一阶段。 + package syncer import ( @@ -24,10 +27,8 @@ type OpKind string const ( OpCreateUser OpKind = "create_user" OpUpdateUser OpKind = "update_user" - OpDeactivate OpKind = "deactivate_user" - OpReactivate OpKind = "reactivate_user" + OpDeleteUser OpKind = "delete_user" OpCreateGroup OpKind = "create_group" - OpUpdateGroup OpKind = "update_group" OpAddMember OpKind = "add_member" OpRemoveMember OpKind = "remove_member" ) @@ -37,99 +38,81 @@ type Op struct { Kind OpKind `json:"kind"` // Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。 Target string `json:"target"` - // Name 是展示名,方便界面阅读。 + // Name 是展示名(用户中文名 / 部门中文名)。 Name string `json:"name"` - // DN 是操作涉及的条目 DN(新建时为目标 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 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序 - // (例如父分组必须先于子分组创建)。 + User *mapping.DesiredUser `json:"-"` + Group *mapping.DesiredGroup `json:"-"` + MemberUID string `json:"-"` + AttrsHash string `json:"-"` + // Seq 记录生成顺序,用于在同一阶段内保持稳定输出。 Seq int `json:"-"` } -// Plan 是一次同步的完整计划。 +// Plan 是一次物化的完整计划。 type Plan struct { - Ops []Op `json:"ops"` - // Warnings 是不阻塞执行、但需要人工留意的信息。 - Warnings []string `json:"warnings"` - Stats Stats `json:"stats"` + Ops []Op `json:"ops"` + 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"` + CreateUser int `json:"createUser"` + UpdateUser int `json:"updateUser"` + DeleteUser int `json:"deleteUser"` + CreateGroup int `json:"createGroup"` + AddMember int `json:"addMember"` + RemoveMember int `json:"removeMember"` + TotalUsers int `json:"totalUsers"` + Unchanged int `json:"unchanged"` } -// Input 是计算计划所需的全部输入。 +// 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 + // Desired 是期望状态(来自镜像 BuildFromMirror)。 + Desired *mapping.DesiredState + // ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。 + ActualUsers map[string]*ldaplib.Entry + // ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。 + ActualGroups map[string]*ldaplib.Entry + // OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。 + OwnedUsers map[string]struct{} + // ConfirmedLeave 是已人工确认离职、待删除的用户集。 + ConfirmedLeave map[string]bool + // AdminDN 与基础 OU DN。 + AdminDN string + PeopleOU string + GroupRootDN string } -// opPhase 决定操作的执行顺序。 +// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。 // -// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系 -// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。 -// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前, -// 集成测试立刻暴露了这个问题。 +// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在, +// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。 func opPhase(k OpKind) int { switch k { - case OpCreateGroup, OpUpdateGroup: + case OpCreateGroup: return 0 case OpCreateUser, OpUpdateUser: return 1 - case OpReactivate: - return 2 case OpAddMember: - return 3 + return 2 case OpRemoveMember: + return 3 + case OpDeleteUser: return 4 - case OpDeactivate: - return 5 default: - return 6 + return 5 } } -// sortOps 按依赖顺序排列操作。 -// -// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键: -// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在 -// “od-root” 之前,导致子分组先于父分组创建而失败)。 +// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。 func sortOps(ops []Op) { sort.SliceStable(ops, func(i, j int) bool { pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind) @@ -140,288 +123,171 @@ func sortOps(ops []Op) { }) } -// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。 +// 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), - }, + Warnings: make([]string, 0), + Stats: Stats{TotalUsers: len(in.ActualUsers)}, } 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 处理分组的创建与更新。 +// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。 func planGroups(p *Plan, in *Input) { + desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups)) for _, g := range in.Desired.Groups { - existing, exists := in.Groups[strings.ToLower(g.DN)] + desired[strings.ToLower(g.DN)] = g + } - 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++ + for _, g := range in.Desired.Groups { + if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok { 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++ - } + p.Ops = append(p.Ops, Op{ + Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g, + Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN), + }) + p.Stats.CreateGroup++ } - // 台账中存在、但期望状态里已消失的分组:不自动删除。 - // 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。 - 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)) + // 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。 + for _, e := range in.ActualGroups { + if _, ok := desired[strings.ToLower(e.DN)]; ok { + continue } + p.Warnings = append(p.Warnings, + fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN)) } } -// planUsers 处理用户的创建、更新、停用与恢复。 +// planUsers 处理用户的创建、更新与删除。 func planUsers(p *Plan, in *Input) { - desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users)) + desiredByUID := make(map[string]*mapping.DesiredUser, 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 - } + existing, ok := in.ActualUsers[strings.ToLower(u.UID)] + if !ok { 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, + Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, + User: u, AttrsHash: AttrsHash(u), + Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName), }) p.Stats.CreateUser++ + continue } + if attrsDiffer(existing, u) { + p.Ops = append(p.Ops, Op{ + Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN, + User: u, AttrsHash: AttrsHash(u), + Detail: "用户属性变化:" + describeAttrDiff(existing, u), + }) + p.Stats.UpdateUser++ + continue + } + p.Stats.Unchanged++ } - // 台账中在职、但期望状态里已消失:候选停用。 - // 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。 - for uid, state := range in.LedgerAccounts { - if state != "active" { + // 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。 + // 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。 + for uid := range in.ActualUsers { + if _, want := desiredByUID[uid]; want { continue } - if _, ok := desiredByUID[uid]; ok { + if _, owned := in.OwnedUsers[uid]; !owned { continue } - entry, ok := in.Active[uid] - if !ok { - // 台账说在职但 LDAP 里找不到:数据不一致,仅告警。 + if !in.ConfirmedLeave[uid] { p.Warnings = append(p.Warnings, - fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN)) + fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid)) 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), + Kind: OpDeleteUser, Target: uid, + Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName), + DN: ldaplib.UserDN(uid, in.PeopleOU), + Detail: fmt.Sprintf("删除已确认离职用户 %s", uid), }) - p.Stats.Deactivate++ + p.Stats.DeleteUser++ } } -// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。 +// planMembership 比对组的成员,产出加组/移组操作。 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 + e, ok := in.ActualGroups[strings.ToLower(g.DN)] + actual := make(map[string]struct{}) + if ok { + actual = e.Members() } + desired := lowerMembers(g.Members) - actual := existing.Members() - - // 期望有、实际没有 → 加入 for memberDN := range g.Members { if _, has := actual[strings.ToLower(memberDN)]; has { continue } + uid, ok := uidFromDN(memberDN) + if !ok { + 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, + Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid, + Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name), }) p.Stats.AddMember++ } - // 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。 + if !ok { + continue + } + // 移出:实际有、期望没有,且只移受管、永不移 admin。 for memberDN := range actual { - if _, want := g.Members[memberDN]; want { + if _, want := desired[memberDN]; want { continue } if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) { continue } - // 只移除我们管理的用户,避免把外部手工加入的成员踢掉。 - if !isManagedMember(memberDN, in) { + uid, ok := uidFromDN(memberDN) + if !ok { + continue + } + if _, managed := in.OwnedUsers[uid]; !managed { p.Warnings = append(p.Warnings, - fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN))) + fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid)) 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, + Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid, + Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name), }) p.Stats.RemoveMember++ } } } -// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。 -// -// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源, -// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。 -func isManagedMember(memberDN string, in *Input) bool { - uid, ok := uidFromDN(memberDN) - if !ok { - return false +// lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。 +func lowerMembers(m map[string]struct{}) map[string]struct{} { + out := make(map[string]struct{}, len(m)) + for dn := range m { + out[strings.ToLower(dn)] = struct{}{} } - if _, managed := in.LedgerAccounts[uid]; managed { - return true - } - // 本轮新建的用户此时尚未进入台账,也应当被视为受管。 - for _, u := range in.Desired.Users { - if u.UID == uid { - return true - } - } - return false + return out } -// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。 +// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。 func uidFromDN(dn string) (string, bool) { first := dn if i := strings.Index(dn, ","); i >= 0 { @@ -434,33 +300,19 @@ func uidFromDN(dn string) (string, bool) { 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 { +// attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。 +// +// LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName); +// 默认不建自定义属性,因此只比对这四类可写硬字段。 +func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) 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}, + {ldaplib.AttrDisplayName, want.DisplayName}, + {ldaplib.AttrGivenName, want.GivenName}, + {ldaplib.AttrSN, want.SN}, } for _, c := range checks { if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) { @@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool { } // describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。 -func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string { +func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string { fields := []struct { label string attr string @@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) 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) @@ -498,27 +343,58 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string { diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want)) } if len(diffs) == 0 { - return "属性无变化" + return "无变化" } return strings.Join(diffs, "; ") } -// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异, -// 避免因为无关紧要的格式差异产生大量无意义的更新操作。 -func normalizeAttr(s string) string { - return strings.ToLower(strings.TrimSpace(s)) -} +func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) } -// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。 -func AttrsHash(u *mapping.User) string { +// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。 +func AttrsHash(u *mapping.DesiredUser) 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, - } { + for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} { h.Write([]byte(v)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) } + +// Guard 是执行前的安全阈值。 +type Guard struct { + // MaxDeactivate 单次允许删除的最大账号数,0 表示不限制。 + MaxDeactivate int + // MaxDeactivatePercent 单次允许删除的账号比例(0-1),0 表示不限制。 + MaxDeactivatePercent float64 + // AllowBulk 显式跳过全部阈值检查。 + AllowBulk bool +} + +// GuardError 表示计划未通过安全阈值检查。 +type GuardError struct{ Message string } + +func (e *GuardError) Error() string { return e.Message } + +// Check 校验计划是否满足安全阈值。 +func (g Guard) Check(p *Plan) error { + if g.AllowBulk { + return nil + } + deletes := p.Stats.DeleteUser + if deletes == 0 { + return nil + } + if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate { + return &GuardError{Message: fmt.Sprintf( + "本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)} + } + if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 { + ratio := float64(deletes) / float64(p.Stats.TotalUsers) + if ratio > g.MaxDeactivatePercent { + return &GuardError{Message: fmt.Sprintf( + "本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑", + deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)} + } + } + return nil +} \ No newline at end of file diff --git a/internal/syncer/plan_test.go b/internal/syncer/plan_test.go index 4a1c303..66bf971 100644 --- a/internal/syncer/plan_test.go +++ b/internal/syncer/plan_test.go @@ -9,43 +9,42 @@ import ( ) const ( - testUserOU = "ou=people,dc=example,dc=com" - testInactiveOU = "ou=Inactive,dc=example,dc=com" - testGroupRoot = "ou=feishuroot,dc=example,dc=com" - testAdminDN = "cn=admin,dc=example,dc=com" + peopleOU = "ou=people,dc=example,dc=com" + groupRoot = "ou=groups,dc=example,dc=com" + testAdmin = "uid=admin," + peopleOU ) -// entry 构造一个 LDAP 条目,便于测试。 func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry { return ldaplib.NewEntry(dn, attrs, members) } -func newUser(uid string) *mapping.User { - return &mapping.User{ - UID: uid, - DN: "uid=" + uid + "," + testUserOU, - InactiveDN: "uid=" + uid + "," + testInactiveOU, - CN: uid, - SN: uid, - GivenName: uid, - DisplayName: uid, - Mail: uid + "@example.com", - Description: uid, +func dUser(uid string) *mapping.DesiredUser { + return &mapping.DesiredUser{ + UID: uid, DN: "uid=" + uid + "," + peopleOU, + Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid, } } -func baseInput(desired *mapping.State) *Input { +func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup { + ms := make(map[string]struct{}, len(members)) + for _, m := range members { + ms[m] = struct{}{} + } + return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms} +} + +func depName(deptID string) string { return "部门-" + deptID } + +func baseIn(desired *mapping.DesiredState) *Input { return &Input{ - Desired: desired, - Active: map[string]*ldaplib.Entry{}, - Inactive: map[string]*ldaplib.Entry{}, - Groups: map[string]*ldaplib.Entry{}, - LedgerAccounts: map[string]string{}, - LedgerGroups: map[string]string{}, - AdminDN: testAdminDN, - GroupRootDN: testGroupRoot, - UserOUDN: testUserOU, - InactiveOUDN: testInactiveOU, + Desired: desired, + ActualUsers: map[string]*ldaplib.Entry{}, + ActualGroups: map[string]*ldaplib.Entry{}, + OwnedUsers: map[string]struct{}{}, + ConfirmedLeave: map[string]bool{}, + AdminDN: testAdmin, + PeopleOU: peopleOU, + GroupRootDN: groupRoot, } } @@ -57,313 +56,224 @@ func kindsOf(plan *Plan) map[OpKind]int { return out } +func plainDesired() *mapping.DesiredState { + return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}} +} + func TestComputePlan(t *testing.T) { + rdDN := "cn=od-rd," + groupRoot tests := []struct { name string - desired *mapping.State + desired *mapping.DesiredState mutate func(in *Input) wantKinds map[OpKind]int - // wantDetailContains 断言某个操作详情包含特定文本 - wantDetailContains string - // forbidKinds 断言这些操作绝不出现(安全性回归) - forbidKinds []OpKind + forbid []OpKind + wantWarn string }{ { - name: "全新用户:创建", - desired: &mapping.State{ - Users: []*mapping.User{newUser("alice")}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "全新用户:创建", + desired: withUsers(plainDesired(), dUser("alice")), wantKinds: map[OpKind]int{OpCreateUser: 1}, }, { - name: "属性一致:不产生操作", - desired: &mapping.State{ - Users: []*mapping.User{newUser("alice")}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "属性一致:不产生操作", + desired: withUsers(plainDesired(), dUser("alice")), mutate: func(in *Input) { - u := newUser("alice") - in.Active["alice"] = entry(u.DN, map[string]string{ - ldaplib.AttrCN: u.CN, - ldaplib.AttrSN: u.SN, - ldaplib.AttrGivenName: u.GivenName, - ldaplib.AttrDisplayName: u.DisplayName, - ldaplib.AttrMail: u.Mail, - ldaplib.AttrMobile: u.Mobile, - ldaplib.AttrEmployeeNumber: u.EmployeeNumber, - ldaplib.AttrDepartmentNumber: u.DepartmentNumber, - ldaplib.AttrPostalAddress: u.PostalAddress, - ldaplib.AttrBusinessCategory: u.BusinessCategory, - ldaplib.AttrDescription: u.Description, + u := dUser("alice") + in.ActualUsers["alice"] = entry(u.DN, map[string]string{ + ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName, + ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN, }, nil) - in.LedgerAccounts["alice"] = "active" + in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{}, }, { - name: "邮箱变化:更新属性", - desired: &mapping.State{ - Users: []*mapping.User{ - func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(), - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "邮箱变化:更新属性", + desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()), mutate: func(in *Input) { - u := newUser("alice") - in.Active["alice"] = entry(u.DN, map[string]string{ - ldaplib.AttrCN: u.CN, - ldaplib.AttrDisplayName: u.DisplayName, - ldaplib.AttrMail: "old@example.com", + in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{ + ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice", + ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice", }, nil) - in.LedgerAccounts["alice"] = "active" + in.OwnedUsers["alice"] = struct{}{} }, - wantKinds: map[OpKind]int{OpUpdateUser: 1}, - wantDetailContains: "alice.new@example.com", + wantKinds: map[OpKind]int{OpUpdateUser: 1}, }, { - name: "台账在职但飞书消失:停用", - desired: &mapping.State{ - Users: []*mapping.User{}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "在 LLDAP、台账、已确认离职,但不在期望:删除", + desired: plainDesired(), mutate: func(in *Input) { - in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{ - ldaplib.AttrDisplayName: "Bob", - }, nil) - in.LedgerAccounts["bob"] = "active" + in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{ + ldaplib.AttrDisplayName: "Bob"}, nil) + in.OwnedUsers["bob"] = struct{}{} + in.ConfirmedLeave["bob"] = true }, - wantKinds: map[OpKind]int{OpDeactivate: 1}, + wantKinds: map[OpKind]int{OpDeleteUser: 1}, }, { - name: "非台账账号缺席:不得停用(关键安全回归)", - desired: &mapping.State{ - Users: []*mapping.User{}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)", + desired: plainDesired(), mutate: func(in *Input) { - // LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。 - in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{ - ldaplib.AttrDisplayName: "Service", - }, nil) + in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil) + in.OwnedUsers["bob"] = struct{}{} }, - wantKinds: map[OpKind]int{}, - forbidKinds: []OpKind{OpDeactivate}, + wantKinds: map[OpKind]int{}, + forbid: []OpKind{OpDeleteUser}, + wantWarn: "未完成离职确认", }, { - name: "在 Inactive 且台账受管:恢复", - desired: &mapping.State{ - Users: []*mapping.User{newUser("carol")}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "在 LLDAP、非受管(服务账号):绝不删除", + desired: plainDesired(), mutate: func(in *Input) { - in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{ - ldaplib.AttrDisplayName: "Carol", - }, nil) - in.LedgerAccounts["carol"] = "inactive" + in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil) + in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管 }, - wantKinds: map[OpKind]int{OpReactivate: 1}, + wantKinds: map[OpKind]int{}, + forbid: []OpKind{OpDeleteUser}, }, { - name: "在 Inactive 但非台账:只告警不改动", - desired: &mapping.State{ - Users: []*mapping.User{newUser("dave")}, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "分组不存在:创建", + desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), + wantKinds: map[OpKind]int{OpCreateGroup: 1}, + }, + { + name: "孤儿组(actual 有、期望没有):不删只告警", + desired: plainDesired(), mutate: func(in *Input) { - in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil) + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, + map[string]string{ldaplib.AttrDescription: "x"}, nil) }, - wantKinds: map[OpKind]int{}, - forbidKinds: []OpKind{OpReactivate}, + wantKinds: map[OpKind]int{}, + forbid: []OpKind{OpCreateGroup}, + wantWarn: "未自动删除", }, { - name: "分组不存在:创建并加成员", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, - wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1}, - }, - { - name: "成员缺席:加入分组", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "成员缺席:加入分组", + desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), mutate: func(in *Input) { - in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( - "cn=yanfa,"+testGroupRoot, - map[string]string{ldaplib.AttrDescription: "研发部"}, - []string{testAdminDN}) - in.LedgerAccounts["alice"] = "active" + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil) + in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{OpAddMember: 1}, }, { - name: "成员多余且受管:移出分组", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "成员多余且受管:移出分组", + desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { - in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( - "cn=yanfa,"+testGroupRoot, - map[string]string{ldaplib.AttrDescription: "研发部"}, - []string{testAdminDN, "uid=alice," + testUserOU}) - in.LedgerAccounts["alice"] = "active" + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, + []string{testAdmin, "uid=alice," + peopleOU}) + in.OwnedUsers["alice"] = struct{}{} }, wantKinds: map[OpKind]int{OpRemoveMember: 1}, }, { - name: "admin 永不被移出分组(关键安全回归)", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "admin 永不被移出分组(关键安全回归)", + desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { - in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( - "cn=yanfa,"+testGroupRoot, - map[string]string{ldaplib.AttrDescription: "研发部"}, - []string{testAdminDN}) + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, + []string{testAdmin}) + in.OwnedUsers["admin"] = struct{}{} }, - wantKinds: map[OpKind]int{}, - forbidKinds: []OpKind{OpRemoveMember}, + wantKinds: map[OpKind]int{}, + forbid: []OpKind{OpRemoveMember}, }, { - // 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让 - // isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑 - // 才能阻止它被移出——否则该用例会失败。没有这层构造, - // 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。 - name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, + name: "外部非受管成员:不移出,仅告警", + desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)), mutate: func(in *Input) { - adminUIDDN := "uid=admin," + testUserOU - in.AdminDN = adminUIDDN - in.LedgerAccounts["admin"] = "active" - in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( - "cn=yanfa,"+testGroupRoot, - map[string]string{ldaplib.AttrDescription: "研发部"}, - []string{adminUIDDN}) + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, + []string{testAdmin, "uid=external," + peopleOU}) }, - wantKinds: map[OpKind]int{}, - forbidKinds: []OpKind{OpRemoveMember}, - }, - { - name: "外部手工成员:不移出,仅告警(关键安全回归)", - desired: &mapping.State{ - Users: []*mapping.User{}, - Groups: []*mapping.Group{ - { - DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", - Members: map[string]struct{}{}, - }, - }, - GroupByDeptID: map[string]*mapping.Group{}, - }, - mutate: func(in *Input) { - in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( - "cn=yanfa,"+testGroupRoot, - map[string]string{ldaplib.AttrDescription: "研发部"}, - []string{testAdminDN, "uid=external," + testUserOU}) - }, - wantKinds: map[OpKind]int{}, - forbidKinds: []OpKind{OpRemoveMember}, + wantKinds: map[OpKind]int{}, + forbid: []OpKind{OpRemoveMember}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - in := baseInput(tt.desired) + in := baseIn(tt.desired) if tt.mutate != nil { tt.mutate(in) } - plan := ComputePlan(in) got := kindsOf(plan) - for kind, want := range tt.wantKinds { if got[kind] != want { - t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s", - kind, got[kind], want, dumpPlan(plan)) + t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan)) } } for kind, count := range got { - if _, expected := tt.wantKinds[kind]; !expected { - t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s", - kind, count, dumpPlan(plan)) + if _, exp := tt.wantKinds[kind]; !exp { + t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan)) } } - for _, kind := range tt.forbidKinds { + for _, kind := range tt.forbid { if got[kind] > 0 { - t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan)) + t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan)) } } - if tt.wantDetailContains != "" { + if tt.wantWarn != "" { found := false - for _, op := range plan.Ops { - if strings.Contains(op.Detail, tt.wantDetailContains) { + for _, w := range plan.Warnings { + if strings.Contains(w, tt.wantWarn) { found = true break } } if !found { - t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s", - tt.wantDetailContains, dumpPlan(plan)) + t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings) } } }) } } -// TestComputePlanNeverTouchesPassword 是一个结构性回归测试: -// 无论输入如何,计划中都不能出现改写 userPassword 的操作。 -// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。 -func TestComputePlanNeverTouchesPassword(t *testing.T) { - u := newUser("alice") - in := baseInput(&mapping.State{ - Users: []*mapping.User{u}, - GroupByDeptID: map[string]*mapping.Group{}, - }) - in.LedgerAccounts["bob"] = "active" - in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil) +// TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。 +func TestDeleteIsLastPhase(t *testing.T) { + rdDN := "cn=od-rd," + groupRoot + desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice")) + in := baseIn(desired) + in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{ + ldaplib.AttrDisplayName: "alice"}, nil) + in.OwnedUsers["alice"] = struct{}{} + in.OwnedUsers["bob"] = struct{}{} + in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil) + in.ConfirmedLeave["bob"] = true + in.OwnedUsers["carol"] = struct{}{} + in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil) + in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU}) + plan := ComputePlan(in) + + // bob:删除;alice 创建;carol 移出分组。 + // 任何删除操作都必须排在任何创建/加/移之后。 + maxOther, minDelete := -1, 1<<30 + for i, op := range plan.Ops { + if op.Kind == OpDeleteUser { + if i < minDelete { + minDelete = i + } + } else { + if i > maxOther { + maxOther = i + } + } + } + if minDelete <= maxOther { + t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan)) + } +} + +// TestComputePlanNeverTouchesPassword 是结构性回归: +// 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。 +func TestComputePlanNeverTouchesPassword(t *testing.T) { + in := baseIn(withUsers(plainDesired(), dUser("alice"))) plan := ComputePlan(in) for _, op := range plan.Ops { - if strings.Contains(strings.ToLower(op.Detail), "userpassword") { - t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail) + if strings.Contains(strings.ToLower(op.Detail), "userpassword") || + strings.Contains(strings.ToLower(op.Detail), "password") { + t.Errorf("计划出现涉及密码的操作: %s", op.Detail) } } } @@ -375,40 +285,17 @@ func TestGuard(t *testing.T) { plan *Plan wantErr bool }{ - { - name: "无停用时通过", - guard: Guard{MaxDeactivatePercent: 0.2}, - plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}}, - }, - { - name: "超过绝对上限被拒", - guard: Guard{MaxDeactivate: 5}, - plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}}, - wantErr: true, - }, - { - name: "超过比例上限被拒", - guard: Guard{MaxDeactivatePercent: 0.2}, - plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}}, - wantErr: true, - }, - { - name: "比例在阈值内通过", - guard: Guard{MaxDeactivatePercent: 0.2}, - plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}}, - }, - { - name: "强制模式跳过检查", - guard: Guard{MaxDeactivate: 1, AllowBulk: true}, - plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}}, - }, + {"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false}, + {"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true}, + {"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true}, + {"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false}, + {"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.guard.Check(tt.plan) if tt.wantErr && err == nil { - t.Errorf("期望返回错误,实际通过") + t.Errorf("期望报错,实际通过") } if !tt.wantErr && err != nil { t.Errorf("期望通过,实际报错: %v", err) @@ -417,14 +304,30 @@ func TestGuard(t *testing.T) { } } +func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState { + s.Users = append(s.Users, us...) + return s +} + +func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState { + s.Groups = append(s.Groups, gs...) + if s.GroupByDeptID == nil { + s.GroupByDeptID = map[string]*mapping.DesiredGroup{} + } + for _, g := range gs { + s.GroupByDeptID[g.DeptID] = g + } + return s +} + func dumpPlan(p *Plan) string { var b strings.Builder for _, op := range p.Ops { - b.WriteString("\n ") + b.WriteString("\n [") b.WriteString(string(op.Kind)) - b.WriteString(" target=") + b.WriteString("] ") b.WriteString(op.Target) - b.WriteString(" detail=") + b.WriteString(": ") b.WriteString(op.Detail) } for _, w := range p.Warnings { @@ -432,4 +335,4 @@ func dumpPlan(p *Plan) string { b.WriteString(w) } return b.String() -} +} \ No newline at end of file diff --git a/internal/syncer/runner.go b/internal/syncer/runner.go index c947870..1bddf02 100644 --- a/internal/syncer/runner.go +++ b/internal/syncer/runner.go @@ -1,58 +1,80 @@ package syncer +// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。 +// +// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化 +// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。 + import ( "context" "fmt" "log/slog" "strings" - "time" + "sync" "idsync/internal/config" "idsync/internal/feishu" "idsync/internal/ldaplib" + "idsync/internal/lldapgql" "idsync/internal/mapping" "idsync/internal/store" ) -// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。 +// Runner 编排快照与物化。 type Runner struct { - cfg *config.Config - feishu *feishu.Client - ldap *ldaplib.Client - store *store.Store - log *slog.Logger + cfg *config.Config + feishu *feishu.Client + ldap *ldaplib.Client + gql *lldapgql.Client + store *store.Store + snapshot *SnapshotRunner + log *slog.Logger - // guard 是执行前的安全阈值。 guard Guard + // mu 让快照与物化在单实例内互斥。 + mu sync.Mutex + groupIDs map[string]int } // NewRunner 构造编排器。 -func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner { +func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, g *lldapgql.Client, + s *store.Store, log *slog.Logger, opts SnapshotOptions) *Runner { return &Runner{ - cfg: cfg, - feishu: f, - ldap: l, - store: s, - log: log, + cfg: cfg, + feishu: f, + ldap: l, + gql: g, + store: s, + snapshot: NewSnapshotRunner(cfg, f, s, log, opts), + log: log, guard: Guard{ - MaxDeactivate: cfg.Sync.MaxDeactivate, - MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent, + MaxDeactivate: cfg.Guard.MaxDeactivate, + MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent, }, } } -// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码, -// 因此预览内容与真正会发生的事情一致。 +// RunSnapshot 触发一轮快照(飞书 → 镜像 + 离职流程推进)。 +func (r *Runner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.snapshot.RunSnapshot(ctx) +} + +// Preview 只计算物化计划,不做任何写入。dry-run 与真正执行共用同一份计划代码。 func (r *Runner) Preview(ctx context.Context) (*Plan, error) { plan, _, err := r.plan(ctx) return plan, err } -// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。 +// Run 执行一轮物化(镜像 → LLDAP)。dryRun 为 true 时只计算并返回计划。 func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) { + r.mu.Lock() + defer r.mu.Unlock() + run, err := r.store.StartRun(modeOf(dryRun), trigger) if err != nil { - return nil, nil, fmt.Errorf("创建同步记录失败: %w", err) + return nil, nil, fmt.Errorf("创建物化记录失败: %w", err) } plan, desired, err := r.prepare(ctx, run, force) @@ -63,7 +85,7 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin fillRunFromPlan(run, plan) run.Status = "success" if err := r.store.FinishRun(run); err != nil { - r.log.Warn("写入同步记录失败", "err", err) + r.log.Warn("写入物化记录失败", "err", err) } return plan, nil, nil } @@ -71,17 +93,16 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin res := r.apply(ctx, plan) fillRunFromResult(run, plan, res) - // 只有全部成功才回写台账:部分失败时保持台账不变, - // 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。 + // 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。 if res.Failed == 0 { - if err := r.persist(plan, desired); err != nil { + if err := r.persist(ctx, plan, desired); err != nil { run.Status = "failed" - run.ErrorMsg = "回写台账失败: " + err.Error() + run.ErrorMsg = "回写镜像失败: " + err.Error() _ = r.store.FinishRun(run) - return plan, res, fmt.Errorf("回写台账失败: %w", err) + return plan, res, fmt.Errorf("回写镜像失败: %w", err) } } else { - r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed) + r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed) } run.Status = "success" @@ -90,14 +111,14 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin run.ErrorMsg = strings.Join(res.Errors, "; ") } if err := r.store.FinishRun(run); err != nil { - r.log.Warn("写入同步记录失败", "err", err) + 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) { +func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.DesiredState, error) { plan, desired, err := r.plan(ctx) if err != nil { run.Status = "failed" @@ -106,8 +127,6 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (* return nil, nil, err } - // 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。 - // 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。 guard := r.guard if force { guard.AllowBulk = true @@ -121,215 +140,104 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (* 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) +// plan 读两侧状态并计算物化计划。 +func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) { + // 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。 + activeUsers, err := r.store.ActiveUsers() if err != nil { - return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err) + return nil, nil, 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, "、"))) + depts, err := r.store.ActiveDepartments() + if err != nil { + return nil, nil, err } + desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN) - desired := mapping.Build(snap, mapping.BuildOptions{ - UserOUDN: r.cfg.UserOUDN(), - InactiveOUDN: r.cfg.InactiveOUDN(), - GroupRootDN: r.cfg.GroupRootDN(), - }) - + // 实际侧:读 LLDAP 的 LDAP 视图。 conn, err := r.ldap.Dial() if err != nil { return nil, nil, err } defer conn.Close() - active, err := conn.ListUsers(r.cfg.UserOUDN()) + actualUsers, 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()) + actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN()) if err != nil { return nil, nil, err } - accounts, err := r.store.AccountsByUID() + // 所有权与删除白名单都来自镜像台账。 + all, err := r.store.AllMirrorUsers() 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 + owned := make(map[string]struct{}, len(all)) + confirmed := make(map[string]bool) + for _, u := range all { + if u.Skipped { + continue + } + owned[u.UID] = struct{}{} + if u.State == store.StateConfirmedLeave { + confirmed[u.UID] = true + } } plan := ComputePlan(&Input{ Desired: desired, - Active: active, - Inactive: inactive, - Groups: groups, - LedgerAccounts: ledgerAccounts, - LedgerGroups: ledgerGroups, + ActualUsers: actualUsers, + ActualGroups: actualGroups, + OwnedUsers: owned, + ConfirmedLeave: confirmed, AdminDN: r.cfg.Ldap.AdminDN, + PeopleOU: r.cfg.UserOUDN(), 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 执行计划。 +// apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。 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) + if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil { + return &Result{Failed: 1, Errors: []string{err.Error()}} + } + groups, err := r.gql.Groups(ctx) + if err != nil { + return &Result{Failed: 1, Errors: []string{err.Error()}} + } + groupIDs := make(map[string]int, len(groups)) + for _, g := range groups { + groupIDs[g.DisplayName] = g.ID + } + + exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log) + res := exec.Apply(ctx, plan, groupIDs) + r.groupIDs = groupIDs + return res } -// 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。 +// persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。 +func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error { 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 { + if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil { + return err + } + } + for deptID, gid := range r.groupIDs { + if err := r.store.SetDeptGroupID(deptID, gid); 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 { + if op.Kind == OpDeleteUser { + if err := r.store.MarkDeleted(op.Target); err != nil { return err } } } - return nil } @@ -341,24 +249,21 @@ func modeOf(dryRun bool) string { return "apply" } -// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。 +// 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.Updated = plan.Stats.UpdateUser run.Added = plan.Stats.AddMember run.Removed = plan.Stats.RemoveMember run.Skipped = plan.Stats.Unchanged } -// fillRunFromResult 用实际执行结果填充同步记录。 +// 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 -} +} \ No newline at end of file diff --git a/main.go b/main.go index fbfb553..52b2f48 100644 --- a/main.go +++ b/main.go @@ -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) -} +} \ No newline at end of file