caowangrenbo f997e48a75 feat: feishu 离职卡片 + 快照 runner(快照→镜像→离职流程)
快照段(飞书→SQLite 镜像→离职双确认推进)落地,不碰 LLDAP:
- feishu 新增离职确认卡片:自定义机器人 webhook 发送 interactive 卡片,
  确认按钮 url 跳程序自己的一次性端点;本地假 webhook 单测验证载荷与错误处理
- config.ConfirmURL/ResignConfirmRoute:确认端点路由与 URL 拼接(server/syncer 共用避免循环)
- mapping.FlatDeptInfo:部门树拍平为拼音点分命名并计算 DN(纯函数),
  供快照落库持久化 pinyin_name/dn,物化与 WebUI 复用
- SnapshotRunner.RunSnapshot:拉飞书→事务写镜像→离职生命周期 reconcile
  (机器确认→token+卡片→pending_confirm;节流防刷屏;重新可见撤销并作废 token)
- 链路单测:快照落库、离职机器确认、发卡、状态机转移、节流、重新入职全部通过
2026-09-29 23:24:39 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00
2026-09-29 00:10:59 +08:00

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 指向错误租户、部门过滤写错,都会让期望状态突然大幅缩小):

  1. 默认 dry-run。命令行不加 -apply、界面不点确认,都不会写入任何内容。
  2. 停用阈值。单次停用超过 max-deactivate 条或 max-deactivate-percent 比例时拒绝执行,需 -force 或界面强制确认。
  3. 所有权隔离。非台账条目永不被修改或删除。
  4. admin 保护。admin 永不被移出分组;外部手工加入的成员只告警、不移除。
  5. 部门树建树有环保护,分组按层级排序,保证父分组先于子分组创建。

构建

宿主无需安装 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),而是在预览页告警,交由人工处理。
  • 飞书侧删除的部门不会被自动删除分组,同样只告警。
S
Description
No description provided
Readme
399 KiB
Languages
Go 87.4%
JavaScript 5.8%
HTML 4.2%
Makefile 1%
CSS 0.8%
Other 0.8%