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:
+188
-312
@@ -1,10 +1,13 @@
|
||||
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
|
||||
// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
|
||||
//
|
||||
// 设计要点:
|
||||
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
|
||||
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
|
||||
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
|
||||
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
|
||||
// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
|
||||
// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
|
||||
// 执行共用同一份代码——预览即所得。
|
||||
//
|
||||
// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
|
||||
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
|
||||
// 状态才会生成删除操作,且删除永远排在最后一阶段。
|
||||
|
||||
package syncer
|
||||
|
||||
import (
|
||||
@@ -24,10 +27,8 @@ type OpKind string
|
||||
const (
|
||||
OpCreateUser OpKind = "create_user"
|
||||
OpUpdateUser OpKind = "update_user"
|
||||
OpDeactivate OpKind = "deactivate_user"
|
||||
OpReactivate OpKind = "reactivate_user"
|
||||
OpDeleteUser OpKind = "delete_user"
|
||||
OpCreateGroup OpKind = "create_group"
|
||||
OpUpdateGroup OpKind = "update_group"
|
||||
OpAddMember OpKind = "add_member"
|
||||
OpRemoveMember OpKind = "remove_member"
|
||||
)
|
||||
@@ -37,99 +38,81 @@ type Op struct {
|
||||
Kind OpKind `json:"kind"`
|
||||
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
|
||||
Target string `json:"target"`
|
||||
// Name 是展示名,方便界面阅读。
|
||||
// Name 是展示名(用户中文名 / 部门中文名)。
|
||||
Name string `json:"name"`
|
||||
// DN 是操作涉及的条目 DN(新建时为目标 DN)。
|
||||
// DN 是操作涉及的组/用户 DN。
|
||||
DN string `json:"dn,omitempty"`
|
||||
// Detail 是人类可读的说明,直接展示在预览页。
|
||||
Detail string `json:"detail"`
|
||||
|
||||
// 以下字段供执行器使用,不参与 JSON 展示。
|
||||
User *mapping.User `json:"-"`
|
||||
Group *mapping.Group `json:"-"`
|
||||
MemberDN string `json:"-"`
|
||||
GroupDN string `json:"-"`
|
||||
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。
|
||||
TargetDN string `json:"-"`
|
||||
AttrsHash string `json:"-"`
|
||||
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
|
||||
// (例如父分组必须先于子分组创建)。
|
||||
User *mapping.DesiredUser `json:"-"`
|
||||
Group *mapping.DesiredGroup `json:"-"`
|
||||
MemberUID string `json:"-"`
|
||||
AttrsHash string `json:"-"`
|
||||
// Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
|
||||
Seq int `json:"-"`
|
||||
}
|
||||
|
||||
// Plan 是一次同步的完整计划。
|
||||
// Plan 是一次物化的完整计划。
|
||||
type Plan struct {
|
||||
Ops []Op `json:"ops"`
|
||||
// Warnings 是不阻塞执行、但需要人工留意的信息。
|
||||
Warnings []string `json:"warnings"`
|
||||
Stats Stats `json:"stats"`
|
||||
Ops []Op `json:"ops"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Stats Stats `json:"stats"`
|
||||
}
|
||||
|
||||
// Stats 是计划的分类统计。
|
||||
type Stats struct {
|
||||
CreateUser int `json:"createUser"`
|
||||
UpdateUser int `json:"updateUser"`
|
||||
Deactivate int `json:"deactivateUser"`
|
||||
Reactivate int `json:"reactivateUser"`
|
||||
CreateGroup int `json:"createGroup"`
|
||||
UpdateGroup int `json:"updateGroup"`
|
||||
AddMember int `json:"addMember"`
|
||||
RemoveMember int `json:"removeMember"`
|
||||
TotalAccounts int `json:"totalAccounts"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
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 是计算计划所需的全部输入。
|
||||
// Input 是计算物化计划所需的全部输入。
|
||||
type Input struct {
|
||||
// Desired 是期望状态(来自飞书)
|
||||
Desired *mapping.State
|
||||
// Active 是 ou=people 下现有用户:uid(小写)→ 条目
|
||||
Active map[string]*ldaplib.Entry
|
||||
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目
|
||||
Inactive map[string]*ldaplib.Entry
|
||||
// Groups 是现有分组:DN(小写)→ 条目
|
||||
Groups map[string]*ldaplib.Entry
|
||||
// LedgerAccounts 是台账中的账号:uid → state(active/inactive)
|
||||
LedgerAccounts map[string]string
|
||||
// LedgerGroups 是台账中的分组:dept_id → dn
|
||||
LedgerGroups map[string]string
|
||||
// AdminDN 与相关基础 DN
|
||||
AdminDN string
|
||||
GroupRootDN string
|
||||
UserOUDN string
|
||||
InactiveOUDN string
|
||||
// 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 决定操作的执行顺序。
|
||||
// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
|
||||
//
|
||||
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
|
||||
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
|
||||
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
|
||||
// 集成测试立刻暴露了这个问题。
|
||||
// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
|
||||
// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
|
||||
func opPhase(k OpKind) int {
|
||||
switch k {
|
||||
case OpCreateGroup, OpUpdateGroup:
|
||||
case OpCreateGroup:
|
||||
return 0
|
||||
case OpCreateUser, OpUpdateUser:
|
||||
return 1
|
||||
case OpReactivate:
|
||||
return 2
|
||||
case OpAddMember:
|
||||
return 3
|
||||
return 2
|
||||
case OpRemoveMember:
|
||||
return 3
|
||||
case OpDeleteUser:
|
||||
return 4
|
||||
case OpDeactivate:
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
// sortOps 按依赖顺序排列操作。
|
||||
//
|
||||
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
|
||||
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
|
||||
// “od-root” 之前,导致子分组先于父分组创建而失败)。
|
||||
// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
|
||||
func sortOps(ops []Op) {
|
||||
sort.SliceStable(ops, func(i, j int) bool {
|
||||
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
|
||||
@@ -140,288 +123,171 @@ func sortOps(ops []Op) {
|
||||
})
|
||||
}
|
||||
|
||||
// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。
|
||||
// ComputePlan 计算物化计划。它是纯函数,不产生任何副作用。
|
||||
func ComputePlan(in *Input) *Plan {
|
||||
p := &Plan{
|
||||
Ops: make([]Op, 0, 64),
|
||||
Warnings: append([]string{}, in.Desired.Warnings...),
|
||||
Stats: Stats{
|
||||
TotalAccounts: len(in.LedgerAccounts),
|
||||
},
|
||||
Warnings: make([]string, 0),
|
||||
Stats: Stats{TotalUsers: len(in.ActualUsers)},
|
||||
}
|
||||
|
||||
planGroups(p, in)
|
||||
planUsers(p, in)
|
||||
planMembership(p, in)
|
||||
|
||||
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
|
||||
// (mapping 已按父先于子的顺序产出分组)。
|
||||
for i := range p.Ops {
|
||||
p.Ops[i].Seq = i
|
||||
}
|
||||
sortOps(p.Ops)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// planGroups 处理分组的创建与更新。
|
||||
// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
|
||||
func planGroups(p *Plan, in *Input) {
|
||||
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
|
||||
for _, g := range in.Desired.Groups {
|
||||
existing, exists := in.Groups[strings.ToLower(g.DN)]
|
||||
desired[strings.ToLower(g.DN)] = g
|
||||
}
|
||||
|
||||
if !exists {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpCreateGroup,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
DN: g.DN,
|
||||
Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN),
|
||||
Group: g,
|
||||
})
|
||||
p.Stats.CreateGroup++
|
||||
for _, g := range in.Desired.Groups {
|
||||
if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 分组已存在:仅当描述不一致时更新。
|
||||
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮
|
||||
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理,
|
||||
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。
|
||||
if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateGroup,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
DN: g.DN,
|
||||
Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name),
|
||||
Group: g,
|
||||
})
|
||||
p.Stats.UpdateGroup++
|
||||
}
|
||||
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++
|
||||
}
|
||||
|
||||
// 台账中存在、但期望状态里已消失的分组:不自动删除。
|
||||
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
|
||||
wantedDept := make(map[string]string, len(in.Desired.Groups))
|
||||
for _, g := range in.Desired.Groups {
|
||||
wantedDept[g.DeptID] = g.DN
|
||||
}
|
||||
for deptID, dn := range in.LedgerGroups {
|
||||
wantDN, stillWanted := wantedDept[deptID]
|
||||
switch {
|
||||
case !stillWanted:
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn))
|
||||
case !strings.EqualFold(wantDN, dn):
|
||||
// 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的
|
||||
// uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)",
|
||||
deptID, dn, wantDN))
|
||||
// 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
|
||||
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 处理用户的创建、更新、停用与恢复。
|
||||
// planUsers 处理用户的创建、更新与删除。
|
||||
func planUsers(p *Plan, in *Input) {
|
||||
desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users))
|
||||
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 {
|
||||
_, inActiveOU := in.Active[u.UID]
|
||||
_, inInactiveOU := in.Inactive[u.UID]
|
||||
|
||||
switch {
|
||||
case inActiveOU:
|
||||
// 已存在且在职:比对属性是否漂移。
|
||||
existing := in.Active[u.UID]
|
||||
canonical := strings.ToLower(existing.DN)
|
||||
|
||||
// DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。
|
||||
// 用大小写无关比较:LDAP 服务端可能对 DN 做规范化,
|
||||
// 直接字符串比较会造成每轮都产生无意义的“更新”。
|
||||
if !strings.EqualFold(existing.DN, u.DN) {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN),
|
||||
User: u,
|
||||
TargetDN: canonical,
|
||||
})
|
||||
p.Stats.UpdateUser++
|
||||
continue
|
||||
}
|
||||
|
||||
if attrsDiffer(existing, u) {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: "用户属性发生变化:" + describeAttrDiff(existing, u),
|
||||
User: u,
|
||||
TargetDN: canonical,
|
||||
})
|
||||
p.Stats.UpdateUser++
|
||||
continue
|
||||
}
|
||||
p.Stats.Unchanged++
|
||||
|
||||
case inInactiveOU:
|
||||
// 存在于 ou=Inactive 但期望是在职:恢复。
|
||||
// 仅当台账确认该账号由我们管理时才动它。
|
||||
if _, managed := in.LedgerAccounts[u.UID]; !managed {
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
|
||||
continue
|
||||
}
|
||||
existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
|
||||
if !ok {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpReactivate,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
|
||||
User: u,
|
||||
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
|
||||
})
|
||||
p.Stats.Reactivate++
|
||||
|
||||
default:
|
||||
// 两处都没有:新建。
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpCreateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN),
|
||||
User: u,
|
||||
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++
|
||||
}
|
||||
|
||||
// 台账中在职、但期望状态里已消失:候选停用。
|
||||
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
|
||||
for uid, state := range in.LedgerAccounts {
|
||||
if state != "active" {
|
||||
// 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
|
||||
// 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
|
||||
for uid := range in.ActualUsers {
|
||||
if _, want := desiredByUID[uid]; want {
|
||||
continue
|
||||
}
|
||||
if _, ok := desiredByUID[uid]; ok {
|
||||
if _, owned := in.OwnedUsers[uid]; !owned {
|
||||
continue
|
||||
}
|
||||
entry, ok := in.Active[uid]
|
||||
if !ok {
|
||||
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
|
||||
if !in.ConfirmedLeave[uid] {
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
|
||||
fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpDeactivate,
|
||||
Target: uid,
|
||||
Name: entry.Get(ldaplib.AttrDisplayName),
|
||||
DN: ldaplib.UserDN(uid, in.InactiveOUDN),
|
||||
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
|
||||
TargetDN: strings.ToLower(entry.DN),
|
||||
Kind: OpDeleteUser, Target: uid,
|
||||
Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
|
||||
DN: ldaplib.UserDN(uid, in.PeopleOU),
|
||||
Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
|
||||
})
|
||||
p.Stats.Deactivate++
|
||||
p.Stats.DeleteUser++
|
||||
}
|
||||
}
|
||||
|
||||
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
|
||||
// planMembership 比对组的成员,产出加组/移组操作。
|
||||
func planMembership(p *Plan, in *Input) {
|
||||
for _, g := range in.Desired.Groups {
|
||||
existing, ok := in.Groups[strings.ToLower(g.DN)]
|
||||
if !ok {
|
||||
// 分组是新建的,成员关系会在创建后由执行器处理;
|
||||
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。
|
||||
for memberDN := range g.Members {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpAddMember,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: g.DN,
|
||||
MemberDN: memberDN,
|
||||
})
|
||||
p.Stats.AddMember++
|
||||
}
|
||||
continue
|
||||
e, ok := in.ActualGroups[strings.ToLower(g.DN)]
|
||||
actual := make(map[string]struct{})
|
||||
if ok {
|
||||
actual = e.Members()
|
||||
}
|
||||
desired := lowerMembers(g.Members)
|
||||
|
||||
actual := existing.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,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: existing.DN,
|
||||
MemberDN: memberDN,
|
||||
Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
|
||||
})
|
||||
p.Stats.AddMember++
|
||||
}
|
||||
|
||||
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 移出:实际有、期望没有,且只移受管、永不移 admin。
|
||||
for memberDN := range actual {
|
||||
if _, want := g.Members[memberDN]; want {
|
||||
if _, want := desired[memberDN]; want {
|
||||
continue
|
||||
}
|
||||
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
|
||||
continue
|
||||
}
|
||||
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
|
||||
if !isManagedMember(memberDN, in) {
|
||||
uid, ok := uidFromDN(memberDN)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, managed := in.OwnedUsers[uid]; !managed {
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
|
||||
fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpRemoveMember,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: existing.DN,
|
||||
MemberDN: memberDN,
|
||||
Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
|
||||
Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
|
||||
})
|
||||
p.Stats.RemoveMember++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
|
||||
//
|
||||
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
|
||||
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
|
||||
func isManagedMember(memberDN string, in *Input) bool {
|
||||
uid, ok := uidFromDN(memberDN)
|
||||
if !ok {
|
||||
return false
|
||||
// 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{}{}
|
||||
}
|
||||
if _, managed := in.LedgerAccounts[uid]; managed {
|
||||
return true
|
||||
}
|
||||
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
|
||||
for _, u := range in.Desired.Users {
|
||||
if u.UID == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return out
|
||||
}
|
||||
|
||||
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。
|
||||
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
|
||||
func uidFromDN(dn string) (string, bool) {
|
||||
first := dn
|
||||
if i := strings.Index(dn, ","); i >= 0 {
|
||||
@@ -434,33 +300,19 @@ func uidFromDN(dn string) (string, bool) {
|
||||
return strings.ToLower(strings.TrimSpace(first[4:])), true
|
||||
}
|
||||
|
||||
// memberRDN 从成员 DN 中提取用于展示的标识。
|
||||
func memberRDN(dn string) string {
|
||||
if uid, ok := uidFromDN(dn); ok {
|
||||
return uid
|
||||
}
|
||||
return dn
|
||||
}
|
||||
|
||||
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
|
||||
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
|
||||
// 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.AttrCN, want.CN},
|
||||
{ldaplib.AttrSN, want.SN},
|
||||
{ldaplib.AttrGivenName, want.GivenName},
|
||||
{ldaplib.AttrDisplayName, want.DisplayName},
|
||||
{ldaplib.AttrMail, want.Mail},
|
||||
{ldaplib.AttrMobile, want.Mobile},
|
||||
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||
{ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||
{ldaplib.AttrDescription, want.Description},
|
||||
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
|
||||
{ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||
{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) {
|
||||
@@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
|
||||
}
|
||||
|
||||
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
|
||||
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
|
||||
fields := []struct {
|
||||
label string
|
||||
attr string
|
||||
@@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
}{
|
||||
{"mail", ldaplib.AttrMail, want.Mail},
|
||||
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
|
||||
{"mobile", ldaplib.AttrMobile, want.Mobile},
|
||||
{"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||
{"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||
{"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||
{"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||
{"givenName", ldaplib.AttrGivenName, want.GivenName},
|
||||
{"sn", ldaplib.AttrSN, want.SN},
|
||||
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||
}
|
||||
|
||||
var diffs []string
|
||||
for _, f := range fields {
|
||||
old := existing.Get(f.attr)
|
||||
@@ -498,27 +343,58 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
|
||||
}
|
||||
if len(diffs) == 0 {
|
||||
return "属性无变化"
|
||||
return "无变化"
|
||||
}
|
||||
return strings.Join(diffs, "; ")
|
||||
}
|
||||
|
||||
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
|
||||
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
|
||||
func normalizeAttr(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
|
||||
func AttrsHash(u *mapping.User) string {
|
||||
// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
|
||||
func AttrsHash(u *mapping.DesiredUser) string {
|
||||
h := sha256.New()
|
||||
for _, v := range []string{
|
||||
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
|
||||
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
|
||||
u.BusinessCategory, u.Description,
|
||||
} {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user