- 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 永不移出、删除最后阶段
139 lines
3.9 KiB
Go
139 lines
3.9 KiB
Go
package syncer
|
||
|
||
// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。
|
||
//
|
||
// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的
|
||
// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId
|
||
// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
|
||
"idsync/internal/lldapgql"
|
||
)
|
||
|
||
// ExecConfig 是执行器所需的环境参数。
|
||
type ExecConfig struct {
|
||
PeopleOU string
|
||
}
|
||
|
||
// Result 是执行结果统计。
|
||
type Result struct {
|
||
Created int
|
||
Updated int
|
||
Deleted int
|
||
Added int
|
||
Removed int
|
||
Failed int
|
||
Errors []string
|
||
}
|
||
|
||
// Executor 把计划应用到 LLDAP(写走 GraphQL)。
|
||
type Executor struct {
|
||
gql *lldapgql.Client
|
||
cfg ExecConfig
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewExecutor 构造执行器。
|
||
func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor {
|
||
return &Executor{gql: gql, cfg: cfg, log: log}
|
||
}
|
||
|
||
// Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个
|
||
// 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射,
|
||
// 会在创建新组时被原地扩充。
|
||
func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result {
|
||
res := &Result{}
|
||
|
||
for _, op := range plan.Ops {
|
||
if err := ctx.Err(); err != nil {
|
||
res.Errors = append(res.Errors, "物化被取消: "+err.Error())
|
||
return res
|
||
}
|
||
|
||
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)
|
||
continue
|
||
}
|
||
|
||
switch op.Kind {
|
||
case OpCreateUser, OpCreateGroup:
|
||
res.Created++
|
||
case OpUpdateUser:
|
||
res.Updated++
|
||
case OpDeleteUser:
|
||
res.Deleted++
|
||
case OpAddMember:
|
||
res.Added++
|
||
case OpRemoveMember:
|
||
res.Removed++
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
// applyOne 执行单个操作。
|
||
func (e *Executor) applyOne(ctx context.Context, op Op, groupIDs map[string]int) error {
|
||
switch op.Kind {
|
||
case OpCreateGroup:
|
||
// 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:
|
||
// 创建用户不设密码:密码由 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:
|
||
// 省略即不改:这里显式带全四个可写硬字段,与 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:
|
||
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:
|
||
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)
|
||
}
|
||
}
|
||
|
||
// sp 生成 *string。
|
||
func sp(s string) *string { return &s } |