新增镜像存储层并保留旧台账过渡: - MirrorUser/MirrorDepartment/MirrorUserDepartment/ResignConfirmToken 模型 - ReplaceMirror 单事务整体替换:用户/部门/关系一致更新,失败回滚保留旧版 - 生命周期状态机:active→leave_candidate→pending_confirm→confirmed_leave→deleted, 非法转移结构性报错;快照只覆盖飞书事实不清洗状态 - 一次性离职确认 token:crypto/rand 生成、一次使用、过期作废、可批量 revoke - 单测:事务回滚、状态机转移、token 一次/过期/复用被拒 全部通过 - docs/migration-baseline.md 记录阶段0基线字段
idsync
把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 go-ldap-admin。
它做什么,不做什么
做:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。
不做:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。
这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具不在认证路径上——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。
工作方式
飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP
▲ │
└─────────────── SQLite 台账(所有权记录)◀───────────┘
三个关键设计:
1. 用官方 SDK,一次拉全量。
使用 github.com/larksuite/oapi-sdk-go/v3。部门一次取回整棵树(fetch_child=true),用户按部门枚举后按 union_id 去重。
注意 BatchGetId 的 emails 参数明确不支持企业邮箱,因此不能靠邮箱反查用户,只能从部门入口枚举。
2. 离职由缺席推断,再逐个确认。
FindByDepartment 不返回离职人员,所以「离职」只能先由「台账在职但本轮缺席」推断出来;随后对每个候选调用 User.Get 读取 status.is_resigned 确认。只有确认离职才会迁移——缺席也可能只是通讯录权限范围被收窄,直接迁移会误伤在职员工。
3. SQLite 台账是删除权限的唯一来源。
台账记录「哪些条目是本工具创建的」。任何不在台账中的条目(服务账号、手工账号、admin)在计划里永不出现,因此不可能被同步任务误改或误删。这在接管一个既有目录时尤其重要。
同步内容
登录名
uid 取自企业邮箱(enterprise_email)的 @ 前缀。
刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,回落到个人邮箱会产生不可预期、且在离职后仍然有效的登录名。缺少企业邮箱的用户会被跳过并在界面上显著列出,不会被静默丢弃。前缀冲突时同样跳过并报告,由人工修正邮箱。
LDAP 布局
<base_dn>
├── ou=people 在职用户(Authelia 的 base_dn 指向这里)
│ └── uid=<邮箱前缀>,ou=people,<base_dn>
├── ou=Inactive 离职用户(位于 Authelia base_dn 之外 → 自动无法登录)
│ └── uid=<邮箱前缀>,ou=Inactive,<base_dn>
└── ou=<group-root> 部门树
└── cn=<部门拼音>,cn=<父部门拼音>,...,ou=<group-root>,<base_dn>
部门用 groupOfUniqueNames + uniqueMember,因此 Authelia 的 Remote-Groups 与基于组的 ACL 可直接使用。
部门 CN 用拼音:中文作 RDN 需要转义成 #hex 形式,可读性差且部分工具链兼容不佳。
属性映射
| LDAP 属性 | 来源 |
|---|---|
uid / cn |
enterprise_email 的 @ 前缀 |
sn |
name |
displayName |
name |
givenName |
en_name,为空回落 name |
mail |
enterprise_email |
mobile |
mobile |
employeeNumber |
employee_no |
departmentNumber |
job_title |
postalAddress |
work_station |
businessCategory |
所属部门名(逗号拼接) |
employeeType |
Active / Resigned |
userPassword |
仅创建时写入,更新路径永不触碰 |
与 Authelia 的衔接
mail必须是企业邮箱:Authelia 重置密码时从 LDAP 的mail属性取收件地址。若该属性为空,重置流程会直接失败并报user ... has no email address configured。ou=Inactive必须在 Authelia 的base_dn之外:这是离职用户无法登录的机制。请确认你的base_dn是否正好是ou=people,<base>。- 更新路径绝不写
userPassword:Authelia 重置密码时直接写 LDAP 的userPassword。如果同步的更新路径也写这个属性,用户刚设好的密码会在下一轮同步时被打回初始值。代码中有结构性检查阻止这一点。 - 密码必须自行哈希:slapd 不会对普通 Modify 写入的
userPassword自动加盐。本工具写入{SSHA},不依赖服务端password-hash配置。
新用户会得到一个随机初始密码(或用 ldap.initial-password 指定),用户不可知。首次登录走 Authelia 的「忘记密码」——该流程不需要旧密码。
安全护栏
默认开启,用于防住「最坏的那一次同步」(飞书应用权限被收窄、token 指向错误租户、部门过滤写错,都会让期望状态突然大幅缩小):
- 默认 dry-run。命令行不加
-apply、界面不点确认,都不会写入任何内容。 - 停用阈值。单次停用超过
max-deactivate条或max-deactivate-percent比例时拒绝执行,需-force或界面强制确认。 - 所有权隔离。非台账条目永不被修改或删除。
- admin 保护。
admin永不被移出分组;外部手工加入的成员只告警、不移除。 - 部门树建树有环保护,分组按层级排序,保证父分组先于子分组创建。
构建
宿主无需安装 Go 或 Node,全部通过容器完成(本机已有 golang:1.26-trixie 与 node:20-alpine 镜像):
make deps # 下载 Go 依赖
make css # 构建 WebUI 样式(需要 Node 容器)
make build # 产出 bin/idsync
make test # 单元测试
make vet
make run # 本地运行
关于代理与网络:若
proxy.golang.org不可直连,Makefile已在容器调用上带上http_proxy/https_proxy并强制--network=host。默认网络下容器内的127.0.0.1指向容器自身, 无法访问宿主代理——这是必须用--network=host的原因。拉取基础镜像时同样需要代理(
podman pull不经由 Makefile):podman pull --http-proxy=http://127.0.0.1:10808 docker.io/library/golang:1.26-trixie # 或为本次会话导出: export http_proxy=http://127.0.0.1:10808注意不要用
containers.conf的[engine] env全局注入代理:那会让所有容器的http_proxy都指向宿主,导致容器内访问自身或本地服务时被代理拦截。
internal/webui/dist/ 下的前端产物会被 go:embed 打进二进制,因此部署时不需要 Node。修改前端后需重新执行 make css 并重新编译。
集成测试
集成测试对真实的 OpenLDAP 运行,覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、modrdn 移动后密码是否保留、以及「更新绝不触碰 userPassword」:
podman run -d --name idsync-ldap -p 3899:389 \
-e LDAP_ORGANISATION="Example Inc" -e LDAP_DOMAIN="example.com" \
-e LDAP_ADMIN_PASSWORD=adminpass \
docker.io/osixia/openldap:1.5.0
make test-integration
端到端测试还会启动一个假的飞书 OpenAPI 服务(internal/syncer/e2e_test.go),在没有真实租户凭据的情况下验证完整链路:首次全量创建、幂等重跑、属性漂移、离职迁移、重新入职、以及护栏拦截。
运行
cp config.example.yml config.yml
# 生成会话密钥与管理员密码哈希
./bin/idsync -gen-secret
./bin/idsync -hash-password '你的密码'
# 只预览,不写入(推荐先跑这个)
./bin/idsync -config config.yml -dry-run
# 确认无误后执行
./bin/idsync -config config.yml -apply
# 服务模式(含 WebUI 与定时同步)
./bin/idsync -config config.yml
建议的首次接管流程:先 -dry-run 看计划 → 核对后 -apply → 打开界面确认用户与分组 → 观察几轮 dry-run 的定时任务后再把 sync.default-dry-run 改为 false。
管理界面
六个页面:概览(连通性与计数)、用户(受管清单 + 一致性检查)、部门分组、变更预览(最有价值的一页:dry-run 计划表,确认后执行)、同步历史。
样式用 Tailwind v4 + FlyonUI。FlyonUI 官方不支持 CDN 方式(样式在构建期生成),因此走官方的构建路径,产物内嵌进二进制。
主题为白/暗底 + 绿色主色,取自 Gitea 的品牌视觉(#609926,暗色下 #7ab63a;注意 Gitea 自身的 --color-primary 其实是蓝色,品牌绿是它的 logo 色)。右上角按钮切换,偏好存在 localStorage。
部署
podman build -t idsync .
podman run -d --name idsync -p 8899:8899 \
-v $PWD/config.yml:/app/config.yml:ro \
-v idsync-data:/app/data \
idsync
已知边界
- 不做密码同步下发。Authelia 重置密码只写 LDAP,不发出任何事件或回调。因此对于「只认自己库里本地密码、既不吃 LDAP 也不读 HTTP 头」的服务,无法通过本工具同步密码。这类服务建议套一层 forward-auth(由 Authelia 注入
Remote-User/Remote-Email/Remote-Groups),或迁移到支持 LDAP 的同类方案。 - 部门改名会导致分组 DN 变化。此时不会自动改名(自动改名会牵动成员的
uniqueMember引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。 - 飞书侧删除的部门不会被自动删除分组,同样只告警。