- vendored LLDAP schema.graphql(v0.6.3),GraphQL 端点实测为 /api/graphql - 手工 GraphQL 客户端(genqlient v0.8.1 因依赖的 x/tools v0.24 无法在 Go 1.26 编译, 按计划风险节回归运行时客户端):登录 /auth/simple/login、createUser/updateUser/ deleteUser/createGroup/deleteGroup/addUserToGroup/removeUserFromGroup、ListGroups/Users、 预留 AddUserAttribute(默认不启用) - 语义对齐硬约束:updateUser 省略即不改(指针字段)、绝不写密码 - 集成冒烟(真实 LLDAP 容器):create→group→add→query→update(省略不变)→remove→delete 全通过;addUserToGroup 对重复添加报 UNIQUE 约束(非幂等),靠物化 diff 保证不重复加 - AGENTS.md 标注 lldapgql 改为手工客户端 + /api/graphql 端点
273 lines
17 KiB
Markdown
273 lines
17 KiB
Markdown
# 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 的可信度就没了。
|
||
|
||
### 迁移状态(写代码前先对号入座)
|
||
|
||
以下模块仍是 OpenLDAP 单阶段时代的旧实现,正在迁移到本文档描述的目标架构:
|
||
|
||
- `internal/ldaplib/ops.go` 的写路径(Add/Modify/ModifyDN)、`internal/password`(SSHA)、
|
||
`config.yml` 的 `user-ou / inactive-ou / group-root-name / group-object-class /
|
||
group-member-attr / initial-password` 仍是旧概念,**迁移完成前不要把它们当目标行为**。
|
||
- `internal/store` 现仅是轻量台账,尚未扩展为全字段镜像 + 部门关系表。
|
||
- `internal/feishu` 尚无卡片发送能力,离职确认通道未落地。
|
||
|
||
改代码时以本文档的硬约束为准,逐步把旧实现收敛到目标架构。
|
||
|
||
## 硬约束
|
||
|
||
### 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` 取收件地址,为空会失败。
|
||
|
||
## 代码风格
|
||
|
||
- 注释用中文,与现有代码一致。
|
||
- **注释解释「为什么」,不解释「做什么」**。多数决策反直觉(如「为什么不回落个人邮箱」
|
||
「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手
|
||
「优化」掉正确行为。不要给显而易见的代码加注释。
|
||
- 错误信息带对象标识与原因,能直接定位。
|
||
- 新增的安全约束尽量写成**结构性拦截**(代码主动检查并报错),而不只是写进文档。 |