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:
+164
-106
@@ -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, `<!doctype html><html lang="zh"><meta charset="utf-8">
|
||||
<title>%s</title><body style="font-family:sans-serif;padding:2rem">
|
||||
<h1>%s</h1><p>%s</p></body></html>`,
|
||||
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)
|
||||
}
|
||||
+70
-205
@@ -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 }
|
||||
+188
-312
@@ -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
|
||||
}
|
||||
+182
-279
@@ -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()
|
||||
}
|
||||
}
|
||||
+110
-205
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user