- 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 永不移出、删除最后阶段
269 lines
7.3 KiB
Go
269 lines
7.3 KiB
Go
package syncer
|
|
|
|
// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。
|
|
//
|
|
// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化
|
|
// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
|
|
"idsync/internal/config"
|
|
"idsync/internal/feishu"
|
|
"idsync/internal/ldaplib"
|
|
"idsync/internal/lldapgql"
|
|
"idsync/internal/mapping"
|
|
"idsync/internal/store"
|
|
)
|
|
|
|
// Runner 编排快照与物化。
|
|
type Runner struct {
|
|
cfg *config.Config
|
|
feishu *feishu.Client
|
|
ldap *ldaplib.Client
|
|
gql *lldapgql.Client
|
|
store *store.Store
|
|
snapshot *SnapshotRunner
|
|
log *slog.Logger
|
|
|
|
guard Guard
|
|
// mu 让快照与物化在单实例内互斥。
|
|
mu sync.Mutex
|
|
groupIDs map[string]int
|
|
}
|
|
|
|
// NewRunner 构造编排器。
|
|
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,
|
|
gql: g,
|
|
store: s,
|
|
snapshot: NewSnapshotRunner(cfg, f, s, log, opts),
|
|
log: log,
|
|
guard: Guard{
|
|
MaxDeactivate: cfg.Guard.MaxDeactivate,
|
|
MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 执行一轮物化(镜像 → 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)
|
|
}
|
|
|
|
plan, desired, err := r.prepare(ctx, run, force)
|
|
if err != nil {
|
|
return plan, nil, err
|
|
}
|
|
if dryRun {
|
|
fillRunFromPlan(run, plan)
|
|
run.Status = "success"
|
|
if err := r.store.FinishRun(run); err != nil {
|
|
r.log.Warn("写入物化记录失败", "err", err)
|
|
}
|
|
return plan, nil, nil
|
|
}
|
|
|
|
res := r.apply(ctx, plan)
|
|
fillRunFromResult(run, plan, res)
|
|
|
|
// 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。
|
|
if res.Failed == 0 {
|
|
if err := r.persist(ctx, plan, desired); err != nil {
|
|
run.Status = "failed"
|
|
run.ErrorMsg = "回写镜像失败: " + err.Error()
|
|
_ = r.store.FinishRun(run)
|
|
return plan, res, fmt.Errorf("回写镜像失败: %w", err)
|
|
}
|
|
} else {
|
|
r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed)
|
|
}
|
|
|
|
run.Status = "success"
|
|
if res.Failed > 0 {
|
|
run.Status = "failed"
|
|
run.ErrorMsg = strings.Join(res.Errors, "; ")
|
|
}
|
|
if err := r.store.FinishRun(run); err != nil {
|
|
r.log.Warn("写入物化记录失败", "err", err)
|
|
}
|
|
|
|
return plan, res, nil
|
|
}
|
|
|
|
// prepare 计算计划并做安全检查。
|
|
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"
|
|
run.ErrorMsg = err.Error()
|
|
_ = r.store.FinishRun(run)
|
|
return nil, nil, err
|
|
}
|
|
|
|
guard := r.guard
|
|
if force {
|
|
guard.AllowBulk = true
|
|
}
|
|
if err := guard.Check(plan); err != nil {
|
|
run.Status = "failed"
|
|
run.ErrorMsg = err.Error()
|
|
_ = r.store.FinishRun(run)
|
|
return plan, desired, err
|
|
}
|
|
return plan, desired, nil
|
|
}
|
|
|
|
// 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, err
|
|
}
|
|
depts, err := r.store.ActiveDepartments()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN)
|
|
|
|
// 实际侧:读 LLDAP 的 LDAP 视图。
|
|
conn, err := r.ldap.Dial()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
actualUsers, err := conn.ListUsers(r.cfg.UserOUDN())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// 所有权与删除白名单都来自镜像台账。
|
|
all, err := r.store.AllMirrorUsers()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
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,
|
|
ActualUsers: actualUsers,
|
|
ActualGroups: actualGroups,
|
|
OwnedUsers: owned,
|
|
ConfirmedLeave: confirmed,
|
|
AdminDN: r.cfg.Ldap.AdminDN,
|
|
PeopleOU: r.cfg.UserOUDN(),
|
|
GroupRootDN: r.cfg.GroupRootDN(),
|
|
})
|
|
return plan, desired, nil
|
|
}
|
|
|
|
// apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。
|
|
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
|
|
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 在物化全部成功后回写镜像:用户 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.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
|
|
}
|
|
}
|
|
for _, op := range plan.Ops {
|
|
if op.Kind == OpDeleteUser {
|
|
if err := r.store.MarkDeleted(op.Target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// modeOf 把 dry-run 标志转成记录用的模式名。
|
|
func modeOf(dryRun bool) string {
|
|
if dryRun {
|
|
return "dry-run"
|
|
}
|
|
return "apply"
|
|
}
|
|
|
|
// fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。
|
|
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
|
|
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
|
|
run.Updated = plan.Stats.UpdateUser
|
|
run.Added = plan.Stats.AddMember
|
|
run.Removed = plan.Stats.RemoveMember
|
|
run.Skipped = plan.Stats.Unchanged
|
|
}
|
|
|
|
// fillRunFromResult 用实际执行结果填充物化记录。
|
|
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
|
|
run.Created = res.Created
|
|
run.Updated = res.Updated
|
|
run.Added = res.Added
|
|
run.Removed = res.Removed
|
|
run.Failed = res.Failed
|
|
run.Skipped = plan.Stats.Unchanged
|
|
} |