# 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 布局 ``` ├── ou=people 在职用户(Authelia 的 base_dn 指向这里) │ └── uid=<邮箱前缀>,ou=people, ├── ou=Inactive 离职用户(位于 Authelia base_dn 之外 → 自动无法登录) │ └── uid=<邮箱前缀>,ou=Inactive, └── ou= 部门树 └── cn=<部门拼音>,cn=<父部门拼音>,...,ou=, ``` 部门用 `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,`。 - **更新路径绝不写 `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` 镜像): ```bash 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): > ```bash > 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」: ```bash 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`),在没有真实租户凭据的情况下验证完整链路:首次全量创建、幂等重跑、属性漂移、离职迁移、重新入职、以及护栏拦截。 ## 运行 ```bash 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`。 ## 部署 ```bash 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),而是在预览页告警,交由人工处理。 - 飞书侧删除的部门不会被自动删除分组,同样只告警。