- 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 永不移出、删除最后阶段
400 lines
12 KiB
Go
400 lines
12 KiB
Go
// 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
|
||
} |