package syncer import ( "context" "fmt" "log/slog" "strings" "idsync/internal/ldaplib" "idsync/internal/mapping" "idsync/internal/password" ) // 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 } // Result 是执行结果统计。 type Result struct { Created int Updated int Moved int Added int Removed int Failed int Errors []string } // Executor 把计划应用到 LDAP。 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 } // NewExecutor 构造执行器。 func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor { return &Executor{client: client, cfg: cfg, log: log} } // Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作—— // 一次同步中某个用户的数据问题不应阻断其余几百人的同步。 func (e *Executor) Apply(ctx context.Context, plan *Plan) *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()) return res } if err := e.applyOne(conn, op); 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) continue } switch op.Kind { case OpCreateUser: res.Created++ case OpUpdateUser, OpReactivate: res.Updated++ if op.Kind == OpReactivate { res.Moved++ } case OpDeactivate: res.Updated++ res.Moved++ case OpAddMember: res.Added++ case OpRemoveMember: res.Removed++ } } return res } // applyOne 执行单个操作。 func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) 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) 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) 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) case OpAddMember: return conn.AddMember(op.GroupDN, op.MemberDN) case OpRemoveMember: return conn.RemoveMember(op.GroupDN, op.MemberDN) 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 }