Files
idsync/AGENTS.md
T
caowangrenbo 6d31cba631 docs: 交付收尾(README/Dockerfile/Makefile 对齐 + 移除迁移状态节)
- README 重写:两阶段架构、LDAP 读/GraphQL 写、部门索引组名、离职双确认、
  部署(起 LLDAP + Authelia 官方 lldap 模板 + 配 idsync + 构建运行)、命令、测试
- Makefile:test-integration 改指本地 LLDAP(3890/17170),新增 make lldap 起容器,
  头部注释更新为 LLDAP
- AGENTS.md:移除「迁移状态」节(迁移已完成,旧实现已删除/收敛)
2026-09-29 23:58:29 +08:00

16 KiB
Raw Permalink Blame History

AGENTS.md

给在本仓库工作的编码 agent 的指导。先读「硬约束」一节——那里列的都是破坏后不会报错、 只会静默失效的东西,是本项目最容易踩的坑。

项目定位

把飞书通讯录同步到 LLDAP,替代 go-ldap-admin。链路是两阶段:飞书 → SQLite 镜像 → LLDAP。

它不在认证路径上。 鉴权链是「业务系统 → Authelia → LLDAP bind」,本工具负责「飞书有 什么数据、LDAP 该长什么样、两者差异是什么」。它不实现认证、会话、登录页、密码重置—— 这些由 Authelia 负责。唯一的例外是管理界面自身的单管理员登录(internal/server/auth.go), 那只是保护这个界面本身,与 LDAP 用户无关。

本工具会发送的唯一对外通知是「离职人工确认」飞书卡片(走自定义机器人 webhook 发到 用户管理群),它属于同步业务流程而非认证链路,与 Authelia 无关。

判断某个需求是否属于本项目:如果它与「飞书有什么数据、LDAP 该长什么样、两者差异是什么」 有关,属于本项目;如果与「谁有权限登录」有关,不属于。

架构

飞书通讯录 ──快照(低频/可手动)──▶ SQLite 镜像 ──物化(略高频)──▶ LLDAP(只在职用户)
                                   (全字段+部门关系             │
                                    +生命周期状态)               │ 读走 LDAP / 写走 GraphQL
                                                                 ▼
                                                        Admin? 离职用户走「机器确认 +
                                                        飞书卡片人工确认(一次性端点)」后才删

数据单向流动,每一层职责单一:

包 职责 关键约束
internal/config 配置加载、默认值、校验、DN 拼接 快照/物化两套 schedule、SMTP 与 webhook 配置在此归一
internal/feishu 只读飞书:拉通讯录、确认离职、发离职确认卡片 不在这里做映射或写入决策
internal/store SQLite 镜像 + 台账 + 部门关系表 快照整体刷新、权限唯一来源
internal/mapping 镜像 → 期望 LDAP 状态(纯函数) DN/组名构造规则集中于此
internal/ldaplib LLDAP 读原语:连接/bind/搜索 不写任何属性
internal/lldapgql LLDAP 写客户端(登录 + 手工 GraphQL 客户端 + 属性 schema 引导) 见硬约束 8/9;GraphQL 端点为 /api/graphql
internal/syncer diff 引擎(纯函数)+ 执行器 + 编排 见「硬约束」
internal/server HTTP 接口 + 单管理员会话 + 一次性确认端点 确认端点不经 Auth 中间件(token 即凭证)
internal/webui go:embed 前端产物 + ETag

分层的意义:mapping 与 syncer.ComputePlan 都是纯函数——输入镜像、输出状态/计划, 不触碰 IO。因此它们能被完整单测,且 dry-run 与真正执行共用同一份代码,预览即所得。 改动它们时务必维持纯度,否则 dry-run 的可信度就没了。

硬约束

1. 真相源层级:飞书唯一,SQLite 只镜像

飞书是数据的唯一真相;SQLite 只做镜像 + 生命周期标记;LLDAP 只物化在职状态。 SQLite 永不「发明」飞书没有的数据。任何写 SQLite 的逻辑都只能是「从飞书快照写入」或 「生命周期状态转移」。禁止在 SQLite 里手改业务字段(那样会让下轮快照打回,出现幽灵漂移)。

2. 镜像刷新必须事务化

飞书全量快照要在单个事务里整体替换(用户/部门/关系三张表一致更新,或带版本号)。 快照中途失败必须回滚到上一版镜像,绝不允许 SQLite 停留在「半新半旧」状态——物化阶段读 到不一致镜像会污染 LDAP。物化与快照之间需要互斥,两者不能在单实例里并发执行。

3. LDAP 只在职;离职 = 双确认后才删

LLDAP 只有 ou=people(无 inactive OU、无禁用标志),所以「离职」= 从 LLDAP 物理删除。 在分配 confirmed_leave 状态前,任何人不得把用户移出删除保护;未确认的离职候选绝不入计划。

流程:缺席 → 飞书 is_resigned 机器确认(feishu 包,保留)→ 发飞书管理群卡片 (含一次性「确认离职」按钮,跳转确认端点)→ 确认端点校验一次性 token → 标记 confirmed_leave → 下一轮物化删除。两道闸缺一不可,任何路径都不得绕过。

  • token 用 crypto/rand 生成,存 SQLite(带过期时间,如 72h),一次使用。
  • 确认端点挂在 internal/server,不经 Auth 中间件——token 本身即凭证;点击后回渲染 一个极简确认页,不要回显用户敏感信息。
  • 用户重新在飞书可见 → 撤销 leave_candidate 回 active,未用的 token 一并作废。
  • 未确认 = 不删除(安全侧默认对)。重复提醒要有节流,防止每轮都刷屏。

飞书卡片是单向通知(程序收不到点击回执),所以确认必须落在我们自己的一次性 HTTP 端点, webhook 卡片按钮的 url 跳到它。不要尝试用卡片 callback 回执——那需要开放平台回调配置, 不值当。

4. 镜像台账 = 删除/修改权限的唯一来源

store 记录「哪些条目是本工具创建的」。任何不在镜像台账中的条目(服务账号、手工账号、 admin)在计划里永不出现。改动 diff 逻辑时不要引入「靠属性嗅探判断归属」——那不如 台账可靠。台账按期望状态整体登记,而不是只记录本次执行的操作。

5. 操作顺序是硬约束,不是偏好

syncer/plan.go 的 opPhase 决定执行顺序:分组 → 用户 → 恢复 → 加组 → 移组 → 删除。 删除永远是最后一阶段(且只处理 confirmed_leave)。同阶段内按生成顺序(Seq)而非 字母序排序(分组必须父先于子创建,曾因此出过 No Such Object)。

6. deleteUser 会级联清理成员关系

LLDAP 的 memberships 表外键是 ON DELETE CASCADE(源码已核实),删用户会自动把它从 所有组移除,不需要先移出组再删;deleteGroup 同理。但删除仍应放在最后阶段, 且执行前先读一次镜像确认该用户仍在职视图外。

7. 读走 LDAP 的视图必须与 LLDAP 一致

需要跟 LLDAP 的 LDAP 视图对齐,否则比对会静默失效:

  • 用户 objectClass 是 inetOrgPerson(person/posixAccount 为别名),Authelia 官方模板 的 users_filter 用 objectClass=person——命中,无需任何自定义属性。
  • 组对内同时响应 member 与 uniqueMember,读取两种都认(Members() 现状保留)。
  • 组 CN 即 LLDAP 的 displayName;组名唯一。
  • GraphQL 写侧只配 LLDAP 内置 addUserToGroup,不要用自定义属性模拟成员关系。

Authelia 用官方 implementation: lldap + 官方模板(users_filter、groups_filter、 attributes: username=uid / display_name=cn / mail=mail / member_of=memberOf), 不得定制。给 Authelia 建专用 bind 用户,授予 lldap_strict_readonly + lldap_password_manager,别拿 admin 给它用。

8. GraphQL 写属性:省略即不改

LLDAP 的 UpdateUserInput 语义是「省略的字段 = 不改变」。genqlient 对可选输入字段 生成指针、且 nil 会从请求中省略——这正好匹配语义,不要用显式 null 覆盖字段。

  • insertAttributes 对列表属性要全量提供整份列表;removeAttributes 只列要删的。
  • 对应旧约束「对不存在属性执行 Delete 会让整个 Modify 失败」:删除/替换前以镜像现状为准, 只处理确认存在的属性。
  • 硬字段(uid/displayName/email)与自定义属性分开传,别混淆。

9. 密码:LLDAP 独占,程序不碰

GraphQL 没有密码 mutation;LLDAP 自己管哈希。因此:

  • 创建用户不设密码,首次登录走 Authelia「忘记密码」(bind 用户需 lldap_password_manager 权限即可写)。这比旧「随机初始密码 + SSHA」简单且更安全。
  • internal/password 包与 initial-password 配置项在迁移中删除,不要复用它们。
  • 任何路径都不得尝试写密码或注入预计算的哈希。

10. 自定义属性(若启用)不得撞硬编码名

若下游系统需要通过 LDAP 读自定义属性(如 mobile),才需要 addUserAttribute 声明(幂等 引导、isList=false、editable+visible)。名称不得与 LLDAP 硬编码属性冲突(uid、mail、 cn、sn、givenName、avatar、creationDate、memberOf、objectClass、uuid 等)。 Authelia 官方模板不依赖任何自定义属性——默认不建。后续若要对接 ssh/pam(例如给用户 打「可访问哪些服务器」的范围标记)再按需用自定义属性引入,届时同步工具负责声明与维护。

11. 登录名只用企业邮箱

uid 取自 enterprise_email 的 @ 前缀。不要回落到个人邮箱:企业邮箱是组织内唯一且 受控的标识。缺少企业邮箱的用户被跳过,且必须产生告警(进 SQLite 的 skipped 记录, WebUI 要能列出来),让管理者发现有人没同步进来。

12. 组名以 dept_id 为稳定唯一标识

LLDAP 组不能嵌套(组里只能有用户),部门树在 LLDAP 中全部平铺在 ou=groups 下。 LDAP 组名直接用飞书部门 ID(cn=<dept_id>,ou=groups,...),不编码祖先路径——部门树 的父子/层级关系只维护在 SQLite 的 departments.parent_id,不从 LDAP 组名解码。

  • dept_id 全局唯一 → 组名无重名、无中文转义、无空拼音问题。
  • dept_id 不随部门名变化 → 部门改名/挪动不再改变组 DN,消除了「改名波及子孙组」的连锁问题。
  • 可读性损失由界面补偿:WebUI/日志查中文名走 SQLite,DN 不是给人拼读的载体。
  • 关联键提醒:LLDAP 在 GraphQL 里引用组用自增 int groupId,不是 displayName 也不是 dept_id。SQLite departments 表要把 dept_id ↔ (displayName, groupId) 一起持久化, 物化时经此取 groupId,不要凭名字或拼音反推。

13. 静态资源缓存需带内容指纹

webui.AssetHandler 用内容指纹做 ETag 且 no-cache,HTML 完全不缓存。不要改成单纯 max-age:前端产物内嵌在二进制里,升级后浏览器会继续用旧 JS,界面显示 undefined 等。

常见改动怎么做

同步一个新属性到 LDAP:给 feishu 领域模型加字段 → 快照落库到 SQLite 镜像列 → mapping 从镜像取 → GraphQL attributes(或绑定到硬字段)→ 物化 diff 的 attrsDiffer / describeAttrDiff 各加一条——漏了最后一步会出现「写入了但预览里看不到」。 若走自定义属性,还要在启动引导里加 addUserAttribute。

新增配置项:internal/config 结构体字段 + setDefaults + normalize 校验 → main.go 的 printEffectiveConfig 加打印 → 更新 config.example.yml。快照/物化各自的 schedule、webhook 地址、确认端点对外 URL 都在这。

新增操作类型:syncer/plan.go 加 OpKind → opPhase 定阶段 → exec.go 的 applyOne 加分支(现在走 GraphQL)→ plan_test.go 加表驱动用例。

新增 WebUI 页面:web/src/index.html 加页签与 <section> → web/src/app.js 加渲染与 switchView 分支 → 必须重新构建前端(sh scripts/build-webui.sh,用 node 容器)并 重新编译。后端接口加在 internal/server/server.go 的 routes():受管理者界面保护的加 Auth.Middleware;离职确认端点不加(token 即凭证)。

新增离职确认端点:server 路由 + SQLite 一次性 token 校验 + 回调置 confirmed_leave, 复用 feishu 包发卡片。注意确认端点必须能被公网/反代访问(见部署项)。

测试

make test              # 单元测试(无需外部依赖)
make test-integration  # 对真实 LLDAP 运行(见下)
make vet
  • internal/syncer/plan_test.go 表驱动纯函数测试,覆盖创建/更新/删除/恢复/漂移/成员增删 与安全回归。新增安全约束时请用变异验证真正会失败——写过测试不等于测试有效。
  • internal/ldaplib/integration_test.go 覆盖 DN 转义、objectClass 别名命中、 member/uniqueMember 双读等。迁移到 LLDAP 后,原 OpenLDAP 特有的断言(modrdn 移动、 SSHA 编码、「更新不碰 userPassword」)随旧实现删除。
  • internal/syncer/e2e_test.go 保留假飞书 OpenAPI 服务,无需真实租户凭据跑完整链路, 但后端要换成真 LLDAP 容器。
  • 新增重点用例:SQLite 镜像事务化(快照中途失败不污染镜像)、离职状态机(候选→待确认→ 已确认→删除)、一次性 token(用过即废、过期失效)、物化与快照互斥。

集成测试前起一个 LLDAP(端口 3890=LDAP,17170=WebUI/GraphQL;admin 口令按 LLDAP 官方镜像文档初始化):

podman run -d --name idsync-lldap \
  -e LLDAP_LDAP_BASE_DN=dc=example,dc=com \
  -e LLDAP_JWT_SECRET='<random>' -e LLDAP_LDAP_USER_PASS='<admin-pass>' \
  -p 3890:3890 -p 17170:17170 \
  lldap/lldap

已知测试缺口:internal/feishu、internal/store、internal/webui 目前没有测试, 迁移后优先补镜像与状态机的覆盖。假服务器必须模仿真实接口语义——曾有假服务器在 has_more=false 时返回空 page_token(真实接口省略该字段),导致 SDK 无限翻页。

环境

宿主没有 Go,也没有 Node。 构建都在容器内进行,Makefile 已封装。

make deps / make css / make build / make run / make shell

两个必须注意的环境细节:

  1. 容器需要 --network=host。默认网络下容器内 127.0.0.1 指向容器自身,够不到宿主 代理。Makefile 已强制。
  2. 不要用 containers.conf 的 [engine] env 全局注入代理——会拦截容器访问本地服务 (例如集成测试连宿主上的 LLDAP)。拉镜像用 podman pull --http-proxy=...。

internal/webui/dist/ 产物(go:embed 依赖)需提交进版本库;config.yml(含凭据)、 bin/、data/、backup/ 已在 .gitignore。任何时候都不要把真实凭据或导出的 LDIF 提交进版本库。

部署关键项

  • LLDAP 端口:LDAP 明文 3890、TLS 6360;到 LLDAP 的连接建议用 ldaps:// 或 StartTLS (Authelia 重置密码时新密码会经此连接)。
  • Authelia 官方模板 + 专用 bind 用户:implementation: lldap,bind 用户授予 lldap_strict_readonly + lldap_password_manager,不拿 admin。别加任何自定义 users_filter。
  • 离职卡片:配置飞书自定义机器人 webhook 地址 → 发给用户管理群;卡片按钮跳「一次性 确认端点」。确认端点对外 URL 必须可公网/反代访问(由 server 配置给出,邮件/webhook 卡片的按钮链接用它拼接)。若要写多维表格留痕,用 app 凭证 + Bitable API(可选)。
  • 两套 schedule:快照低频(默认每天一次,避开飞书频控与权限波动)、物化略高频(默认 每 2–4 小时一次)。两者都要支持 WebUI/手动触发,且不能并发跑。先观察几轮 dry-run 再从 default-dry-run: true 放开。
  • mail 必须是企业邮箱:Authelia 重置密码时从 LDAP mail 取收件地址,为空会失败。

代码风格

  • 注释用中文,与现有代码一致。
  • 注释解释「为什么」,不解释「做什么」。多数决策反直觉(如「为什么不回落个人邮箱」 「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手 「优化」掉正确行为。不要给显而易见的代码加注释。
  • 错误信息带对象标识与原因,能直接定位。
  • 新增的安全约束尽量写成结构性拦截(代码主动检查并报错),而不只是写进文档。