- 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 永不移出、删除最后阶段
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package syncer
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"idsync/internal/ldaplib"
|
|
"idsync/internal/mapping"
|
|
)
|
|
|
|
const (
|
|
peopleOU = "ou=people,dc=example,dc=com"
|
|
groupRoot = "ou=groups,dc=example,dc=com"
|
|
testAdmin = "uid=admin," + peopleOU
|
|
)
|
|
|
|
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
|
|
return ldaplib.NewEntry(dn, attrs, members)
|
|
}
|
|
|
|
func dUser(uid string) *mapping.DesiredUser {
|
|
return &mapping.DesiredUser{
|
|
UID: uid, DN: "uid=" + uid + "," + peopleOU,
|
|
Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid,
|
|
}
|
|
}
|
|
|
|
func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup {
|
|
ms := make(map[string]struct{}, len(members))
|
|
for _, m := range members {
|
|
ms[m] = struct{}{}
|
|
}
|
|
return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms}
|
|
}
|
|
|
|
func depName(deptID string) string { return "部门-" + deptID }
|
|
|
|
func baseIn(desired *mapping.DesiredState) *Input {
|
|
return &Input{
|
|
Desired: desired,
|
|
ActualUsers: map[string]*ldaplib.Entry{},
|
|
ActualGroups: map[string]*ldaplib.Entry{},
|
|
OwnedUsers: map[string]struct{}{},
|
|
ConfirmedLeave: map[string]bool{},
|
|
AdminDN: testAdmin,
|
|
PeopleOU: peopleOU,
|
|
GroupRootDN: groupRoot,
|
|
}
|
|
}
|
|
|
|
func kindsOf(plan *Plan) map[OpKind]int {
|
|
out := make(map[OpKind]int)
|
|
for _, op := range plan.Ops {
|
|
out[op.Kind]++
|
|
}
|
|
return out
|
|
}
|
|
|
|
func plainDesired() *mapping.DesiredState {
|
|
return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}}
|
|
}
|
|
|
|
func TestComputePlan(t *testing.T) {
|
|
rdDN := "cn=od-rd," + groupRoot
|
|
tests := []struct {
|
|
name string
|
|
desired *mapping.DesiredState
|
|
mutate func(in *Input)
|
|
wantKinds map[OpKind]int
|
|
forbid []OpKind
|
|
wantWarn string
|
|
}{
|
|
{
|
|
name: "全新用户:创建",
|
|
desired: withUsers(plainDesired(), dUser("alice")),
|
|
wantKinds: map[OpKind]int{OpCreateUser: 1},
|
|
},
|
|
{
|
|
name: "属性一致:不产生操作",
|
|
desired: withUsers(plainDesired(), dUser("alice")),
|
|
mutate: func(in *Input) {
|
|
u := dUser("alice")
|
|
in.ActualUsers["alice"] = entry(u.DN, map[string]string{
|
|
ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName,
|
|
ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN,
|
|
}, nil)
|
|
in.OwnedUsers["alice"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
},
|
|
{
|
|
name: "邮箱变化:更新属性",
|
|
desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
|
|
mutate: func(in *Input) {
|
|
in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{
|
|
ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice",
|
|
ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice",
|
|
}, nil)
|
|
in.OwnedUsers["alice"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{OpUpdateUser: 1},
|
|
},
|
|
{
|
|
name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
|
|
desired: plainDesired(),
|
|
mutate: func(in *Input) {
|
|
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{
|
|
ldaplib.AttrDisplayName: "Bob"}, nil)
|
|
in.OwnedUsers["bob"] = struct{}{}
|
|
in.ConfirmedLeave["bob"] = true
|
|
},
|
|
wantKinds: map[OpKind]int{OpDeleteUser: 1},
|
|
},
|
|
{
|
|
name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
|
|
desired: plainDesired(),
|
|
mutate: func(in *Input) {
|
|
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
|
in.OwnedUsers["bob"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
forbid: []OpKind{OpDeleteUser},
|
|
wantWarn: "未完成离职确认",
|
|
},
|
|
{
|
|
name: "在 LLDAP、非受管(服务账号):绝不删除",
|
|
desired: plainDesired(),
|
|
mutate: func(in *Input) {
|
|
in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
|
|
in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
forbid: []OpKind{OpDeleteUser},
|
|
},
|
|
{
|
|
name: "分组不存在:创建",
|
|
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
|
wantKinds: map[OpKind]int{OpCreateGroup: 1},
|
|
},
|
|
{
|
|
name: "孤儿组(actual 有、期望没有):不删只告警",
|
|
desired: plainDesired(),
|
|
mutate: func(in *Input) {
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
|
|
map[string]string{ldaplib.AttrDescription: "x"}, nil)
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
forbid: []OpKind{OpCreateGroup},
|
|
wantWarn: "未自动删除",
|
|
},
|
|
{
|
|
name: "成员缺席:加入分组",
|
|
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
|
|
mutate: func(in *Input) {
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
|
|
in.OwnedUsers["alice"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{OpAddMember: 1},
|
|
},
|
|
{
|
|
name: "成员多余且受管:移出分组",
|
|
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
|
mutate: func(in *Input) {
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
|
[]string{testAdmin, "uid=alice," + peopleOU})
|
|
in.OwnedUsers["alice"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{OpRemoveMember: 1},
|
|
},
|
|
{
|
|
name: "admin 永不被移出分组(关键安全回归)",
|
|
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
|
mutate: func(in *Input) {
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
|
[]string{testAdmin})
|
|
in.OwnedUsers["admin"] = struct{}{}
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
forbid: []OpKind{OpRemoveMember},
|
|
},
|
|
{
|
|
name: "外部非受管成员:不移出,仅告警",
|
|
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
|
mutate: func(in *Input) {
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
|
[]string{testAdmin, "uid=external," + peopleOU})
|
|
},
|
|
wantKinds: map[OpKind]int{},
|
|
forbid: []OpKind{OpRemoveMember},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
in := baseIn(tt.desired)
|
|
if tt.mutate != nil {
|
|
tt.mutate(in)
|
|
}
|
|
plan := ComputePlan(in)
|
|
got := kindsOf(plan)
|
|
for kind, want := range tt.wantKinds {
|
|
if got[kind] != want {
|
|
t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan))
|
|
}
|
|
}
|
|
for kind, count := range got {
|
|
if _, exp := tt.wantKinds[kind]; !exp {
|
|
t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
|
|
}
|
|
}
|
|
for _, kind := range tt.forbid {
|
|
if got[kind] > 0 {
|
|
t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan))
|
|
}
|
|
}
|
|
if tt.wantWarn != "" {
|
|
found := false
|
|
for _, w := range plan.Warnings {
|
|
if strings.Contains(w, tt.wantWarn) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。
|
|
func TestDeleteIsLastPhase(t *testing.T) {
|
|
rdDN := "cn=od-rd," + groupRoot
|
|
desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice"))
|
|
in := baseIn(desired)
|
|
in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{
|
|
ldaplib.AttrDisplayName: "alice"}, nil)
|
|
in.OwnedUsers["alice"] = struct{}{}
|
|
in.OwnedUsers["bob"] = struct{}{}
|
|
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
|
in.ConfirmedLeave["bob"] = true
|
|
in.OwnedUsers["carol"] = struct{}{}
|
|
in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil)
|
|
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU})
|
|
|
|
plan := ComputePlan(in)
|
|
|
|
// bob:删除;alice 创建;carol 移出分组。
|
|
// 任何删除操作都必须排在任何创建/加/移之后。
|
|
maxOther, minDelete := -1, 1<<30
|
|
for i, op := range plan.Ops {
|
|
if op.Kind == OpDeleteUser {
|
|
if i < minDelete {
|
|
minDelete = i
|
|
}
|
|
} else {
|
|
if i > maxOther {
|
|
maxOther = i
|
|
}
|
|
}
|
|
}
|
|
if minDelete <= maxOther {
|
|
t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan))
|
|
}
|
|
}
|
|
|
|
// TestComputePlanNeverTouchesPassword 是结构性回归:
|
|
// 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。
|
|
func TestComputePlanNeverTouchesPassword(t *testing.T) {
|
|
in := baseIn(withUsers(plainDesired(), dUser("alice")))
|
|
plan := ComputePlan(in)
|
|
for _, op := range plan.Ops {
|
|
if strings.Contains(strings.ToLower(op.Detail), "userpassword") ||
|
|
strings.Contains(strings.ToLower(op.Detail), "password") {
|
|
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGuard(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
guard Guard
|
|
plan *Plan
|
|
wantErr bool
|
|
}{
|
|
{"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false},
|
|
{"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true},
|
|
{"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true},
|
|
{"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false},
|
|
{"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := tt.guard.Check(tt.plan)
|
|
if tt.wantErr && err == nil {
|
|
t.Errorf("期望报错,实际通过")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("期望通过,实际报错: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState {
|
|
s.Users = append(s.Users, us...)
|
|
return s
|
|
}
|
|
|
|
func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState {
|
|
s.Groups = append(s.Groups, gs...)
|
|
if s.GroupByDeptID == nil {
|
|
s.GroupByDeptID = map[string]*mapping.DesiredGroup{}
|
|
}
|
|
for _, g := range gs {
|
|
s.GroupByDeptID[g.DeptID] = g
|
|
}
|
|
return s
|
|
}
|
|
|
|
func dumpPlan(p *Plan) string {
|
|
var b strings.Builder
|
|
for _, op := range p.Ops {
|
|
b.WriteString("\n [")
|
|
b.WriteString(string(op.Kind))
|
|
b.WriteString("] ")
|
|
b.WriteString(op.Target)
|
|
b.WriteString(": ")
|
|
b.WriteString(op.Detail)
|
|
}
|
|
for _, w := range p.Warnings {
|
|
b.WriteString("\n WARN ")
|
|
b.WriteString(w)
|
|
}
|
|
return b.String()
|
|
} |