chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user