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

261 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 包发卡片。注意确认端点必须能被公网/反代访问(见部署项)。
## 测试
```bash
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
官方镜像文档初始化):
```bash
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` 已封装。
```bash
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` 取收件地址,为空会失败。
## 代码风格
- 注释用中文,与现有代码一致。
- **注释解释「为什么」,不解释「做什么」**。多数决策反直觉(如「为什么不回落个人邮箱」
「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手
「优化」掉正确行为。不要给显而易见的代码加注释。
- 错误信息带对象标识与原因,能直接定位。
- 新增的安全约束尽量写成**结构性拦截**(代码主动检查并报错),而不只是写进文档。