Files
caowangrenbo 126e52a6ed 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 永不移出、删除最后阶段
2026-09-29 23:48:55 +08:00

400 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
//
// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
// 执行共用同一份代码——预览即所得。
//
// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
// 状态才会生成删除操作,且删除永远排在最后一阶段。
package syncer
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
)
// OpKind 是操作类型。
type OpKind string
const (
OpCreateUser OpKind = "create_user"
OpUpdateUser OpKind = "update_user"
OpDeleteUser OpKind = "delete_user"
OpCreateGroup OpKind = "create_group"
OpAddMember OpKind = "add_member"
OpRemoveMember OpKind = "remove_member"
)
// Op 是一次待执行(或仅预览)的操作。
type Op struct {
Kind OpKind `json:"kind"`
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
Target string `json:"target"`
// Name 是展示名(用户中文名 / 部门中文名)。
Name string `json:"name"`
// DN 是操作涉及的组/用户 DN。
DN string `json:"dn,omitempty"`
// Detail 是人类可读的说明,直接展示在预览页。
Detail string `json:"detail"`
// 以下字段供执行器使用,不参与 JSON 展示。
User *mapping.DesiredUser `json:"-"`
Group *mapping.DesiredGroup `json:"-"`
MemberUID string `json:"-"`
AttrsHash string `json:"-"`
// Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
Seq int `json:"-"`
}
// Plan 是一次物化的完整计划。
type Plan struct {
Ops []Op `json:"ops"`
Warnings []string `json:"warnings"`
Stats Stats `json:"stats"`
}
// Stats 是计划的分类统计。
type Stats struct {
CreateUser int `json:"createUser"`
UpdateUser int `json:"updateUser"`
DeleteUser int `json:"deleteUser"`
CreateGroup int `json:"createGroup"`
AddMember int `json:"addMember"`
RemoveMember int `json:"removeMember"`
TotalUsers int `json:"totalUsers"`
Unchanged int `json:"unchanged"`
}
// Input 是计算物化计划所需的全部输入。
type Input struct {
// Desired 是期望状态(来自镜像 BuildFromMirror)。
Desired *mapping.DesiredState
// ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。
ActualUsers map[string]*ldaplib.Entry
// ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。
ActualGroups map[string]*ldaplib.Entry
// OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。
OwnedUsers map[string]struct{}
// ConfirmedLeave 是已人工确认离职、待删除的用户集。
ConfirmedLeave map[string]bool
// AdminDN 与基础 OU DN。
AdminDN string
PeopleOU string
GroupRootDN string
}
// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
//
// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
func opPhase(k OpKind) int {
switch k {
case OpCreateGroup:
return 0
case OpCreateUser, OpUpdateUser:
return 1
case OpAddMember:
return 2
case OpRemoveMember:
return 3
case OpDeleteUser:
return 4
default:
return 5
}
}
// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
func sortOps(ops []Op) {
sort.SliceStable(ops, func(i, j int) bool {
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
if pi != pj {
return pi < pj
}
return ops[i].Seq < ops[j].Seq
})
}
// ComputePlan 计算物化计划。它是纯函数,不产生任何副作用。
func ComputePlan(in *Input) *Plan {
p := &Plan{
Ops: make([]Op, 0, 64),
Warnings: make([]string, 0),
Stats: Stats{TotalUsers: len(in.ActualUsers)},
}
planGroups(p, in)
planUsers(p, in)
planMembership(p, in)
for i := range p.Ops {
p.Ops[i].Seq = i
}
sortOps(p.Ops)
return p
}
// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
func planGroups(p *Plan, in *Input) {
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
for _, g := range in.Desired.Groups {
desired[strings.ToLower(g.DN)] = g
}
for _, g := range in.Desired.Groups {
if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g,
Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN),
})
p.Stats.CreateGroup++
}
// 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
for _, e := range in.ActualGroups {
if _, ok := desired[strings.ToLower(e.DN)]; ok {
continue
}
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN))
}
}
// planUsers 处理用户的创建、更新与删除。
func planUsers(p *Plan, in *Input) {
desiredByUID := make(map[string]*mapping.DesiredUser, len(in.Desired.Users))
for _, u := range in.Desired.Users {
desiredByUID[u.UID] = u
}
for _, u := range in.Desired.Users {
existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
if !ok {
p.Ops = append(p.Ops, Op{
Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName),
})
p.Stats.CreateUser++
continue
}
if attrsDiffer(existing, u) {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: "用户属性变化:" + describeAttrDiff(existing, u),
})
p.Stats.UpdateUser++
continue
}
p.Stats.Unchanged++
}
// 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
// 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
for uid := range in.ActualUsers {
if _, want := desiredByUID[uid]; want {
continue
}
if _, owned := in.OwnedUsers[uid]; !owned {
continue
}
if !in.ConfirmedLeave[uid] {
p.Warnings = append(p.Warnings,
fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpDeleteUser, Target: uid,
Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
DN: ldaplib.UserDN(uid, in.PeopleOU),
Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
})
p.Stats.DeleteUser++
}
}
// planMembership 比对组的成员,产出加组/移组操作。
func planMembership(p *Plan, in *Input) {
for _, g := range in.Desired.Groups {
e, ok := in.ActualGroups[strings.ToLower(g.DN)]
actual := make(map[string]struct{})
if ok {
actual = e.Members()
}
desired := lowerMembers(g.Members)
for memberDN := range g.Members {
if _, has := actual[strings.ToLower(memberDN)]; has {
continue
}
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
})
p.Stats.AddMember++
}
if !ok {
continue
}
// 移出:实际有、期望没有,且只移受管、永不移 admin。
for memberDN := range actual {
if _, want := desired[memberDN]; want {
continue
}
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
continue
}
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
if _, managed := in.OwnedUsers[uid]; !managed {
p.Warnings = append(p.Warnings,
fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
})
p.Stats.RemoveMember++
}
}
}
// lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。
func lowerMembers(m map[string]struct{}) map[string]struct{} {
out := make(map[string]struct{}, len(m))
for dn := range m {
out[strings.ToLower(dn)] = struct{}{}
}
return out
}
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
func uidFromDN(dn string) (string, bool) {
first := dn
if i := strings.Index(dn, ","); i >= 0 {
first = dn[:i]
}
first = strings.TrimSpace(first)
if len(first) < 4 || !strings.EqualFold(first[:4], "uid=") {
return "", false
}
return strings.ToLower(strings.TrimSpace(first[4:])), true
}
// attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。
//
// LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName);
// 默认不建自定义属性,因此只比对这四类可写硬字段。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool {
checks := []struct {
attr string
want string
}{
{ldaplib.AttrMail, want.Mail},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrSN, want.SN},
}
for _, c := range checks {
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
return true
}
}
return false
}
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
fields := []struct {
label string
attr string
want string
}{
{"mail", ldaplib.AttrMail, want.Mail},
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
{"givenName", ldaplib.AttrGivenName, want.GivenName},
{"sn", ldaplib.AttrSN, want.SN},
}
var diffs []string
for _, f := range fields {
old := existing.Get(f.attr)
if normalizeAttr(old) == normalizeAttr(f.want) {
continue
}
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
}
if len(diffs) == 0 {
return "无变化"
}
return strings.Join(diffs, "; ")
}
func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
func AttrsHash(u *mapping.DesiredUser) string {
h := sha256.New()
for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} {
h.Write([]byte(v))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
// Guard 是执行前的安全阈值。
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
}
deletes := p.Stats.DeleteUser
if deletes == 0 {
return nil
}
if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)}
}
if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 {
ratio := float64(deletes) / float64(p.Stats.TotalUsers)
if ratio > g.MaxDeactivatePercent {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)}
}
}
return nil
}