chore: 初始化 idsync 项目

飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+624
View File
@@ -0,0 +1,624 @@
//go:build integration
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
//
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
// 并断言最终落在 OpenLDAP 里的内容。
//
// 启用方式(需要真实 LDAP 实例):
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
package syncer_test
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/store"
"idsync/internal/syncer"
)
// fakeDept 是假服务返回的部门。
type fakeDept struct {
openID string
parentID string
name string
}
// fakeUser 是假服务返回的用户。
type fakeUser struct {
unionID string
userID string
name string
enName string
enterpriseEm string
email string
mobile string
employeeNo string
jobTitle string
deptIDs []string
resigned bool
}
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。
type fakeFeishu struct {
depts []fakeDept
users []fakeUser
// served 记录各接口被调用的次数,用于验证分页与去重行为。
calls map[string]int
}
func (f *fakeFeishu) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.calls["token"]++
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"tenant_access_token": "fake-token",
"expire": 7200,
})
})
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
f.calls["departments"]++
parent := r.URL.Query().Get("parent_department_id")
if parent == "" {
parent = "0"
}
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true}
if fetchChild {
for changed := true; changed; {
changed = false
for _, d := range f.depts {
if !want[d.openID] && want[d.parentID] {
want[d.openID] = true
changed = true
}
}
}
}
items := make([]map[string]any, 0)
for _, d := range f.depts {
if !want[d.openID] || d.openID == parent {
continue
}
items = append(items, map[string]any{
"name": d.name,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
})
})
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
f.calls["find_by_department"]++
deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0)
for _, u := range f.users {
if u.resigned {
continue // 与真实接口一致:不返回离职人员
}
if !containsStr(u.deptIDs, deptID) {
continue
}
items = append(items, map[string]any{
"union_id": u.unionID,
"user_id": u.userID,
"name": u.name,
"en_name": u.enName,
"enterprise_email": u.enterpriseEm,
"email": u.email,
"mobile": u.mobile,
"employee_no": u.employeeNo,
"job_title": u.jobTitle,
"department_ids": u.deptIDs,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
})
})
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
f.calls["user_get"]++
// 从路径末尾取出 union_id
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1]
for _, u := range f.users {
if u.unionID != unionID {
continue
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"user": map[string]any{
"union_id": unionID,
"user_id": u.userID,
"name": u.name,
"status": map[string]any{"is_resigned": u.resigned},
},
},
})
return
}
writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
})
return mux
}
func writeFakeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) {
t.Helper()
ldapURL := os.Getenv("LDAP_TEST_URL")
if ldapURL == "" {
ldapURL = "ldap://127.0.0.1:3899"
}
base := os.Getenv("LDAP_TEST_BASE_DN")
if base == "" {
base = "dc=example,dc=com"
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍:
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS")
if groupOC == "" {
groupOC = "groupOfUniqueNames"
}
ldapCfg := config.Ldap{
URL: ldapURL, BaseDN: base,
AdminDN: "cn=admin," + base, AdminPass: "adminpass",
GroupObjectClass: groupOC,
TimeoutSec: 10,
}
if err := ldapCfg.NormalizeForTest(); err != nil {
t.Fatalf("LDAP 配置规范化失败: %v", err)
}
ldapClient, err := ldaplib.NewClient(ldapCfg, log)
if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err)
}
if err := ldapClient.Ping(); err != nil {
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
}
fake := &fakeFeishu{calls: map[string]int{}}
ts := httptest.NewServer(fake.handler())
feishuClient, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake",
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
}, log)
if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err)
}
dbPath := t.TempDir() + "/idsync.db"
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("打开台账失败: %v", err)
}
ldapCfg.UserOU = "ou=people"
ldapCfg.InactiveOU = "ou=Inactive"
ldapCfg.GroupRootName = "feishuroot"
cfg := &config.Config{
System: config.System{Mode: "release", DataDir: t.TempDir()},
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: ldapCfg,
Database: config.Database{Source: dbPath},
Sync: config.Sync{MaxDeactivatePercent: 0.5},
}
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log)
cleanup := func() {
ts.Close()
st.Close()
}
return runner, ldapClient, st, fake, cleanup
}
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
// 属性变更、部门调整、离职迁移与重新入职。
func TestEndToEndSync(t *testing.T) {
runner, ldapClient, st, fake, cleanup := setup(t)
defer cleanup()
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
// 准备目录结构并清理历史残留。
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
}
}
defer func() {
wipeTestOUs(t, conn, base)
conn.Close()
}()
// 两个部门,其中“研发部”是“后端组”的父部门。
// 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root),
// 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。
fake.depts = []fakeDept{
{openID: "od-root", parentID: "0", name: "Acme"},
{openID: "od-aaa-child", parentID: "od-root", name: "yanfa"},
}
fake.users = []fakeUser{
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San",
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com",
mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师",
deptIDs: []string{"od-aaa-child"}},
{unionID: "u2", userID: "2", name: "李四", enName: "Li Si",
enterpriseEm: "lisi@example.com", email: "personal2@x.com",
mobile: "13800000002", employeeNo: "E002", jobTitle: "经理",
deptIDs: []string{"od-aaa-child"}},
// 没有企业邮箱:应当被跳过而不是回落到个人邮箱。
{unionID: "u3", userID: "3", name: "王五",
enterpriseEm: "", email: "wangwu@personal.com",
deptIDs: []string{"od-aaa-child"}},
}
wipeTestOUs(t, conn, base)
// ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ----
plan, res, err := runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("dry-run 失败: %v", err)
}
if res != nil {
t.Fatalf("dry-run 不应返回执行结果")
}
if plan.Stats.CreateUser != 2 {
t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)",
plan.Stats.CreateUser)
}
foundEmailWarning := false
for _, wr := range plan.Warnings {
if strings.Contains(wr, "企业邮箱") {
foundEmailWarning = true
break
}
}
if !foundEmailWarning {
t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings)
}
// dry-run 后 LDAP 中不应有任何用户。
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if len(users) != 0 {
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
}
// ---- 第二步:真正执行 ----
_, res, err = runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("执行同步失败: %v", err)
}
if res.Failed != 0 {
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors)
}
users, err = conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if _, ok := users["zhangsan"]; !ok {
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
}
if _, ok := users["lisi"]; !ok {
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
}
if _, ok := users["wangwu"]; ok {
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
}
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。
zs := users["zhangsan"]
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" {
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got)
}
if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" {
t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got)
}
if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" {
t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got)
}
// 断言分组与成员关系。
groups, err := conn.ListGroups(groupRoot)
if err != nil {
t.Fatalf("读取分组失败: %v", err)
}
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。
acmeDN := "cn=acme," + groupRoot
yanfaDN := "cn=yanfa," + acmeDN
g, ok := groups[yanfaDN]
if !ok {
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
}
members := g.Members()
if _, has := members["uid=zhangsan,"+peopleOU]; !has {
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members)
}
// 父部门分组也应存在,验证树形嵌套正确。
if _, ok := groups[acmeDN]; !ok {
t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups))
}
// 台账应记录两个账号。
accounts, err := st.AccountsByUID()
if err != nil {
t.Fatalf("读取台账失败: %v", err)
}
if len(accounts) != 2 {
t.Errorf("台账记录数 = %d, 期望 2", len(accounts))
}
// ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ----
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("第二次 dry-run 失败: %v", err)
}
total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup +
plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember +
plan.Stats.Deactivate + plan.Stats.Reactivate
if total != 0 {
t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan))
}
// ---- 第四步:属性变更应被检测并同步 ----
fake.users[0].jobTitle = "高级工程师"
fake.users[0].mobile = "13900000001"
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("变更检测失败: %v", err)
}
if plan.Stats.UpdateUser != 1 {
t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser)
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("应用变更失败: %v", err)
}
users, _ = conn.ListUsers(peopleOU)
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
t.Errorf("职位未更新: %q", got)
}
// ---- 第五步:离职应迁入 ou=Inactive ----
fake.users[1].resigned = true
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("离职检测失败: %v", err)
}
if plan.Stats.Deactivate != 1 {
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行离职迁移失败: %v", err)
}
users, _ = conn.ListUsers(peopleOU)
if _, still := users["lisi"]; still {
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
}
inactive, _ := conn.ListUsers(inactiveOU)
if _, moved := inactive["lisi"]; !moved {
t.Errorf("lisi 未迁入 %s", inactiveOU)
}
// 离职用户也应从部门分组中移除。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
}
// ---- 第六步:重新入职应迁回 ----
fake.users[1].resigned = false
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("复职检测失败: %v", err)
}
if plan.Stats.Reactivate != 1 {
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan))
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行复职失败: %v", err)
}
users, _ = conn.ListUsers(peopleOU)
if _, back := users["lisi"]; !back {
t.Errorf("lisi 复职后未回到 %s", peopleOU)
}
// 复职后应重新加入部门分组。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
}
}
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。
func TestEndToEndGuard(t *testing.T) {
runner, ldapClient, _, fake, cleanup := setup(t)
defer cleanup()
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
defer conn.Close()
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
}
}
fake.depts = []fakeDept{{openID: "od-root", parentID: "0", name: "Acme"}}
fake.users = nil
for i := 0; i < 4; i++ {
fake.users = append(fake.users, fakeUser{
unionID: fmt.Sprintf("g%d", i), userID: fmt.Sprintf("%d", i),
name: fmt.Sprintf("用户%d", i), enterpriseEm: fmt.Sprintf("user%d@example.com", i),
deptIDs: []string{"od-root"},
})
}
wipeTestOUs(t, conn, base)
defer wipeTestOUs(t, conn, base)
if _, res, err := runner.Run(t.Context(), false, false, "test"); err != nil || res.Failed != 0 {
t.Fatalf("初次同步失败: err=%v res=%+v", err, res)
}
// 让飞书侧几乎所有用户消失:这模拟了权限范围被误改的灾难场景。
fake.users = fake.users[:1]
if _, _, err := runner.Run(t.Context(), false, false, "test"); err == nil {
t.Fatalf("停用 3/4 账号应当被护栏拦截,却成功执行了")
} else if !strings.Contains(err.Error(), "停用") {
t.Errorf("护栏错误信息应当说明停用数量问题,实际: %v", err)
}
// 强制模式应当放行。
if _, res, err := runner.Run(t.Context(), false, true, "test"); err != nil {
t.Fatalf("强制模式执行失败: %v", err)
} else if res.Failed != 0 {
t.Errorf("强制模式有 %d 个操作失败: %v", res.Failed, res.Errors)
}
}
// wipeTestOUs 删除测试用 OU 下的全部用户与分组。
//
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职),
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) {
t.Helper()
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
for _, ou := range []string{peopleOU, inactiveOU} {
users, err := conn.ListUsers(ou)
if err != nil {
continue
}
for uid := range users {
_ = conn.DeleteUser("uid=" + uid + "," + ou)
}
}
groups, err := conn.ListGroups(groupRoot)
if err != nil {
return
}
// 先删深层分组,再删浅层,避免「非叶子节点无法删除」。
dns := keysOf(groups)
sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) })
for _, dn := range dns {
_ = conn.DeleteGroup(groups[dn].DN)
}
}
// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。
func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot }
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func dumpOps(p *syncer.Plan) string {
var b strings.Builder
for _, op := range p.Ops {
fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail)
}
for _, wr := range p.Warnings {
fmt.Fprintf(&b, "\n 警告: %s", wr)
}
return b.String()
}
+274
View File
@@ -0,0 +1,274 @@
package syncer
import (
"context"
"fmt"
"log/slog"
"strings"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
"idsync/internal/password"
)
// Guard 是执行前的安全阈值。
//
// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、
// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。
// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。
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
}
deactivate := p.Stats.Deactivate
if deactivate == 0 {
return nil
}
if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate {
return &GuardError{Message: fmt.Sprintf(
"本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑",
deactivate, g.MaxDeactivate)}
}
if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 {
ratio := float64(deactivate) / float64(p.Stats.TotalAccounts)
if ratio > g.MaxDeactivatePercent {
return &GuardError{Message: fmt.Sprintf(
"本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)}
}
}
return nil
}
// Result 是执行结果统计。
type Result struct {
Created int
Updated int
Moved int
Added int
Removed int
Failed int
Errors []string
}
// Executor 把计划应用到 LDAP。
type Executor struct {
client *ldaplib.Client
cfg ExecConfig
log *slog.Logger
}
// ExecConfig 是执行器所需的环境参数。
type ExecConfig struct {
UserOUDN string
InactiveOUDN string
GroupRootDN string
GroupRootName string
AdminDN string
// InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。
InitialPassword string
}
// NewExecutor 构造执行器。
func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor {
return &Executor{client: client, cfg: cfg, log: log}
}
// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作——
// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。
func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result {
res := &Result{}
conn, err := e.client.Dial()
if err != nil {
res.Failed++
res.Errors = append(res.Errors, err.Error())
return res
}
defer conn.Close()
// 确保基础 OU 存在,否则后续写入会失败。
//
// ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、
// 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串
// 难以理解的 No Such Object。
baseOUs := []struct{ dn, name string }{
{e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)},
{e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)},
{e.cfg.GroupRootDN, e.cfg.GroupRootName},
}
for _, ou := range baseOUs {
if ou.dn == "" {
continue
}
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
res.Failed++
res.Errors = append(res.Errors, err.Error())
return res
}
}
for _, op := range plan.Ops {
if err := ctx.Err(); err != nil {
res.Errors = append(res.Errors, "同步被取消: "+err.Error())
return res
}
if err := e.applyOne(conn, op); err != nil {
res.Failed++
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
res.Errors = append(res.Errors, msg)
e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err)
continue
}
switch op.Kind {
case OpCreateUser:
res.Created++
case OpUpdateUser, OpReactivate:
res.Updated++
if op.Kind == OpReactivate {
res.Moved++
}
case OpDeactivate:
res.Updated++
res.Moved++
case OpAddMember:
res.Added++
case OpRemoveMember:
res.Removed++
}
}
return res
}
// applyOne 执行单个操作。
func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error {
switch op.Kind {
case OpCreateGroup:
// 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。
return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN)
case OpUpdateGroup:
return conn.UpdateGroup(op.DN, op.Group.Name)
case OpCreateUser:
plain := e.cfg.InitialPassword
if plain == "" {
generated, err := password.Random()
if err != nil {
return err
}
plain = generated
}
// 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希,
// 直接写明文会让密码以明文形式留在目录里。
encoded, err := password.EncodeSSHA(plain)
if err != nil {
return err
}
return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded)
case OpUpdateUser:
// 若 DN 因 OU 配置变化而不同,先移动再更新属性。
if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) {
uid := op.User.UID
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
return err
}
}
// 读取现状:UpdateUser 需要据此决定哪些属性应当删除,
// 对不存在的属性执行 Delete 会导致整个 Modify 失败。
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
if err != nil {
return err
}
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
case OpDeactivate:
// 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录;
// 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留,
// 便于日后重新入职时原样恢复。
uid := op.Target
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil {
return err
}
return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned)
case OpReactivate:
uid := op.User.UID
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
return err
}
if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil {
return err
}
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
if err != nil {
return err
}
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
case OpAddMember:
return conn.AddMember(op.GroupDN, op.MemberDN)
case OpRemoveMember:
return conn.RemoveMember(op.GroupDN, op.MemberDN)
default:
return fmt.Errorf("未知操作类型: %s", op.Kind)
}
}
// toLDAPUser 把期望状态转成 LDAP 写入结构。
func toLDAPUser(u *mapping.User) *ldaplib.User {
return &ldaplib.User{
UID: u.UID,
CN: u.CN,
SN: u.SN,
GivenName: u.GivenName,
DisplayName: u.DisplayName,
Mail: u.Mail,
Mobile: u.Mobile,
EmployeeNumber: u.EmployeeNumber,
DepartmentNumber: u.DepartmentNumber,
PostalAddress: u.PostalAddress,
BusinessCategory: u.BusinessCategory,
EmployeeType: u.EmployeeType,
Description: u.Description,
}
}
// ouName 从 OU 的 DN 中取出 ou 的值。
func ouName(dn string) string {
first := dn
if i := strings.Index(dn, ","); i >= 0 {
first = dn[:i]
}
if i := strings.Index(first, "="); i >= 0 {
return first[i+1:]
}
return first
}
+524
View File
@@ -0,0 +1,524 @@
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
//
// 设计要点:
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
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"
OpDeactivate OpKind = "deactivate_user"
OpReactivate OpKind = "reactivate_user"
OpCreateGroup OpKind = "create_group"
OpUpdateGroup OpKind = "update_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)。
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 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
// (例如父分组必须先于子分组创建)。
Seq int `json:"-"`
}
// Plan 是一次同步的完整计划。
type Plan struct {
Ops []Op `json:"ops"`
// Warnings 是不阻塞执行、但需要人工留意的信息。
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"`
}
// 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
}
// opPhase 决定操作的执行顺序。
//
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
// 集成测试立刻暴露了这个问题。
func opPhase(k OpKind) int {
switch k {
case OpCreateGroup, OpUpdateGroup:
return 0
case OpCreateUser, OpUpdateUser:
return 1
case OpReactivate:
return 2
case OpAddMember:
return 3
case OpRemoveMember:
return 4
case OpDeactivate:
return 5
default:
return 6
}
}
// sortOps 按依赖顺序排列操作。
//
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
// “od-root” 之前,导致子分组先于父分组创建而失败)。
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: append([]string{}, in.Desired.Warnings...),
Stats: Stats{
TotalAccounts: len(in.LedgerAccounts),
},
}
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 处理分组的创建与更新。
func planGroups(p *Plan, in *Input) {
for _, g := range in.Desired.Groups {
existing, exists := in.Groups[strings.ToLower(g.DN)]
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++
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++
}
}
// 台账中存在、但期望状态里已消失的分组:不自动删除。
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
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))
}
}
}
// planUsers 处理用户的创建、更新、停用与恢复。
func planUsers(p *Plan, in *Input) {
desiredByUID := make(map[string]*mapping.User, 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
}
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,
})
p.Stats.CreateUser++
}
}
// 台账中在职、但期望状态里已消失:候选停用。
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
for uid, state := range in.LedgerAccounts {
if state != "active" {
continue
}
if _, ok := desiredByUID[uid]; ok {
continue
}
entry, ok := in.Active[uid]
if !ok {
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
p.Warnings = append(p.Warnings,
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
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),
})
p.Stats.Deactivate++
}
}
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
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
}
actual := existing.Members()
// 期望有、实际没有 → 加入
for memberDN := range g.Members {
if _, has := actual[strings.ToLower(memberDN)]; has {
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,
})
p.Stats.AddMember++
}
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
for memberDN := range actual {
if _, want := g.Members[memberDN]; want {
continue
}
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
continue
}
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
if !isManagedMember(memberDN, in) {
p.Warnings = append(p.Warnings,
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
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,
})
p.Stats.RemoveMember++
}
}
}
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
//
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
func isManagedMember(memberDN string, in *Input) bool {
uid, ok := uidFromDN(memberDN)
if !ok {
return false
}
if _, managed := in.LedgerAccounts[uid]; managed {
return true
}
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
for _, u := range in.Desired.Users {
if u.UID == uid {
return true
}
}
return false
}
// 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
}
// 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 {
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},
}
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.User) string {
fields := []struct {
label string
attr string
want 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)
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, "; ")
}
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
func normalizeAttr(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
func AttrsHash(u *mapping.User) 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,
} {
h.Write([]byte(v))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
+435
View File
@@ -0,0 +1,435 @@
package syncer
import (
"strings"
"testing"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
)
const (
testUserOU = "ou=people,dc=example,dc=com"
testInactiveOU = "ou=Inactive,dc=example,dc=com"
testGroupRoot = "ou=feishuroot,dc=example,dc=com"
testAdminDN = "cn=admin,dc=example,dc=com"
)
// entry 构造一个 LDAP 条目,便于测试。
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
return ldaplib.NewEntry(dn, attrs, members)
}
func newUser(uid string) *mapping.User {
return &mapping.User{
UID: uid,
DN: "uid=" + uid + "," + testUserOU,
InactiveDN: "uid=" + uid + "," + testInactiveOU,
CN: uid,
SN: uid,
GivenName: uid,
DisplayName: uid,
Mail: uid + "@example.com",
Description: uid,
}
}
func baseInput(desired *mapping.State) *Input {
return &Input{
Desired: desired,
Active: map[string]*ldaplib.Entry{},
Inactive: map[string]*ldaplib.Entry{},
Groups: map[string]*ldaplib.Entry{},
LedgerAccounts: map[string]string{},
LedgerGroups: map[string]string{},
AdminDN: testAdminDN,
GroupRootDN: testGroupRoot,
UserOUDN: testUserOU,
InactiveOUDN: testInactiveOU,
}
}
func kindsOf(plan *Plan) map[OpKind]int {
out := make(map[OpKind]int)
for _, op := range plan.Ops {
out[op.Kind]++
}
return out
}
func TestComputePlan(t *testing.T) {
tests := []struct {
name string
desired *mapping.State
mutate func(in *Input)
wantKinds map[OpKind]int
// wantDetailContains 断言某个操作详情包含特定文本
wantDetailContains string
// forbidKinds 断言这些操作绝不出现(安全性回归)
forbidKinds []OpKind
}{
{
name: "全新用户:创建",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateUser: 1},
},
{
name: "属性一致:不产生操作",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrSN: u.SN,
ldaplib.AttrGivenName: u.GivenName,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: u.Mail,
ldaplib.AttrMobile: u.Mobile,
ldaplib.AttrEmployeeNumber: u.EmployeeNumber,
ldaplib.AttrDepartmentNumber: u.DepartmentNumber,
ldaplib.AttrPostalAddress: u.PostalAddress,
ldaplib.AttrBusinessCategory: u.BusinessCategory,
ldaplib.AttrDescription: u.Description,
}, nil)
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{},
},
{
name: "邮箱变化:更新属性",
desired: &mapping.State{
Users: []*mapping.User{
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: "old@example.com",
}, nil)
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpUpdateUser: 1},
wantDetailContains: "alice.new@example.com",
},
{
name: "台账在职但飞书消失:停用",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Bob",
}, nil)
in.LedgerAccounts["bob"] = "active"
},
wantKinds: map[OpKind]int{OpDeactivate: 1},
},
{
name: "非台账账号缺席:不得停用(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Service",
}, nil)
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpDeactivate},
},
{
name: "在 Inactive 且台账受管:恢复",
desired: &mapping.State{
Users: []*mapping.User{newUser("carol")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
ldaplib.AttrDisplayName: "Carol",
}, nil)
in.LedgerAccounts["carol"] = "inactive"
},
wantKinds: map[OpKind]int{OpReactivate: 1},
},
{
name: "在 Inactive 但非台账:只告警不改动",
desired: &mapping.State{
Users: []*mapping.User{newUser("dave")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpReactivate},
},
{
name: "分组不存在:创建并加成员",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
},
{
name: "成员缺席:加入分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpAddMember: 1},
},
{
name: "成员多余且受管:移出分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=alice," + testUserOU})
in.LedgerAccounts["alice"] = "active"
},
wantKinds: map[OpKind]int{OpRemoveMember: 1},
},
{
name: "admin 永不被移出分组(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
{
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
// 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。
name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
adminUIDDN := "uid=admin," + testUserOU
in.AdminDN = adminUIDDN
in.LedgerAccounts["admin"] = "active"
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{adminUIDDN})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
{
name: "外部手工成员:不移出,仅告警(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=external," + testUserOU})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := baseInput(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 _, expected := tt.wantKinds[kind]; !expected {
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s",
kind, count, dumpPlan(plan))
}
}
for _, kind := range tt.forbidKinds {
if got[kind] > 0 {
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
}
}
if tt.wantDetailContains != "" {
found := false
for _, op := range plan.Ops {
if strings.Contains(op.Detail, tt.wantDetailContains) {
found = true
break
}
}
if !found {
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
tt.wantDetailContains, dumpPlan(plan))
}
}
})
}
}
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试:
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。
func TestComputePlanNeverTouchesPassword(t *testing.T) {
u := newUser("alice")
in := baseInput(&mapping.State{
Users: []*mapping.User{u},
GroupByDeptID: map[string]*mapping.Group{},
})
in.LedgerAccounts["bob"] = "active"
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil)
plan := ComputePlan(in)
for _, op := range plan.Ops {
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
}
}
}
func TestGuard(t *testing.T) {
tests := []struct {
name string
guard Guard
plan *Plan
wantErr bool
}{
{
name: "无停用时通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}},
},
{
name: "超过绝对上限被拒",
guard: Guard{MaxDeactivate: 5},
plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}},
wantErr: true,
},
{
name: "超过比例上限被拒",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}},
wantErr: true,
},
{
name: "比例在阈值内通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}},
},
{
name: "强制模式跳过检查",
guard: Guard{MaxDeactivate: 1, AllowBulk: true},
plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}},
},
}
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 dumpPlan(p *Plan) string {
var b strings.Builder
for _, op := range p.Ops {
b.WriteString("\n ")
b.WriteString(string(op.Kind))
b.WriteString(" target=")
b.WriteString(op.Target)
b.WriteString(" detail=")
b.WriteString(op.Detail)
}
for _, w := range p.Warnings {
b.WriteString("\n WARN ")
b.WriteString(w)
}
return b.String()
}
+364
View File
@@ -0,0 +1,364 @@
package syncer
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
"idsync/internal/store"
)
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
type Runner struct {
cfg *config.Config
feishu *feishu.Client
ldap *ldaplib.Client
store *store.Store
log *slog.Logger
// guard 是执行前的安全阈值。
guard Guard
}
// NewRunner 构造编排器。
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
return &Runner{
cfg: cfg,
feishu: f,
ldap: l,
store: s,
log: log,
guard: Guard{
MaxDeactivate: cfg.Sync.MaxDeactivate,
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
},
}
}
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
// 因此预览内容与真正会发生的事情一致。
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
plan, _, err := r.plan(ctx)
return plan, err
}
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
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(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.State, 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
}
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
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 读取两侧状态并计算计划,同时把计划规模记录到 run。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
if err != nil {
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
}
r.log.Info("已读取飞书数据",
"departments", len(snap.Departments), "users", len(snap.Users),
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
var skipWarnings []string
for _, u := range snap.SkippedNoEmail {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
feishu.DisplayName(u), u.UnionID))
}
for _, d := range snap.SkippedDuplicate {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
d.UID, strings.Join(d.Names, "、")))
}
desired := mapping.Build(snap, mapping.BuildOptions{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
})
conn, err := r.ldap.Dial()
if err != nil {
return nil, nil, err
}
defer conn.Close()
active, err := conn.ListUsers(r.cfg.UserOUDN())
if err != nil {
return nil, nil, err
}
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
if err != nil {
return nil, nil, err
}
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil {
return nil, nil, err
}
accounts, err := r.store.AccountsByUID()
if err != nil {
return nil, nil, err
}
groupLedger, err := r.store.GroupsByDeptID()
if err != nil {
return nil, nil, err
}
ledgerAccounts := make(map[string]string, len(accounts))
for uid, a := range accounts {
ledgerAccounts[uid] = a.State
}
ledgerGroups := make(map[string]string, len(groupLedger))
for deptID, g := range groupLedger {
ledgerGroups[deptID] = g.DN
}
plan := ComputePlan(&Input{
Desired: desired,
Active: active,
Inactive: inactive,
Groups: groups,
LedgerAccounts: ledgerAccounts,
LedgerGroups: ledgerGroups,
AdminDN: r.cfg.Ldap.AdminDN,
GroupRootDN: r.cfg.GroupRootDN(),
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
})
plan.Warnings = append(skipWarnings, plan.Warnings...)
// 对“候选停用”的用户,向飞书确认是否真的离职。
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
if err := r.confirmResigned(ctx, plan, snap); err != nil {
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
}
return plan, desired, nil
}
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
var uids []string
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
accounts, err := r.store.AccountsByUID()
if err != nil {
return err
}
for i := range plan.Ops {
if plan.Ops[i].Kind != OpDeactivate {
continue
}
acc, ok := accounts[plan.Ops[i].Target]
if !ok || acc.UnionID == "" {
continue
}
uids = append(uids, acc.UnionID)
}
if len(uids) == 0 {
return nil
}
resigned, err := r.feishu.CheckResigned(ctx, uids)
if err != nil {
return err
}
confirmed := make(map[string]bool, len(resigned))
for _, rr := range resigned {
confirmed[rr.UnionID] = true
}
keep := make([]Op, 0, len(plan.Ops))
for _, op := range plan.Ops {
if op.Kind != OpDeactivate {
keep = append(keep, op)
continue
}
acc := accounts[op.Target]
if acc != nil && confirmed[acc.UnionID] {
keep = append(keep, op)
continue
}
// 未能确认离职:不动作,并明确告知。
plan.Stats.Deactivate--
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
op.Target, r.cfg.InactiveOUDN()))
}
plan.Ops = keep
return nil
}
// apply 执行计划。
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
exec := NewExecutor(r.ldap, ExecConfig{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
GroupRootName: r.cfg.Ldap.GroupRootName,
AdminDN: r.cfg.Ldap.AdminDN,
InitialPassword: r.cfg.Ldap.InitialPassword,
}, r.log)
return exec.Apply(ctx, plan)
}
// persist 把本次同步的期望状态回写台账。
//
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
now := time.Now()
// 分组:凡在本次期望范围内的都登记。
for _, g := range desired.Groups {
if err := r.store.UpsertGroup(&store.Group{
DeptID: g.DeptID,
DN: g.DN,
Name: g.Name,
ParentID: g.ParentID,
State: "active",
LastSyncedAt: now,
}); err != nil {
return err
}
}
// 用户:在职的登记为 active。
for _, u := range desired.Users {
if err := r.store.UpsertAccount(&store.Account{
UID: u.UID,
DN: u.DN,
Email: u.Mail,
Name: u.DisplayName,
State: "active",
AttrsHash: AttrsHash(u),
DeptIDs: strings.Join(u.DeptIDs, ","),
LastSyncedAt: now,
}); err != nil {
return err
}
}
// 停用的账号:更新状态与,DN 指向离职 OU。
for _, op := range plan.Ops {
if op.Kind == OpDeactivate {
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); 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 + plan.Stats.UpdateGroup +
plan.Stats.Reactivate + plan.Stats.Deactivate
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
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.Moved = res.Moved
run.Added = res.Added
run.Removed = res.Removed
run.Failed = res.Failed
run.Skipped = plan.Stats.Unchanged
}