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

- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
This commit is contained in:
2026-09-29 23:48:55 +08:00
parent 979a98d502
commit 126e52a6ed
6 changed files with 835 additions and 1191 deletions
+70 -205
View File
@@ -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 }