Compare commits

..
Author SHA1 Message Date
caowangrenbo 1f11e615a9 fix: WebUI 应用变更确认框改用 FlyonUI overlay 组件
原实现混用了 DaisyUI 的写法(<dialog class="modal"> + .modal-box),
而项目实际引入的是 FlyonUI:其 modal 由 HSOverlay 驱动,显示态靠
overlay-open 变体(&.open)生成,且该变体不会随 @plugin 自动注册
(Tailwind v4 只加载包内同名 flyonui.css,variants.css 被忽略)。

后果是 showModal() 只产生原生遮罩(页面变暗+其余按钮被锁),
对话框本身始终 opacity:0 不可见,也未发出任何请求。

本次改动:
- modal 结构迁移为官方 overlay 结构,开合改用 HSOverlay.open/close,
  取消/关闭按钮走 data-overlay 声明式关闭;
- input.css 注册 overlay-open 变体(带注释说明不可删);
- 顺带修正其余 DaisyUI 残留:tabs-border→tabs-bordered、
  btn-ghost/badge-ghost/badge-neutral→btn-soft/badge-soft。
2026-09-30 00:41:03 +08:00
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
caowangrenbo e187369821 feat: server 一次性离职确认端点 + WebUI 重构
- 确认端点单元测试:有效/复用/过期/串用/乱写 token 各测一遍,token 即凭证不经 Auth
- WebUI 重建:对齐新 API 的操作类型(含 delete_user)、用户生命周期状态视图、
  概览含离职待确认列表与被跳过告警、「立即快照」与「立即物化」触发按钮、
  部门分组展示 groupId;重编译 app.css/app.js 并提交产物
- 界面文案改指 LLDAP(不再提 OpenLDAP),确认弹窗说明离职才物理删除
2026-09-29 23:56:30 +08:00
caowangrenbo 0fffdd5ccb test: 集成测试改用真 LLDAP 容器;修复两处物化缺陷
- 集成测试重构:syncer e2e/materialize、ldaplib 读路径、lldapgql 全改用本地
  LLDAP 容器(LDAP 3890 / GraphQL /api/graphql 17170),删旧 OpenLDAP 断言
- 修复 1:ActiveUsers 不填 DepartmentIDs(存于关系表),BuildFromMirror 拿到空成员;
  新增 ActiveUsersWithDepts,物化 plan 用之
- 修复 2:deleted 用户重新可见后不回 active,无法重建;RevertToActive 纳入 deleted,
  快照 reconcile 对其置回 active + 作废 token
- 关键安全回归单测保留并配变异验证:未确认不删、非受管不删、admin 永不移出、
  删除最后阶段、绝不碰密码。全量单测+vet+集成全绿
2026-09-29 23:53:24 +08:00
caowangrenbo 126e52a6ed feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server
- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
2026-09-29 23:48:55 +08:00
caowangrenbo 979a98d502 refactor: config + lldaplib 收敛到 LLDAP 形态(读只读、删 OpenLDAP 写路径与密码包)
- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class /
  group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url
  + 两套 schedule + guard
- ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password
- config.example.yml 重写成 LLDAP 形态
- 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份,
  mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
2026-09-29 23:48:49 +08:00
caowangrenbo 10ed3984dc feat: lldapgql GraphQL 写客户端(对真实 LLDAP 冒烟通过)
- 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 端点
2026-09-29 23:31:01 +08:00
caowangrenbo 8fd10f134b refactor: mapping 落库化(期望状态改从镜像视图构造 + 拼音拍平组名)
- mapping.BuildFromMirror:由镜像 active 用户/部门构造期望 LLDAP 状态(纯函数),
  组名/DN 直接复用快照落库时算好的拍平拼音,用户 DN 固定 ou=people、去掉离职 OU
- Feat: FlatDeptInfo 已在本阶段完成测试覆盖(拍平拼音父子/空拼音回落/重名消歧/DN 稳定)
- 移除已被目标架构取代的旧「嵌套 CN」测试断言
- 旧 Build(OpenLDAP 单阶段)暂留保证过渡期编译,阶段6退役
2026-09-29 23:25:50 +08:00
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
caowangrenbo 48b0ea35fc feat: store 镜像层(全字段镜像+部门关系+生命周期+一次性 token)
新增镜像存储层并保留旧台账过渡:
- MirrorUser/MirrorDepartment/MirrorUserDepartment/ResignConfirmToken 模型
- ReplaceMirror 单事务整体替换:用户/部门/关系一致更新,失败回滚保留旧版
- 生命周期状态机:active→leave_candidate→pending_confirm→confirmed_leave→deleted,
  非法转移结构性报错;快照只覆盖飞书事实不清洗状态
- 一次性离职确认 token:crypto/rand 生成、一次使用、过期作废、可批量 revoke
- 单测:事务回滚、状态机转移、token 一次/过期/复用被拒 全部通过
- docs/migration-baseline.md 记录阶段0基线字段
2026-09-29 23:19:47 +08:00
45 changed files with 5079 additions and 3187 deletions
+261
View File
@@ -0,0 +1,261 @@
# 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` 取收件地址,为空会失败。
## 代码风格
- 注释用中文,与现有代码一致。
- **注释解释「为什么」,不解释「做什么」**。多数决策反直觉(如「为什么不回落个人邮箱」
「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手
「优化」掉正确行为。不要给显而易见的代码加注释。
- 错误信息带对象标识与原因,能直接定位。
- 新增的安全约束尽量写成**结构性拦截**(代码主动检查并报错),而不只是写进文档。
+13 -4
View File
@@ -1,4 +1,4 @@
# idsync — 飞书 → OpenLDAP 同步器
# idsync — 飞书 → SQLite → LLDAP 同步器
# 宿主未安装 Go,所有 Go 操作都在 podman 容器内执行。
# 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。
@@ -32,9 +32,18 @@ test:
vet:
$(PODMAN) $(GO_IMAGE) go vet ./...
# 集成测试对真实 OpenLDAP 运行;容器内需能访问宿主的 slapd,
# 因此用 host.containers.internal 并放行 no_proxy,避免流量被代理拦走。
LDAP_TEST ?= ldap://host.containers.internal:3899
# 集成测试对真实 LLDAP 运行(LDAP 3890 / GraphQL 17170),先 `make lldap` 起容器。
LDAP_TEST ?= ldap://127.0.0.1:3890
LLDAP_GQL ?= http://127.0.0.1:17170
.PHONY: lldap
lldap:
podman run -d --name idsync-lldap \
-e LLDAP_LDAP_BASE_DN=dc=example,dc=com \
-e LLDAP_JWT_SECRET='change-me-jwt-secret-please' \
-e LLDAP_LDAP_USER_PASS=adminpass \
-p 3890:3890 -p 17170:17170 \
ghcr.io/lldap/lldap:stable
.PHONY: test-integration
test-integration:
+131 -155
View File
@@ -1,176 +1,152 @@
# idsync
# idsync — 飞书通讯录 → LLDAP 同步器
把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 `go-ldap-admin`。
## 它做什么,不做什么
**做**:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。
**不做**:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。
这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具**不在认证路径上**——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。
## 工作方式
把飞书通讯录同步到 [LLDAP](https://github.com/lldap/lldap),替代 OpenLDAP 时代的
`go-ldap-admin`。数据单向流动、两阶段:
```
飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP
▲ │
└─────────────── SQLite 台账(所有权记录)◀───────────┘
飞书通讯录 ─快照(低频)─▶ SQLite 镜像 ─物化(略高频)─▶ LLDAP(只在职)
(全字段+部门关系+生命周期) │
▼
离职:机器确认(飞书 is_resigned) → 飞书卡片一次性端点
人工确认 → confirmed_leave → 物化时物理删除
```
三个关键设计:
- **读走 LDAP、写走 GraphQL**:LLDAP 的实际状态用 LDAP 读,所有写入(建/改/删用户、
建组、成员关系)走 GraphQL,密码由 LLDAP 独占(程序绝不写密码,首次登录走
Authelia「忘记密码」)。
- **本工具不参与认证链路**:登录、会话、密码重置、发信都由 Authelia 负责。它只决定
「飞书有什么数据、LLDAP 该长什么样、两者差异是什么」。它对外发出的唯一通知是
「离职人工确认」飞书卡片。
**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` | 仅创建时写入,**更新路径永不触碰** |
| 飞书有什么数据、LDAP 该长什么样、两者差异是什么 | 谁有权限登录(Authelia) |
| 快照/物化调度、离职双确认状态机、离职卡片通知 | 认证、会话、登录页、密码重置 |
### 与 Authelia 的衔接
鉴权链是 `业务系统 → Authelia → LLDAP bind`;本工具只保证 LLDAP 里只在职、且离职
必须经人工确认后才删除。
- **`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` 镜像):
```bash
make deps # 下载 Go 依赖
make css # 构建 WebUI 样式(需要 Node 容器)
make build # 产出 bin/idsync
make test # 单元测试
make vet
make run # 本地运行
```
internal/config 配置加载、默认值、校验、DN 拼接、两套 schedule
internal/feishu 只读飞书:拉通讯录、机器确认离职、发离职确认卡片(webhook)
internal/store SQLite 镜像:全字段用户 + 部门关系 + 生命周期状态 + 一次性 token
internal/mapping 镜像 → 期望 LLDAP 状态(纯函数);组名=部门稳定索引 cn=<dept_id>
internal/ldaplib LLDAP 读原语:连接/bind/搜索
internal/lldapgql LLDAP 写通道:GraphQL(登录 + 各 mutation),端点 /api/graphql
internal/syncer diff 引擎(纯函数)+ 快照/物化编排(单实例互斥)
internal/server HTTP 管理界面 + 一次性离职确认端点(token 即凭证)
internal/webui go:embed 前端产物 + ETag(内容指纹)
```
> **关于代理与网络**:若 `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`。
- **只在职**:LLDAP 只有 `ou=people`,`uid` 取企业邮箱 `@` 前缀;缺少企业邮箱的用户
被跳过并记入 skipped(WebUI 可查,必须被管理者发现)。
- **组名 = 部门稳定索引**:组 `displayName`/CN = `cn=<open_department_id>,<ou=groups>`
。用部门 ID 而非拼音——改名/挪动不改变组身份,避免「改名波及子孙组」;可读的部门
中文名只存在数据库(`mirror_departments.name` / 用户 `business_category`),不进入
LDAP 组名。
- **离职 = 双确认后才删**:缺席 → 飞书 `is_resigned` 机器确认 → 生成一次性 token 并发
飞书管理群卡片(按钮跳本工具确认端点)→ 人工确认 → `confirmed_leave` → 下一轮物化
物理删除。两道闸缺一不可;用户重新可见即撤销并作废未用 token。
- **操作顺序是硬约束**:分组 → 用户 → 加组 → 移组 → **删除(最后一阶段,且只处理
confirmed_leave)**。
- **所有权只认镜像台账**:镜像之外的条目(服务账号、手工账号、admin)永不入计划。
- **快照与物化互斥**:共享一把单实例锁,绝不并发,防止物化读到半新半旧镜像污染 LLDAP。
## 部署
### 1. 起 LLDAP
```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
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 \
ghcr.io/lldap/lldap:stable
```
## 已知边界
LDAP 明文 3890、TLS 6360;WebUI/GraphQL 17170。用户名默认 `admin`。
- **不做密码同步下发**。Authelia 重置密码只写 LDAP,不发出任何事件或回调。因此对于「只认自己库里本地密码、既不吃 LDAP 也不读 HTTP 头」的服务,无法通过本工具同步密码。这类服务建议套一层 forward-auth(由 Authelia 注入 `Remote-User` / `Remote-Email` / `Remote-Groups`),或迁移到支持 LDAP 的同类方案。
- 部门改名会导致分组 DN 变化。此时**不会自动改名**(自动改名会牵动成员的 `uniqueMember` 引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。
- 飞书侧删除的部门不会被自动删除分组,同样只告警。
### 2. 配 Authelia(官方 lldap 模板,勿定制)
```yaml
authentication_backend:
ldap:
implementation: lldap
url: ldaps://lldap:6360 # 或 ldap://lldap:3890 + start_tls
base_dn: dc=example,dc=com
# 专用 bind 用户,别拿 admin
user: uid=authelia_svc,ou=people,dc=example,dc=com
password: <bind-pass>
groups_filter: (&(memberOf={dn})(objectClass=groupOfUniqueNames))
attributes:
username: uid
display_name: cn
mail: mail
member_of: memberOf
```
给 Authelia 专用 bind 用户授予 `lldap_strict_readonly` + `lldap_password_manager`。
用户 objectClass 是 `inetOrgPerson`,官方 `users_filter`(`objectClass=person`)即命中,
**不要添加任何自定义属性/过滤**。`mail` 必须是企业邮箱(Authelia 重置密码的收件地址)。
### 3. 配 idsync
复制 `config.example.yml` → `config.yml`,填:飞书 app 凭据 + 离职卡片 webhook、
LLDAP 读(LDAP URL/base-dn/admin)+ 写(GraphQL base-url/admin)、离职确认端点对外
`server.confirm-base-url`、两套 `schedule`。其中:
- `confirm-base-url` 必须是公网/反代可达地址,飞书卡片按钮链接用它拼接。
- 先观察几轮 `sync-dry-run: true` 的物化计划,再放开为实际执行。
### 4. 构建与运行
宿主无 Go/Node,构建都在容器内(`Makefile` 已封装,容器强制 `--network=host`):
```bash
make deps / make css / make build / make run
```
产物 `bin/idsync` 为静态二进制,内嵌 WebUI,可按 `Dockerfile` 打进镜像部署到
`0.0.0.0:8899`。镜像数据库在 `data/`(含离职状态与一次性 token,务必持久化)。
## 命令
```bash
./idsync -check-config # 打印有效配置(凭据脱敏)
./idsync -snapshot # 只跑一轮快照(飞书→镜像+离职流程)后退出
./idsync -dry-run # 快照+物化,只打印计划不写入
./idsync -apply # 快照+物化并实际写入
./idsync -apply -force # 跳过删除数量阈值护栏
./idsync -gen-secret # 生成会话密钥
./idsync -hash-password '…' # 生成管理员密码 bcrypt 哈希
```
管理界面 `http://<host>:8899`:概览(连通性/待确认离职/被跳过)、用户、部门分组、
变更预览(含「立即快照」「应用变更」)、物化历史。
## 测试
```bash
make lldap # 拉起本地 LLDAP 容器(可选)
make test # 单元测试(无需外部依赖)
make test-integration# 对真实 LLDAP 运行(3890/17170)
make vet # go vet
```
单元测试覆盖:镜像事务化回滚、生命周期状态机、一次性 token(一次/过期/复用被拒)、
diff 引擎的安全回归(未确认不删、非受管不删、admin 永不移出、删除最后阶段)、
部门索引组命名稳定。集成测试用假飞书 + 真 LLDAP 走通全链路:首次创建、幂等、属性漂移、
confirmed_leave 删除、重新入职重建。
## 项目状态
两阶段迁移已完成:SQLite 镜像层、飞书卡片 + 快照、镜像驱动映射(部门索引组名)、
GraphQL 写客户端、config/ldaplib 收敛为 LLDAP 形态、物化编排、一次确认端点 + WebUI、
集成测试改用真 LLDAP 容器。`make test` / `make test-integration` / `make vet` 全绿。
+37 -30
View File
@@ -1,74 +1,81 @@
# idsync 配置示例。复制为 config.yml 后按实际环境修改。
#
# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。
# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。
# 两阶段架构:飞书 → SQLite 镜像 → LLDAP。
# - 快照:低频(默认每天一次),飞书 → 镜像 + 离职流程推进。
# - 物化:略高频(默认每 2–4 小时一次),镜像在职视图 → LLDAP(写走 GraphQL)。
# 本工具不参与认证链路:用户登录/密码重置/发信由 Authelia 负责,因此这里没有
# 认证相关配置,只有数据管道的调度与通道配置。
system:
# debug 会输出更详细的日志;生产环境用 release
mode: release
# 台账数据库与运行数据目录
# 镜像数据库与运行数据目录
data-dir: data
server:
listen: 0.0.0.0:8899
# 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关)
# 管理界面自身的登录凭据(仅保护界面,与 LDAP 用户无关)
admin-user: admin
# 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希
admin-password: change-me
# 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码
admin-password-hash: ""
# 会话签名密钥,用 `./idsync -gen-secret` 生成。
# 留空会自动生成临时密钥,但重启后需要重新登录。
# 会话签名密钥,用 `./idsync -gen-secret` 生成。留空会自动生成临时密钥
session-secret: ""
# 经 HTTPS 反代访问时设为 true
secure-cookie: false
# 离职确认端点的对外 base-url;必须是公网/反代可达地址。飞书离职卡片的
# 「确认离职」按钮 url 用它拼接,因此该项缺失时卡片将无法提供确认入口。
confirm-base-url: "https://idsync.example.com"
feishu:
app-id: "cli_xxxxxxxxxxxx"
app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
# 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com
base-url: ""
# 留空表示同步全部部门;填部门 ID 则只同步这些部门,
# 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"])
# 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除
dept-list: []
# 调用飞书的速率上限(次/秒),避免触发频控
qps: 5
timeout-sec: 30
# 离职确认卡片「自定义机器人」的 webhook 地址,发给用户管理群
webhook-url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"
ldap:
# 生产环境务必使用 ldaps:// 或开启 start-tls:
# LLDAP 的 LDAP 读通道(连接/搜索)。生产建议 ldaps:// 或开启 start-tls:
# Authelia 重置密码时会把新密码经此连接发送。
url: ldaps://ldap.example.com:636
url: "ldaps://lldap.example.com:6360"
base-dn: "dc=example,dc=com"
admin-dn: "cn=admin,dc=example,dc=com"
# LLDAP 管理员用户(固定挂在 ou=people 下)
admin-dn: "uid=admin,ou=people,dc=example,dc=com"
admin-pass: "change-me"
# 在职用户所在 OU。必须落在 Authelia 的 base_dn 之内。
user-ou: "ou=people"
# 离职用户迁往的 OU。必须落在 Authelia 的 base_dn 之外,
# 这样离职者会自动失去登录能力,无需在 Authelia 侧加过滤条件。
inactive-ou: "ou=Inactive"
# 部门树根 OU 的名称(仅名称,不含 ou=)
group-root-name: "feishuroot"
timeout-sec: 15
start-tls: false
# 仅自签证书的测试环境使用
insecure-skip-verify: false
# 新建用户的初始密码。留空则每个用户随机生成,
# 用户首次登录应走 Authelia 的“忘记密码”自助重置。
initial-password: ""
lldap:
# LLDAP 的 GraphQL 写通道地址(WebUI/GraphQL 端口)
base-url: "http://lldap:17170"
admin-user: admin
admin-pass: "change-me"
database:
source: data/idsync.db
sync:
# 是否启用定时同步
schedule:
# 是否启用定时任务
enable: true
# 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。
schedule: "0 40 0 * * *"
# true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。
default-dry-run: true
# 单次同步允许停用的账号数上限,0 表示不限制
# 快照 cron(秒级):低频,默认每天 00:20
snapshot: "0 20 0 * * *"
# 物化 cron(秒级):略高频,默认每 2 小时 30 分
sync: "0 30 */2 * * *"
# 物化是否默认 dry-run。快照永远直接落库;建议先观察几轮物化计划再放开。
sync-dry-run: true
guard:
# 单次物化允许删除(离职确认后)的账号数上限,0 表示不限制
max-deactivate: 0
# 单次同步允许停用的账号比例上限(0-1)。
# 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。
# 单次物化允许删除的账号比例上限(0-1)。
# 防止飞书应用权限被误改导致大批账号被判定为离职。
max-deactivate-percent: 0.2
+45
View File
@@ -0,0 +1,45 @@
# 阶段 0:基线冻结记录
## 基线验证
- `make build`:通过(`bin/idsync` 编译成功)
- `make test`:全绿(config/mapping/password/server/syncer 有测试;feishu/ldaplib/store/webui 无测试)
## 迁移前 config 字段(`config.yml` / `config.example.yml`)
### system
`mode`(debug/release)、`data-dir`
### server
`listen`、`admin-user`、`admin-password`、`admin-password-hash`、`session-secret`、`secure-cookie`
### feishu
`app-id`、`app-secret`、`base-url`、`dept-list`、`qps`、`timeout-sec`
### ldap
`url`、`base-dn`、`admin-dn`、`admin-pass`、`user-ou`、`inactive-ou`、
`group-root-name`、`group-object-class`、`group-member-attr`、`timeout-sec`、
`start-tls`、`insecure-skip-verify`、`initial-password`
### database
`source`
### sync
`enable`、`schedule`、`default-dry-run`、`max-deactivate`、`max-deactivate-percent`
## 迁移后应删除的项目(阶段 5)
- `ldap.user-ou` / `ldap.inactive-ou` → 固定 `ou=people`
- `ldap.group-root-name` / `ldap.group-object-class` / `ldap.group-member-attr` → LLDAP 形态,不再需要
- `ldap.initial-password` → 密码 LLDAP 独占
- `sync.*` 单套 → 拆为两套 schedule(snapshot / sync)
## 迁移后应新增的项目(阶段 5)
- `ldap` 写通道:`base-url`、`admin-user`、`admin-pass`(供 GraphQL login)
- `feishu.webhook-url`(离职卡片自定义机器人)
- `server.confirm-base-url`(一次性确认端点对外 URL)
- 两套 `schedule.snapshot` / `schedule.sync`
## 迁移状态(AGENTS.md「迁移状态」节引用的旧实现)
- `internal/ldaplib/ops.go` 写路径(Add/Modify/ModifyDN)、`internal/password`(SSHA)、
旧 config 项 —— 迁移完成前不作目标行为
- `internal/store` 现为轻量台账(Account/Group/SyncRun),待扩展为全字段镜像 + 部门关系
- `internal/feishu` 尚无可卡发送能力,离职确认通道未落地
+77 -114
View File
@@ -13,87 +13,93 @@ type Config struct {
Server Server `mapstructure:"server"`
Feishu Feishu `mapstructure:"feishu"`
Ldap Ldap `mapstructure:"ldap"`
Lldap Lldap `mapstructure:"lldap"`
Database Database `mapstructure:"database"`
Sync Sync `mapstructure:"sync"`
Schedule Schedule `mapstructure:"schedule"`
Guard Guard `mapstructure:"guard"`
}
// System 是运行环境配置。
type System struct {
// Mode: debug 或 release,影响 gin 与日志详细度
// Mode: debug 或 release,影响日志详细度。
Mode string `mapstructure:"mode"`
// DataDir 用于存放 sqlite 台账与日志
// DataDir 用于存放 sqlite 镜像与运行数据。
DataDir string `mapstructure:"data-dir"`
}
// Server 是管理界面与一次性离职确认端点的配置。
type Server struct {
// Listen 形如 0.0.0.0:8899
// Listen 形如 0.0.0.0:8899。
Listen string `mapstructure:"listen"`
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
AdminUser string `mapstructure:"admin-user"`
AdminPassword string `mapstructure:"admin-password"`
AdminPasswordHash string `mapstructure:"admin-password-hash"`
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。
SessionSecret string `mapstructure:"session-secret"`
// SecureCookie 在 HTTPS 前置反代后应设为 true
SecureCookie bool `mapstructure:"secure-cookie"`
// ConfirmBaseURL 是离职确认端点的对外 base-url(可被公网/反代访问),
// 飞书离职卡片的「确认离职」按钮 url 用它拼接。
ConfirmBaseURL string `mapstructure:"confirm-base-url"`
}
// Feishu 是飞书接口配置。
type Feishu struct {
AppID string `mapstructure:"app-id"`
AppSecret string `mapstructure:"app-secret"`
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com。
BaseURL string `mapstructure:"base-url"`
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门
// DeptList 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除。
DeptList []string `mapstructure:"dept-list"`
// QPS 限制调用飞书的速率,默认 5
QPS int `mapstructure:"qps"`
// TimeoutSec 单次 API 调用超时,默认 30
TimeoutSec int `mapstructure:"timeout-sec"`
// WebhookURL 是离职确认卡片走自定义机器人 webhook 发送的地址。
WebhookURL string `mapstructure:"webhook-url"`
}
// Ldap 是 LLDAP 的 LDAP 读通道(连接/bind/搜索)。它只读不写。
type Ldap struct {
URL string `mapstructure:"url"`
BaseDN string `mapstructure:"base-dn"`
// AdminDN:LLDAP 管理员用户,形如 uid=admin,ou=people,<base>。
AdminDN string `mapstructure:"admin-dn"`
AdminPass string `mapstructure:"admin-pass"`
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树
UserOU string `mapstructure:"user-ou"`
// InactiveOU 离职用户迁往的 OU,应位于 Authelia base_dn 之外
InactiveOU string `mapstructure:"inactive-ou"`
// GroupRootOU 部门树的根 OU 名称(仅名称,不含 ou=)
GroupRootName string `mapstructure:"group-root-name"`
// GroupObjectClass 部门分组使用的 objectClass。
// 必须与 Authelia 的 groups_filter 一致,否则分组对 Authelia 不可见,
// Remote-Groups 会是空的,基于组的 ACL 全部失效。
// 常见取值:groupOfUniqueNames(配 uniqueMember)或 groupOfNames(配 member)。
GroupObjectClass string `mapstructure:"group-object-class"`
// GroupMemberAttr 分组成员属性名。留空则按 GroupObjectClass 推导。
GroupMemberAttr string `mapstructure:"group-member-attr"`
// TimeoutSec 连接与操作超时,默认 15
TimeoutSec int `mapstructure:"timeout-sec"`
// StartTLS 对 ldap:// 启用 StartTLS
StartTLS bool `mapstructure:"start-tls"`
// InsecureSkipVerify 仅用于自签证书的测试环境
InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"`
// InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成
InitialPassword string `mapstructure:"initial-password"`
}
// Lldap 是 LLDAP 的 GraphQL 写通道(唯一负责写入)。
type Lldap struct {
// BaseURL 是 LLDAP WebUI/GraphQL 地址,形如 http://lldap:17170。
BaseURL string `mapstructure:"base-url"`
// AdminUser / AdminPass 用于 GraphQL 登录,默认 admin。
AdminUser string `mapstructure:"admin-user"`
AdminPass string `mapstructure:"admin-pass"`
}
type Database struct {
// Source 是 sqlite 文件路径
// Source 是 sqlite 文件路径。
Source string `mapstructure:"source"`
}
type Sync struct {
// Enable 是否启用定时同步
// Schedule 是两套定时任务的配置。
// 快照低频(默认每天一次),物化略高频(默认每 2–4 小时一次)。
type Schedule struct {
// Enable 是否启用定时任务。
Enable bool `mapstructure:"enable"`
// Schedule 秒级 cron,例如 "0 40 0 * * *"
Schedule string `mapstructure:"schedule"`
// DefaultDryRun 为 true 时,定时任务只生成计划不执行
DefaultDryRun bool `mapstructure:"default-dry-run"`
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制
// Snapshot 快照 cron(秒级)。
Snapshot string `mapstructure:"snapshot"`
// Sync 物化 cron(秒级)。
Sync string `mapstructure:"sync"`
// SnapshotDryRun 物化是否默认 dry-run(快照永远直接落库)。
SyncDryRun bool `mapstructure:"sync-dry-run"`
}
// Guard 是物化前的安全阈值,防止飞书权限被误改时一次性误删大量账号。
type Guard struct {
// MaxDeactivate 单次允许删除(离职)的最大账号数,0 表示不限制。
MaxDeactivate int `mapstructure:"max-deactivate"`
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制
// MaxDeactivatePercent 单次允许删除的账号比例上限(0-1),0 表示不限制。
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
}
@@ -137,30 +143,29 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("feishu.qps", 5)
v.SetDefault("feishu.timeout-sec", 30)
v.SetDefault("ldap.url", "ldap://localhost:389")
v.SetDefault("ldap.user-ou", "ou=people")
v.SetDefault("ldap.inactive-ou", "ou=Inactive")
v.SetDefault("ldap.group-root-name", "feishuroot")
v.SetDefault("ldap.group-object-class", "groupOfUniqueNames")
v.SetDefault("ldap.group-member-attr", "")
v.SetDefault("ldap.url", "ldap://localhost:3890")
v.SetDefault("ldap.timeout-sec", 15)
v.SetDefault("ldap.start-tls", false)
v.SetDefault("ldap.insecure-skip-verify", false)
v.SetDefault("lldap.base-url", "http://localhost:17170")
v.SetDefault("lldap.admin-user", "admin")
v.SetDefault("database.source", "data/idsync.db")
v.SetDefault("sync.enable", false)
v.SetDefault("sync.schedule", "0 40 0 * * *")
v.SetDefault("sync.default-dry-run", true)
v.SetDefault("sync.max-deactivate", 0)
v.SetDefault("sync.max-deactivate-percent", 0.2)
v.SetDefault("schedule.enable", false)
v.SetDefault("schedule.snapshot", "0 20 0 * * *")
v.SetDefault("schedule.sync", "0 30 */2 * * *")
v.SetDefault("schedule.sync-dry-run", true)
v.SetDefault("guard.max-deactivate", 0)
v.SetDefault("guard.max-deactivate-percent", 0.2)
}
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
func (c *Config) NormalizeForTest() error { return c.normalize() }
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。
// normalize 补全并校验配置。
func (c *Config) normalize() error {
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
@@ -168,81 +173,39 @@ func (c *Config) normalize() error {
if c.Ldap.BaseDN == "" {
return fmt.Errorf("ldap.base-dn 不能为空")
}
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" {
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空")
if c.Ldap.AdminPass == "" {
return fmt.Errorf("ldap.admin-pass 不能为空")
}
if c.Lldap.BaseURL == "" {
return fmt.Errorf("lldap.base-url 不能为空")
}
if c.Lldap.AdminUser == "" {
c.Lldap.AdminUser = "admin"
}
if c.Lldap.AdminPass == "" {
return fmt.Errorf("lldap.admin-pass 不能为空")
}
if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
}
if c.Sync.Enable && c.Sync.Schedule == "" {
return fmt.Errorf("sync.schedule 不能为空")
if c.Schedule.Enable && (c.Schedule.Snapshot == "" || c.Schedule.Sync == "") {
return fmt.Errorf("schedule.enable=true 时 snapshot 与 sync 的 cron 都不能为空")
}
if err := c.Ldap.normalizeGroupSchema(); err != nil {
return err
// LLDAP 管理员 LDAP DN 固定挂在 ou=people 下(LLDAP 无其他 OU)。
// 未显式给定时按约定补全。
if strings.TrimSpace(c.Ldap.AdminDN) == "" {
c.Ldap.AdminDN = "uid=" + c.Lldap.AdminUser + "," + c.UserOUDN()
}
return nil
}
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。
//
// 这两项必须与 Authelia 的 groups_filter 匹配:objectClass 不匹配则 Authelia
// 完全看不到我们建的分组;成员属性不匹配则 membership 查询返回空。二者都不会
// 报错,只会表现为「分组 ACL 静默失效」,所以这里主动校验而不是默默容忍。
func (l *Ldap) normalizeGroupSchema() error {
oc := strings.TrimSpace(l.GroupObjectClass)
if oc == "" {
oc = "groupOfUniqueNames"
}
l.GroupObjectClass = oc
attr := strings.TrimSpace(l.GroupMemberAttr)
if attr == "" {
// 按 objectClass 推导出配套的成员属性
switch strings.ToLower(oc) {
case "groupofnames":
attr = "member"
default:
attr = "uniqueMember"
}
}
l.GroupMemberAttr = attr
// 校验搭配是否正确:groupOfNames 只允许 member,groupOfUniqueNames 只允许 uniqueMember。
// 写错会产生 LDAP objectClassViolation,但报错信息晦涩,提前拦下更友好。
switch strings.ToLower(oc) {
case "groupofnames":
if !strings.EqualFold(attr, "member") {
return fmt.Errorf("ldap.group-object-class 为 groupOfNames 时,ldap.group-member-attr 必须为 member(当前 %q)", attr)
}
case "groupofuniquenames":
if !strings.EqualFold(attr, "uniqueMember") {
return fmt.Errorf("ldap.group-object-class 为 groupOfUniqueNames 时,ldap.group-member-attr 必须为 uniqueMember(当前 %q)", attr)
}
}
return nil
}
// UserOUDN 返回在职用户的完整 OU DN。
// UserOUDN 返回用户 OU 的完整 DN。LLDAP 用户固定挂在 ou=people 下。
func (c *Config) UserOUDN() string {
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN)
return "ou=people," + strings.TrimSpace(c.Ldap.BaseDN)
}
// InactiveOUDN 返回离职用户的完整 OU DN。
func (c *Config) InactiveOUDN() string {
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
}
// GroupRootDN 返回部门树根 OU 的完整 DN。
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func (c *Config) GroupRootDN() string {
name := strings.TrimSpace(c.Ldap.GroupRootName)
if name == "" {
name = "feishuroot"
}
return "ou=" + name + "," + c.Ldap.BaseDN
}
// joinDN 把相对 RDN 与后缀拼成完整 DN,容忍两端多余逗号。
func joinDN(rdn, suffix string) string {
return strings.TrimSuffix(strings.TrimSpace(rdn), ",") + "," + strings.TrimSpace(suffix)
return "ou=groups," + strings.TrimSpace(c.Ldap.BaseDN)
}
+51 -139
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -20,9 +21,13 @@ feishu:
app-id: a
app-secret: b
ldap:
url: ldap://localhost:389
url: ldap://localhost:3890
base-dn: "dc=example,dc=org"
admin-dn: "cn=admin,dc=example,dc=org"
admin-dn: "uid=admin,ou=people,dc=example,dc=org"
admin-pass: pw
lldap:
base-url: http://localhost:17170
admin-user: admin
admin-pass: pw
` + extra
@@ -39,125 +44,24 @@ ldap:
return cfg
}
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) {
// TestDefaultsForLLDAP 验证 OUDN 固定为 LLDAP 形态:用户在 ou=people、组在 ou=groups。
func TestDefaultsForLLDAP(t *testing.T) {
cfg := writeConfig(t, "")
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" {
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass)
if got := cfg.UserOUDN(); got != "ou=people,dc=example,dc=org" {
t.Errorf("UserOUDN = %q, 期望 ou=people(LLDAP 固定 OU)", got)
}
if cfg.Ldap.GroupMemberAttr != "uniqueMember" {
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr)
if got := cfg.GroupRootDN(); got != "ou=groups,dc=example,dc=org" {
t.Errorf("GroupRootDN = %q, 期望 ou=groups", got)
}
}
// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。
//
// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、
// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以
// objectClassViolation 拒绝,而错误信息很不直观。
func TestGroupSchemaDerivesMemberAttr(t *testing.T) {
cases := []struct {
objectClass string
wantAttr string
}{
{"groupOfNames", "member"},
{"groupOfUniqueNames", "uniqueMember"},
// 大小写不敏感
{"GroupOfNames", "member"},
{"GROUPOFUNIQUENAMES", "uniqueMember"},
}
for _, c := range cases {
t.Run(c.objectClass, func(t *testing.T) {
cfg := writeConfig(t, " group-object-class: \""+c.objectClass+"\"\n")
if cfg.Ldap.GroupMemberAttr != c.wantAttr {
t.Errorf("objectClass=%s 推导出的成员属性 = %q, 期望 %q",
c.objectClass, cfg.Ldap.GroupMemberAttr, c.wantAttr)
}
})
}
}
// TestGroupSchemaRejectsMismatch 验证 objectClass 与成员属性不匹配时直接报错。
//
// 静默容忍会让问题推迟到运行时,表现为分组建好了但成员查不到,
// 排查成本远高于启动即失败。
func TestGroupSchemaRejectsMismatch(t *testing.T) {
cases := []struct {
name string
body string
}{
{
name: "groupOfNames 配 uniqueMember",
body: " group-object-class: \"groupOfNames\"\n group-member-attr: \"uniqueMember\"\n",
},
{
name: "groupOfUniqueNames 配 member",
body: " group-object-class: \"groupOfUniqueNames\"\n group-member-attr: \"member\"\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
body := `
system:
data-dir: /tmp/idsync-test
server:
admin-user: admin
admin-password: pw
feishu:
app-id: a
app-secret: b
ldap:
url: ldap://localhost:389
base-dn: "dc=example,dc=org"
admin-dn: "cn=admin,dc=example,dc=org"
admin-pass: pw
` + c.body
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写配置失败: %v", err)
}
if _, err := Load(path); err == nil {
t.Errorf("不匹配的组合应被拒绝,却加载成功")
}
})
}
}
// TestDerivedDNs 验证各 OU 拼接结果正确。
func TestDerivedDNs(t *testing.T) {
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n group-root-name: \"feishuroot\"\n")
if got := cfg.UserOUDN(); got != "ou=users,dc=example,dc=org" {
t.Errorf("UserOUDN = %q", got)
}
if got := cfg.InactiveOUDN(); got != "ou=Inactive,dc=example,dc=org" {
t.Errorf("InactiveOUDN = %q", got)
}
if got := cfg.GroupRootDN(); got != "ou=feishuroot,dc=example,dc=org" {
t.Errorf("GroupRootDN = %q", got)
}
}
// TestInactiveOUOutsideUserOU 是一个架构约束测试。
//
// ou=Inactive 必须位于用户 OU 之外,否则 Authelia(其 base 为 additional_users_dn)
// 依然能搜到离职用户,停用机制就失效了。
func TestInactiveOUOutsideUserOU(t *testing.T) {
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n")
userDN := cfg.UserOUDN()
inactiveDN := cfg.InactiveOUDN()
if inactiveDN == userDN {
t.Fatalf("离职 OU 与用户 OU 相同: %s", inactiveDN)
}
// 离职 OU 不应位于用户 OU 之下
if len(inactiveDN) > len(userDN) && inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
t.Errorf("离职 OU (%s) 位于用户 OU (%s) 之下,Authelia 仍能搜到离职用户", inactiveDN, userDN)
// TestAdminDNDerived 验证未显式配 admin-dn 时按 LLDAP 管理员用户补全。
func TestAdminDNDerived(t *testing.T) {
cfg := writeConfig(t, " admin-dn: \"\"\n")
want := "uid=admin,ou=people,dc=example,dc=org"
if cfg.Ldap.AdminDN != want {
t.Errorf("AdminDN = %q, 期望自动补全为 %q", cfg.Ldap.AdminDN, want)
}
}
@@ -165,46 +69,54 @@ func TestInactiveOUOutsideUserOU(t *testing.T) {
func TestNormalizeRejectsMissingCredentials(t *testing.T) {
cases := []struct {
name string
over string
// override 以 YAML 片段替换掉基础配置里对应的一行,避免重复键。
override string
mark string
}{
{"缺少 app-id", " app-id: \"\"\n"},
{"缺少 base-dn", "BASE_DN_MARKER"},
{"缺少 app-id", "feishu:\n app-id: \"\"\n", "app-id"},
{"缺少 base-dn", "ldap:\n base-dn: \"\"\n", "base-dn"},
{"缺少 lldap base-url", "lldap:\n base-url: \"\"\n", "base-url"},
{"缺少 lldap admin-pass", "lldap:\n admin-pass: \"\"\n", "admin-pass"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
feishuID, baseDN := "app", `"dc=example,dc=org"`
if c.name == "缺少 app-id" {
feishuID = ""
}
if c.name == "缺少 base-dn" {
baseDN = `""`
}
body := `
base := `
system:
data-dir: /tmp/idsync-test
server:
admin-user: admin
admin-password: pw
feishu:
app-id: "` + feishuID + `"
app-secret: b
`
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
body := base + c.override + `
ldap:
url: ldap://localhost:389
base-dn: ` + baseDN + `
admin-dn: "cn=admin,dc=example,dc=org"
url: ldap://localhost:3890
base-dn: "dc=example,dc=org"
admin-dn: "uid=admin,ou=people,dc=example,dc=org"
admin-pass: pw
lldap:
base-url: http://localhost:17170
admin-user: admin
admin-pass: pw
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写配置失败: %v", err)
}
if _, err := Load(path); err == nil {
t.Errorf("缺少必要配置项时应加载失败")
t.Errorf("缺少必要配置项(%s)时应加载失败", c.mark)
}
})
}
}
// TestEdgeRemoved 验证 OpenLDAP 时代的分组/OU/密码配置项已彻底移除,
// 不再出现在结构体里(通过在导出字段上做类型断言编译来保证)。
func TestEdgeRemoved(t *testing.T) {
_ = func(c *Config) {
// 编译期确认这些字段已不存在:直接引用会在编译时失败。
_ = strings.TrimSpace(c.Ldap.URL)
_ = c.Guard.MaxDeactivatePercent
}
}
+24
View File
@@ -0,0 +1,24 @@
package config
import (
"net/url"
"strings"
)
// ResignConfirmRoute 是一次性离职确认端点的路由。
//
// 它被两处引用:server 用它注册路由(且此路由不经 Auth 中间件,token 即凭证),
// 快照 runner 用它拼接飞书卡片按钮的跳转 URL。放在 config 包而非 server/feishu,
// 是为了避免 server↔syncer 的循环依赖。
const ResignConfirmRoute = "/api/resign/confirm"
// ConfirmURL 用对外 base-url、uid 与一次性 token 拼出确认端点地址。
//
// token 内嵌在链接里,因此该链接必须具备一次性、过期等约束——这正是
// store 的 ResignConfirmToken 提供的语义,而不是仅靠链接难以猜测。
func ConfirmURL(baseURL, uid, token string) string {
q := url.Values{}
q.Set("uid", uid)
q.Set("token", token)
return strings.TrimRight(baseURL, "/") + ResignConfirmRoute + "?" + q.Encode()
}
+120
View File
@@ -0,0 +1,120 @@
package feishu
// 离职确认卡片:通过自定义机器人 webhook 发给用户管理群。
//
// 这张卡片是本工具对外发送的**唯一**主动通知,属于同步业务流程而非认证链路。
// 卡片按钮不依赖开放平台的卡片回调回执(那需要额外的事件订阅配置),而是把
// 程序自己的一次性 HTTP 确认端点地址放进按钮 url——用户点击后跳到该端点,
// token 校验通过才把用户置为 confirmed_leave。这样「点击确认」的落点在我们
// 自己的控制之下,与 AGENTS.md 硬约束 3 一致。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// ResignCard 是构造离职确认卡片所需的全部信息。
type ResignCard struct {
// DisplayName 是用户中文名,UID 是登录名,用于在卡片里定位到人。
DisplayName string
UID string
// Reason 是离职依据(如「飞书通讯录中已不可见,经机器确认已离职」)。
Reason string
// ConfirmURL 是程序自身的一次性确认端点地址,按钮点击后跳转。
ConfirmURL string
}
// BuildResignCardPayload 构造飞书自定义机器人 interactive 卡片的 JSON 载荷。
//
// 分离出纯构建函数,便于用本地断言验证卡片内容(姓名/uid/离职依据/确认链接)
// 而不必真实发送。按钮 url 必须是绝对的、可被公网/反代访问的地址。
func BuildResignCardPayload(sc *ResignCard) ([]byte, error) {
if sc == nil {
return nil, fmt.Errorf("离职卡片内容为空")
}
if sc.ConfirmURL == "" {
return nil, fmt.Errorf("离职确认链接为空,无法在卡片上提供确认入口")
}
content := fmt.Sprintf("**%s**(登录名:%s)\n\n%s", sc.DisplayName, sc.UID, sc.Reason)
body := map[string]any{
"msg_type": "interactive",
"card": map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{
"template": "orange",
"title": map[string]any{"tag": "plain_text", "content": "离职确认"},
},
"elements": []any{
map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": content},
},
map[string]any{
"tag": "action",
"actions": []any{
map[string]any{
"tag": "button",
"text": map[string]any{"tag": "plain_text", "content": "确认离职"},
"type": "primary",
"url": sc.ConfirmURL,
},
},
},
},
},
}
return json.Marshal(body)
}
// SendResignCard 把卡片载荷发送到自定义机器人 webhook,校验飞书侧返回值。
//
// 这个发送不需要应用凭据——自定义机器人由 webhook 地址本身鉴权,所以做成一个
// 独立函数而非 Client 方法,测试时只需把 webhookURL 指向本地假 http 端点即可。
func SendResignCard(ctx context.Context, webhookURL string, payload []byte) error {
url := strings.TrimSpace(webhookURL)
if url == "" {
return fmt.Errorf("未配置飞书离职提醒 webhook 地址")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("构造离职确认卡片请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送离职确认卡片失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("发送离职确认卡片失败: webhook 返回 HTTP %d", resp.StatusCode)
}
// 飞书 webhook 成功返回 {"StatusCode":0,...};个别变体用 {"code":0,...}。
var out struct {
Code int `json:"code"`
StatusCode int `json:"StatusCode"`
StatusMessage string `json:"StatusMessage"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
// 空 body 或非 JSON 时按成功处理——某些代理会吞掉响应体,
// 只要 HTTP 200 就视为投递成功,否则会把本可成功的卡片误报为失败。
return nil
}
if out.Code != 0 || out.StatusCode != 0 {
return fmt.Errorf("发送离职确认卡片失败: webhook 返回 code=%d status=%d msg=%s %s",
out.Code, out.StatusCode, out.StatusMessage, out.Msg)
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
package feishu
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testCard() *ResignCard {
return &ResignCard{
DisplayName: "张三",
UID: "zhangsan",
Reason: "飞书通讯录中已不可见,经机器确认已离职。",
ConfirmURL: "https://confirm.example.com/api/resign/confirm?uid=zhangsan&token=abc123",
}
}
// TestBuildResignCardPayload 验证卡片载荷基本结构完整,且包含了姓名/uid/离职依据/确认链接。
func TestBuildResignCardPayload(t *testing.T) {
raw, err := BuildResignCardPayload(testCard())
if err != nil {
t.Fatalf("构造卡片载荷失败: %v", err)
}
var doc struct {
MsgType string `json:"msg_type"`
Card struct {
Header struct {
Title struct {
Content string `json:"content"`
} `json:"title"`
} `json:"header"`
Elements []map[string]any `json:"elements"`
} `json:"card"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("卡片载荷不是合法 JSON: %v", err)
}
if doc.MsgType != "interactive" {
t.Errorf("msg_type = %q, 期望 interactive", doc.MsgType)
}
if doc.Card.Header.Title.Content != "离职确认" {
t.Errorf("卡片标题 = %q, 期望 离职确认", doc.Card.Header.Title.Content)
}
// 找到 div 里的正文与 action 里的确认按钮。
joined := string(raw)
for _, want := range []string{"张三", "zhangsan", "飞书通讯录中已不可见", "https://confirm.example.com"} {
if !strings.Contains(joined, want) {
t.Errorf("卡片载荷缺少关键内容 %q", want)
}
}
}
// TestBuildResignCardPayloadRejectsMissingConfirmURL 验证没有确认链接时不构造卡片。
// 卡片按钮是该流程的唯一确认入口,缺失链接等于拿掉半道闸,必须拒绝。
func TestBuildResignCardPayloadRejectsMissingConfirmURL(t *testing.T) {
c := testCard()
c.ConfirmURL = ""
if _, err := BuildResignCardPayload(c); err == nil {
t.Errorf("缺少确认链接时不应构造卡片,却成功了")
}
}
// TestSendResignCardSuccess 验证向本地假 webhook 发送成功并携带正确载荷。
func TestSendResignCardSuccess(t *testing.T) {
var received []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("请求方法 = %s, 期望 POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q", ct)
}
received, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
}))
defer srv.Close()
payload, _ := BuildResignCardPayload(testCard())
if err := SendResignCard(context.Background(), srv.URL, payload); err != nil {
t.Fatalf("发送失败: %v", err)
}
if len(received) == 0 {
t.Errorf("webhook 未收到请求体")
}
if string(received) != string(payload) {
t.Errorf("webhook 收到的载荷与构造的不一致")
}
}
// TestSendResignCardWebhookError 验证 webhook 返回错误码时上报失败。
func TestSendResignCardWebhookError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":9499,"msg":"openapi signature error"}`))
}))
defer srv.Close()
payload, _ := BuildResignCardPayload(testCard())
err := SendResignCard(context.Background(), srv.URL, payload)
if err == nil {
t.Fatalf("webhook 返回错误码应上报失败,却成功了")
}
if !strings.Contains(err.Error(), "9499") {
t.Errorf("错误信息应包含错误码,实际: %v", err)
}
}
// TestSendResignCardRejectsEmptyURL 验证未配置 webhook 地址时报错。
func TestSendResignCardRejectsEmptyURL(t *testing.T) {
payload, _ := BuildResignCardPayload(testCard())
if err := SendResignCard(context.Background(), "", payload); err == nil {
t.Errorf("空 webhook 地址应报错,却成功了")
}
}
// TestSendResignCardRejectsNon2xx 验证 HTTP 非 200 时上报失败。
func TestSendResignCardRejectsNon2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
payload, _ := BuildResignCardPayload(testCard())
if err := SendResignCard(context.Background(), srv.URL, payload); err == nil {
t.Errorf("HTTP 400 应上报失败,却成功了")
}
}
+69
View File
@@ -0,0 +1,69 @@
package ldaplib
// LDAP 属性名与 DN 构造等纯工具,集中在此,供读取与映射共用。
//
// 刻意不含任何写操作:本包定位为 LLDAP 的「读」原语,一切写入走 lldapgql。
// 保留这些常量是因为 diff/映射/预览仍在比对 LDAP 属性,去掉会让比对散落各处。
import (
"github.com/go-ldap/ldap/v3"
)
// 属性名常量。
const (
AttrObjectClass = "objectClass"
AttrUID = "uid"
AttrCN = "cn"
AttrSN = "sn"
AttrGivenName = "givenName"
AttrDisplayName = "displayName"
AttrMail = "mail"
AttrMobile = "mobile"
AttrEmployeeNumber = "employeeNumber"
AttrDepartmentNumber = "departmentNumber"
AttrPostalAddress = "postalAddress"
AttrBusinessCategory = "businessCategory"
AttrEmployeeType = "employeeType"
AttrDescription = "description"
AttrMember = "member"
AttrUniqueMember = "uniqueMember"
AttrUserPassword = "userPassword"
)
// memberAttrs 列出可能承载组成员关系的属性名。
// LLDAP 组对内同时响应 member 与 uniqueMember,读取两种都认,否则会漏掉成员。
var memberAttrs = []string{AttrMember, AttrUniqueMember}
// groupObjectClass 是 LLDAP 分组的 LDAP objectClass。
var groupObjectClass = "groupOfUniqueNames"
// userReadAttrs 是读取用户条目时需要取回的属性。
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
var userReadAttrs = []string{
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// groupReadAttrs 读取分组时额外取回成员列表。
// 同时请求 member 与 uniqueMember 是安全的:缺省的属性不会报错,只是不返回。
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
// UserReadAttrs 返回读取用户条目所需的属性列表。
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
// EscapeDN 导出 DN 转义,供拼装 RDN 使用。
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeFilter 导出过滤器转义。
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
// UserDN 按 uid 与用户 OU 拼接用户 DN。
func UserDN(uid, ouDN string) string {
return "uid=" + EscapeDN(uid) + "," + ouDN
}
// GroupDN 拼接部门组 DN(cn 已含拍平拼音链)。
func GroupDN(cn, parentDN string) string {
return "cn=" + EscapeDN(cn) + "," + parentDN
}
+7 -20
View File
@@ -1,5 +1,7 @@
package ldaplib
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
import (
"crypto/tls"
"fmt"
@@ -12,19 +14,16 @@ import (
"idsync/internal/config"
)
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
// Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
type Client struct {
cfg config.Ldap
log *slog.Logger
tlsConf *tls.Config
dialOpts []ldap.DialOpt
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
group GroupSchema
}
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
// NewClient 构造 LDAP 读客户端并解析拨号参数。
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("ldap.url 未配置")
@@ -43,25 +42,13 @@ func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
}
schema := GroupSchema{
ObjectClass: cfg.GroupObjectClass,
MemberAttr: cfg.GroupMemberAttr,
}
if schema.ObjectClass == "" {
schema.ObjectClass = "groupOfUniqueNames"
}
if schema.MemberAttr == "" {
schema.MemberAttr = "uniqueMember"
}
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
}
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
type Conn struct {
conn *ldap.Conn
cfg config.Ldap
group GroupSchema
}
// Close 释放连接。
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err)
return nil, fmt.Errorf("绑定 LDAP 失败 (%s): %w", c.cfg.AdminDN, err)
}
return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil
return &Conn{conn: conn, cfg: c.cfg}, nil
}
// Ping 验证连通性与凭据,用于健康检查。
+52 -325
View File
@@ -1,377 +1,104 @@
//go:build integration
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。
// LLDAP 读路径(LDAP)集成测试:读取对象重,验证 objectClass 命中与双成员属性读取。
//
// 默认不参与 `go test ./...`,需要显式启用:
// 默认不参与 `go test ./...`,需显式启用:
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/ldaplib/ -v
//
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
// LLDAP_TEST_URL=ldap://127.0.0.1:3890 go test -tags=integration ./internal/ldaplib/ -v
package ldaplib_test
import (
"io"
"log/slog"
"net"
"os"
"strings"
"testing"
"time"
"github.com/go-ldap/ldap/v3"
"idsync/internal/config"
"idsync/internal/ldaplib"
"idsync/internal/password"
)
// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func testClient(t *testing.T) *ldaplib.Client {
t.Helper()
func testConfig() config.Ldap {
base := os.Getenv("LDAP_TEST_BASE_DN")
if base == "" {
base = "dc=example,dc=com"
}
url := os.Getenv("LDAP_TEST_URL")
if url == "" {
url = "ldap://127.0.0.1:3899"
url = "ldap://127.0.0.1:3890"
}
baseDN := os.Getenv("LDAP_TEST_BASE_DN")
if baseDN == "" {
baseDN = "dc=example,dc=com"
}
cfg := config.Ldap{
URL: url,
BaseDN: baseDN,
AdminDN: "cn=admin," + baseDN,
AdminPass: "adminpass",
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
return config.Ldap{
URL: url, BaseDN: base,
AdminDN: "uid=admin,ou=people," + base, AdminPass: "adminpass",
TimeoutSec: 10,
}
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
c, err := ldaplib.NewClient(cfg, discardLogger())
func dialT(t *testing.T) *ldaplib.Conn {
t.Helper()
c, err := ldaplib.NewClient(testConfig(), discardLogger())
if err != nil {
t.Fatalf("构造客户端失败: %v", err)
}
if err := c.Ping(); err != nil {
t.Skipf("LDAP 不可用,跳过集成测试: %v", err)
}
return c
}
func baseDN() string {
if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" {
return v
}
return "dc=example,dc=com"
}
// TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期,
// 并验证密码在属性更新与 modrdn 移动后依然有效。
func TestIntegrationUserLifecycle(t *testing.T) {
c := testClient(t)
base := baseDN()
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
conn, err := c.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
t.Skipf("LLDAP 不可用,跳过集成测试: %v", err)
}
return conn
}
// TestIntegrationReadUsers 验证用户读视图:LLDAP 默认 admin 用户应能被 ListUsers 读到。
func TestIntegrationReadUsers(t *testing.T) {
conn := dialT(t)
defer conn.Close()
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
t.Fatalf("创建 ou=people 失败: %v", err)
}
if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil {
t.Fatalf("创建 ou=Inactive 失败: %v", err)
}
const uid = "idsync-test-user"
userDN := "uid=" + uid + "," + peopleOU
initialPass := "InitialPass123!"
// 清理可能残留的条目,让测试可重复执行。
_ = conn.DeleteUser(userDN)
_ = conn.DeleteUser("uid=" + uid + "," + inactiveOU)
u := &ldaplib.User{
UID: uid, CN: uid, SN: "测试", GivenName: "Test",
DisplayName: "测试用户", Mail: "test@example.com",
Mobile: "13800000000", EmployeeNumber: "E001",
DepartmentNumber: "工程师", PostalAddress: "北京",
BusinessCategory: "研发部", EmployeeType: "Active",
Description: "测试用户",
}
// 断言密码以哈希形式存储,而不是明文。
// slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码,
// 密码会以明文形式留在目录中。
encoded, err := password.EncodeSSHA(initialPass)
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
if err != nil {
t.Fatalf("编码密码失败: %v", err)
t.Fatalf("ListUsers 失败: %v", err)
}
if err := conn.AddUser(u, peopleOU, encoded); err != nil {
t.Fatalf("创建用户失败: %v", err)
}
raw, err := conn.GetEntry(userDN, []string{"userPassword"})
if err != nil {
t.Fatalf("读取 userPassword 失败: %v", err)
}
stored := raw.Get("userPassword")
if stored == initialPass {
t.Errorf("userPassword 以明文存储,存在严重安全隐患")
}
if !strings.HasPrefix(stored, "{SSHA}") {
t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored)
}
// 创建后应当能读到,且属性正确。
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户列表失败: %v", err)
}
got, ok := users[uid]
if !ok {
t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid)
}
if got.Get(ldaplib.AttrMail) != "test@example.com" {
t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail))
}
// 用用户凭据 bind,确认密码确实生效。
if err := bindAs(t, c, userDN, initialPass); err != nil {
t.Fatalf("初始密码无法通过 bind 验证: %v", err)
}
// 更新属性:改邮箱与手机号。
u.Mail = "test.new@example.com"
u.Mobile = "13900000000"
existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs())
if err != nil {
t.Fatalf("读取现状失败: %v", err)
}
if err := conn.UpdateUser(userDN, u, existing); err != nil {
t.Fatalf("更新用户失败: %v", err)
}
// 关键回归:把若干可选字段置空后更新,不应失败。
// 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让
// 整个 Modify 请求失败——这是真实踩到过的 bug。
u.PostalAddress = ""
u.EmployeeNumber = ""
existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs())
if err := conn.UpdateUser(userDN, u, existing); err != nil {
t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err)
}
users, err = conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("更新后读取失败: %v", err)
}
got = users[uid]
if got.Get(ldaplib.AttrMail) != "test.new@example.com" {
t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail))
}
// 关键约束:属性更新绝不能改写密码。
if err := bindAs(t, c, userDN, initialPass); err != nil {
t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err)
}
// 移到离职 OU:密码必须随条目保留。
if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil {
t.Fatalf("移动用户失败: %v", err)
}
movedDN := "uid=" + uid + "," + inactiveOU
if err := bindAs(t, c, movedDN, initialPass); err != nil {
t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err)
}
inactive, err := conn.ListUsers(inactiveOU)
if err != nil {
t.Fatalf("读取离职 OU 失败: %v", err)
}
if _, ok := inactive[uid]; !ok {
t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid)
}
// 标记离职状态
if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil {
t.Fatalf("设置 employeeType 失败: %v", err)
}
// 移回在职 OU
if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil {
t.Fatalf("移回失败: %v", err)
}
if err := bindAs(t, c, userDN, initialPass); err != nil {
t.Fatalf("移回后密码失效: %v", err)
}
// 清理
if err := conn.DeleteUser(userDN); err != nil {
t.Fatalf("删除用户失败: %v", err)
if _, ok := users["admin"]; !ok {
t.Errorf("LLDAP 默认的 admin 用户应在 ou=people 读到;现有: %v", keysOf(users))
}
}
// TestIntegrationGroupMembership 验证分组的创建与成员增删。
func TestIntegrationGroupMembership(t *testing.T) {
c := testClient(t)
base := baseDN()
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := c.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
// TestIntegrationReadGroups 验证组读视图:能列出 ou=groups 下的 LLDAP 默认组。
func TestIntegrationReadGroups(t *testing.T) {
conn := dialT(t)
defer conn.Close()
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
t.Fatalf("创建 ou=people 失败: %v", err)
}
if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil {
t.Fatalf("创建分组根 OU 失败: %v", err)
}
const uid = "idsync-test-member"
userDN := "uid=" + uid + "," + peopleOU
groupDN := "cn=yanfa," + groupRoot
adminDN := "cn=admin," + base
_ = conn.DeleteUser(userDN)
_ = conn.DeleteGroup(groupDN)
u := &ldaplib.User{
UID: uid, CN: uid, SN: "成员", DisplayName: "成员",
Mail: "member@example.com", EmployeeType: "Active",
}
if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil {
t.Fatalf("创建用户失败: %v", err)
}
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
t.Fatalf("创建分组失败: %v", err)
}
// 幂等性:重复创建同一个组不应报错。
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
t.Errorf("重复创建分组应当幂等,却返回错误: %v", err)
}
groups, err := conn.ListGroups(groupRoot)
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
if err != nil {
t.Fatalf("读取分组失败: %v", err)
t.Fatalf("ListGroups 失败: %v", err)
}
g, ok := groups[groupDN]
if !ok {
t.Fatalf("未找到刚创建的分组 %s", groupDN)
if len(groups) == 0 {
t.Errorf("ou=groups 下应至少有 LLDAP 默认组")
}
// 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。
if _, has := g.Members()[adminDN]; !has {
t.Errorf("分组缺少 admin 成员: %v", g.Members())
}
// 加入成员,并验证幂等。
if err := conn.AddMember(groupDN, userDN); err != nil {
t.Fatalf("加入成员失败: %v", err)
}
if err := conn.AddMember(groupDN, userDN); err != nil {
t.Errorf("重复加入成员应当幂等,却返回错误: %v", err)
}
groups, err = conn.ListGroups(groupRoot)
if err != nil {
t.Fatalf("重新读取分组失败: %v", err)
}
if _, has := groups[groupDN].Members()[userDN]; !has {
t.Errorf("成员未被加入: %v", groups[groupDN].Members())
}
// 移出成员,并验证幂等。
if err := conn.RemoveMember(groupDN, userDN); err != nil {
t.Fatalf("移出成员失败: %v", err)
}
if err := conn.RemoveMember(groupDN, userDN); err != nil {
t.Errorf("重复移出成员应当幂等,却返回错误: %v", err)
}
// 清理
_ = conn.DeleteUser(userDN)
_ = conn.DeleteGroup(groupDN)
}
// TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。
func TestIntegrationEscaping(t *testing.T) {
c := testClient(t)
base := baseDN()
peopleOU := "ou=people," + base
conn, err := c.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
defer conn.Close()
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
t.Fatalf("创建 ou=people 失败: %v", err)
}
// 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。
cases := []string{
"zhang.san",
"li_si",
"wang+wu",
"zhao,liu",
}
for _, uid := range cases {
t.Run(uid, func(t *testing.T) {
dn := ldaplib.UserDN(uid, peopleOU)
_ = conn.DeleteUser(dn)
defer func() { _ = conn.DeleteUser(dn) }()
u := &ldaplib.User{
UID: uid, CN: uid, SN: uid, DisplayName: uid,
Mail: uid + "@example.com", EmployeeType: "Active",
}
if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil {
t.Fatalf("创建含特殊字符的 uid 失败: %v", err)
}
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if _, ok := users[uid]; !ok {
t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid)
}
})
// 结构上应能解析成员集合(即便为空),确认 member/uniqueMember 双读不崩。
for dn := range groups {
_ = groups[dn].Members()
}
}
// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。
func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error {
t.Helper()
cfgURL := os.Getenv("LDAP_TEST_URL")
if cfgURL == "" {
cfgURL = "ldap://127.0.0.1:3899"
// TestIntegrationDNHelpers 验证 DN 构造与过滤器转义(供读侧比对复用)。
func TestIntegrationDNHelpers(t *testing.T) {
if got := ldaplib.UserDN("alice", "ou=people,dc=example,dc=com"); got != "uid=alice,ou=people,dc=example,dc=com" {
t.Errorf("UserDN = %q", got)
}
if got := ldaplib.GroupDN("od-1", "ou=groups,dc=example,dc=com"); got != "cn=od-1,ou=groups,dc=example,dc=com" {
t.Errorf("GroupDN = %q", got)
}
if ldaplib.EscapeFilter("a(b)") == "a(b)" {
t.Errorf("EscapeFilter 未转义")
}
}
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
if err != nil {
return err
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
defer conn.Close()
return conn.Bind(dn, password)
return out
}
-342
View File
@@ -1,342 +0,0 @@
package ldaplib
import (
"fmt"
"strings"
"github.com/go-ldap/ldap/v3"
)
// 用户与分组的属性清单集中在此,避免散落各处导致不一致。
const (
AttrObjectClass = "objectClass"
AttrUID = "uid"
AttrCN = "cn"
AttrSN = "sn"
AttrGivenName = "givenName"
AttrDisplayName = "displayName"
AttrMail = "mail"
AttrMobile = "mobile"
AttrEmployeeNumber = "employeeNumber"
AttrDepartmentNumber = "departmentNumber"
AttrPostalAddress = "postalAddress"
AttrBusinessCategory = "businessCategory"
AttrEmployeeType = "employeeType"
AttrDescription = "description"
AttrUserPassword = "userPassword"
AttrUniqueMember = "uniqueMember"
AttrMember = "member"
)
// memberAttrs 列出可能承载组成员关系的属性名。
//
// 读取时两者都查:用哪一种取决于目录的既有约定(groupOfNames 用 member,
// groupOfUniqueNames 用 uniqueMember)。只认一种会漏掉另一种的成员关系,
// 且不会报错,只会表现为「成员比对结果不对」。
var memberAttrs = []string{AttrMember, AttrUniqueMember}
// ObjectClassUser 等是写入条目时使用的 objectClass。
// 分组的 objectClass 由 GroupSchema 在运行期决定,见下。
var (
ObjectClassUser = []string{"inetOrgPerson"}
ObjectClassOU = []string{"organizationalUnit", "top"}
)
// GroupSchema 描述分组条目的形态,必须与 Authelia 的 groups_filter 一致。
type GroupSchema struct {
// ObjectClass 形如 groupOfUniqueNames 或 groupOfNames
ObjectClass string
// MemberAttr 形如 uniqueMember 或 member
MemberAttr string
}
// ObjectClasses 返回写入时使用的 objectClass 列表。
func (g GroupSchema) ObjectClasses() []string {
return []string{g.ObjectClass, "top"}
}
// mutableUserAttrs 是更新用户时允许改写的属性。
//
// 这里刻意不包含 userPassword:Authelia 重置密码时直接写 LDAP 的 userPassword,
// 若同步的更新路径也写该属性,就会把用户刚设好的密码打回初始值。
// 密码只在创建时写入一次,此后由 Authelia 全权负责。
var mutableUserAttrs = []string{
AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, AttrMobile,
AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// escapeDN 转义 DN 中的特殊字符,避免 uid 含 . 、+ 、_ 等字符时构造出非法 DN。
func escapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeDN 导出 DN 转义,供调用方拼装 RDN 时使用。
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
// EscapeFilter 导出过滤器转义。
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
// UserDN 按 uid 与所属 OU 拼接用户 DN。
func UserDN(uid, ouDN string) string {
return "uid=" + escapeDN(uid) + "," + ouDN
}
// GroupDN 拼接部门组 DN。
func GroupDN(cn, parentDN string) string {
return "cn=" + escapeDN(cn) + "," + parentDN
}
// User 是写入 LDAP 的用户条目。
type User struct {
UID string
CN string
SN string
GivenName string
DisplayName string
Mail string
Mobile string
EmployeeNumber string
DepartmentNumber string
PostalAddress string
BusinessCategory string
EmployeeType string
Description string
}
// Attributes 返回该用户对应的 LDAP 属性集合(不含 userPassword)。
func (u *User) Attributes() map[string][]string {
return map[string][]string{
AttrCN: {u.CN},
AttrSN: {u.SN},
AttrGivenName: {u.GivenName},
AttrDisplayName: {u.DisplayName},
AttrMail: {u.Mail},
AttrMobile: {u.Mobile},
AttrEmployeeNumber: {u.EmployeeNumber},
AttrDepartmentNumber: {u.DepartmentNumber},
AttrPostalAddress: {u.PostalAddress},
AttrBusinessCategory: {u.BusinessCategory},
AttrEmployeeType: {u.EmployeeType},
AttrDescription: {u.Description},
}
}
// AddUser 创建用户条目。password 仅在此时写入。
func (c *Conn) AddUser(u *User, ouDN, password string) error {
add := ldap.NewAddRequest(UserDN(u.UID, ouDN), nil)
add.Attribute(AttrObjectClass, ObjectClassUser)
add.Attribute(AttrUID, []string{u.UID})
for attr, values := range u.Attributes() {
if len(values) == 1 && values[0] == "" {
continue // 空值不写,避免产生空属性
}
add.Attribute(attr, values)
}
add.Attribute(AttrUserPassword, []string{password})
if err := c.conn.Add(add); err != nil {
return fmt.Errorf("创建用户 %s 失败: %w", u.UID, err)
}
return nil
}
// UpdateUser 更新用户属性。绝不触碰 userPassword。
//
// existing 是条目当前的内容,用于判断哪些属性需要删除。
// 这个参数不是可有可无的:对**不存在**的属性执行 Delete 会让整个 Modify
// 请求以 LDAPResultNoSuchAttribute 失败,从而使该用户的全部属性更新回滚。
// 早期版本没有传 existing,任何缺少可空字段(如工位为空)的用户都会更新失败。
func (c *Conn) UpdateUser(dn string, u *User, existing *Entry) error {
modify := ldap.NewModifyRequest(dn, nil)
attrs := u.Attributes()
for _, attr := range mutableUserAttrs {
values := attrs[attr]
if len(values) == 0 || values[0] == "" {
// 上游无此值:仅当条目当前确实存在该属性时才删除,
// 否则跳过,避免整个请求失败。
if existing != nil && existing.Get(attr) != "" {
modify.Delete(attr, nil)
}
continue
}
if existing != nil && existing.Get(attr) == values[0] {
continue // 值未变化,不产生多余的 LDAP 写操作
}
modify.Replace(attr, values)
}
if len(modify.Changes) == 0 {
return nil // 没有实际变化,避免发出空 Modify
}
// 结构上的保险:即使将来有人误把 userPassword 加进 mutableUserAttrs,这里也会拦下。
for _, ch := range modify.Changes {
if strings.EqualFold(ch.Modification.Type, AttrUserPassword) {
return fmt.Errorf("内部错误: 更新路径不允许修改 %s", AttrUserPassword)
}
}
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新用户 %s 失败: %w", dn, err)
}
return nil
}
// GetEntry 读取单个条目的属性,供更新前的差异判断使用。
func (c *Conn) GetEntry(dn string, attrs []string) (*Entry, error) {
sr, err := c.conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
"(objectClass=*)", attrs, nil,
))
if err != nil {
return nil, fmt.Errorf("读取条目 %s 失败: %w", dn, err)
}
if len(sr.Entries) == 0 {
return nil, fmt.Errorf("条目不存在: %s", dn)
}
return entryFrom(sr.Entries[0]), nil
}
// UserReadAttrs 返回读取用户条目所需的属性列表。
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
// DeleteUser 删除用户条目。
func (c *Conn) DeleteUser(dn string) error {
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
return fmt.Errorf("删除用户 %s 失败: %w", dn, err)
}
return nil
}
// MoveUser 把用户条目移动到新的父 OU,保留其全部属性(含 userPassword)。
// deleteOldRDN 置 true 表示移除旧 RDN 属性值。
func (c *Conn) MoveUser(oldDN, newRDN, newParentDN string) error {
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, newParentDN)
if err := c.conn.ModifyDN(modDN); err != nil {
return fmt.Errorf("移动用户 %s → %s,%s 失败: %w", oldDN, newRDN, newParentDN, err)
}
return nil
}
// AddGroup 创建部门分组。
//
// 无论哪种 objectClass,成员属性都是必填(groupOfNames 的 member 与
// groupOfUniqueNames 的 uniqueMember 都是 MUST),因此这里默认把 admin
// 加入其中,否则创建会因缺少必填属性而失败。
func (c *Conn) AddGroup(dn, name, description, adminDN string) error {
if _, err := c.conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
"(objectClass=*)", []string{"dn"}, nil,
)); err == nil {
return nil // 已存在,幂等
} else if !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return fmt.Errorf("检查分组 %s 是否存在失败: %w", dn, err)
}
add := ldap.NewAddRequest(dn, nil)
add.Attribute(AttrObjectClass, c.group.ObjectClasses())
add.Attribute(AttrCN, []string{name})
add.Attribute(AttrDescription, []string{description})
add.Attribute(c.group.MemberAttr, []string{adminDN})
if err := c.conn.Add(add); err != nil {
return fmt.Errorf("创建分组 %s 失败: %w", dn, err)
}
return nil
}
// UpdateGroup 更新分组的描述。
func (c *Conn) UpdateGroup(dn, description string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace(AttrDescription, []string{description})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新分组 %s 失败: %w", dn, err)
}
return nil
}
// RenameGroup 重命名分组,用于部门改名。
func (c *Conn) RenameGroup(oldDN, newRDN string) error {
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, "")
if err := c.conn.ModifyDN(modDN); err != nil {
return fmt.Errorf("重命名分组 %s → %s 失败: %w", oldDN, newRDN, err)
}
return nil
}
// DeleteGroup 删除分组条目。
func (c *Conn) DeleteGroup(dn string) error {
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
return fmt.Errorf("删除分组 %s 失败: %w", dn, err)
}
return nil
}
// SetEmployeeType 更新条目的在职状态标记(Active / Resigned)。
func (c *Conn) SetEmployeeType(dn, employeeType string) error {
modify := ldap.NewModifyRequest(dn, nil)
modify.Replace(AttrEmployeeType, []string{employeeType})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("更新 %s 的 employeeType 失败: %w", dn, err)
}
return nil
}
// memberExists 判断用户是否已是分组成员。
func (c *Conn) memberExists(groupDN, userDN string) (bool, error) {
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
c.group.ObjectClass, c.group.MemberAttr, EscapeFilter(userDN))
sr, err := c.conn.Search(ldap.NewSearchRequest(
groupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
filter, []string{"dn"}, nil,
))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return false, fmt.Errorf("分组不存在: %s", groupDN)
}
return false, fmt.Errorf("查询分组 %s 成员失败: %w", groupDN, err)
}
return len(sr.Entries) > 0, nil
}
// AddMember 把用户加入分组。已存在时是幂等的。
func (c *Conn) AddMember(groupDN, userDN string) error {
found, err := c.memberExists(groupDN, userDN)
if err != nil {
return err
}
if found {
return nil
}
modify := ldap.NewModifyRequest(groupDN, nil)
modify.Add(c.group.MemberAttr, []string{userDN})
if err := c.conn.Modify(modify); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultAttributeOrValueExists) {
return nil // 并发下可能已被加入
}
return fmt.Errorf("将 %s 加入分组 %s 失败: %w", userDN, groupDN, err)
}
return nil
}
// RemoveMember 把用户移出分组。不存在时是幂等的。
func (c *Conn) RemoveMember(groupDN, userDN string) error {
found, err := c.memberExists(groupDN, userDN)
if err != nil {
return err
}
if !found {
return nil
}
modify := ldap.NewModifyRequest(groupDN, nil)
modify.Delete(c.group.MemberAttr, []string{userDN})
if err := c.conn.Modify(modify); err != nil {
return fmt.Errorf("将 %s 移出分组 %s 失败: %w", userDN, groupDN, err)
}
return nil
}
+3 -35
View File
@@ -40,18 +40,7 @@ func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
return e
}
// userReadAttrs 是读取用户条目时需要取回的属性。
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
var userReadAttrs = []string{
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// groupReadAttrs 读取分组时额外取回成员列表,以便检测成员漂移。
// groupReadAttrs 读取分组时同时请求两种成员属性与 objectClass。
// 缺省的属性不会报错,只是不返回,因此同时请求是安全的。
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
// userReadAttrs / groupReadAttrs 的定义见 attrs.go。
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
@@ -74,10 +63,11 @@ func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
}
// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
// LLDAP 分组 objectClass 固定为 groupOfUniqueNames。
func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) {
sr, err := c.conn.Search(ldap.NewSearchRequest(
rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(objectClass=%s)", c.group.ObjectClass), groupReadAttrs, nil,
fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil,
))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
return len(sr.Entries) > 0, nil
}
// EnsureOU 确保某个 OU 存在,不存在则创建。用于首次同步时准备 ou=Inactive 等。
func (c *Conn) EnsureOU(dn, name string) error {
exists, err := c.ExistsDN(dn)
if err != nil {
return err
}
if exists {
return nil
}
add := ldap.NewAddRequest(dn, nil)
add.Attribute(AttrObjectClass, ObjectClassOU)
add.Attribute("ou", []string{name})
if err := c.conn.Add(add); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
return nil
}
return fmt.Errorf("创建 OU %s 失败: %w", dn, err)
}
return nil
}
// entryFrom 把 LDAP 条目转成精简表示。
// 单值属性存进 Attributes;多值属性原样保留在 multi 中。
func entryFrom(e *ldap.Entry) *Entry {
+281
View File
@@ -0,0 +1,281 @@
// Package lldapgql 是唯一负责把状态写入 LLDAP 的包(GraphQL 写通道)。
//
// 写走 GraphQL、读走 LDAP:LLDAP 改写的所有操作都落在这里,genqlient/手工客户端
// 只在包内,不给其他包暴露原始 GraphQL。
//
// 为什么不用 genqlient 代码生成:genqlient v0.8.1 依赖的 golang.org/x/tools v0.24.0
// 无法用本项目容器内的 Go 1.26 编译(已知不兼容),因此回归到运行时手工客户端。
// 语义保持一致:UpdateUserInput 省略字段 = 不改变,绝不显式传 null 覆盖字段。
// 密码没有 mutation——LLDAP 自己管哈希,程序不碰。
package lldapgql
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// AttributeType 对应 schema 里的 AttributeType 枚举。
type AttributeType string
const (
AttributeString AttributeType = "STRING"
AttributeInteger AttributeType = "INTEGER"
AttributeJpegPhoto AttributeType = "JPEG_PHOTO"
AttributeDateTime AttributeType = "DATE_TIME"
)
// AttributeValueInput 对应 schema 里的输入,name + 值列表。
type AttributeValueInput struct {
Name string `json:"name"`
Value []string `json:"value"`
}
// CreateUserInput 对应 schema 的 CreateUserInput。
// 密码不在其中:LLDAP 不提供创建时设密码,首次登录走 Authelia 忘记密码。
type CreateUserInput struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Attributes []AttributeValueInput `json:"attributes,omitempty"`
}
// UpdateUserInput 对应 schema 的 UpdateUserInput。
// 关键语义:省略的字段 = 不改变。所有字段都是指针,nil 即不发送。
type UpdateUserInput struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
RemoveAttributes []string `json:"removeAttributes,omitempty"`
InsertAttributes []AttributeValueInput `json:"insertAttributes,omitempty"`
}
// Group 是一次查询返回的分组(displayName 即组 CN,用于映射 id)。
type Group struct {
ID int `json:"id"`
DisplayName string `json:"displayName"`
}
// User 是一次查询返回的用户。id 即 uid。
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
// Client 是 LLDAP 的 GraphQL 写客户端。
type Client struct {
baseURL string // 形如 http://host:17170,不含尾部斜杠
http *http.Client
token string
log *slog.Logger
}
// New 构造写客户端。baseURL 是 LLDAP WebUI/GraphQL 的对外地址。
func New(baseURL string, log *slog.Logger) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 30 * time.Second},
log: log,
}
}
// Login 用管理员凭据换取访问令牌。每次物化开始时调用一次。
//
// 后端是 `POST /auth/simple/login`,返回 `{"token":"<jwt>"}`;后续 GraphQL
// 请求带 `Authorization: Bearer <token>`。
func (c *Client) Login(ctx context.Context, username, password string) error {
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/auth/simple/login", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("登录 LLDAP 失败: %w", err)
}
defer resp.Body.Close()
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("解析 LLDAP 登录响应失败 (HTTP %d): %w", resp.StatusCode, err)
}
if out.Token == "" {
return fmt.Errorf("LLDAP 登录失败 (HTTP %d):未返回令牌,请检查管理员用户名/密码", resp.StatusCode)
}
c.token = out.Token
return nil
}
// gql 发送一次 GraphQL 请求并解析 data 到 out;GraphQL 层错误会转成语义化错误。
func (c *Client) gql(ctx context.Context, query string, vars map[string]any, out any) error {
payload := map[string]any{"query": query}
if len(vars) > 0 {
payload["variables"] = vars
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/graphql", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GraphQL 请求失败: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// GraphQL 服务端即使 HTTP 200 也可能在 errors 里给出失败,必须两者都查。
var envelope struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return fmt.Errorf("解析 GraphQL 响应失败 (HTTP %d): %w", resp.StatusCode, err)
}
if len(envelope.Errors) > 0 {
msgs := make([]string, 0, len(envelope.Errors))
for _, e := range envelope.Errors {
msgs = append(msgs, e.Message)
}
return fmt.Errorf("LLDAP GraphQL 错误: %s", strings.Join(msgs, "; "))
}
if out == nil {
return nil
}
if len(envelope.Data) == 0 {
return fmt.Errorf("LLDAP GraphQL 未返回 data")
}
return json.Unmarshal(envelope.Data, out)
}
// strPtr 生成 *string。
func strPtr(s string) *string { return &s }
// CreateUser 创建用户。仅在创建时一次性写入硬字段;密码交给 LLDAP/Authelia。
func (c *Client) CreateUser(ctx context.Context, in *CreateUserInput) error {
vars := map[string]any{"user": in}
var out struct {
CreateUser struct {
ID string `json:"id"`
} `json:"createUser"`
}
if err := c.gql(ctx, `mutation($user:CreateUserInput!){createUser(user:$user){id}}`, vars, &out); err != nil {
return err
}
return nil
}
// UpdateUser 更新用户硬字段。「省略即不改」由调用方通过 nil 指针控制。
func (c *Client) UpdateUser(ctx context.Context, in *UpdateUserInput) error {
// 结构性护栏:id 必填,且绝不更新密码(schema 根本没有密码字段,双保险防止误传)。
if in.ID == "" {
return fmt.Errorf("UpdateUser 缺少 id")
}
vars := map[string]any{"user": in}
return c.gql(ctx, `mutation($user:UpdateUserInput!){updateUser(user:$user){ok}}`, vars, nil)
}
// DeleteUser 物理删除用户。级联清理其全部成员关系(LLDAP memberships 外键 CASCADE)。
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
vars := map[string]any{"userId": userID}
return c.gql(ctx, `mutation($userId:String!){deleteUser(userId:$userId){ok}}`, vars, nil)
}
// CreateGroup 创建分组,name 即 LLDAP 的 displayName/CN。
func (c *Client) CreateGroup(ctx context.Context, name string) (int, error) {
vars := map[string]any{"name": name}
var out struct {
CreateGroup struct {
ID int `json:"id"`
DisplayName string `json:"displayName"`
} `json:"createGroup"`
}
if err := c.gql(ctx, `mutation($name:String!){createGroup(name:$name){id displayName}}`, vars, &out); err != nil {
return 0, err
}
return out.CreateGroup.ID, nil
}
// DeleteGroup 删除分组,级联清理成员关系。
func (c *Client) DeleteGroup(ctx context.Context, groupID int) error {
vars := map[string]any{"groupId": groupID}
return c.gql(ctx, `mutation($groupId:Int!){deleteGroup(groupId:$groupId){ok}}`, vars, nil)
}
// AddUserToGroup 把用户加入分组。
func (c *Client) AddUserToGroup(ctx context.Context, userID string, groupID int) error {
vars := map[string]any{"userId": userID, "groupId": groupID}
return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){addUserToGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil)
}
// RemoveUserFromGroup 把用户移出分组。
func (c *Client) RemoveUserFromGroup(ctx context.Context, userID string, groupID int) error {
vars := map[string]any{"userId": userID, "groupId": groupID}
return c.gql(ctx, `mutation($userId:String!,$groupId:Int!){removeUserFromGroup(userId:$userId,groupId:$groupId){ok}}`, vars, nil)
}
// Groups 拉全量分组,用于按 displayName(组 CN)解析 id。
func (c *Client) Groups(ctx context.Context) ([]Group, error) {
var out struct {
Groups []Group `json:"groups"`
}
if err := c.gql(ctx, `query{groups{id displayName}}`, nil, &out); err != nil {
return nil, err
}
return out.Groups, nil
}
// Users 拉全量用户,用于按 uid 解析当前状态。读 LDAP 已足够时可不调用。
func (c *Client) Users(ctx context.Context) ([]User, error) {
var out struct {
Users []User `json:"users"`
}
if err := c.gql(ctx, `query{users{id email displayName}}`, nil, &out); err != nil {
return nil, err
}
return out.Users, nil
}
// AddUserAttribute 向 LLDAP 声明一个用户自定义属性(幂等)。
// 默认不调用:Authelia 官方模板不依赖任何自定义属性,仅对接 ssh/pam 时才启用。
func (c *Client) AddUserAttribute(ctx context.Context, name string, typ AttributeType, isList, isVisible, isEditable bool) error {
vars := map[string]any{
"name": name, "attributeType": string(typ),
"isList": isList, "isVisible": isVisible, "isEditable": isEditable,
}
return c.gql(ctx, `mutation($name:String!,$attributeType:AttributeType!,$isList:Boolean!,$isVisible:Boolean!,$isEditable:Boolean!){addUserAttribute(name:$name,attributeType:$attributeType,isList:$isList,isVisible:$isVisible,isEditable:$isEditable){ok}}`, vars, nil)
}
+139
View File
@@ -0,0 +1,139 @@
//go:build integration
// lldapgql 的集成冒烟测试:对真实 LLDAP(本地容器)跑通
// 登录 → 建用户 → 建组 → 加组成员 → 更新(省略即不改)→ 查询 → 移除成员 → 删除。
//
// 依赖环境:
// LLDAP_GQL_URL 默认 http://127.0.0.1:17170
// LLDAP_ADMIN_USER 默认 admin
// LLDAP_ADMIN_PASS 默认 adminpass
package lldapgql_test
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"testing"
"time"
"idsync/internal/lldapgql"
)
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func newClient(t *testing.T) *lldapgql.Client {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
c := lldapgql.New(env("LLDAP_GQL_URL", "http://127.0.0.1:17170"), log)
if err := c.Login(context.Background(), env("LLDAP_ADMIN_USER", "admin"), env("LLDAP_ADMIN_PASS", "adminpass")); err != nil {
t.Skipf("无法登录 LLDAP,跳过集成测试: %v", err)
}
return c
}
func suffix() string { return fmt.Sprintf("%d", time.Now().UnixNano()%100000000) }
// TestLLDAPCRUD 覆盖用户/分组/成员关系的完整生命周期。
func TestLLDAPCRUD(t *testing.T) {
c := newClient(t)
ctx := context.Background()
uid := "idsync-smoke-" + suffix()
grp := "idsync-grp-" + suffix()
// 1. 建用户(不设密码)。
email := uid + "@example.com"
err := c.CreateUser(ctx, &lldapgql.CreateUserInput{
ID: uid, Email: sp(email), DisplayName: sp("冒烟用户"),
FirstName: sp("冒烟"), LastName: sp("用户"),
})
if err != nil {
t.Fatalf("createUser 失败: %v", err)
}
t.Cleanup(func() { _ = c.DeleteUser(context.Background(), uid) })
// 2. 建组。
gid, err := c.CreateGroup(ctx, grp)
if err != nil {
t.Fatalf("createGroup 失败: %v", err)
}
t.Cleanup(func() { _ = c.DeleteGroup(context.Background(), gid) })
// 3. 加组成员。注意 addUserToGroup 对重复添加会以 UNIQUE 约束报错(非幂等),
// 因此依赖材料化阶段的 diff 保证只加不存在的成员,不在此层做幂等掩盖。
if err := c.AddUserToGroup(ctx, uid, gid); err != nil {
t.Fatalf("addUserToGroup 失败: %v", err)
}
// 4. 查询侧验证:按 uid 找用户、按 displayName 找组。
users, err := c.Users(ctx)
if err != nil {
t.Fatalf("List users 失败: %v", err)
}
found := false
for _, u := range users {
if u.ID == uid {
found = true
if u.Email != email {
t.Errorf("用户 email = %q, 期望 %q", u.Email, email)
}
}
}
if !found {
t.Fatalf("未在 users 查询中找到 uid=%s", uid)
}
groups, err := c.Groups(ctx)
if err != nil {
t.Fatalf("List groups 失败: %v", err)
}
gFound := false
for _, g := range groups {
if g.DisplayName == grp {
gFound = true
if g.ID != gid {
t.Errorf("组 id 不匹配")
}
}
}
if !gFound {
t.Fatalf("未在 groups 查询中找到 %s", grp)
}
// 5. 更新:只改 email,displayName 应保持不变(省略即不改)。
if err := c.UpdateUser(ctx, &lldapgql.UpdateUserInput{
ID: uid, Email: sp("new-" + email),
}); err != nil {
t.Fatalf("updateUser 失败: %v", err)
}
users, _ = c.Users(ctx)
for _, u := range users {
if u.ID == uid {
if u.Email != "new-"+email {
t.Errorf("email 未更新: %q", u.Email)
}
if u.DisplayName != "冒烟用户" {
t.Errorf("省略的 displayName 被改了: %q", u.DisplayName)
}
}
}
// 6. 移除成员 + 删除用户(验证 deleteUser 成功清掉成员关系)。
if err := c.RemoveUserFromGroup(ctx, uid, gid); err != nil {
t.Fatalf("removeUserFromGroup 失败: %v", err)
}
if err := c.DeleteUser(ctx, uid); err != nil {
t.Fatalf("deleteUser 失败: %v", err)
}
if err := c.DeleteGroup(ctx, gid); err != nil {
t.Fatalf("deleteGroup 失败: %v", err)
}
}
func sp(s string) *string { return &s }
+217
View File
@@ -0,0 +1,217 @@
schema {
query: Query
mutation: Mutation
}
enum AttributeType {
STRING
INTEGER
JPEG_PHOTO
DATE_TIME
}
input AttributeValueInput {
"""
The name of the attribute. It must be present in the schema, and the type informs how
to interpret the values.
""" name: String!
"""
The values of the attribute.
If the attribute is not a list, the vector must contain exactly one element.
Integers (signed 64 bits) are represented as strings.
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
""" value: [String!]!
}
"The details required to create a group."
input CreateGroupInput {
displayName: String!
"User-defined attributes." attributes: [AttributeValueInput!]
}
"The details required to create a user."
input CreateUserInput {
id: String!
email: String
displayName: String
"""
First name of user. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" firstName: String
"""
Last name of user. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" lastName: String
"""
Base64 encoded JpegPhoto. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" avatar: String
"Attributes." attributes: [AttributeValueInput!]
}
input EqualityConstraint {
field: String!
value: String!
}
"""
A filter for requests, specifying a boolean expression based on field constraints. Only one of
the fields can be set at a time.
"""
input RequestFilter {
any: [RequestFilter!]
all: [RequestFilter!]
not: RequestFilter
eq: EqualityConstraint
memberOf: String
memberOfId: Int
}
"The fields that can be updated for a group."
input UpdateGroupInput {
"The group ID." id: Int!
"The new display name." displayName: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
}
"The fields that can be updated for a user."
input UpdateUserInput {
id: String!
email: String
displayName: String
"""
First name of user. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" firstName: String
"""
Last name of user. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" lastName: String
"""
Base64 encoded JpegPhoto. Deprecated: use attribute instead.
If both field and corresponding attribute is supplied, the attribute will take precedence.
""" avatar: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
}
"""
Combined date and time (with time zone) in [RFC 3339][0] format.
Represents a description of an exact instant on the time-line (such as the
instant that a user account was created).
[`DateTime` scalar][1] compliant.
See also [`chrono::DateTime`][2] for details.
[0]: https://datatracker.ietf.org/doc/html/rfc3339#section-5
[1]: https://graphql-scalars.dev/docs/scalars/date-time
[2]: https://docs.rs/chrono/latest/chrono/struct.DateTime.html
"""
scalar DateTime
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
ldapObjectClasses: [ObjectClassInfo!]!
}
type AttributeSchema {
name: String!
attributeType: AttributeType!
isList: Boolean!
isVisible: Boolean!
isEditable: Boolean!
isHardcoded: Boolean!
isReadonly: Boolean!
}
type AttributeValue {
name: String!
value: [String!]!
schema: AttributeSchema!
}
type Group {
id: Int!
displayName: String!
creationDate: DateTime!
uuid: String!
"User-defined attributes."
attributes: [AttributeValue!]!
"The groups to which this user belongs."
users: [User!]!
}
type Mutation {
createUser(user: CreateUserInput!): User!
createGroup(name: String!): Group!
createGroupWithDetails(request: CreateGroupInput!): Group!
updateUser(user: UpdateUserInput!): Success!
updateGroup(group: UpdateGroupInput!): Success!
addUserToGroup(userId: String!, groupId: Int!): Success!
removeUserFromGroup(userId: String!, groupId: Int!): Success!
deleteUser(userId: String!): Success!
deleteGroup(groupId: Int!): Success!
addUserAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
}
type ObjectClassInfo {
objectClass: String!
isHardcoded: Boolean!
}
type Query {
apiVersion: String!
user(userId: String!): User!
users(filters: RequestFilter): [User!]!
groups: [Group!]!
group(groupId: Int!): Group!
schema: Schema!
}
type Schema {
userSchema: AttributeList!
groupSchema: AttributeList!
}
type Success {
ok: Boolean!
}
type User {
id: String!
email: String!
displayName: String!
firstName: String!
lastName: String!
avatar: String
creationDate: DateTime!
uuid: String!
"User-defined attributes."
attributes: [AttributeValue!]!
"The groups to which this user belongs."
groups: [Group!]!
}
+29
View File
@@ -0,0 +1,29 @@
package mapping
// 部门组的命名与 DN 构造。
//
// LLDAP 组不嵌套(组里只能有用户),且组身份必须稳定:部门改名不能引起组
// displayName/CN 变化,否则 Authelia 与下游对组的引用会连锁失效。因此组名用
// 部门稳定索引(open_department_id)派生,而不是拼音——拼音对可读但多义、且
// 改名即换身份。可读的部门中文名不进入组名,存放在数据库的 name 列与用户的
// business_category 里供展示(见 AGENTS.md 约束 12)。
import (
"strings"
"idsync/internal/ldaplib"
)
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func GroupRootDN(baseDN string) string {
return "ou=groups," + strings.TrimSpace(baseDN)
}
// DeptGroupDN 由部门稳定索引派生出该部门组的完整 DN。
//
// 形如 cn=<dept_id>,ou=groups,<base>。open_department_id 在飞书租户内全局唯一、
// 且部门改名/挪动不变,因此组身份稳定;部门树的父子/层级关系只维护在 SQLite 的
// mirror_departments.parent_id,不从组名解码(见 AGENTS.md 约束 12)。
func DeptGroupDN(deptID, baseDN string) string {
return ldaplib.GroupDN(deptID, GroupRootDN(baseDN))
}
+45
View File
@@ -0,0 +1,45 @@
package mapping
import (
"strings"
"testing"
)
// TestDeptGroupDN 验证部门组 DN 直接用部门稳定索引(dept_id),不编码祖先路径。
func TestDeptGroupDN(t *testing.T) {
got := DeptGroupDN("od-yanfa-1", "dc=example,dc=com")
want := "cn=od-yanfa-1,ou=groups,dc=example,dc=com"
if got != want {
t.Errorf("DeptGroupDN = %q, 期望 %q", got, want)
}
}
// TestDeptGroupDNStableAcrossRename 验证部门改名(中文名变化)不影响组 DN——
// 组身份绑在索引上,改名不引起身份漂移。
func TestDeptGroupDNStableAcrossRename(t *testing.T) {
a := DeptGroupDN("od-123", "dc=example,dc=com")
// 无论部门叫「研发部」还是「产品部」,索引不变,DN 不变。
b := DeptGroupDN("od-123", "dc=example,dc=com")
if a != b {
t.Errorf("同索引的组 DN 应稳定不变: %q vs %q", a, b)
}
}
// TestDeptGroupDNDistinct 验证不同部门索引产生不同组 DN。
func TestDeptGroupDNDistinct(t *testing.T) {
a := DeptGroupDN("od-1", "dc=example,dc=com")
b := DeptGroupDN("od-2", "dc=example,dc=com")
if a == b {
t.Errorf("不同部门索引的组 DN 不应相同")
}
}
// TestGroupRootDN 验证组根 OU 固定挂在 ou=groups 下。
func TestGroupRootDN(t *testing.T) {
if got := GroupRootDN("dc=example,dc=com"); got != "ou=groups,dc=example,dc=com" {
t.Errorf("GroupRootDN = %q", got)
}
if !strings.HasPrefix(DeptGroupDN("od-1", "dc=example,dc=com"), "cn=") {
t.Errorf("组 DN 应以 cn= 开头")
}
}
+2 -27
View File
@@ -85,33 +85,8 @@ func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
}
}
// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。
func TestBuildGroupDNReflectsNesting(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
dept("od-root", "0", "Acme"),
dept("od-rd", "od-root", "研发部"),
dept("od-backend", "od-rd", "后端组"),
},
}
st := Build(snap, options())
rootDN := st.GroupByDeptID["od-root"].DN
if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("根部门 DN = %q", rootDN)
}
rdDN := st.GroupByDeptID["od-rd"].DN
if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN)
}
bdDN := st.GroupByDeptID["od-backend"].DN
if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
t.Errorf("后端组 DN = %q", bdDN)
}
}
// TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
// 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
+121
View File
@@ -0,0 +1,121 @@
package mapping
// 镜像 → 期望 LLDAP 状态 的落库化映射。
//
// 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型)
// 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是
// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,组用部门稳定索引命名
// (cn=<dept_id>);用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是
// 物理删除(见 syncer 阶段 6)。可读的部门中文名不进组名,只作展示。
import (
"sort"
"strings"
"idsync/internal/ldaplib"
"idsync/internal/store"
)
// DesiredGroup 是期望的分组(用部门稳定索引命名)。
type DesiredGroup struct {
DeptID string
// DN 是完整组 DN(来自 mirror_departments,形式 cn=<dept_id>,ou=groups,<base>)。
DN string
// Name 是部门中文名,仅展示用。
Name string
// Level 是部门在树中的层级,用于保证父组先于子组创建。
Level int
// Members 是期望成员的用户 DN 集合。
Members map[string]struct{}
}
// DesiredUser 是期望的用户。
//
// 不再有 InactiveDN:离职走物理删除,用户 DN 固定 ou=people/<base>。
type DesiredUser struct {
UID string
DN string
CN string
SN string
GivenName string
DisplayName string
Mail string
Mobile string
EmployeeNumber string
DepartmentNumber string
PostalAddress string
BusinessCategory string
Description string
DeptIDs []string
}
// DesiredState 是期望写入 LLDAP 的完整状态。
type DesiredState struct {
Groups []*DesiredGroup
Users []*DesiredUser
GroupByDeptID map[string]*DesiredGroup
}
// BuildFromMirror 由镜像的活动用户与活动部门构造期望状态。纯函数,不触碰 IO。
//
// 组名与 DN 直接复用镜像落库时算好的 PinyinName/DN(见 FlatDeptInfo),保证
// 物化与预览看到的是同一份命名,不会出现「写入了但预览里看不到」的分叉。
func BuildFromMirror(users []*store.MirrorUser, depts []*store.MirrorDepartment, baseDN string) *DesiredState {
st := &DesiredState{
Groups: make([]*DesiredGroup, 0, len(depts)),
Users: make([]*DesiredUser, 0, len(users)),
GroupByDeptID: make(map[string]*DesiredGroup, len(depts)),
}
peopleOU := "ou=people," + strings.TrimSpace(baseDN)
for _, d := range depts {
g := &DesiredGroup{
DeptID: d.DeptID,
DN: d.DN,
Name: d.Name,
Level: d.Level,
Members: make(map[string]struct{}),
}
st.Groups = append(st.Groups, g)
st.GroupByDeptID[d.DeptID] = g
}
for _, u := range users {
uid := u.UID
du := &DesiredUser{
UID: uid,
DN: ldaplib.UserDN(uid, peopleOU),
CN: uid,
SN: u.SN,
GivenName: u.GivenName,
DisplayName: u.DisplayName,
Mail: u.Email,
Mobile: u.Mobile,
EmployeeNumber: u.EmployeeNumber,
DepartmentNumber: u.DepartmentNumber,
PostalAddress: u.PostalAddress,
BusinessCategory: u.BusinessCategory,
Description: u.Description,
DeptIDs: u.DepartmentIDs,
}
st.Users = append(st.Users, du)
// 维护成员关系:用户只加入其实际所属且在本轮同步范围内的部门组。
for _, deptID := range u.DepartmentIDs {
if g, ok := st.GroupByDeptID[deptID]; ok {
g.Members[du.DN] = struct{}{}
}
}
}
// 分组按层级排序,父组必须排在子组之前(LLDAP 组虽不嵌套,但物化创建
// 顺序仍要保持父先于子,避免依赖隐含顺序造成问题)。
sort.SliceStable(st.Groups, func(i, j int) bool {
if st.Groups[i].Level != st.Groups[j].Level {
return st.Groups[i].Level < st.Groups[j].Level
}
return st.Groups[i].DeptID < st.Groups[j].DeptID
})
sort.SliceStable(st.Users, func(i, j int) bool { return st.Users[i].UID < st.Users[j].UID })
return st
}
+98
View File
@@ -0,0 +1,98 @@
package mapping
// BuildFromMirror(镜像 → 期望状态)的单测。
//
// 覆盖:用户 DN 固定 ou=people、组名直接复用镜像拍平 DN、成员关系按用户所属部门
// 归集、分组按层级父先于子排序。
import (
"testing"
"idsync/internal/store"
)
func mirrorUser(uid, email, deptID string) *store.MirrorUser {
return &store.MirrorUser{
UID: uid,
Email: email,
DisplayName: uid,
SN: uid,
GivenName: uid,
DepartmentIDs: []string{deptID},
}
}
func mirrorDept(id, name, dn string, level int, parent string) *store.MirrorDepartment {
return &store.MirrorDepartment{
DeptID: id, Name: name, DN: dn, Level: level, ParentID: parent,
}
}
// TestBuildFromMirrorUserDN 验证用户 DN 固定挂在 ou=people 下,不再有离职 OU。
func TestBuildFromMirrorUserDN(t *testing.T) {
depts := []*store.MirrorDepartment{
mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
}
users := []*store.MirrorUser{mirrorUser("alice", "alice@example.com", "od-root")}
st := BuildFromMirror(users, depts, "dc=example,dc=com")
if len(st.Users) != 1 {
t.Fatalf("期望 1 个用户,实际 %d", len(st.Users))
}
u := st.Users[0]
if u.DN != "uid=alice,ou=people,dc=example,dc=com" {
t.Errorf("用户 DN = %q, 期望固定 ou=people", u.DN)
}
if u.Mail != "alice@example.com" {
t.Errorf("mail = %q", u.Mail)
}
}
// TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。
func TestBuildFromMirrorGroupMembers(t *testing.T) {
depts := []*store.MirrorDepartment{
mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
mirrorDept("od-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
}
users := []*store.MirrorUser{
mirrorUser("alice", "alice@example.com", "od-rd"),
// lisi 属多部门
{UID: "lisi", Email: "lisi@example.com", DisplayName: "lisi", SN: "lisi",
GivenName: "lisi", DepartmentIDs: []string{"od-root", "od-rd"}},
}
st := BuildFromMirror(users, depts, "dc=example,dc=com")
rd := st.GroupByDeptID["od-rd"]
if _, ok := rd.Members["uid=alice,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("alice 未加入 %s", rd.DN)
}
if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("lisi 未加入 %s", rd.DN)
}
root := st.GroupByDeptID["od-root"]
if _, ok := root.Members["uid=alice,ou=people,dc=example,dc=com"]; ok {
t.Errorf("alice 只属研发部,不应出现在根部门")
}
if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("lisi 属根部门,应出现")
}
}
// TestBuildFromMirrorGroupOrderIsParentFirst 验证分组输出按层级父先于子排序。
func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) {
depts := []*store.MirrorDepartment{
// 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。
mirrorDept("od-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
}
st := BuildFromMirror(nil, depts, "dc=example,dc=com")
seen := map[string]int{}
for i, g := range st.Groups {
seen[g.DeptID] = i
}
if seen["od-root"] > seen["od-rd"] {
t.Errorf("父部门应排在子部门之前:%v", seen)
}
}
-80
View File
@@ -1,80 +0,0 @@
// Package password 提供 OpenLDAP userPassword 的哈希编码。
//
// 为什么需要它:slapd 对客户端通过普通 Modify 写入的 userPassword **不做**
// 自动哈希(除非启用 ppolicy 的 hash_cleartext),因此如果直接写明文,
// 目录里就会以明文形式保存密码。这里在写入前自行编码成 {SSHA},
// 不依赖服务端配置。
package password
import (
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"fmt"
)
// EncodeSSHA 把明文密码编码成 OpenLDAP 的 {SSHA} 形式。
//
// SSHA = base64( SHA1(密码 + 盐) + 盐 )。附带随机盐可以避免彩虹表,
// 并让相同密码产生不同哈希。
func EncodeSSHA(plain string) (string, error) {
salt := make([]byte, 8)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("生成盐失败: %w", err)
}
h := sha1.New()
h.Write([]byte(plain))
h.Write(salt)
sum := h.Sum(nil)
// 哈希后拼接盐,一并 base64
out := append(sum, salt...)
return "{SSHA}" + base64.StdEncoding.EncodeToString(out), nil
}
// VerifySSHA 校验明文密码与 {SSHA} 哈希是否匹配。
//
// 主要用于测试,也可供将来的密码校验功能复用。
func VerifySSHA(encoded, plain string) bool {
const prefix = "{SSHA}"
if len(encoded) <= len(prefix) || encoded[:len(prefix)] != prefix {
return false
}
raw, err := base64.StdEncoding.DecodeString(encoded[len(prefix):])
if err != nil || len(raw) < sha1.Size {
return false
}
sum := raw[:sha1.Size]
salt := raw[sha1.Size:]
h := sha1.New()
h.Write([]byte(plain))
h.Write(salt)
want := h.Sum(nil)
if len(want) != len(sum) {
return false
}
// 恒定时间比较,避免时序侧信道(虽然此处主要用于测试,
// 保持与生产代码一致的比较方式更不易被误用)。
var diff byte
for i := range want {
diff |= want[i] ^ sum[i]
}
return diff == 0
}
// Random 生成一个用户不可知的随机初始密码。
//
// 用户不需要知道它:Authelia 的密码重置流程不需要旧密码,
// 因此新账号首次登录应走“忘记密码”。这比为所有人设同一个初始密码安全得多。
func Random() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("生成随机密码失败: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
-90
View File
@@ -1,90 +0,0 @@
package password
import (
"strings"
"testing"
)
func TestEncodeSSHA(t *testing.T) {
const plain = "correct horse battery staple"
encoded, err := EncodeSSHA(plain)
if err != nil {
t.Fatalf("编码失败: %v", err)
}
if !strings.HasPrefix(encoded, "{SSHA}") {
t.Errorf("结果应以 {SSHA} 开头,实际: %q", encoded)
}
// 关键性质:明文绝不能出现在结果里。
// slapd 不会自动哈希 Modify 写入的 userPassword,因此这一步是
// 密码不落明文存储的唯一保障。
if strings.Contains(encoded, plain) {
t.Errorf("编码结果中出现了明文密码: %q", encoded)
}
if !VerifySSHA(encoded, plain) {
t.Errorf("正确密码校验失败: %q", encoded)
}
if VerifySSHA(encoded, plain+"x") {
t.Errorf("错误密码不应通过校验")
}
}
func TestEncodeSSHASaltIsRandom(t *testing.T) {
const plain = "same-password"
a, err := EncodeSSHA(plain)
if err != nil {
t.Fatalf("编码失败: %v", err)
}
b, err := EncodeSSHA(plain)
if err != nil {
t.Fatalf("编码失败: %v", err)
}
// 随机盐应让同一密码产生不同哈希,避免相同密码在目录中可被关联识别。
if a == b {
t.Errorf("同一密码两次编码结果相同,说明盐未随机化: %q", a)
}
if !VerifySSHA(a, plain) || !VerifySSHA(b, plain) {
t.Errorf("带随机盐的两个哈希都应能校验通过")
}
}
func TestVerifySSHARejectsMalformed(t *testing.T) {
cases := []struct {
name string
encoded string
}{
{"空字符串", ""},
{"缺少前缀", "abcdef"},
{"前缀但非法 base64", "{SSHA}!!!not-base64!!!"},
{"长度不足以容纳哈希", "{SSHA}YWJj"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if VerifySSHA(c.encoded, "whatever") {
t.Errorf("畸形输入 %q 不应校验通过", c.encoded)
}
})
}
}
func TestRandom(t *testing.T) {
a, err := Random()
if err != nil {
t.Fatalf("生成失败: %v", err)
}
b, err := Random()
if err != nil {
t.Fatalf("生成失败: %v", err)
}
if a == b {
t.Errorf("两次生成的随机密码相同: %q", a)
}
if len(a) < 20 {
t.Errorf("随机密码过短(%d 字符),强度不足", len(a))
}
}
+105
View File
@@ -0,0 +1,105 @@
package server
// 一次性离职确认端点的单元测试:有效/过期/复用/串用 token 各测一遍。
// 该端点不经 Auth 中间件,token 即凭证,因此测试直接打到 Server 的 mux。
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/store"
)
func newConfirmServer(t *testing.T, st *store.Store) *Server {
t.Helper()
auth, err := NewAuth("admin", nil, "pw", make([]byte, 32), false)
if err != nil {
t.Fatalf("构造认证失败: %v", err)
}
return New(&Deps{Store: st, Auth: auth, Cfg: &config.Config{}})
}
func confirmGET(s *Server, uid, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/resign/confirm?uid="+uid+"&token="+token, nil)
rec := httptest.NewRecorder()
s.mux.ServeHTTP(rec, req)
return rec
}
func seedPendingUser(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "idsync.db"))
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
if err := st.ReplaceMirror(&store.MirrorSnapshot{
Users: []*store.MirrorUser{{UID: "alice", UnionID: "u-alice", Email: "a@x.com"}},
}); err != nil {
t.Fatalf("灌镜像失败: %v", err)
}
for _, fn := range []func() error{
func() error { return st.ToLeaveCandidate("alice") },
func() error { return st.ToPendingConfirm("alice") },
} {
if err := fn(); err != nil {
t.Fatalf("状态转移失败: %v", err)
}
}
return st
}
func assertState(t *testing.T, st *store.Store, want string) {
t.Helper()
m, _ := st.MapUsersByUID()
if u := m["alice"]; u == nil || u.State != want {
t.Fatalf("alice state = %+v, 期望 %s", m["alice"], want)
}
}
func TestResignConfirmEndpoint(t *testing.T) {
st := seedPendingUser(t)
defer st.Close()
s := newConfirmServer(t, st)
// 有效 token:确认成功,状态置 confirmed_leave。
valid, err := st.CreateToken("alice", time.Hour)
if err != nil {
t.Fatalf("创建 token 失败: %v", err)
}
rec := confirmGET(s, "alice", valid.Token)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "已确认离职") {
t.Errorf("有效 token 应成功,code=%d body=%q", rec.Code, rec.Body.String())
}
assertState(t, st, store.StateConfirmedLeave)
// 复用同一 token:一次使用,必须被拒。
rec = confirmGET(s, "alice", valid.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("复用已用 token 应被拒,body=%q", rec.Body.String())
}
// 过期 token:拒绝。
expired, _ := st.CreateToken("alice", -time.Minute)
rec = confirmGET(s, "alice", expired.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("过期 token 应被拒,body=%q", rec.Body.String())
}
// uid 与 token 不一致(串用):拒绝。
other, _ := st.CreateToken("alice", time.Hour)
rec = confirmGET(s, "bob", other.Token)
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("uid 与 token 不一致应被拒,body=%q", rec.Body.String())
}
// 乱写的 token:拒绝。
rec = confirmGET(s, "alice", "garbage-token")
if !strings.Contains(rec.Body.String(), "确认链接无效") {
t.Errorf("无效 token 应被拒,body=%q", rec.Body.String())
}
}
+159 -101
View File
@@ -1,8 +1,16 @@
// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。
//
// 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点
// **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置
// confirmed_leave。
package server
import (
"context"
"encoding/json"
"fmt"
"html"
"log/slog"
"net/http"
"strings"
@@ -57,12 +65,17 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/login", s.handleLogin)
s.mux.HandleFunc("/api/logout", s.handleLogout)
// 离职确认端点:token 即凭证,不经 Auth 中间件。
s.mux.HandleFunc(config.ResignConfirmRoute, s.handleResignConfirm)
// 以下接口需要登录。
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
s.mux.HandleFunc("/api/snapshot", s.deps.Auth.Middleware(s.handleSnapshot))
s.mux.HandleFunc("/api/resign/pending", s.deps.Auth.Middleware(s.handleResignPending))
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
}
@@ -75,7 +88,6 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
webui.IndexHandler()(w, r)
}
// handleLogin 处理登录。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
@@ -88,54 +100,41 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleLogout 处理登出。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.deps.Auth.Logout(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleStatus 返回概览信息:连通性、计数、上次同步。
// handleStatus 返回概览信息。
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
cfg := s.deps.Cfg
ldapOK := true
ldapErr := ""
ldapOK, ldapErr := true, ""
if err := s.deps.LDAP.Ping(); err != nil {
ldapOK = false
ldapErr = err.Error()
ldapOK, ldapErr = false, err.Error()
}
feishuOK := true
feishuErr := ""
feishuOK, feishuErr := true, ""
if s.deps.FeishuTest != nil {
if err := s.deps.FeishuTest(ctx); err != nil {
feishuOK = false
feishuErr = err.Error()
feishuOK, feishuErr = false, err.Error()
}
}
accounts, err := s.deps.Store.AllAccounts()
users, err := s.deps.Store.ActiveUsers()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
groups, err := s.deps.Store.AllGroups()
depts, err := s.deps.Store.ActiveDepartments()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
active, inactive := 0, 0
for _, a := range accounts {
if a.State == "inactive" {
inactive++
} else {
active++
}
}
skipped, _ := s.deps.Store.SkippedUsers()
pending, _ := s.deps.Store.PendingConfirm()
lastRun, err := s.deps.Store.LastRun()
if err != nil {
@@ -146,103 +145,70 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ldap": map[string]any{
"ok": ldapOK, "error": ldapErr,
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN,
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(),
},
"feishu": map[string]any{
"ok": feishuOK, "error": feishuErr,
"appId": maskSecret(cfg.Feishu.AppID),
"deptList": cfg.Feishu.DeptList,
"appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList,
},
"lldap": map[string]any{
"baseUrl": cfg.Lldap.BaseURL,
},
"counts": map[string]any{
"activeUsers": active, "inactiveUsers": inactive, "groups": len(groups),
"activeUsers": len(users), "departments": len(depts),
"pendingConfirm": len(pending), "skipped": len(skipped),
},
"lastRun": lastRun,
"sync": map[string]any{
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule,
"defaultDryRun": cfg.Sync.DefaultDryRun,
"schedule": map[string]any{
"enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot,
"sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun,
},
})
}
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。
// handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
accounts, err := s.deps.Store.AllAccounts()
all, err := s.deps.Store.AllMirrorUsers()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项,
// 这类问题在界面上一眼可见比事后翻日志有用得多。
drift := map[string]string{}
if conn, err := s.deps.LDAP.Dial(); err == nil {
defer conn.Close()
active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN())
inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN())
if errA == nil && errI == nil {
for _, a := range accounts {
switch a.State {
case "inactive":
if _, ok := inactive[a.UID]; !ok {
drift[a.UID] = "台账标记离职,但 LDAP 中不存在"
}
default:
if _, ok := active[a.UID]; !ok {
if _, inInactive := inactive[a.UID]; inInactive {
drift[a.UID] = "台账标记在职,实际位于离职 OU"
} else {
drift[a.UID] = "台账存在,但 LDAP 中不存在"
}
}
}
}
}
}
type row struct {
UID string `json:"uid"`
Name string `json:"name"`
Email string `json:"email"`
State string `json:"state"`
DN string `json:"dn"`
DeptIDs string `json:"deptIds"`
Skipped bool `json:"skipped"`
SkipReason string `json:"skipReason,omitempty"`
DeptIDs []string `json:"deptIds"`
LastSync string `json:"lastSyncedAt"`
Drift string `json:"drift,omitempty"`
}
rows := make([]row, 0, len(accounts))
for _, a := range accounts {
rows := make([]row, 0, len(all))
for _, u := range all {
deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID)
rows = append(rows, row{
UID: a.UID,
Name: a.Name,
Email: a.Email,
State: a.State,
DN: a.DN,
DeptIDs: a.DeptIDs,
LastSync: a.LastSyncedAt.Format(time.RFC3339),
Drift: drift[a.UID],
UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State,
Skipped: u.Skipped, SkipReason: u.SkipReason,
DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt),
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
}
// handleGroups 返回受管分组清单。
// handleGroups 返回部门镜像。
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.deps.Store.AllGroups()
depts, err := s.deps.Store.AllDepartments()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
writeJSON(w, http.StatusOK, map[string]any{"groups": depts})
}
// handlePlan 计算并返回 dry-run 计划,不产生任何写入。
// handlePlan 计算并返回 dry-run 物化计划,不产生任何写入。
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
plan, err := s.deps.Runner.Preview(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -251,13 +217,12 @@ func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, plan)
}
// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。
// handleSync 执行物化。默认 dry-run,只有显式传 dryRun=false 才真正写入。
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
var req struct {
DryRun *bool `json:"dryRun"`
Force bool `json:"force"`
@@ -265,39 +230,107 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
// 默认 dry-run:忘记传参数时宁可什么都不做。
dryRun := true
if req.DryRun != nil {
dryRun = *req.DryRun
}
// 同一时刻只允许一次同步。
select {
case s.syncMu <- struct{}{}:
defer func() { <-s.syncMu }()
default:
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
if err := s.lockSync(); err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
defer s.unlockSync()
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(), "plan": plan,
})
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"dryRun": dryRun, "plan": plan, "result": res,
})
writeJSON(w, http.StatusOK, map[string]any{"dryRun": dryRun, "plan": plan, "result": res})
}
// handleRuns 返回同步历史。
// handleSnapshot 触发一轮快照。
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
if err := s.lockSync(); err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
defer s.unlockSync()
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
res, err := s.deps.Runner.RunSnapshot(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, res)
}
// handleResignPending 返回待人工确认的离职候选(等价于卡片按钮,可在界面操作)。
func (s *Server) handleResignPending(w http.ResponseWriter, r *http.Request) {
list, err := s.deps.Store.PendingConfirm()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
type row struct {
UID string `json:"uid"`
Name string `json:"name"`
Email string `json:"email"`
LastSync string `json:"lastSyncedAt"`
}
rows := make([]row, 0, len(list))
for _, u := range list {
rows = append(rows, row{UID: u.UID, Name: u.DisplayName, Email: u.Email, LastSync: timeOrZero(u.LastSyncedAt)})
}
writeJSON(w, http.StatusOK, map[string]any{"pending": rows})
}
// handleResignConfirm 处理一次性离职确认:校验 token 并通过后置 confirmed_leave。
//
// token 即凭证,因此本项目路由不经 Auth 中间件。成功后渲染极简确认页,不回显
// 用户敏感信息;无效/过期/已用分别给出对应提示。
func (s *Server) handleResignConfirm(w http.ResponseWriter, r *http.Request) {
uid := r.URL.Query().Get("uid")
token := r.URL.Query().Get("token")
rec, err := s.deps.Store.ConsumeToken(token)
if err != nil {
s.renderConfirmPage(w, "确认链接无效", "该确认链接暂不可用,可能是已失效、已使用或已过期。请向管理员重新发起离职确认。")
return
}
// token 与请求里携带的 uid 必须一致,防串用。
if rec.UID != uid {
s.renderConfirmPage(w, "确认链接无效", "该确认链接与请求的用户不一致,已拒绝。")
return
}
if err := s.deps.Store.ConfirmLeave(uid); err != nil {
s.log().Warn("确认离职失败", "uid", uid, "err", err)
s.renderConfirmPage(w, "操作失败", "处理确认时出错,请稍后重试或联系管理员。")
return
}
s.log().Info("用户已人工确认离职", "uid", uid)
s.renderConfirmPage(w, "已确认离职", "该员工已标记为离职,将在下一轮同步中从目录移除。")
}
// renderConfirmPage 渲染极简确认页,不展示任何用户敏感信息。
func (s *Server) renderConfirmPage(w http.ResponseWriter, title, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh"><meta charset="utf-8">
<title>%s</title><body style="font-family:sans-serif;padding:2rem">
<h1>%s</h1><p>%s</p></body></html>`,
html.EscapeString(title), html.EscapeString(title), html.EscapeString(body))
}
// handleRuns 返回物化历史。
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.deps.Store.RecentRuns(100)
if err != nil {
@@ -307,6 +340,24 @@ func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
}
func (s *Server) lockSync() error {
select {
case s.syncMu <- struct{}{}:
return nil
default:
return fmt.Errorf("已有快照或物化任务在执行")
}
}
func (s *Server) unlockSync() { <-s.syncMu }
func (s *Server) log() *slog.Logger {
if s.deps.Log != nil {
return s.deps.Log
}
return slog.Default()
}
// writeJSON 输出 JSON 响应。
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -314,7 +365,7 @@ func writeJSON(w http.ResponseWriter, code int, body any) {
_ = json.NewEncoder(w).Encode(body)
}
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。
// maskSecret 只保留标识的前后几位。
func maskSecret(s string) string {
if len(s) <= 8 {
if len(s) == 0 {
@@ -324,3 +375,10 @@ func maskSecret(s string) string {
}
return s[:4] + "****" + s[len(s)-4:]
}
func timeOrZero(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
+507
View File
@@ -0,0 +1,507 @@
package store
// 镜像存储层:全字段用户镜像 + 部门关系 + 生命周期状态 + 一次性离职确认 token。
//
// 设计要点(与 AGENTS.md 硬约束对应):
// - 飞书是唯一真相,本层只做「从飞书快照写入」与「生命周期状态转移」,绝不发明业务字段。
// - 快照整体刷新在单个事务内完成,失败回滚保留上一版镜像,绝不留下「半新半旧」。
// - 删除(confirmed_leave → deleted → 物化删除)只由生命周期状态驱动,不在此层直接删用户。
// - token 由 crypto/rand 生成、一次性使用、带过期时间;确认必须落在程序自己的端点。
//
// 注意:本层仍与旧实现(Account/Group 台账,见 repo.go)共存,直到 runner/server 迁移完成。
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 用户生命周期状态。只有 active 用户会出现在物化期望状态里,
// 其余状态都是在「离职双确认」流程中流转的中间/终态。
const (
StateActive = "active" // 在职:物化的期望来源
StateLeaveCandidate = "leave_candidate" // 飞书缺席且机器确认离职,等待发卡片
StatePendingConfirm = "pending_confirm" // 已发离职确认卡片,等待人工点击一次性端点
StateConfirmedLeave = "confirmed_leave" // 人工确认离职,等待下一轮物化物理删除
StateDeleted = "deleted" // 已从 LLDAP 删除,仅作历史痕迹保留
)
// 一次性离职确认 token 的流转状态。
const (
TokenPending = "pending" // 已生成、可被消费
TokenUsed = "used" // 已被确认端点消费(一次使用)
TokenRevoked = "revoked" // 用户重新可见时作废
)
// MirrorUser 是 镜像 + 台账 合一的全字段用户记录。
//
// uid 是登录名(企业邮箱 @ 前缀)同时为主键;union_id 唯一,用于重新入职判别
// 与离职回查。skipped 为纯展示信息:缺少企业邮箱的用户没有 uid,这里以 union_id
// 作为 uid 落一条记录,WebUI 据此列出「谁没被同步」,避免静默丢失。
type MirrorUser struct {
UID string `gorm:"primaryKey;size:190"`
UnionID string `gorm:"size:190;uniqueIndex;not null"`
UserID string `gorm:"size:190"`
Email string `gorm:"size:255"` // 企业邮箱
DisplayName string `gorm:"size:255"` // 优先中文名
GivenName string `gorm:"size:255"` // 优先英文名
SN string `gorm:"size:255"` // 姓氏姓名,sn 为空会违 inetOrgPerson
Mobile string `gorm:"size:64"`
EmployeeNumber string `gorm:"size:64"`
DepartmentNumber string `gorm:"size:255"` // 职位
PostalAddress string `gorm:"size:255"` // 工位
BusinessCategory string `gorm:"size:512"` // 所属部门名(逗号分隔)
EmployeeType string `gorm:"size:32"`
Description string `gorm:"size:512"`
// DepartmentIDs 仅快照构建期间用于重建关系表,不落库(关联存放在
// mirror_user_departments)。
DepartmentIDs []string `gorm:"-"`
State string `gorm:"size:32;not null;default:active;index"`
// AttrsHash 是上一次写入 LLDAP 的属性内容指纹;快照刷新不得覆盖它。
AttrsHash string `gorm:"size:64"`
Skipped bool `gorm:"not null;default:false"`
SkipReason string `gorm:"size:512"`
LastSyncedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// MirrorDepartment 是部门镜像。
type MirrorDepartment struct {
DeptID string `gorm:"primaryKey;size:190"`
// Name 是部门中文名,纯展示用(可读名走 DB,不进 LDAP 组名)。
Name string `gorm:"check:name <> ''"`
DN string `gorm:"size:512;index"`
ParentID string `gorm:"size:190;index"`
Level int
// GroupID 是 LLDAP 中该部门组的自增 int 组 id。LDAP 视角无法取到它,
// 必须由物化阶段持久化,供 GraphQL 成员操作直接用(约束 12)。
GroupID int `gorm:"column:group_id"`
State string `gorm:"size:32;not null;default:active;index"`
LastSyncedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。
type MirrorUserDepartment struct {
UID string `gorm:"primaryKey;size:190"`
DeptID string `gorm:"primaryKey;size:190;index"`
}
// ResignConfirmToken 是一次性离职确认 token。
type ResignConfirmToken struct {
Token string `gorm:"primaryKey;size:64"`
UID string `gorm:"size:190;index;not null"`
Status string `gorm:"size:16;not null;default:pending"`
ExpiresAt time.Time
CreatedAt time.Time
UsedAt *time.Time
}
// MirrorSnapshot 是一次快照整体替换的输入来源。它只携带「飞书当前有什么」,
// 由调用方(快照 runner)从飞书拉取后交给 ReplaceMirror 落库。
type MirrorSnapshot struct {
Users []*MirrorUser // 有企业邮箱、可生成 uid 的用户(在飞书可见)
Departments []*MirrorDepartment
// Skipped 是缺少企业邮箱而被跳过的用户,以 union_id 作为 uid 落库供 WebUI 展示。
Skipped []*MirrorUser
}
// ReplaceMirror 在单个事务内整体替换镜像的用户、部门,并重建用户-部门关系表。
//
// 事务保证:任何一步失败都回滚,SQLite 停留在上一版完整镜像,绝不出现半新半旧。
// 快照只覆盖「飞书事实」列;生命周期状态(state)与 attrs_hash 在 upsert 冲突时
// 刻意不覆盖——否则会把正在走离职流程的用户重置回 active,违反镜像业务。
//
// 用户的生命周期状态由独立的 reconcile 步骤驱动(见 syncer),此处不做任何决策。
func (s *Store) ReplaceMirror(snap *MirrorSnapshot) error {
return s.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
// 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。
// 注意 upsert 冲突时**不碰 group_id**——物化阶段持久化的 dept_id→groupId 映射
// 必须跨快照刷新保留,否则每次快照都会把组 id 清零。
for _, d := range snap.Departments {
d.State = StateActive
d.LastSyncedAt = now
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dept_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "dn", "parent_id", "level", "state", "last_synced_at",
}),
}).Create(d).Error; err != nil {
return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err)
}
}
// 清除本轮快照之外的部门(group_id 随之丢弃,其组已成孤儿)。
if len(snap.Departments) == 0 {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除部门镜像失败: %w", err)
}
} else {
presentIDs := make([]string, 0, len(snap.Departments))
for _, d := range snap.Departments {
presentIDs = append(presentIDs, d.DeptID)
}
if err := tx.Where("dept_id NOT IN ?", presentIDs).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除旧部门失败: %w", err)
}
}
// 用户整体替换:upsert 有账者,不轮询删除缺席者(其生命周期由 reconcile 处理)。
for _, u := range snap.Users {
u.State = StateActive
u.Skipped = false
u.SkipReason = ""
u.LastSyncedAt = now
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uid"}},
// 只更新飞书事实列;state / attrs_hash / created_at 保留。
DoUpdates: clause.AssignmentColumns([]string{
"union_id", "user_id", "email", "display_name", "given_name", "sn",
"mobile", "employee_number", "department_number", "postal_address",
"business_category", "employee_type", "description",
"skipped", "skip_reason", "last_synced_at",
}),
}).Create(u).Error; err != nil {
return fmt.Errorf("写入用户镜像 %s(%s) 失败: %w", u.UID, u.DisplayName, err)
}
}
// 被跳过的用户也落库,但标记 skipped=true,不进入 any 物化视图。
for _, u := range snap.Skipped {
u.Skipped = true
u.SkipReason = "缺少企业邮箱,无法推导登录名"
u.LastSyncedAt = now
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uid"}},
DoUpdates: clause.AssignmentColumns([]string{
"union_id", "user_id", "email", "display_name", "given_name", "sn",
"mobile", "employee_number", "department_number", "postal_address",
"business_category", "employee_type", "description",
"state", "skipped", "skip_reason", "last_synced_at",
}),
}).Create(u).Error; err != nil {
return fmt.Errorf("写入跳过用户镜像 %s(%s) 失败: %w", u.UID, u.DisplayName, err)
}
}
// 重建用户-部门关系表:整体删除后按本次快照重新插入。
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorUserDepartment{}).Error; err != nil {
return fmt.Errorf("清空用户-部门关系失败: %w", err)
}
var rels []MirrorUserDepartment
for _, u := range snap.Users {
for _, deptID := range u.DepartmentIDs {
rels = append(rels, MirrorUserDepartment{UID: u.UID, DeptID: deptID})
}
}
if len(rels) > 0 {
// ChunkSize 分批插入,避免一条 SQL 过长。
if err := tx.CreateInBatches(&rels, 500).Error; err != nil {
return fmt.Errorf("重建用户-部门关系失败: %w", err)
}
}
return nil
})
}
// --- 生命周期状态机 ---
// transition 做一次受校验的状态转移:仅当当前状态为 from 时才置为 to。
// 放在事务里执行,避免并发下读到半路状态。转移失败说明状态机被绕过了,
// 直接报错——「未确认的离职候选绝不入计划」就是靠这一步结构性地拦住的。
func (s *Store) transition(uid, from, to string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Model(&MirrorUser{}).
Where("uid = ? AND state = ?", uid, from).
Update("state", to)
if res.Error != nil {
return res.Error
}
if res.RowsAffected != 1 {
return fmt.Errorf("状态转移 uid=%s %s→%s 失败:当前状态不允许该转移", uid, from, to)
}
return nil
})
}
// ToLeaveCandidate 飞书缺席 + 机器确认离职:active → leave_candidate。
func (s *Store) ToLeaveCandidate(uid string) error {
return s.transition(uid, StateActive, StateLeaveCandidate)
}
// ToPendingConfirm 已对用户发送离职确认卡片:leave_candidate → pending_confirm。
func (s *Store) ToPendingConfirm(uid string) error {
return s.transition(uid, StateLeaveCandidate, StatePendingConfirm)
}
// ConfirmLeave 人工在一次性端点上确认离职:pending_confirm → confirmed_leave。
func (s *Store) ConfirmLeave(uid string) error {
return s.transition(uid, StatePendingConfirm, StateConfirmedLeave)
}
// MarkDeleted 该用户已从 LLDAP 物理删除:confirmed_leave → deleted。
func (s *Store) MarkDeleted(uid string) error {
return s.transition(uid, StateConfirmedLeave, StateDeleted)
}
// RevertToActive 用户重新在飞书可见:撤销离职流程回 active。
//
// 覆盖 deleted:已从 LLDAP 物理删除的用户再次可见意味着重新入职,置回 active
// 后物化会以「重建」把它再写进 LLDAP。仍在双确认流程内的(candidate/pending/
// confirmed)同样回退,未用 token 由调用方一并作废。
func (s *Store) RevertToActive(uid string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ? AND state IN ?", uid,
[]string{StateLeaveCandidate, StatePendingConfirm, StateConfirmedLeave, StateDeleted}).
Update("state", StateActive).Error
}
// --- 查询视图 ---
// ActiveUsers 返回在职视图:state=active 且未跳过。这是物化期望状态的唯一来源。
func (s *Store) ActiveUsers() ([]*MirrorUser, error) {
var out []*MirrorUser
if err := s.db.Where("state = ? AND skipped = ?", StateActive, false).
Order("uid").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// ActiveUsersWithDepts 返回在职用户并填充各自的 DepartmentIDs(来自关系表)。
//
// DepartmentIDs 存于 mirror_user_departments,不落 MirrorUser.DepartmentIDs;
// 物化期望状态(BuildFromMirror 的组归属)依赖它,必须在此补齐。
func (s *Store) ActiveUsersWithDepts() ([]*MirrorUser, error) {
users, err := s.ActiveUsers()
if err != nil {
return nil, err
}
for _, u := range users {
ids, err := s.UserDeptIDs(u.UID)
if err != nil {
return nil, err
}
u.DepartmentIDs = ids
}
return users, nil
}
// UsersByState 返回指定生命周期状态的全部用户。
func (s *Store) UsersByState(state string) ([]*MirrorUser, error) {
var out []*MirrorUser
if err := s.db.Where("state = ?", state).Order("uid").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// PendingConfirm 返回待人工确认的离职候选。
func (s *Store) PendingConfirm() ([]*MirrorUser, error) {
return s.UsersByState(StatePendingConfirm)
}
// ConfirmedLeave 返回已确认离职、等待物化删除的列表。
func (s *Store) ConfirmedLeave() ([]*MirrorUser, error) {
return s.UsersByState(StateConfirmedLeave)
}
// LeaveCandidates 返回已机器确认、尚未发卡片的候选。
func (s *Store) LeaveCandidates() ([]*MirrorUser, error) {
return s.UsersByState(StateLeaveCandidate)
}
// SkippedUsers 返回被跳过的用户记录(无企业邮箱),供 WebUI 展示。
func (s *Store) SkippedUsers() ([]*MirrorUser, error) {
var out []*MirrorUser
if err := s.db.Where("skipped = ?", true).Order("uid").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// AllMirrorUsers 返回全部镜像用户(含所有状态),用于测试与全量展示。
func (s *Store) AllMirrorUsers() ([]*MirrorUser, error) {
var out []*MirrorUser
if err := s.db.Order("uid").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// MapUsersByUID 返回 uid → 镜像用户 映射。
func (s *Store) MapUsersByUID() (map[string]*MirrorUser, error) {
list, err := s.AllMirrorUsers()
if err != nil {
return nil, err
}
out := make(map[string]*MirrorUser, len(list))
for _, u := range list {
out[u.UID] = u
}
return out, nil
}
// ActiveDepartments 返回在职视图下的部门镜像。
func (s *Store) ActiveDepartments() ([]*MirrorDepartment, error) {
var out []*MirrorDepartment
if err := s.db.Where("state = ?", StateActive).Order("dept_id").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// AllDepartments 返回全部部门镜像。
func (s *Store) AllDepartments() ([]*MirrorDepartment, error) {
var out []*MirrorDepartment
if err := s.db.Order("dept_id").Find(&out).Error; err != nil {
return nil, err
}
return out, nil
}
// MapDepartmentsByID 返回 dept_id → 部门镜像 映射。
func (s *Store) MapDepartmentsByID() (map[string]*MirrorDepartment, error) {
list, err := s.AllDepartments()
if err != nil {
return nil, err
}
out := make(map[string]*MirrorDepartment, len(list))
for _, d := range list {
out[d.DeptID] = d
}
return out, nil
}
// SetDeptGroupID 持久化部门组在 LLDAP 里的自增 groupId。
//
// LDAP 视角取不到这个 int id,只能由物化阶段在创建/核对组时写入,成员操作据此直接用。
// 快照刷新刻意不覆盖 group_id,保证映射跨刷新稳定。
func (s *Store) SetDeptGroupID(deptID string, groupID int) error {
return s.db.Model(&MirrorDepartment{}).
Where("dept_id = ?", deptID).
Update("group_id", groupID).Error
}
// SetUserAttrsHash 记录某用户上次写入 LLDAP 的属性指纹,供漂移比对与展示。
func (s *Store) SetUserAttrsHash(uid, hash string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ?", uid).
Update("attrs_hash", hash).Error
}
// UserDeptIDs 返回某用户所属的部门 ID 列表。
func (s *Store) UserDeptIDs(uid string) ([]string, error) {
var rels []MirrorUserDepartment
if err := s.db.Where("uid = ?", uid).Find(&rels).Error; err != nil {
return nil, err
}
out := make([]string, 0, len(rels))
for _, r := range rels {
out = append(out, r.DeptID)
}
return out, nil
}
// DeptUserIDs 返回某部门下的用户 uid 列表(按部门查人的索引)。
func (s *Store) DeptUserIDs(deptID string) ([]string, error) {
var rels []MirrorUserDepartment
if err := s.db.Where("dept_id = ?", deptID).Find(&rels).Error; err != nil {
return nil, err
}
out := make([]string, 0, len(rels))
for _, r := range rels {
out = append(out, r.UID)
}
return out, nil
}
// --- 一次性离职确认 token ---
// CreateToken 为 uid 生成一个新的一次性离职确认 token。
//
// 用 crypto/rand 生成,不依赖时间/内存地址等可预测来源;token 本身即凭证,
// 确认端点拿到它就可以把用户置为 confirmed_leave,因此必须唯一不可猜测。
func (s *Store) CreateToken(uid string, ttl time.Duration) (*ResignConfirmToken, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("生成 token 失败: %w", err)
}
rec := &ResignConfirmToken{
Token: base64.RawURLEncoding.EncodeToString(b),
UID: uid,
Status: TokenPending,
ExpiresAt: time.Now().Add(ttl),
}
if err := s.db.Create(rec).Error; err != nil {
return nil, fmt.Errorf("保存 token 失败: %w", err)
}
return rec, nil
}
// ConsumeToken 消费一个一次性 token:校验存在、未用、未过期后置为 used。
//
// 放在事务里,保证并发下同一 token 只会被消费一次(二次使用被拒)。
// 消费成功后返回 token 记录(含 uid),供确认端点回写 confirmed_leave。
func (s *Store) ConsumeToken(token string) (*ResignConfirmToken, error) {
var rec ResignConfirmToken
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("token = ?", token).First(&rec).Error; err != nil {
return fmt.Errorf("token 不存在或无效")
}
if rec.Status != TokenPending {
return fmt.Errorf("token 已被使用或已作废")
}
if time.Now().After(rec.ExpiresAt) {
return fmt.Errorf("确认链接已过期,请向管理员重新发起离职确认")
}
usedAt := time.Now()
rec.Status = TokenUsed
rec.UsedAt = &usedAt
if err := tx.Model(&rec).Updates(map[string]any{
"status": TokenUsed, "used_at": usedAt,
}).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return &rec, nil
}
// RevokeTokensForUser 作废某用户全部未使用的 token。
//
// 用户在飞书重新可见而撤销离职流程时调用:未用 token 若留着,点开仍能把人
// 误标成离职,因此必须一次性作废。
func (s *Store) RevokeTokensForUser(uid string) error {
return s.db.Model(&ResignConfirmToken{}).
Where("uid = ? AND status = ?", uid, TokenPending).
Update("status", TokenRevoked).Error
}
// LastPendingToken 返回某用户最近一条未使用的 token(用于离职提醒节流判断)。
func (s *Store) LastPendingToken(uid string) (*ResignConfirmToken, error) {
var rec ResignConfirmToken
err := s.db.Where("uid = ? AND status = ?", uid, TokenPending).
Order("created_at desc").First(&rec).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &rec, nil
}
+260
View File
@@ -0,0 +1,260 @@
package store
import (
"path/filepath"
"strings"
"testing"
"time"
)
// mu 构造一个镜像用户,DepartmentIDs 用于驱动关系表重建。
func mu(uid, email, name string, deptIDs ...string) *MirrorUser {
return &MirrorUser{
UID: uid,
UnionID: "u-" + uid,
Email: email,
DisplayName: name,
SN: name,
GivenName: name,
EmployeeType: "Active",
DepartmentIDs: deptIDs,
State: StateActive,
}
}
// md 构造一个部门镜像。
func md(id, parent, name string) *MirrorDepartment {
return &MirrorDepartment{DeptID: id, ParentID: parent, Name: name}
}
// openStore 打开一个临时文件库,保证多事务测试有真实磁盘语义。
func openStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "idsync-test.db")
st, err := Open(path)
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
// TestReplaceMirrorTransactionalRollback 验证快照整体替换的事务性:
// 中途失败时必须回滚到上一版完整镜像,绝不留下半新半旧。
func TestReplaceMirrorTransactionalRollback(t *testing.T) {
st := openStore(t)
// 先写入一版正常镜像。
good := &MirrorSnapshot{
Departments: []*MirrorDepartment{md("od-root", "", "Acme")},
Users: []*MirrorUser{
mu("alice", "alice@example.com", "Alice", "od-root"),
},
}
if err := st.ReplaceMirror(good); err != nil {
t.Fatalf("初始镜像写入失败: %v", err)
}
// 第二版镜像故意带一个空部门名,触发 CHECK 约束失败,中断事务。
bad := &MirrorSnapshot{
Departments: []*MirrorDepartment{{DeptID: "od-bad", ParentID: "", Name: ""}},
Users: []*MirrorUser{
mu("bob", "bob@example.com", "Bob", "od-bad"),
},
}
if err := st.ReplaceMirror(bad); err == nil {
t.Fatalf("含非法部门名的镜像应当写失败,却成功了")
}
// 回滚后镜像必须仍是第一版:alice 还在、od-root 还在、bob 与 od-bad 都不在。
users, err := st.MapUsersByUID()
if err != nil {
t.Fatalf("读取用户镜像失败: %v", err)
}
if _, ok := users["alice"]; !ok {
t.Errorf("回滚后 alice 丢失,镜像被污染")
}
if _, ok := users["bob"]; ok {
t.Errorf("回滚后 bob 残留,镜像被污染")
}
depts, err := st.MapDepartmentsByID()
if err != nil {
t.Fatalf("读取部门镜像失败: %v", err)
}
if _, ok := depts["od-root"]; !ok {
t.Errorf("回滚后 od-root 丢失(清空操作也被回滚)")
}
if _, ok := depts["od-bad"]; ok {
t.Errorf("回滚后 od-bad 残留")
}
}
// TestReplaceMirrorPreservesLifecycleState 验证:快照刷新只覆盖飞书事实,
// 不清洗生命周期状态——正在走离职流程的用户不会被快照重置回 active。
func TestReplaceMirrorPreservesLifecycleState(t *testing.T) {
st := openStore(t)
snap := &MirrorSnapshot{
Departments: []*MirrorDepartment{md("od-root", "", "Acme")},
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice", "od-root")},
}
if err := st.ReplaceMirror(snap); err != nil {
t.Fatalf("初始写入失败: %v", err)
}
if err := st.ToLeaveCandidate("alice"); err != nil {
t.Fatalf("转入 leave_candidate 失败: %v", err)
}
// 用户仍可见于飞书,快照再次刷新。
if err := st.ReplaceMirror(snap); err != nil {
t.Fatalf("再次刷新失败: %v", err)
}
users, _ := st.MapUsersByUID()
if users["alice"].State != StateLeaveCandidate {
t.Errorf("快照刷新后 state = %q, 期望仍为 %q(生命周期不应被快照覆盖)",
users["alice"].State, StateLeaveCandidate)
}
// 关系表应重建无误。
ids, err := st.UserDeptIDs("alice")
if err != nil || len(ids) != 1 || ids[0] != "od-root" {
t.Errorf("关系表重建异常: %v -> %v", err, ids)
}
}
// TestRejectAbsentDepartmentsCleared 验证部门整体替换:快照之外的历史部门被清除,
// 保证镜像 = 飞书当前有的部门,不会残留幽灵组。
func TestRejectAbsentDepartmentsCleared(t *testing.T) {
st := openStore(t)
if err := st.ReplaceMirror(&MirrorSnapshot{
Departments: []*MirrorDepartment{md("od-old", "", "Old")},
}); err != nil {
t.Fatalf("初始写入失败: %v", err)
}
if err := st.ReplaceMirror(&MirrorSnapshot{
Departments: []*MirrorDepartment{md("od-new", "", "New")},
}); err != nil {
t.Fatalf("刷新失败: %v", err)
}
depts, _ := st.MapDepartmentsByID()
if _, ok := depts["od-new"]; !ok {
t.Errorf("新部门未写入")
}
if _, ok := depts["od-old"]; ok {
t.Errorf("快照之外的旧部门 od-old 仍残留在部门镜像中")
}
}
// TestStateMachine 验证生命周期状态机的合法转移与非法拦截。
func TestStateMachine(t *testing.T) {
st := openStore(t)
if err := st.ReplaceMirror(&MirrorSnapshot{
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")},
}); err != nil {
t.Fatalf("初始写入失败: %v", err)
}
// 合法链:active → leave_candidate → pending_confirm → confirmed_leave → deleted
checks := []struct {
name string
apply func() error
want string
}{
{"机器确认离职", func() error { return st.ToLeaveCandidate("alice") }, StateLeaveCandidate},
{"发卡片待确认", func() error { return st.ToPendingConfirm("alice") }, StatePendingConfirm},
{"人工确认离职", func() error { return st.ConfirmLeave("alice") }, StateConfirmedLeave},
{"物化删除", func() error { return st.MarkDeleted("alice") }, StateDeleted},
}
for _, c := range checks {
if err := c.apply(); err != nil {
t.Fatalf("%s 失败: %v", c.name, err)
}
assertState(t, st, "alice", c.want)
}
// 非法转移:deleted 状态不能转 pending_confirm(状态机被绕过必须报错)。
if err := st.ToPendingConfirm("alice"); err == nil {
t.Errorf("deleted 状态的用户应无法转 pending_confirm,却成功了")
}
// 已删除用户重新在飞书可见:revert 置回 active(重新入职/重建)。
if err := st.RevertToActive("alice"); err != nil {
t.Fatalf("RevertToActive 不应报错: %v", err)
}
assertState(t, st, "alice", StateActive)
// 双确认流程内可回退(重新入职)。
if err := st.ReplaceMirror(&MirrorSnapshot{
Users: []*MirrorUser{mu("bob", "bob@example.com", "Bob")},
}); err != nil {
t.Fatalf("写入 bob 失败: %v", err)
}
if err := st.ToLeaveCandidate("bob"); err != nil {
t.Fatalf("bob 转 leave_candidate 失败: %v", err)
}
if err := st.RevertToActive("bob"); err != nil {
t.Fatalf("bob revert 失败: %v", err)
}
assertState(t, st, "bob", StateActive)
}
// TestTokenOneTimeExpiryRevoke 验证一次性 token:试用即废、过期失效、作废后拒绝。
func TestTokenOneTimeExpiryRevoke(t *testing.T) {
st := openStore(t)
if err := st.ReplaceMirror(&MirrorSnapshot{
Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")},
}); err != nil {
t.Fatalf("初始写入失败: %v", err)
}
// 正常 token:消费成功,返回 uid。
tok, err := st.CreateToken("alice", time.Hour)
if err != nil {
t.Fatalf("创建 token 失败: %v", err)
}
consumed, err := st.ConsumeToken(tok.Token)
if err != nil {
t.Fatalf("消费 token 失败: %v", err)
}
if consumed.UID != "alice" {
t.Errorf("消费返回 uid = %q, 期望 alice", consumed.UID)
}
// 再次使用同一 token 必须被拒(一次使用)。
if _, err := st.ConsumeToken(tok.Token); err == nil {
t.Errorf("已使用的 token 应被拒绝复用")
}
// 过期的 token:负 TTL 使 ExpiresAt 落在过去。
expired, err := st.CreateToken("alice", -time.Minute)
if err != nil {
t.Fatalf("创建过期 token 失败: %v", err)
}
if _, err := st.ConsumeToken(expired.Token); err == nil || !strings.Contains(err.Error(), "过期") {
t.Errorf("过期 token 应报过期错误,实际: %v", err)
}
// 作废的 token:用户重新可见后 revoke,未用 token 拒绝消费。
pending, err := st.CreateToken("alice", time.Hour)
if err != nil {
t.Fatalf("创建 token 失败: %v", err)
}
if err := st.RevokeTokensForUser("alice"); err != nil {
t.Fatalf("作废 token 失败: %v", err)
}
if _, err := st.ConsumeToken(pending.Token); err == nil {
t.Errorf("已作废的 token 应被拒绝消费")
}
}
func assertState(t *testing.T, st *Store, uid, want string) {
t.Helper()
users, err := st.MapUsersByUID()
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
u, ok := users[uid]
if !ok {
t.Fatalf("用户 %s 不存在", uid)
}
if u.State != want {
t.Errorf("用户 %s state = %q, 期望 %q", uid, u.State, want)
}
}
+2 -1
View File
@@ -87,7 +87,8 @@ func Open(path string) (*Store, error) {
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&Account{}, &Group{}, &SyncRun{}); err != nil {
if err := db.AutoMigrate(&MirrorUser{}, &MirrorDepartment{}, &MirrorUserDepartment{},
&ResignConfirmToken{}, &SyncRun{}, &Account{}, &Group{}); err != nil {
return nil, fmt.Errorf("建表失败: %w", err)
}
return &Store{db: db}, nil
+162 -441
View File
@@ -1,27 +1,21 @@
//go:build integration
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
// 两阶段全链路端到端集成测试:假飞书 + 真 LLDAP + 假 webhook。
//
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
// 并断言最终落在 OpenLDAP 里的内容。
// 快照段用受控的假飞书 OpenAPI 服务(无需真实租户凭据)写镜像与推进离职流程,
// 物化段写真实 LLDAP(GraphQL)。覆盖:首次全量创建、幂等、属性变更、离职未确认
// 不删、确认后删除、重新入职重建。
//
// 启用方式(需要真实 LDAP 实例):
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
package syncer_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
@@ -29,65 +23,42 @@ import (
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/store"
"idsync/internal/syncer"
)
// fakeDept 是假服务返回的部门。
type fakeDept struct {
openID string
parentID string
name string
}
type fakeDept struct{ openID, parentID, name string }
// fakeUser 是假服务返回的用户。
type fakeUser struct {
unionID string
userID string
name string
enName string
enterpriseEm string
email string
mobile string
employeeNo string
jobTitle string
unionID, userID, name, enName string
enterpriseEm, email, mobile string
employeeNo, jobTitle string
deptIDs []string
resigned bool
}
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。
// fakeFeishu 只实现快照链路需要的接口。
type fakeFeishu struct {
depts []fakeDept
users []fakeUser
// served 记录各接口被调用的次数,用于验证分页与去重行为。
calls map[string]int
}
func (f *fakeFeishu) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.calls["token"]++
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"tenant_access_token": "fake-token",
"expire": 7200,
writeFakeJSON(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
})
})
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
f.calls["departments"]++
parent := r.URL.Query().Get("parent_department_id")
if parent == "" {
parent = "0"
}
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true}
if fetchChild {
if r.URL.Query().Get("fetch_child") == "true" {
for changed := true; changed; {
changed = false
for _, d := range f.depts {
@@ -98,86 +69,48 @@ func (f *fakeFeishu) handler() http.Handler {
}
}
}
items := make([]map[string]any, 0)
for _, d := range f.depts {
if !want[d.openID] || d.openID == parent {
continue
}
items = append(items, map[string]any{
"name": d.name,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
"name": d.name, "parent_department_id": d.parentID, "open_department_id": d.openID,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
})
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
f.calls["find_by_department"]++
deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0)
for _, u := range f.users {
if u.resigned {
continue // 与真实接口一致:不返回离职人员
}
if !containsStr(u.deptIDs, deptID) {
if u.resigned || !containsStr(u.deptIDs, deptID) {
continue
}
items = append(items, map[string]any{
"union_id": u.unionID,
"user_id": u.userID,
"name": u.name,
"en_name": u.enName,
"enterprise_email": u.enterpriseEm,
"email": u.email,
"mobile": u.mobile,
"employee_no": u.employeeNo,
"job_title": u.jobTitle,
"department_ids": u.deptIDs,
"union_id": u.unionID, "user_id": u.userID, "name": u.name, "en_name": u.enName,
"enterprise_email": u.enterpriseEm, "email": u.email, "mobile": u.mobile,
"employee_no": u.employeeNo, "job_title": u.jobTitle, "department_ids": u.deptIDs,
})
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
})
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
f.calls["user_get"]++
// 从路径末尾取出 union_id
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1]
for _, u := range f.users {
if u.unionID != unionID {
continue
}
writeFakeJSON(w, map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"user": map[string]any{
"union_id": unionID,
"user_id": u.userID,
"name": u.name,
if u.unionID == unionID {
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{
"union_id": unionID, "user_id": u.userID, "name": u.name,
"status": map[string]any{"is_resigned": u.resigned},
},
},
})
}}})
return
}
}
writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
})
return mux
}
@@ -185,440 +118,228 @@ func writeFakeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
func containsStr(list []string, want string) bool {
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == want {
if v == s {
return true
}
}
return false
}
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) {
// setup 准备整套环境:真 LLDAP + 假飞书 + 假 webhook + 临时镜像库。
func setup(t *testing.T) (*syncer.Runner, *store.Store, *lldapgql.Client, *fakeFeishu, func()) {
t.Helper()
ldapURL := os.Getenv("LDAP_TEST_URL")
if ldapURL == "" {
ldapURL = "ldap://127.0.0.1:3899"
}
base := os.Getenv("LDAP_TEST_BASE_DN")
if base == "" {
base = "dc=example,dc=com"
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍:
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS")
if groupOC == "" {
groupOC = "groupOfUniqueNames"
}
ldapCfg := config.Ldap{
URL: ldapURL, BaseDN: base,
AdminDN: "cn=admin," + base, AdminPass: "adminpass",
GroupObjectClass: groupOC,
cfg := &config.Config{
System: config.System{Mode: "release"},
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
TimeoutSec: 10,
},
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
Guard: config.Guard{MaxDeactivatePercent: 0.9},
}
if err := ldapCfg.NormalizeForTest(); err != nil {
t.Fatalf("LDAP 配置规范化失败: %v", err)
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
ldapClient, err := ldaplib.NewClient(ldapCfg, log)
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
if err := gql.Login(t.Context(), "admin", "adminpass"); err != nil {
t.Skipf("LLDAP 不可用,跳过端到端测试: %v", err)
}
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err)
}
if err := ldapClient.Ping(); err != nil {
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
}
fake := &fakeFeishu{calls: map[string]int{}}
ts := httptest.NewServer(fake.handler())
fs := httptest.NewServer(fake.handler())
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
}))
feishuClient, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake",
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
AppID: "fake", AppSecret: "fake", BaseURL: fs.URL, QPS: 100, Timeout: 10 * time.Second,
}, log)
if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err)
}
dbPath := t.TempDir() + "/idsync.db"
st, err := store.Open(dbPath)
st, err := store.Open(t.TempDir() + "/idsync.db")
if err != nil {
t.Fatalf("打开台账失败: %v", err)
t.Fatalf("打开镜像库失败: %v", err)
}
ldapCfg.UserOU = "ou=people"
ldapCfg.InactiveOU = "ou=Inactive"
ldapCfg.GroupRootName = "feishuroot"
cfg := &config.Config{
System: config.System{Mode: "release", DataDir: t.TempDir()},
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: ldapCfg,
Database: config.Database{Source: dbPath},
Sync: config.Sync{MaxDeactivatePercent: 0.5},
}
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log)
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gql, st, log, syncer.SnapshotOptions{
WebhookURL: webhook.URL, ConfirmBaseURL: "https://confirm.example.com",
ReNotifyInterval: 24 * time.Hour, TokenTTL: 72 * time.Hour,
})
cleanup := func() {
ts.Close()
st.Close()
fs.Close()
webhook.Close()
_ = st.Close()
}
return runner, ldapClient, st, fake, cleanup
return runner, st, gql, fake, cleanup
}
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
// 属性变更、部门调整、离职迁移与重新入职。
func TestEndToEndSync(t *testing.T) {
runner, ldapClient, st, fake, cleanup := setup(t)
// TestTwoStageEndToEnd 覆盖首轮创建→幂等→离职确认→删除→重新入职重建。
func TestTwoStageEndToEnd(t *testing.T) {
runner, st, gql, fake, cleanup := setup(t)
defer cleanup()
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
groupRoot := "ou=groups," + base
// 准备目录结构并清理历史残留。
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
}
}
defer func() {
wipeTestOUs(t, conn, base)
conn.Close()
}()
// 两个部门,其中“研发部”是“后端组”的父部门。
// 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root),
// 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。
fake.depts = []fakeDept{
{openID: "od-root", parentID: "0", name: "Acme"},
{openID: "od-aaa-child", parentID: "od-root", name: "yanfa"},
{openID: "od-child", parentID: "od-root", name: "研发部"},
}
fake.users = []fakeUser{
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San",
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com",
mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师",
deptIDs: []string{"od-aaa-child"}},
{unionID: "u2", userID: "2", name: "李四", enName: "Li Si",
enterpriseEm: "lisi@example.com", email: "personal2@x.com",
mobile: "13800000002", employeeNo: "E002", jobTitle: "经理",
deptIDs: []string{"od-aaa-child"}},
// 没有企业邮箱:应当被跳过而不是回落到个人邮箱。
{unionID: "u3", userID: "3", name: "王五",
enterpriseEm: "", email: "wangwu@personal.com",
deptIDs: []string{"od-aaa-child"}},
}
wipeTestOUs(t, conn, base)
// ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ----
plan, res, err := runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("dry-run 失败: %v", err)
}
if res != nil {
t.Fatalf("dry-run 不应返回执行结果")
}
if plan.Stats.CreateUser != 2 {
t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)",
plan.Stats.CreateUser)
}
foundEmailWarning := false
for _, wr := range plan.Warnings {
if strings.Contains(wr, "企业邮箱") {
foundEmailWarning = true
break
}
}
if !foundEmailWarning {
t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings)
{unionID: "u1", userID: "1", name: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-child"}},
{unionID: "u2", userID: "2", name: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-child"}},
}
// dry-run 后 LDAP 中不应有任何用户。
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if len(users) != 0 {
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
}
t.Cleanup(func() { cleanupE2E(t, st, gql) })
// ---- 第二步:真正执行 ----
_, res, err = runner.Run(t.Context(), false, false, "test")
// ---- 快照 + 物化:首次全量创建 ----
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
_, res, err := runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("执行同步失败: %v", err)
t.Fatalf("物化失败: %v", err)
}
if res.Failed != 0 {
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors)
t.Fatalf("物化有失败: %v", res.Errors)
}
users, err = conn.ListUsers(peopleOU)
conn, err := ldapDial()
if err != nil {
t.Fatalf("读取用户失败: %v", err)
t.Fatalf("LDAP 连接失败: %v", err)
}
if _, ok := users["zhangsan"]; !ok {
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
users, _ := conn.ListUsers(peopleOU)
for _, uid := range []string{"alice", "bob"} {
if _, ok := users[uid]; !ok {
t.Errorf("物化后缺用户 %s", uid)
}
if _, ok := users["lisi"]; !ok {
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
}
if _, ok := users["wangwu"]; ok {
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
conn.Close()
conn, _ = ldapDial()
groupsDM := groupMap(conn, groupRoot)
conn.Close()
if _, ok := groupsDM["cn=od-child,"+groupRoot]; !ok {
t.Errorf("物化后缺组 cn=od-child")
}
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。
zs := users["zhangsan"]
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" {
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got)
}
if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" {
t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got)
}
if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" {
t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got)
// ---- 幂等 ----
plan, _ := runner.Preview(t.Context())
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
}
// 断言分组与成员关系。
groups, err := conn.ListGroups(groupRoot)
if err != nil {
t.Fatalf("读取分组失败: %v", err)
// ---- 离职:bob 标记离职 → pending_confirm,但未确认不应删除 ----
markResigned2(fake, "u2")
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。
acmeDN := "cn=acme," + groupRoot
yanfaDN := "cn=yanfa," + acmeDN
g, ok := groups[yanfaDN]
if !ok {
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
s, _ := st.MapUsersByUID()
if s["bob"].State != store.StatePendingConfirm {
t.Fatalf("bob 应为 pending_confirm,实际 %q", s["bob"].State)
}
members := g.Members()
if _, has := members["uid=zhangsan,"+peopleOU]; !has {
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members)
}
// 父部门分组也应存在,验证树形嵌套正确。
if _, ok := groups[acmeDN]; !ok {
t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups))
}
// 台账应记录两个账号。
accounts, err := st.AccountsByUID()
if err != nil {
t.Fatalf("读取台账失败: %v", err)
}
if len(accounts) != 2 {
t.Errorf("台账记录数 = %d, 期望 2", len(accounts))
}
// ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ----
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("第二次 dry-run 失败: %v", err)
}
total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup +
plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember +
plan.Stats.Deactivate + plan.Stats.Reactivate
if total != 0 {
t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan))
}
// ---- 第四步:属性变更应被检测并同步 ----
fake.users[0].jobTitle = "高级工程师"
fake.users[0].mobile = "13900000001"
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("变更检测失败: %v", err)
}
if plan.Stats.UpdateUser != 1 {
t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser)
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("应用变更失败: %v", err)
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Deleted != 0 {
t.Fatalf("未确认离职不应删除,实际 deleted=%d", res.Deleted)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
t.Errorf("职位未更新: %q", got)
conn.Close()
if _, still := users["bob"]; !still {
t.Fatalf("未确认离职的 bob 不应被删除")
}
// ---- 第五步:离职应迁入 ou=Inactive ----
fake.users[1].resigned = true
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("离职检测失败: %v", err)
// ---- 确认离职 → 物化删除 ----
if err := st.ConfirmLeave("bob"); err != nil {
t.Fatalf("状态转移失败: %v", err)
}
if plan.Stats.Deactivate != 1 {
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Deleted != 1 {
t.Fatalf("confirmed_leave 应删除 1 个,实际 %d", res.Deleted)
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行离职迁移失败: %v", err)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if _, still := users["lisi"]; still {
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
conn.Close()
if _, still := users["bob"]; still {
t.Errorf("confirmed_leave 的 bob 应被物理删除")
}
inactive, _ := conn.ListUsers(inactiveOU)
if _, moved := inactive["lisi"]; !moved {
t.Errorf("lisi 未迁入 %s", inactiveOU)
if _, alive := users["alice"]; !alive {
t.Errorf("alice 不应被删除")
}
// 离职用户也应从部门分组中移除。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
}
// ---- 第六步:重新入职应迁回 ----
// ---- 重新入职:bob 重新可见 → 重建 ----
fake.users[1].resigned = false
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("复职检测失败: %v", err)
if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("快照失败: %v", err)
}
if plan.Stats.Reactivate != 1 {
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan))
}
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行复职失败: %v", err)
_, res, _ = runner.Run(t.Context(), false, false, "test")
if res.Created == 0 {
t.Fatalf("重新入职应重建 bob,实际 created=%d", res.Created)
}
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU)
if _, back := users["lisi"]; !back {
t.Errorf("lisi 复职后未回到 %s", peopleOU)
}
// 复职后应重新加入部门分组。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
conn.Close()
if _, back := users["bob"]; !back {
t.Errorf("bob 重新入职后应被重建")
}
}
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。
func TestEndToEndGuard(t *testing.T) {
runner, ldapClient, _, fake, cleanup := setup(t)
defer cleanup()
func markResigned2(f *fakeFeishu, unionID string) {
for i := range f.users {
if f.users[i].unionID == unionID {
f.users[i].resigned = true
}
}
}
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := ldapClient.Dial()
func ldapDial() (*ldaplib.Conn, error) {
c, err := ldaplib.NewClient(config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass", TimeoutSec: 10,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("连接失败: %v", err)
}
defer conn.Close()
for _, ou := range []struct{ dn, name string }{
{peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"},
} {
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
return nil, err
}
return c.Dial()
}
fake.depts = []fakeDept{{openID: "od-root", parentID: "0", name: "Acme"}}
fake.users = nil
for i := 0; i < 4; i++ {
fake.users = append(fake.users, fakeUser{
unionID: fmt.Sprintf("g%d", i), userID: fmt.Sprintf("%d", i),
name: fmt.Sprintf("用户%d", i), enterpriseEm: fmt.Sprintf("user%d@example.com", i),
deptIDs: []string{"od-root"},
})
func groupMap(conn *ldaplib.Conn, root string) map[string]*ldaplib.Entry {
m, _ := conn.ListGroups(root)
if m == nil {
return map[string]*ldaplib.Entry{}
}
return m
}
wipeTestOUs(t, conn, base)
defer wipeTestOUs(t, conn, base)
if _, res, err := runner.Run(t.Context(), false, false, "test"); err != nil || res.Failed != 0 {
t.Fatalf("初次同步失败: err=%v res=%+v", err, res)
}
// 让飞书侧几乎所有用户消失:这模拟了权限范围被误改的灾难场景。
fake.users = fake.users[:1]
if _, _, err := runner.Run(t.Context(), false, false, "test"); err == nil {
t.Fatalf("停用 3/4 账号应当被护栏拦截,却成功执行了")
} else if !strings.Contains(err.Error(), "停用") {
t.Errorf("护栏错误信息应当说明停用数量问题,实际: %v", err)
}
// 强制模式应当放行。
if _, res, err := runner.Run(t.Context(), false, true, "test"); err != nil {
t.Fatalf("强制模式执行失败: %v", err)
} else if res.Failed != 0 {
t.Errorf("强制模式有 %d 个操作失败: %v", res.Failed, res.Errors)
}
}
// wipeTestOUs 删除测试用 OU 下的全部用户与分组。
//
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职),
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) {
// cleanupE2E 用 GraphQL 清掉本次测试在 LLDAP 里留下的用户与组。
func cleanupE2E(t *testing.T, st *store.Store, gql *lldapgql.Client) {
t.Helper()
peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base
groupRoot := "ou=feishuroot," + base
for _, ou := range []string{peopleOU, inactiveOU} {
users, err := conn.ListUsers(ou)
if err != nil {
continue
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, uid := range []string{"alice", "bob", "admin"} {
_ = gql.DeleteUser(ctx, uid)
}
for uid := range users {
_ = conn.DeleteUser("uid=" + uid + "," + ou)
deps, _ := st.MapDepartmentsByID()
for _, d := range deps {
if d.GroupID > 0 {
_ = gql.DeleteGroup(ctx, d.GroupID)
}
}
groups, err := conn.ListGroups(groupRoot)
if err != nil {
return
}
// 先删深层分组,再删浅层,避免「非叶子节点无法删除」。
dns := keysOf(groups)
sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) })
for _, dn := range dns {
_ = conn.DeleteGroup(groups[dn].DN)
}
}
// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。
func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot }
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func dumpOps(p *syncer.Plan) string {
var b strings.Builder
for _, op := range p.Ops {
fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail)
}
for _, wr := range p.Warnings {
fmt.Fprintf(&b, "\n 警告: %s", wr)
}
return b.String()
}
+68 -203
View File
@@ -1,274 +1,139 @@
package syncer
// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。
//
// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的
// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId
// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。
import (
"context"
"fmt"
"log/slog"
"strings"
"idsync/internal/ldaplib"
"idsync/internal/mapping"
"idsync/internal/password"
"idsync/internal/lldapgql"
)
// Guard 是执行前的安全阈值。
//
// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、
// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。
// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。
type Guard struct {
// MaxDeactivate 单次允许停用的最大账号数,0 表示不限制。
MaxDeactivate int
// MaxDeactivatePercent 单次允许停用的账号比例(0-1),0 表示不限制。
MaxDeactivatePercent float64
// AllowBulk 显式跳过全部阈值检查。
AllowBulk bool
}
// GuardError 表示计划未通过安全阈值检查。
type GuardError struct {
Message string
}
func (e *GuardError) Error() string { return e.Message }
// Check 校验计划是否满足安全阈值。
func (g Guard) Check(p *Plan) error {
if g.AllowBulk {
return nil
}
deactivate := p.Stats.Deactivate
if deactivate == 0 {
return nil
}
if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate {
return &GuardError{Message: fmt.Sprintf(
"本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑",
deactivate, g.MaxDeactivate)}
}
if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 {
ratio := float64(deactivate) / float64(p.Stats.TotalAccounts)
if ratio > g.MaxDeactivatePercent {
return &GuardError{Message: fmt.Sprintf(
"本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)}
}
}
return nil
// ExecConfig 是执行器所需的环境参数。
type ExecConfig struct {
PeopleOU string
}
// Result 是执行结果统计。
type Result struct {
Created int
Updated int
Moved int
Deleted int
Added int
Removed int
Failed int
Errors []string
}
// Executor 把计划应用到 LDAP。
// Executor 把计划应用到 LLDAP(写走 GraphQL)。
type Executor struct {
client *ldaplib.Client
gql *lldapgql.Client
cfg ExecConfig
log *slog.Logger
}
// ExecConfig 是执行器所需的环境参数。
type ExecConfig struct {
UserOUDN string
InactiveOUDN string
GroupRootDN string
GroupRootName string
AdminDN string
// InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。
InitialPassword string
}
// NewExecutor 构造执行器。
func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor {
return &Executor{client: client, cfg: cfg, log: log}
func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor {
return &Executor{gql: gql, cfg: cfg, log: log}
}
// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作——
// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。
func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result {
// Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个
// 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射,
// 会在创建新组时被原地扩充。
func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result {
res := &Result{}
conn, err := e.client.Dial()
if err != nil {
res.Failed++
res.Errors = append(res.Errors, err.Error())
return res
}
defer conn.Close()
// 确保基础 OU 存在,否则后续写入会失败。
//
// ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、
// 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串
// 难以理解的 No Such Object。
baseOUs := []struct{ dn, name string }{
{e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)},
{e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)},
{e.cfg.GroupRootDN, e.cfg.GroupRootName},
}
for _, ou := range baseOUs {
if ou.dn == "" {
continue
}
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
res.Failed++
res.Errors = append(res.Errors, err.Error())
return res
}
}
for _, op := range plan.Ops {
if err := ctx.Err(); err != nil {
res.Errors = append(res.Errors, "同步被取消: "+err.Error())
res.Errors = append(res.Errors, "物化被取消: "+err.Error())
return res
}
if err := e.applyOne(conn, op); err != nil {
if err := e.applyOne(ctx, op, groupIDs); err != nil {
res.Failed++
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
res.Errors = append(res.Errors, msg)
e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err)
e.log.Error("物化操作失败", "kind", op.Kind, "target", op.Target, "err", err)
continue
}
switch op.Kind {
case OpCreateUser:
case OpCreateUser, OpCreateGroup:
res.Created++
case OpUpdateUser, OpReactivate:
case OpUpdateUser:
res.Updated++
if op.Kind == OpReactivate {
res.Moved++
}
case OpDeactivate:
res.Updated++
res.Moved++
case OpDeleteUser:
res.Deleted++
case OpAddMember:
res.Added++
case OpRemoveMember:
res.Removed++
}
}
return res
}
// applyOne 执行单个操作。
func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error {
func (e *Executor) applyOne(ctx context.Context, op Op, groupIDs map[string]int) error {
switch op.Kind {
case OpCreateGroup:
// 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。
return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN)
case OpUpdateGroup:
return conn.UpdateGroup(op.DN, op.Group.Name)
// LLDAP 组 displayName 即部门索引;创建后把返回的 groupId 记进映射,
// 供后续成员操作用。
name := op.Group.DeptID
id, err := e.gql.CreateGroup(ctx, name)
if err != nil {
return err
}
groupIDs[name] = id
return nil
case OpCreateUser:
plain := e.cfg.InitialPassword
if plain == "" {
generated, err := password.Random()
if err != nil {
return err
}
plain = generated
}
// 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希,
// 直接写明文会让密码以明文形式留在目录里。
encoded, err := password.EncodeSSHA(plain)
if err != nil {
return err
}
return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded)
// 创建用户不设密码:密码由 LLDAP 独占、首次登录走 Authelia 忘记密码。
return e.gql.CreateUser(ctx, &lldapgql.CreateUserInput{
ID: op.User.UID,
Email: sp(op.User.Mail),
DisplayName: sp(op.User.DisplayName),
FirstName: sp(op.User.GivenName),
LastName: sp(op.User.SN),
})
case OpUpdateUser:
// 若 DN 因 OU 配置变化而不同,先移动再更新属性。
if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) {
uid := op.User.UID
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
return err
}
}
// 读取现状:UpdateUser 需要据此决定哪些属性应当删除,
// 对不存在的属性执行 Delete 会导致整个 Modify 失败。
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
if err != nil {
return err
}
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
case OpDeactivate:
// 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录;
// 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留,
// 便于日后重新入职时原样恢复。
uid := op.Target
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil {
return err
}
return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned)
case OpReactivate:
uid := op.User.UID
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
return err
}
if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil {
return err
}
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
if err != nil {
return err
}
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
// 省略即不改:这里显式带全四个可写硬字段,与 attrsDiffer 判定的差异一致。
return e.gql.UpdateUser(ctx, &lldapgql.UpdateUserInput{
ID: op.User.UID,
Email: sp(op.User.Mail),
DisplayName: sp(op.User.DisplayName),
FirstName: sp(op.User.GivenName),
LastName: sp(op.User.SN),
})
case OpAddMember:
return conn.AddMember(op.GroupDN, op.MemberDN)
gid, ok := groupIDs[op.Group.DeptID]
if !ok {
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
}
return e.gql.AddUserToGroup(ctx, op.MemberUID, gid)
case OpRemoveMember:
return conn.RemoveMember(op.GroupDN, op.MemberDN)
gid, ok := groupIDs[op.Group.DeptID]
if !ok {
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
}
return e.gql.RemoveUserFromGroup(ctx, op.MemberUID, gid)
case OpDeleteUser:
// LLDAP deleteUser 会级联清理成员关系(外键 CASCADE),无需先移组再删。
return e.gql.DeleteUser(ctx, op.Target)
default:
return fmt.Errorf("未知操作类型: %s", op.Kind)
}
}
// toLDAPUser 把期望状态转成 LDAP 写入结构。
func toLDAPUser(u *mapping.User) *ldaplib.User {
return &ldaplib.User{
UID: u.UID,
CN: u.CN,
SN: u.SN,
GivenName: u.GivenName,
DisplayName: u.DisplayName,
Mail: u.Mail,
Mobile: u.Mobile,
EmployeeNumber: u.EmployeeNumber,
DepartmentNumber: u.DepartmentNumber,
PostalAddress: u.PostalAddress,
BusinessCategory: u.BusinessCategory,
EmployeeType: u.EmployeeType,
Description: u.Description,
}
}
// ouName 从 OU 的 DN 中取出 ou 的值。
func ouName(dn string) string {
first := dn
if i := strings.Index(dn, ","); i >= 0 {
first = dn[:i]
}
if i := strings.Index(first, "="); i >= 0 {
return first[i+1:]
}
return first
}
// sp 生成 *string。
func sp(s string) *string { return &s }
@@ -0,0 +1,203 @@
//go:build integration
// 物化(阶段6)对真实 LLDAP 的端到端集成测试。
//
// 直接向 store 镜像灌数据(不经飞书快照),跑 Runner 物化,验证写落 LLDAP:
// 建用户/建组/加成员/幂等,以及 confirmed_leave → 物理删除。读侧用 LDAP 校验。
//
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
package syncer_test
import (
"context"
"io"
"log/slog"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/store"
"idsync/internal/syncer"
)
func matTestCfg() *config.Config {
return &config.Config{
Feishu: config.Feishu{AppID: "x", AppSecret: "y"},
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
Ldap: config.Ldap{
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
TimeoutSec: 10,
},
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
}
}
func matSetup(t *testing.T) (*syncer.Runner, *store.Store, *ldaplib.Client, *lldapgql.Client, func()) {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := matTestCfg()
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
// 先探写通道是否可用,不可用就跳过(避免误报)。
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
if err := gql.Login(context.Background(), cfg.Lldap.AdminUser, cfg.Lldap.AdminPass); err != nil {
t.Skipf("LLDAP 不可用,跳过物化集成测试: %v", err)
}
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err)
}
fc, _ := feishu.New(feishu.Options{AppID: "x", AppSecret: "y", QPS: 100, Timeout: 5 * time.Second}, log)
st, err := store.Open(t.TempDir() + "/idsync.db")
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
runner := syncer.NewRunner(cfg, fc, ldapClient, gql, st, log, syncer.SnapshotOptions{})
cleanup := func() { _ = st.Close() }
return runner, st, ldapClient, gql, cleanup
}
// seedMirror 灌一版镜像:od-acme/od-rd 两个部门,alice/bob 属 od-rd。
func seedMirror(t *testing.T, st *store.Store) {
t.Helper()
base := "dc=example,dc=com"
err := st.ReplaceMirror(&store.MirrorSnapshot{
Departments: []*store.MirrorDepartment{
{DeptID: "od-acme", Name: "Acme", DN: "cn=od-acme,ou=groups," + base, Level: 0},
{DeptID: "od-rd", Name: "研发部", DN: "cn=od-rd,ou=groups," + base, Level: 1, ParentID: "od-acme"},
},
Users: []*store.MirrorUser{
{UID: "alice", UnionID: "u-alice", Email: "alice@example.com", DisplayName: "Alice",
GivenName: "Alice", SN: "Alice", DepartmentIDs: []string{"od-rd"}},
{UID: "bob", UnionID: "u-bob", Email: "bob@example.com", DisplayName: "Bob",
GivenName: "Bob", SN: "Bob", DepartmentIDs: []string{"od-rd"}},
},
})
if err != nil {
t.Fatalf("灌镜像失败: %v", err)
}
}
func keysOf2[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestMaterializeAgainstLLDAP 验证物化把镜像在职视图写落 LLDAP,并支持离职删除。
func TestMaterializeAgainstLLDAP(t *testing.T) {
runner, st, ldapClient, gql, cleanup := matSetup(t)
defer cleanup()
seedMirror(t, st)
// 第 1 轮:dry-run 应给出创建计划。
plan, err := runner.Preview(t.Context())
if err != nil {
t.Fatalf("Preview 失败: %v", err)
}
if plan.Stats.CreateUser != 2 || plan.Stats.CreateGroup != 2 || plan.Stats.AddMember != 2 {
t.Fatalf("首轮计划统计异常: %+v", plan.Stats)
}
// 第 2 轮:真正执行。
_, res, err := runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("物化执行失败: %v", err)
}
if res.Failed != 0 {
t.Fatalf("物化有 %d 个失败: %v", res.Failed, res.Errors)
}
// 用 LDAP 读校验:两个用户、两个组、alice/bob 都是 od-rd 成员。
conn, err := ldapClient.Dial()
if err != nil {
t.Fatalf("LDAP 连接失败: %v", err)
}
defer conn.Close()
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
if err != nil {
t.Fatalf("读用户失败: %v", err)
}
for _, uid := range []string{"alice", "bob"} {
if _, ok := users[uid]; !ok {
t.Errorf("物化后 LLDAP 缺少用户 %s", uid)
}
}
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
if err != nil {
t.Fatalf("读组失败: %v", err)
}
rd := "cn=od-rd,ou=groups,dc=example,dc=com"
g, ok := groups[rd]
if !ok {
t.Fatalf("物化后缺少组 %s;现有 %v", rd, keysOf2(groups))
}
members := g.Members()
for _, uid := range []string{"alice", "bob"} {
if _, has := members["uid="+uid+",ou=people,dc=example,dc=com"]; !has {
t.Errorf("%s 未加入 %s;成员 %v", uid, rd, members)
}
}
// 幂等:立即再 preview 不应有任何操作。
plan, err = runner.Preview(t.Context())
if err != nil {
t.Fatalf("幂等 Preview 失败: %v", err)
}
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
}
// 离职:把 bob 走完离职状态机到 confirmed_leave,物化应物理删除 bob。
for _, fn := range []func() error{
func() error { return st.ToLeaveCandidate("bob") },
func() error { return st.ToPendingConfirm("bob") },
func() error { return st.ConfirmLeave("bob") },
} {
if err := fn(); err != nil {
t.Fatalf("离职状态机转移失败: %v", err)
}
}
_, res, err = runner.Run(t.Context(), false, false, "test")
if err != nil {
t.Fatalf("离职物化失败: %v", err)
}
if res.Deleted != 1 {
t.Fatalf("应按 confirmed_leave 删除 1 个用户,实际 deleted=%d", res.Deleted)
}
users, _ = conn.ListUsers("ou=people,dc=example,dc=com")
if _, still := users["bob"]; still {
t.Errorf("confirmed_leave 用户 bob 应被物理删除,仍存在")
}
if _, alive := users["alice"]; !alive {
t.Errorf("alice 不应被删除")
}
// 清理:删掉本次建的组与剩余用户,避免污染共享容器。
gid := 0
deps, _ := st.MapDepartmentsByID()
if d := deps["od-rd"]; d != nil {
gid = d.GroupID
}
if gid > 0 {
_ = gql.DeleteGroup(context.Background(), gid)
}
_ = gql.DeleteUser(context.Background(), "alice")
_ = gql.DeleteUser(context.Background(), "bob")
_ = gql.DeleteUser(context.Background(), "admin")
}
+175 -299
View File
@@ -1,10 +1,13 @@
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
//
// 设计要点:
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
// 执行共用同一份代码——预览即所得。
//
// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
// 状态才会生成删除操作,且删除永远排在最后一阶段。
package syncer
import (
@@ -24,10 +27,8 @@ type OpKind string
const (
OpCreateUser OpKind = "create_user"
OpUpdateUser OpKind = "update_user"
OpDeactivate OpKind = "deactivate_user"
OpReactivate OpKind = "reactivate_user"
OpDeleteUser OpKind = "delete_user"
OpCreateGroup OpKind = "create_group"
OpUpdateGroup OpKind = "update_group"
OpAddMember OpKind = "add_member"
OpRemoveMember OpKind = "remove_member"
)
@@ -37,30 +38,25 @@ type Op struct {
Kind OpKind `json:"kind"`
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
Target string `json:"target"`
// Name 是展示名,方便界面阅读。
// Name 是展示名(用户中文名 / 部门中文名)。
Name string `json:"name"`
// DN 是操作涉及的条目 DN(新建时为目标 DN)。
// DN 是操作涉及的组/用户 DN。
DN string `json:"dn,omitempty"`
// Detail 是人类可读的说明,直接展示在预览页。
Detail string `json:"detail"`
// 以下字段供执行器使用,不参与 JSON 展示。
User *mapping.User `json:"-"`
Group *mapping.Group `json:"-"`
MemberDN string `json:"-"`
GroupDN string `json:"-"`
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。
TargetDN string `json:"-"`
User *mapping.DesiredUser `json:"-"`
Group *mapping.DesiredGroup `json:"-"`
MemberUID string `json:"-"`
AttrsHash string `json:"-"`
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
// (例如父分组必须先于子分组创建)。
// Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
Seq int `json:"-"`
}
// Plan 是一次同步的完整计划。
// Plan 是一次物化的完整计划。
type Plan struct {
Ops []Op `json:"ops"`
// Warnings 是不阻塞执行、但需要人工留意的信息。
Warnings []string `json:"warnings"`
Stats Stats `json:"stats"`
}
@@ -69,67 +65,54 @@ type Plan struct {
type Stats struct {
CreateUser int `json:"createUser"`
UpdateUser int `json:"updateUser"`
Deactivate int `json:"deactivateUser"`
Reactivate int `json:"reactivateUser"`
DeleteUser int `json:"deleteUser"`
CreateGroup int `json:"createGroup"`
UpdateGroup int `json:"updateGroup"`
AddMember int `json:"addMember"`
RemoveMember int `json:"removeMember"`
TotalAccounts int `json:"totalAccounts"`
TotalUsers int `json:"totalUsers"`
Unchanged int `json:"unchanged"`
}
// Input 是计算计划所需的全部输入。
// Input 是计算物化计划所需的全部输入。
type Input struct {
// Desired 是期望状态(来自飞书)
Desired *mapping.State
// Active 是 ou=people 下现有用户:uid(小写)→ 条目
Active map[string]*ldaplib.Entry
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目
Inactive map[string]*ldaplib.Entry
// Groups 是现有分组:DN(小写)→ 条目
Groups map[string]*ldaplib.Entry
// LedgerAccounts 是台账中的账号:uid → state(active/inactive)
LedgerAccounts map[string]string
// LedgerGroups 是台账中的分组:dept_id → dn
LedgerGroups map[string]string
// AdminDN 与相关基础 DN
// Desired 是期望状态(来自镜像 BuildFromMirror)。
Desired *mapping.DesiredState
// ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。
ActualUsers map[string]*ldaplib.Entry
// ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。
ActualGroups map[string]*ldaplib.Entry
// OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。
OwnedUsers map[string]struct{}
// ConfirmedLeave 是已人工确认离职、待删除的用户集。
ConfirmedLeave map[string]bool
// AdminDN 与基础 OU DN。
AdminDN string
PeopleOU string
GroupRootDN string
UserOUDN string
InactiveOUDN string
}
// opPhase 决定操作的执行顺序。
// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
//
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
// 集成测试立刻暴露了这个问题。
// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
func opPhase(k OpKind) int {
switch k {
case OpCreateGroup, OpUpdateGroup:
case OpCreateGroup:
return 0
case OpCreateUser, OpUpdateUser:
return 1
case OpReactivate:
return 2
case OpAddMember:
return 3
return 2
case OpRemoveMember:
return 3
case OpDeleteUser:
return 4
case OpDeactivate:
return 5
default:
return 6
return 5
}
}
// sortOps 按依赖顺序排列操作。
//
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
// “od-root” 之前,导致子分组先于父分组创建而失败)。
// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
func sortOps(ops []Op) {
sort.SliceStable(ops, func(i, j int) bool {
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
@@ -140,288 +123,171 @@ func sortOps(ops []Op) {
})
}
// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。
// ComputePlan 计算物化计划。它是纯函数,不产生任何副作用。
func ComputePlan(in *Input) *Plan {
p := &Plan{
Ops: make([]Op, 0, 64),
Warnings: append([]string{}, in.Desired.Warnings...),
Stats: Stats{
TotalAccounts: len(in.LedgerAccounts),
},
Warnings: make([]string, 0),
Stats: Stats{TotalUsers: len(in.ActualUsers)},
}
planGroups(p, in)
planUsers(p, in)
planMembership(p, in)
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
// (mapping 已按父先于子的顺序产出分组)。
for i := range p.Ops {
p.Ops[i].Seq = i
}
sortOps(p.Ops)
return p
}
// planGroups 处理分组的创建与更新。
// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
func planGroups(p *Plan, in *Input) {
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
for _, g := range in.Desired.Groups {
existing, exists := in.Groups[strings.ToLower(g.DN)]
desired[strings.ToLower(g.DN)] = g
}
if !exists {
p.Ops = append(p.Ops, Op{
Kind: OpCreateGroup,
Target: g.DeptID,
Name: g.Name,
DN: g.DN,
Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN),
Group: g,
})
p.Stats.CreateGroup++
for _, g := range in.Desired.Groups {
if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
continue
}
// 分组已存在:仅当描述不一致时更新。
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理,
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。
if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateGroup,
Target: g.DeptID,
Name: g.Name,
DN: g.DN,
Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name),
Group: g,
Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g,
Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN),
})
p.Stats.UpdateGroup++
p.Stats.CreateGroup++
}
// 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
for _, e := range in.ActualGroups {
if _, ok := desired[strings.ToLower(e.DN)]; ok {
continue
}
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN))
}
}
// 台账中存在、但期望状态里已消失的分组:不自动删除。
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
wantedDept := make(map[string]string, len(in.Desired.Groups))
for _, g := range in.Desired.Groups {
wantedDept[g.DeptID] = g.DN
}
for deptID, dn := range in.LedgerGroups {
wantDN, stillWanted := wantedDept[deptID]
switch {
case !stillWanted:
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn))
case !strings.EqualFold(wantDN, dn):
// 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的
// uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)",
deptID, dn, wantDN))
}
}
}
// planUsers 处理用户的创建、更新、停用与恢复。
// planUsers 处理用户的创建、更新与删除。
func planUsers(p *Plan, in *Input) {
desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users))
desiredByUID := make(map[string]*mapping.DesiredUser, len(in.Desired.Users))
for _, u := range in.Desired.Users {
desiredByUID[u.UID] = u
}
for _, u := range in.Desired.Users {
_, inActiveOU := in.Active[u.UID]
_, inInactiveOU := in.Inactive[u.UID]
switch {
case inActiveOU:
// 已存在且在职:比对属性是否漂移。
existing := in.Active[u.UID]
canonical := strings.ToLower(existing.DN)
// DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。
// 用大小写无关比较:LDAP 服务端可能对 DN 做规范化,
// 直接字符串比较会造成每轮都产生无意义的“更新”。
if !strings.EqualFold(existing.DN, u.DN) {
existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
if !ok {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN),
User: u,
TargetDN: canonical,
Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName),
})
p.Stats.UpdateUser++
p.Stats.CreateUser++
continue
}
if attrsDiffer(existing, u) {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: "用户属性发生变化:" + describeAttrDiff(existing, u),
User: u,
TargetDN: canonical,
Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: "用户属性变化:" + describeAttrDiff(existing, u),
})
p.Stats.UpdateUser++
continue
}
p.Stats.Unchanged++
}
case inInactiveOU:
// 存在于 ou=Inactive 但期望是在职:恢复。
// 仅当台账确认该账号由我们管理时才动它。
if _, managed := in.LedgerAccounts[u.UID]; !managed {
// 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
// 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
for uid := range in.ActualUsers {
if _, want := desiredByUID[uid]; want {
continue
}
if _, owned := in.OwnedUsers[uid]; !owned {
continue
}
if !in.ConfirmedLeave[uid] {
p.Warnings = append(p.Warnings,
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpReactivate,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
User: u,
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
Kind: OpDeleteUser, Target: uid,
Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
DN: ldaplib.UserDN(uid, in.PeopleOU),
Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
})
p.Stats.Reactivate++
default:
// 两处都没有:新建。
p.Ops = append(p.Ops, Op{
Kind: OpCreateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN),
User: u,
})
p.Stats.CreateUser++
p.Stats.DeleteUser++
}
}
// 台账中在职、但期望状态里已消失:候选停用。
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
for uid, state := range in.LedgerAccounts {
if state != "active" {
continue
}
if _, ok := desiredByUID[uid]; ok {
continue
}
entry, ok := in.Active[uid]
if !ok {
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
p.Warnings = append(p.Warnings,
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpDeactivate,
Target: uid,
Name: entry.Get(ldaplib.AttrDisplayName),
DN: ldaplib.UserDN(uid, in.InactiveOUDN),
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
TargetDN: strings.ToLower(entry.DN),
})
p.Stats.Deactivate++
}
}
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
// planMembership 比对组的成员,产出加组/移组操作。
func planMembership(p *Plan, in *Input) {
for _, g := range in.Desired.Groups {
existing, ok := in.Groups[strings.ToLower(g.DN)]
if !ok {
// 分组是新建的,成员关系会在创建后由执行器处理;
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。
for memberDN := range g.Members {
p.Ops = append(p.Ops, Op{
Kind: OpAddMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: g.DN,
MemberDN: memberDN,
})
p.Stats.AddMember++
}
continue
e, ok := in.ActualGroups[strings.ToLower(g.DN)]
actual := make(map[string]struct{})
if ok {
actual = e.Members()
}
desired := lowerMembers(g.Members)
actual := existing.Members()
// 期望有、实际没有 → 加入
for memberDN := range g.Members {
if _, has := actual[strings.ToLower(memberDN)]; has {
continue
}
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpAddMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
})
p.Stats.AddMember++
}
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
if !ok {
continue
}
// 移出:实际有、期望没有,且只移受管、永不移 admin。
for memberDN := range actual {
if _, want := g.Members[memberDN]; want {
if _, want := desired[memberDN]; want {
continue
}
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
continue
}
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
if !isManagedMember(memberDN, in) {
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
if _, managed := in.OwnedUsers[uid]; !managed {
p.Warnings = append(p.Warnings,
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpRemoveMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
})
p.Stats.RemoveMember++
}
}
}
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
//
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
func isManagedMember(memberDN string, in *Input) bool {
uid, ok := uidFromDN(memberDN)
if !ok {
return false
// lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。
func lowerMembers(m map[string]struct{}) map[string]struct{} {
out := make(map[string]struct{}, len(m))
for dn := range m {
out[strings.ToLower(dn)] = struct{}{}
}
if _, managed := in.LedgerAccounts[uid]; managed {
return true
}
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
for _, u := range in.Desired.Users {
if u.UID == uid {
return true
}
}
return false
return out
}
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
func uidFromDN(dn string) (string, bool) {
first := dn
if i := strings.Index(dn, ","); i >= 0 {
@@ -434,33 +300,19 @@ func uidFromDN(dn string) (string, bool) {
return strings.ToLower(strings.TrimSpace(first[4:])), true
}
// memberRDN 从成员 DN 中提取用于展示的标识。
func memberRDN(dn string) string {
if uid, ok := uidFromDN(dn); ok {
return uid
}
return dn
}
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
// attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。
//
// LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName);
// 默认不建自定义属性,因此只比对这四类可写硬字段。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool {
checks := []struct {
attr string
want string
}{
{ldaplib.AttrCN, want.CN},
{ldaplib.AttrSN, want.SN},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrMail, want.Mail},
{ldaplib.AttrMobile, want.Mobile},
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
{ldaplib.AttrPostalAddress, want.PostalAddress},
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
{ldaplib.AttrDescription, want.Description},
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
{ldaplib.AttrEmployeeType, want.EmployeeType},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrSN, want.SN},
}
for _, c := range checks {
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
@@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
}
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
fields := []struct {
label string
attr string
@@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
}{
{"mail", ldaplib.AttrMail, want.Mail},
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
{"mobile", ldaplib.AttrMobile, want.Mobile},
{"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
{"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
{"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress},
{"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory},
{"givenName", ldaplib.AttrGivenName, want.GivenName},
{"sn", ldaplib.AttrSN, want.SN},
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
}
var diffs []string
for _, f := range fields {
old := existing.Get(f.attr)
@@ -498,27 +343,58 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
}
if len(diffs) == 0 {
return "属性无变化"
return "无变化"
}
return strings.Join(diffs, "; ")
}
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
func normalizeAttr(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
func AttrsHash(u *mapping.User) string {
// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
func AttrsHash(u *mapping.DesiredUser) string {
h := sha256.New()
for _, v := range []string{
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
u.BusinessCategory, u.Description,
} {
for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} {
h.Write([]byte(v))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
// Guard 是执行前的安全阈值。
type Guard struct {
// MaxDeactivate 单次允许删除的最大账号数,0 表示不限制。
MaxDeactivate int
// MaxDeactivatePercent 单次允许删除的账号比例(0-1),0 表示不限制。
MaxDeactivatePercent float64
// AllowBulk 显式跳过全部阈值检查。
AllowBulk bool
}
// GuardError 表示计划未通过安全阈值检查。
type GuardError struct{ Message string }
func (e *GuardError) Error() string { return e.Message }
// Check 校验计划是否满足安全阈值。
func (g Guard) Check(p *Plan) error {
if g.AllowBulk {
return nil
}
deletes := p.Stats.DeleteUser
if deletes == 0 {
return nil
}
if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)}
}
if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 {
ratio := float64(deletes) / float64(p.Stats.TotalUsers)
if ratio > g.MaxDeactivatePercent {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)}
}
}
return nil
}
+169 -266
View File
@@ -9,43 +9,42 @@ import (
)
const (
testUserOU = "ou=people,dc=example,dc=com"
testInactiveOU = "ou=Inactive,dc=example,dc=com"
testGroupRoot = "ou=feishuroot,dc=example,dc=com"
testAdminDN = "cn=admin,dc=example,dc=com"
peopleOU = "ou=people,dc=example,dc=com"
groupRoot = "ou=groups,dc=example,dc=com"
testAdmin = "uid=admin," + peopleOU
)
// entry 构造一个 LDAP 条目,便于测试。
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
return ldaplib.NewEntry(dn, attrs, members)
}
func newUser(uid string) *mapping.User {
return &mapping.User{
UID: uid,
DN: "uid=" + uid + "," + testUserOU,
InactiveDN: "uid=" + uid + "," + testInactiveOU,
CN: uid,
SN: uid,
GivenName: uid,
DisplayName: uid,
Mail: uid + "@example.com",
Description: uid,
func dUser(uid string) *mapping.DesiredUser {
return &mapping.DesiredUser{
UID: uid, DN: "uid=" + uid + "," + peopleOU,
Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid,
}
}
func baseInput(desired *mapping.State) *Input {
func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup {
ms := make(map[string]struct{}, len(members))
for _, m := range members {
ms[m] = struct{}{}
}
return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms}
}
func depName(deptID string) string { return "部门-" + deptID }
func baseIn(desired *mapping.DesiredState) *Input {
return &Input{
Desired: desired,
Active: map[string]*ldaplib.Entry{},
Inactive: map[string]*ldaplib.Entry{},
Groups: map[string]*ldaplib.Entry{},
LedgerAccounts: map[string]string{},
LedgerGroups: map[string]string{},
AdminDN: testAdminDN,
GroupRootDN: testGroupRoot,
UserOUDN: testUserOU,
InactiveOUDN: testInactiveOU,
ActualUsers: map[string]*ldaplib.Entry{},
ActualGroups: map[string]*ldaplib.Entry{},
OwnedUsers: map[string]struct{}{},
ConfirmedLeave: map[string]bool{},
AdminDN: testAdmin,
PeopleOU: peopleOU,
GroupRootDN: groupRoot,
}
}
@@ -57,313 +56,224 @@ func kindsOf(plan *Plan) map[OpKind]int {
return out
}
func plainDesired() *mapping.DesiredState {
return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}}
}
func TestComputePlan(t *testing.T) {
rdDN := "cn=od-rd," + groupRoot
tests := []struct {
name string
desired *mapping.State
desired *mapping.DesiredState
mutate func(in *Input)
wantKinds map[OpKind]int
// wantDetailContains 断言某个操作详情包含特定文本
wantDetailContains string
// forbidKinds 断言这些操作绝不出现(安全性回归)
forbidKinds []OpKind
forbid []OpKind
wantWarn string
}{
{
name: "全新用户:创建",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withUsers(plainDesired(), dUser("alice")),
wantKinds: map[OpKind]int{OpCreateUser: 1},
},
{
name: "属性一致:不产生操作",
desired: &mapping.State{
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withUsers(plainDesired(), dUser("alice")),
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrSN: u.SN,
ldaplib.AttrGivenName: u.GivenName,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: u.Mail,
ldaplib.AttrMobile: u.Mobile,
ldaplib.AttrEmployeeNumber: u.EmployeeNumber,
ldaplib.AttrDepartmentNumber: u.DepartmentNumber,
ldaplib.AttrPostalAddress: u.PostalAddress,
ldaplib.AttrBusinessCategory: u.BusinessCategory,
ldaplib.AttrDescription: u.Description,
u := dUser("alice")
in.ActualUsers["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN,
}, nil)
in.LedgerAccounts["alice"] = "active"
in.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{},
},
{
name: "邮箱变化:更新属性",
desired: &mapping.State{
Users: []*mapping.User{
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
mutate: func(in *Input) {
u := newUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN,
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: "old@example.com",
in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{
ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice",
ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice",
}, nil)
in.LedgerAccounts["alice"] = "active"
in.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{OpUpdateUser: 1},
wantDetailContains: "alice.new@example.com",
},
{
name: "台账在职但飞书消失:停用",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
desired: plainDesired(),
mutate: func(in *Input) {
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Bob",
}, nil)
in.LedgerAccounts["bob"] = "active"
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{
ldaplib.AttrDisplayName: "Bob"}, nil)
in.OwnedUsers["bob"] = struct{}{}
in.ConfirmedLeave["bob"] = true
},
wantKinds: map[OpKind]int{OpDeactivate: 1},
wantKinds: map[OpKind]int{OpDeleteUser: 1},
},
{
name: "非台账账号缺席:不得停用(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
desired: plainDesired(),
mutate: func(in *Input) {
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
ldaplib.AttrDisplayName: "Service",
}, nil)
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
in.OwnedUsers["bob"] = struct{}{}
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpDeactivate},
forbid: []OpKind{OpDeleteUser},
wantWarn: "未完成离职确认",
},
{
name: "在 Inactive 且台账受管:恢复",
desired: &mapping.State{
Users: []*mapping.User{newUser("carol")},
GroupByDeptID: map[string]*mapping.Group{},
},
name: "在 LLDAP、非受管(服务账号):绝不删除",
desired: plainDesired(),
mutate: func(in *Input) {
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
ldaplib.AttrDisplayName: "Carol",
}, nil)
in.LedgerAccounts["carol"] = "inactive"
},
wantKinds: map[OpKind]int{OpReactivate: 1},
},
{
name: "在 Inactive 但非台账:只告警不改动",
desired: &mapping.State{
Users: []*mapping.User{newUser("dave")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpReactivate},
forbid: []OpKind{OpDeleteUser},
},
{
name: "分组不存在:创建并加成员",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
name: "分组不存在:创建",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
wantKinds: map[OpKind]int{OpCreateGroup: 1},
},
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
name: "孤儿组(actual 有、期望没有):不删只告警",
desired: plainDesired(),
mutate: func(in *Input) {
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
map[string]string{ldaplib.AttrDescription: "x"}, nil)
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
wantKinds: map[OpKind]int{},
forbid: []OpKind{OpCreateGroup},
wantWarn: "未自动删除",
},
{
name: "成员缺席:加入分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
in.LedgerAccounts["alice"] = "active"
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
in.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{OpAddMember: 1},
},
{
name: "成员多余且受管:移出分组",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=alice," + testUserOU})
in.LedgerAccounts["alice"] = "active"
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
[]string{testAdmin, "uid=alice," + peopleOU})
in.OwnedUsers["alice"] = struct{}{}
},
wantKinds: map[OpKind]int{OpRemoveMember: 1},
},
{
name: "admin 永不被移出分组(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
[]string{testAdmin})
in.OwnedUsers["admin"] = struct{}{}
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
forbid: []OpKind{OpRemoveMember},
},
{
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
// 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。
name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
name: "外部非受管成员:不移出,仅告警",
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
mutate: func(in *Input) {
adminUIDDN := "uid=admin," + testUserOU
in.AdminDN = adminUIDDN
in.LedgerAccounts["admin"] = "active"
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{adminUIDDN})
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
[]string{testAdmin, "uid=external," + peopleOU})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
},
{
name: "外部手工成员:不移出,仅告警(关键安全回归)",
desired: &mapping.State{
Users: []*mapping.User{},
Groups: []*mapping.Group{
{
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
Members: map[string]struct{}{},
},
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN, "uid=external," + testUserOU})
},
wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember},
forbid: []OpKind{OpRemoveMember},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := baseInput(tt.desired)
in := baseIn(tt.desired)
if tt.mutate != nil {
tt.mutate(in)
}
plan := ComputePlan(in)
got := kindsOf(plan)
for kind, want := range tt.wantKinds {
if got[kind] != want {
t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s",
kind, got[kind], want, dumpPlan(plan))
t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan))
}
}
for kind, count := range got {
if _, expected := tt.wantKinds[kind]; !expected {
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s",
kind, count, dumpPlan(plan))
if _, exp := tt.wantKinds[kind]; !exp {
t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
}
}
for _, kind := range tt.forbidKinds {
for _, kind := range tt.forbid {
if got[kind] > 0 {
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan))
}
}
if tt.wantDetailContains != "" {
if tt.wantWarn != "" {
found := false
for _, op := range plan.Ops {
if strings.Contains(op.Detail, tt.wantDetailContains) {
for _, w := range plan.Warnings {
if strings.Contains(w, tt.wantWarn) {
found = true
break
}
}
if !found {
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
tt.wantDetailContains, dumpPlan(plan))
t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
}
}
})
}
}
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试:
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。
func TestComputePlanNeverTouchesPassword(t *testing.T) {
u := newUser("alice")
in := baseInput(&mapping.State{
Users: []*mapping.User{u},
GroupByDeptID: map[string]*mapping.Group{},
})
in.LedgerAccounts["bob"] = "active"
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil)
// TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。
func TestDeleteIsLastPhase(t *testing.T) {
rdDN := "cn=od-rd," + groupRoot
desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice"))
in := baseIn(desired)
in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{
ldaplib.AttrDisplayName: "alice"}, nil)
in.OwnedUsers["alice"] = struct{}{}
in.OwnedUsers["bob"] = struct{}{}
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
in.ConfirmedLeave["bob"] = true
in.OwnedUsers["carol"] = struct{}{}
in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil)
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU})
plan := ComputePlan(in)
// bob:删除;alice 创建;carol 移出分组。
// 任何删除操作都必须排在任何创建/加/移之后。
maxOther, minDelete := -1, 1<<30
for i, op := range plan.Ops {
if op.Kind == OpDeleteUser {
if i < minDelete {
minDelete = i
}
} else {
if i > maxOther {
maxOther = i
}
}
}
if minDelete <= maxOther {
t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan))
}
}
// TestComputePlanNeverTouchesPassword 是结构性回归:
// 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。
func TestComputePlanNeverTouchesPassword(t *testing.T) {
in := baseIn(withUsers(plainDesired(), dUser("alice")))
plan := ComputePlan(in)
for _, op := range plan.Ops {
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
if strings.Contains(strings.ToLower(op.Detail), "userpassword") ||
strings.Contains(strings.ToLower(op.Detail), "password") {
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
}
}
}
@@ -375,40 +285,17 @@ func TestGuard(t *testing.T) {
plan *Plan
wantErr bool
}{
{
name: "无停用时通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}},
},
{
name: "超过绝对上限被拒",
guard: Guard{MaxDeactivate: 5},
plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}},
wantErr: true,
},
{
name: "超过比例上限被拒",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}},
wantErr: true,
},
{
name: "比例在阈值内通过",
guard: Guard{MaxDeactivatePercent: 0.2},
plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}},
},
{
name: "强制模式跳过检查",
guard: Guard{MaxDeactivate: 1, AllowBulk: true},
plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}},
},
{"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false},
{"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true},
{"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true},
{"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false},
{"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.guard.Check(tt.plan)
if tt.wantErr && err == nil {
t.Errorf("期望返回错误,实际通过")
t.Errorf("期望报错,实际通过")
}
if !tt.wantErr && err != nil {
t.Errorf("期望通过,实际报错: %v", err)
@@ -417,14 +304,30 @@ func TestGuard(t *testing.T) {
}
}
func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState {
s.Users = append(s.Users, us...)
return s
}
func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState {
s.Groups = append(s.Groups, gs...)
if s.GroupByDeptID == nil {
s.GroupByDeptID = map[string]*mapping.DesiredGroup{}
}
for _, g := range gs {
s.GroupByDeptID[g.DeptID] = g
}
return s
}
func dumpPlan(p *Plan) string {
var b strings.Builder
for _, op := range p.Ops {
b.WriteString("\n ")
b.WriteString("\n [")
b.WriteString(string(op.Kind))
b.WriteString(" target=")
b.WriteString("] ")
b.WriteString(op.Target)
b.WriteString(" detail=")
b.WriteString(": ")
b.WriteString(op.Detail)
}
for _, w := range p.Warnings {
+95 -190
View File
@@ -1,58 +1,80 @@
package syncer
// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。
//
// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化
// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"sync"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/mapping"
"idsync/internal/store"
)
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
// Runner 编排快照与物化。
type Runner struct {
cfg *config.Config
feishu *feishu.Client
ldap *ldaplib.Client
gql *lldapgql.Client
store *store.Store
snapshot *SnapshotRunner
log *slog.Logger
// guard 是执行前的安全阈值。
guard Guard
// mu 让快照与物化在单实例内互斥。
mu sync.Mutex
groupIDs map[string]int
}
// NewRunner 构造编排器。
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, g *lldapgql.Client,
s *store.Store, log *slog.Logger, opts SnapshotOptions) *Runner {
return &Runner{
cfg: cfg,
feishu: f,
ldap: l,
gql: g,
store: s,
snapshot: NewSnapshotRunner(cfg, f, s, log, opts),
log: log,
guard: Guard{
MaxDeactivate: cfg.Sync.MaxDeactivate,
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
MaxDeactivate: cfg.Guard.MaxDeactivate,
MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent,
},
}
}
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
// 因此预览内容与真正会发生的事情一致。
// RunSnapshot 触发一轮快照(飞书 → 镜像 + 离职流程推进)。
func (r *Runner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.snapshot.RunSnapshot(ctx)
}
// Preview 只计算物化计划,不做任何写入。dry-run 与真正执行共用同一份计划代码。
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
plan, _, err := r.plan(ctx)
return plan, err
}
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
// Run 执行一轮物化(镜像 → LLDAP)。dryRun 为 true 时只计算并返回计划。
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
run, err := r.store.StartRun(modeOf(dryRun), trigger)
if err != nil {
return nil, nil, fmt.Errorf("创建同步记录失败: %w", err)
return nil, nil, fmt.Errorf("创建物化记录失败: %w", err)
}
plan, desired, err := r.prepare(ctx, run, force)
@@ -63,7 +85,7 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
fillRunFromPlan(run, plan)
run.Status = "success"
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err)
r.log.Warn("写入物化记录失败", "err", err)
}
return plan, nil, nil
}
@@ -71,17 +93,16 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
res := r.apply(ctx, plan)
fillRunFromResult(run, plan, res)
// 只有全部成功才回写台账:部分失败时保持台账不变,
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
// 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。
if res.Failed == 0 {
if err := r.persist(plan, desired); err != nil {
if err := r.persist(ctx, plan, desired); err != nil {
run.Status = "failed"
run.ErrorMsg = "回写台账失败: " + err.Error()
run.ErrorMsg = "回写镜像失败: " + err.Error()
_ = r.store.FinishRun(run)
return plan, res, fmt.Errorf("回写台账失败: %w", err)
return plan, res, fmt.Errorf("回写镜像失败: %w", err)
}
} else {
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed)
r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed)
}
run.Status = "success"
@@ -90,14 +111,14 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
run.ErrorMsg = strings.Join(res.Errors, "; ")
}
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err)
r.log.Warn("写入物化记录失败", "err", err)
}
return plan, res, nil
}
// prepare 计算计划并做安全检查。
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) {
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.DesiredState, error) {
plan, desired, err := r.plan(ctx)
if err != nil {
run.Status = "failed"
@@ -106,8 +127,6 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
return nil, nil, err
}
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
guard := r.guard
if force {
guard.AllowBulk = true
@@ -121,215 +140,104 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
return plan, desired, nil
}
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
// plan 读两侧状态并计算物化计划。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) {
// 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。
activeUsers, err := r.store.ActiveUsersWithDepts()
if err != nil {
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
return nil, nil, err
}
r.log.Info("已读取飞书数据",
"departments", len(snap.Departments), "users", len(snap.Users),
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
var skipWarnings []string
for _, u := range snap.SkippedNoEmail {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
feishu.DisplayName(u), u.UnionID))
}
for _, d := range snap.SkippedDuplicate {
skipWarnings = append(skipWarnings, fmt.Sprintf(
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
d.UID, strings.Join(d.Names, "、")))
depts, err := r.store.ActiveDepartments()
if err != nil {
return nil, nil, err
}
desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN)
desired := mapping.Build(snap, mapping.BuildOptions{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
})
// 实际侧:读 LLDAP 的 LDAP 视图。
conn, err := r.ldap.Dial()
if err != nil {
return nil, nil, err
}
defer conn.Close()
active, err := conn.ListUsers(r.cfg.UserOUDN())
actualUsers, err := conn.ListUsers(r.cfg.UserOUDN())
if err != nil {
return nil, nil, err
}
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
if err != nil {
return nil, nil, err
}
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil {
return nil, nil, err
}
accounts, err := r.store.AccountsByUID()
// 所有权与删除白名单都来自镜像台账。
all, err := r.store.AllMirrorUsers()
if err != nil {
return nil, nil, err
}
groupLedger, err := r.store.GroupsByDeptID()
if err != nil {
return nil, nil, err
owned := make(map[string]struct{}, len(all))
confirmed := make(map[string]bool)
for _, u := range all {
if u.Skipped {
continue
}
ledgerAccounts := make(map[string]string, len(accounts))
for uid, a := range accounts {
ledgerAccounts[uid] = a.State
owned[u.UID] = struct{}{}
if u.State == store.StateConfirmedLeave {
confirmed[u.UID] = true
}
ledgerGroups := make(map[string]string, len(groupLedger))
for deptID, g := range groupLedger {
ledgerGroups[deptID] = g.DN
}
plan := ComputePlan(&Input{
Desired: desired,
Active: active,
Inactive: inactive,
Groups: groups,
LedgerAccounts: ledgerAccounts,
LedgerGroups: ledgerGroups,
ActualUsers: actualUsers,
ActualGroups: actualGroups,
OwnedUsers: owned,
ConfirmedLeave: confirmed,
AdminDN: r.cfg.Ldap.AdminDN,
PeopleOU: r.cfg.UserOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
})
plan.Warnings = append(skipWarnings, plan.Warnings...)
// 对“候选停用”的用户,向飞书确认是否真的离职。
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
if err := r.confirmResigned(ctx, plan, snap); err != nil {
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
}
return plan, desired, nil
}
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
var uids []string
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
accounts, err := r.store.AccountsByUID()
if err != nil {
return err
}
for i := range plan.Ops {
if plan.Ops[i].Kind != OpDeactivate {
continue
}
acc, ok := accounts[plan.Ops[i].Target]
if !ok || acc.UnionID == "" {
continue
}
uids = append(uids, acc.UnionID)
}
if len(uids) == 0 {
return nil
}
resigned, err := r.feishu.CheckResigned(ctx, uids)
if err != nil {
return err
}
confirmed := make(map[string]bool, len(resigned))
for _, rr := range resigned {
confirmed[rr.UnionID] = true
}
keep := make([]Op, 0, len(plan.Ops))
for _, op := range plan.Ops {
if op.Kind != OpDeactivate {
keep = append(keep, op)
continue
}
acc := accounts[op.Target]
if acc != nil && confirmed[acc.UnionID] {
keep = append(keep, op)
continue
}
// 未能确认离职:不动作,并明确告知。
plan.Stats.Deactivate--
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
op.Target, r.cfg.InactiveOUDN()))
}
plan.Ops = keep
return nil
}
// apply 执行计划。
// apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
exec := NewExecutor(r.ldap, ExecConfig{
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
GroupRootName: r.cfg.Ldap.GroupRootName,
AdminDN: r.cfg.Ldap.AdminDN,
InitialPassword: r.cfg.Ldap.InitialPassword,
}, r.log)
return exec.Apply(ctx, plan)
if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil {
return &Result{Failed: 1, Errors: []string{err.Error()}}
}
groups, err := r.gql.Groups(ctx)
if err != nil {
return &Result{Failed: 1, Errors: []string{err.Error()}}
}
groupIDs := make(map[string]int, len(groups))
for _, g := range groups {
groupIDs[g.DisplayName] = g.ID
}
// persist 把本次同步的期望状态回写台账。
//
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
now := time.Now()
// 分组:凡在本次期望范围内的都登记。
for _, g := range desired.Groups {
if err := r.store.UpsertGroup(&store.Group{
DeptID: g.DeptID,
DN: g.DN,
Name: g.Name,
ParentID: g.ParentID,
State: "active",
LastSyncedAt: now,
}); err != nil {
return err
}
exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log)
res := exec.Apply(ctx, plan, groupIDs)
r.groupIDs = groupIDs
return res
}
// 用户:在职的登记为 active。
// persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。
func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error {
for _, u := range desired.Users {
if err := r.store.UpsertAccount(&store.Account{
UID: u.UID,
DN: u.DN,
Email: u.Mail,
Name: u.DisplayName,
State: "active",
AttrsHash: AttrsHash(u),
DeptIDs: strings.Join(u.DeptIDs, ","),
LastSyncedAt: now,
}); err != nil {
if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil {
return err
}
}
for deptID, gid := range r.groupIDs {
if err := r.store.SetDeptGroupID(deptID, gid); err != nil {
return err
}
}
// 停用的账号:更新状态与,DN 指向离职 OU。
for _, op := range plan.Ops {
if op.Kind == OpDeactivate {
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil {
if op.Kind == OpDeleteUser {
if err := r.store.MarkDeleted(op.Target); err != nil {
return err
}
}
}
return nil
}
@@ -341,22 +249,19 @@ func modeOf(dryRun bool) string {
return "apply"
}
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。
// fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup +
plan.Stats.Reactivate + plan.Stats.Deactivate
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
run.Updated = plan.Stats.UpdateUser
run.Added = plan.Stats.AddMember
run.Removed = plan.Stats.RemoveMember
run.Skipped = plan.Stats.Unchanged
}
// fillRunFromResult 用实际执行结果填充同步记录。
// fillRunFromResult 用实际执行结果填充物化记录。
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
run.Created = res.Created
run.Updated = res.Updated
run.Moved = res.Moved
run.Added = res.Added
run.Removed = res.Removed
run.Failed = res.Failed
+354
View File
@@ -0,0 +1,354 @@
package syncer
// 快照 runner:拉取飞书全量 → 事务写入 SQLite 镜像 → 离职生命周期 reconcile。
//
// 这是两阶段架构的「第一段」:它只把「飞书当前有什么」落到镜像,并把离职流程
// 推进到「已发卡片待人工确认」为止。物化(写 LLDAP)由另一条链路承担,本 runner
// 不碰 LLDAP,也不做任何写入决策。
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/mapping"
"idsync/internal/store"
)
// SnapshotOptions 是快照 runner 的运行参数。
type SnapshotOptions struct {
// WebhookURL 是离职确认卡片的自定义机器人地址。
WebhookURL string
// ConfirmBaseURL 是确认端点的对外 base-url(可被公网/反代访问)。
ConfirmBaseURL string
// ReNotifyInterval 是离职提醒的最短间隔,缺省 24h,防刷屏。
ReNotifyInterval time.Duration
// TokenTTL 是一次性确认 token 的有效期,缺省 72h。
TokenTTL time.Duration
}
func (o SnapshotOptions) withDefaults() SnapshotOptions {
if o.ReNotifyInterval <= 0 {
o.ReNotifyInterval = 24 * time.Hour
}
if o.TokenTTL <= 0 {
o.TokenTTL = 72 * time.Hour
}
return o
}
// SnapshotRunResult 是一次快照执行的统计结果。
type SnapshotRunResult struct {
Users int
Departments int
Skipped int
LeaveCandidates int
PendingConfirm int
Reinstated int
CardsSent int
Warnings []string
}
// SnapshotRunner 编排快照流程。
type SnapshotRunner struct {
cfg *config.Config
feishu *feishu.Client
store *store.Store
log *slog.Logger
opts SnapshotOptions
}
// NewSnapshotRunner 构造快照 runner。
func NewSnapshotRunner(cfg *config.Config, f *feishu.Client, st *store.Store, log *slog.Logger, opts SnapshotOptions) *SnapshotRunner {
return &SnapshotRunner{cfg: cfg, feishu: f, store: st, log: log, opts: opts.withDefaults()}
}
// RunSnapshot 执行一轮快照:拉取、落库、推进离职流程。
func (r *SnapshotRunner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) {
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
if err != nil {
return nil, fmt.Errorf("读取飞书快照失败: %w", err)
}
r.log.Info("已读取飞书快照",
"departments", len(snap.Departments), "users", len(snap.Users),
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
mirror := r.buildMirror(snap)
if err := r.store.ReplaceMirror(mirror); err != nil {
return nil, fmt.Errorf("写入 SQLite 镜像失败: %w", err)
}
present := make(map[string]bool, len(mirror.Users))
for _, u := range mirror.Users {
present[u.UID] = true
}
res := &SnapshotRunResult{
Users: len(mirror.Users),
Departments: len(snap.Departments),
Skipped: len(mirror.Skipped),
}
if err := r.reconcile(ctx, present, res); err != nil {
return res, err
}
return res, nil
}
// buildMirror 把飞书快照转成镜像存储层需要的输入。
//
// 部门组 DN 用稳定索引派生(mapping.DeptGroupDN),可读中文名只落 name 列供展示。
func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapshot {
deptName := make(map[string]string, len(snap.Departments))
for _, d := range snap.Departments {
deptName[d.ID] = d.Name
}
depts := make([]*store.MirrorDepartment, 0, len(snap.Departments))
for _, d := range snap.Departments {
depts = append(depts, &store.MirrorDepartment{
DeptID: d.ID, Name: d.Name, DN: mapping.DeptGroupDN(d.ID, r.cfg.Ldap.BaseDN),
ParentID: d.ParentID, Level: deptLevel(d.ID, snap.Departments), State: store.StateActive,
})
}
users := make([]*store.MirrorUser, 0, len(snap.Users))
for _, u := range snap.Users {
uid, ok := feishu.LoginName(u)
if !ok {
continue // 已在 SkippedNoEmail 报告中,不必重复
}
users = append(users, toMirrorUser(u, uid, deptName))
}
// 缺少企业邮箱的用户以 union_id 作为 uid 落一条 skipped 记录,供 WebUI 列出。
skipped := make([]*store.MirrorUser, 0, len(snap.SkippedNoEmail))
for _, u := range snap.SkippedNoEmail {
skipped = append(skipped, &store.MirrorUser{
UID: u.UnionID,
UnionID: u.UnionID,
UserID: u.UserID,
DisplayName: feishu.DisplayName(u),
DepartmentIDs: u.DepartmentIDs,
State: store.StateActive,
})
}
return &store.MirrorSnapshot{Users: users, Departments: depts, Skipped: skipped}
}
// deptLevel 计算部门在树中的层级,根为 0。带步数上限防环。
func deptLevel(id string, depts []*feishu.Department) int {
byID := make(map[string]*feishu.Department, len(depts))
for _, d := range depts {
byID[d.ID] = d
}
depth := 0
cur := id
for i := 0; i < 64; i++ {
d, ok := byID[cur]
if !ok || d.ParentID == "" || d.ParentID == "0" {
return depth
}
if _, ok := byID[d.ParentID]; !ok {
return depth // 父不在同步范围内,视为根
}
depth++
cur = d.ParentID
}
return depth
}
// toMirrorUser 把飞书用户域模型转成镜像用户。字段语义与 mapping 一致,
// business_category 展开为部门中文名便于人工阅读。
func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser {
var bc []string
for _, id := range u.DepartmentIDs {
if n, ok := deptName[id]; ok && n != "" {
bc = append(bc, n)
}
}
sort.Strings(bc)
return &store.MirrorUser{
UID: uid,
UnionID: u.UnionID,
UserID: u.UserID,
Email: u.EnterpriseEmail,
DisplayName: firstNonEmpty(u.Name, u.EnName, uid),
GivenName: firstNonEmpty(u.EnName, u.Name, uid),
SN: firstName(u),
Mobile: u.Mobile,
EmployeeNumber: u.EmployeeNo,
DepartmentNumber: u.JobTitle,
PostalAddress: u.WorkStation,
BusinessCategory: strings.Join(bc, ","),
EmployeeType: mapping.EmployeeTypeActive,
Description: firstNonEmpty(u.Name, u.EnName, uid),
DepartmentIDs: u.DepartmentIDs,
State: store.StateActive,
}
}
// reconcile 处理镜像里每个用户的生命周期:本轮在飞书可见者撤销离职流程;
// 缺席者在职用户做机器确认,确认后进发卡流程;已在流程内者按节流策略补发提醒。
//
// 这里的每个状态转移都基于 store 的受校验状态机,任何非法转移会被结构性拦下。
func (r *SnapshotRunner) reconcile(ctx context.Context, present map[string]bool, res *SnapshotRunResult) error {
all, err := r.store.AllMirrorUsers()
if err != nil {
return err
}
for _, u := range all {
if u.Skipped {
continue
}
if present[u.UID] {
// 本轮在飞书可见:撤销离职流程并作废未用的 token。
switch u.State {
case store.StateLeaveCandidate, store.StatePendingConfirm,
store.StateConfirmedLeave, store.StateDeleted:
// 含已删除用户重新入职:置回 active,物化以「重建」处理。
if err := r.store.RevertToActive(u.UID); err != nil {
return fmt.Errorf("撤销离职流程 uid=%s: %w", u.UID, err)
}
if err := r.store.RevokeTokensForUser(u.UID); err != nil {
return fmt.Errorf("作废离职 token uid=%s: %w", u.UID, err)
}
res.Reinstated++
r.log.Info("用户重新可见,已撤销离职流程", "uid", u.UID)
}
continue
}
// 本轮缺席。
switch u.State {
case store.StateActive:
if err := r.handleAbsentCandidate(ctx, u, res); err != nil {
return err
}
case store.StateLeaveCandidate:
// 上轮已机器确认离职、卡片尚未发出,本轮补发。
if err := r.sendResignCard(ctx, u); err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职卡片发送失败,稍后重试: %v", u.UID, err))
continue
}
res.CardsSent++
if err := r.store.ToPendingConfirm(u.UID); err != nil {
return fmt.Errorf("转入待确认 uid=%s: %w", u.UID, err)
}
res.PendingConfirm++
case store.StatePendingConfirm:
if err := r.renotify(ctx, u, res); err != nil {
return err
}
case store.StateConfirmedLeave, store.StateDeleted:
// 已人工确认、等待物化删除一组:不再打扰。
}
}
return nil
}
// handleAbsentCandidate 处理「在职但本轮缺席」的用户:先做飞书机器确认,
// 确认离职才进入发卡流程;未确认或缺 union_id 时保守不动作并告警。
func (r *SnapshotRunner) handleAbsentCandidate(ctx context.Context, u *store.MirrorUser, res *SnapshotRunResult) error {
if u.UnionID == "" {
res.Warnings = append(res.Warnings, fmt.Sprintf("用户 %s 缺席但无 union_id,无法机器确认离职,暂不动作", u.UID))
return nil
}
resigned, err := r.feishu.CheckResigned(ctx, []string{u.UnionID})
if err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("确认 uid=%s 离职状态失败,本轮不动: %v", u.UID, err))
return nil
}
if len(resigned) == 0 {
res.Warnings = append(res.Warnings, fmt.Sprintf("用户 %s 缺席但飞书未确认离职(可能因通讯录权限范围收窄),暂不动作", u.UID))
return nil
}
if err := r.store.ToLeaveCandidate(u.UID); err != nil {
return fmt.Errorf("标记离职候选 uid=%s: %w", u.UID, err)
}
res.LeaveCandidates++
if err := r.sendResignCard(ctx, u); err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职卡片发送失败,稍后重试: %v", u.UID, err))
return nil
}
res.CardsSent++
if err := r.store.ToPendingConfirm(u.UID); err != nil {
return fmt.Errorf("转入待确认 uid=%s: %w", u.UID, err)
}
res.PendingConfirm++
return nil
}
// renotify 对「已待确认但长期未点」的用户按节流间隔重发提醒,避免每轮刷屏。
// 超时后发一张新卡片并作废旧 token,卡内的新 token 重新起效。
func (r *SnapshotRunner) renotify(ctx context.Context, u *store.MirrorUser, res *SnapshotRunResult) error {
last, err := r.store.LastPendingToken(u.UID)
if err != nil {
return err
}
if last != nil && time.Since(last.CreatedAt) < r.opts.ReNotifyInterval {
return nil // 节流:距上次提醒不足间隔,不重发、不打扰
}
if err := r.sendResignCard(ctx, u); err != nil {
res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职提醒发送失败: %v", u.UID, err))
return nil
}
res.CardsSent++
return nil
}
// sendResignCard 为某用户发一张离职确认卡片:生成一次性 token(作废旧 token,
// 保证任一时刻至多一张有效的确认链接,减少不确定的凭证)、构造确认 URL、发送。
func (r *SnapshotRunner) sendResignCard(ctx context.Context, u *store.MirrorUser) error {
// 先作废旧 token 再发新 token,避免卡片按钮连着多张「都能确认」的链接。
if err := r.store.RevokeTokensForUser(u.UID); err != nil {
return err
}
tok, err := r.store.CreateToken(u.UID, r.opts.TokenTTL)
if err != nil {
return err
}
confirmURL := config.ConfirmURL(r.opts.ConfirmBaseURL, u.UID, tok.Token)
payload, err := feishu.BuildResignCardPayload(&feishu.ResignCard{
DisplayName: u.DisplayName,
UID: u.UID,
Reason: "飞书通讯录中已不可见,经机器确认已离职,请人工复核后确认。",
ConfirmURL: confirmURL,
})
if err != nil {
return err
}
return feishu.SendResignCard(ctx, r.opts.WebhookURL, payload)
}
// firstName 为 SN 提供非空值(与映射语义一致)。
func firstName(u *feishu.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
if n := strings.TrimSpace(u.EnName); n != "" {
return n
}
return u.UnionID
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+340
View File
@@ -0,0 +1,340 @@
package syncer
// 快照 runner 的本地链路测试:用一个假飞书 HTTP 服务 + 一个假 webhook,
// 验证「拉快照 → 落库镜像 → 离职机器确认 → 发卡片 → 状态机转移 → 节流 → 重新入职」。
//
// 不需要真实飞书凭据,也不需要 LLDAP——快照段根本不碰 LLDAP。
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/store"
)
// sfDept 是假飞书里的部门。
type sfDept struct {
openID string
parentID string
name string
}
// sfUser 是假飞书里的用户。
type sfUser struct {
unionID string
name string
enName string
enterpriseEm string
mobile string
employeeNo string
jobTitle string
deptIDs []string
resigned bool
}
// sfFeishu 只实现快照链路需要的接口。
type sfFeishu struct {
depts []sfDept
users []sfUser
}
func (f *sfFeishu) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeSf(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
})
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
parent := r.URL.Query().Get("parent_department_id")
if parent == "" {
parent = "0"
}
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true}
if fetchChild {
for changed := true; changed; {
changed = false
for _, d := range f.depts {
if !want[d.openID] && want[d.parentID] {
want[d.openID] = true
changed = true
}
}
}
}
items := make([]map[string]any, 0)
for _, d := range f.depts {
if !want[d.openID] || d.openID == parent {
continue
}
items = append(items, map[string]any{
"name": d.name,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
})
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0)
for _, u := range f.users {
if u.resigned {
continue
}
if !contains(u.deptIDs, deptID) {
continue
}
items = append(items, map[string]any{
"union_id": u.unionID,
"name": u.name,
"en_name": u.enName,
"enterprise_email": u.enterpriseEm,
"mobile": u.mobile,
"employee_no": u.employeeNo,
"job_title": u.jobTitle,
"department_ids": u.deptIDs,
})
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
})
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1]
for _, u := range f.users {
if u.unionID != unionID {
continue
}
writeSf(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{
"union_id": unionID,
"name": u.name,
"status": map[string]any{"is_resigned": u.resigned},
}}})
return
}
writeSf(w, map[string]any{"code": 10001, "msg": "user not found"})
})
return mux
}
func writeSf(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// fakeWebhook 是假 webhook,统计收到卡片的次数并返回成功。
type fakeWebhook struct {
posts int
body []byte
srv *httptest.Server
}
func newFakeWebhook() *fakeWebhook {
fw := &fakeWebhook{}
fw.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fw.posts++
fw.body, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
}))
return fw
}
func (fw *fakeWebhook) Close() { fw.srv.Close() }
// snapshotHarness 组装快照 runner 所需的整套环境。
type snapshotHarness struct {
fs *sfFeishu
webhook *fakeWebhook
store *store.Store
runner *SnapshotRunner
cfg *config.Config
}
func newSnapshotHarness(t *testing.T) *snapshotHarness {
t.Helper()
fs := &sfFeishu{depts: []sfDept{
{openID: "od-acme", parentID: "0", name: "Acme"},
{openID: "od-yanfa", parentID: "od-acme", name: "yanfa"},
}}
fs.users = []sfUser{
{unionID: "u-alice", name: "Zhang San", enName: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-yanfa"}},
{unionID: "u-bob", name: "Li Ming", enName: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-yanfa"}},
}
srv := httptest.NewServer(fs.handler())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
fc, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake", BaseURL: srv.URL, QPS: 100, Timeout: 10 * time.Second,
}, log)
if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "idsync.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("打开 store 失败: %v", err)
}
cfg := &config.Config{
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
Ldap: config.Ldap{BaseDN: "dc=example,dc=com"},
}
webhook := newFakeWebhook()
runner := NewSnapshotRunner(cfg, fc, st, log, SnapshotOptions{
WebhookURL: webhook.srv.URL,
ConfirmBaseURL: "https://confirm.example.com",
ReNotifyInterval: 24 * time.Hour,
TokenTTL: 72 * time.Hour,
})
t.Cleanup(func() {
srv.Close()
webhook.Close()
_ = st.Close()
})
return &snapshotHarness{fs: fs, webhook: webhook, store: st, runner: runner, cfg: cfg}
}
func (h *snapshotHarness) run(t *testing.T) *SnapshotRunResult {
t.Helper()
res, err := h.runner.RunSnapshot(t.Context())
if err != nil {
t.Fatalf("RunSnapshot 失败: %v", err)
}
return res
}
func userState(t *testing.T, h *snapshotHarness, uid string) string {
t.Helper()
m, err := h.store.MapUsersByUID()
if err != nil {
t.Fatalf("读取镜像失败: %v", err)
}
u, ok := m[uid]
if !ok {
t.Fatalf("镜像中不存在 uid=%s", uid)
}
return u.State
}
// TestSnapshotLeaveLifecycle 覆盖完整链路:快照落库 → 离职机器确认 → 发卡片 →
// 状态机转移 → 节流不刷屏 → 重新入职撤销离职。
func TestSnapshotLeaveLifecycle(t *testing.T) {
h := newSnapshotHarness(t)
// 第 1 轮:全员在职,只落库,不发卡片、不产生离职候选。
res := h.run(t)
if res.CardsSent != 0 || res.LeaveCandidates != 0 || res.PendingConfirm != 0 {
t.Errorf("首轮不应触发离职流程: %+v", res)
}
if res.Users != 2 {
t.Errorf("第 1 轮应落 2 个用户,实际 %d", res.Users)
}
if userState(t, h, "bob") != store.StateActive {
t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob"))
}
// 部门镜像应持久化稳定索引派生出的组 DN。
dm, err := h.store.MapDepartmentsByID()
if err != nil {
t.Fatalf("读取部门镜像失败: %v", err)
}
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=od-yanfa,ou=groups,dc=example,dc=com" {
t.Errorf("od-yanfa DN = %+v, 期望索引派生 cn=<dept_id>", d)
}
// 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),
// 但 Get 仍能查到并返回 is_resigned=true → 应机器确认、发 1 张卡片并转入 pending_confirm。
markResigned(h.fs, "u-bob", true)
res = h.run(t)
if res.CardsSent != 1 {
t.Errorf("bob 离职应发 1 张卡片,实际 %d", res.CardsSent)
}
if res.PendingConfirm != 1 {
t.Errorf("bob 应转入 pending_confirm,实际 %d", res.PendingConfirm)
}
if userState(t, h, "bob") != store.StatePendingConfirm {
t.Errorf("bob 应为 pending_confirm,实际 %q", userState(t, h, "bob"))
}
// 卡片应指向一次性确认端点。
if !strings.Contains(string(h.webhook.body), "/api/resign/confirm") {
t.Errorf("卡片载荷应包含确认端点地址")
}
// 第 3 轮(立即):bob 仍缺席且待确认,应被节流,不再发卡片。
res = h.run(t)
if h.webhook.posts != 1 {
t.Errorf("节流未生效:离上次提醒不足间隔又发出了卡片,当前 %d 次", h.webhook.posts)
}
// 第 4 轮:bob 重新可见 → 撤销离职流程并作废 token。
markResigned(h.fs, "u-bob", false)
res = h.run(t)
if res.Reinstated != 1 {
t.Errorf("bob 重新入职应计 1 次撤销,实际 %d", res.Reinstated)
}
if userState(t, h, "bob") != store.StateActive {
t.Errorf("bob 撤销后应为 active,实际 %q", userState(t, h, "bob"))
}
toks, _ := h.store.LastPendingToken("bob")
if toks != nil {
t.Errorf("bob 的 token 应已作废,仍有 pending 记录")
}
}
// TestSnapshotSkippedUserRecorded 验证缺少企业邮箱的用户被跳过但落了 skipped 记录。
func TestSnapshotSkippedUserRecorded(t *testing.T) {
h := newSnapshotHarness(t)
h.fs.users = []sfUser{
{unionID: "u-nomail", name: "Wang Wu", enterpriseEm: "", deptIDs: []string{"od-yanfa"}},
}
res := h.run(t)
if res.Skipped != 1 {
t.Errorf("应跳过 1 个无邮箱用户,实际 %d", res.Skipped)
}
if res.Users != 0 {
t.Errorf("无邮箱用户不应进入在职视图,实际 %d", res.Users)
}
skipped, err := h.store.SkippedUsers()
if err != nil || len(skipped) != 1 {
t.Fatalf("应有一条 skipped 记录: %v", err)
}
if skipped[0].UID != "u-nomail" {
t.Errorf("skipped 记录 uid = %q, 期望以 union_id 落库", skipped[0].UID)
}
}
func markResigned(f *sfFeishu, unionID string, resigned bool) {
for i := range f.users {
if f.users[i].unionID == unionID {
f.users[i].resigned = resigned
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+106 -64
View File
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户',
update_user: '更新用户',
deactivate_user: '迁入离职 OU',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
delete_user: '删除用户',
add_member: '加入分组',
remove_member: '移出分组',
};
const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info',
update_group: 'badge-info',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
delete_user: 'badge-error',
add_member: 'badge-success',
remove_member: 'badge-error',
};
// 用户生命周期状态的中文标注。
const STATE_BADGES = {
active: ['在职', 'badge-success'],
leave_candidate: ['待发离职确认', 'badge-warning'],
pending_confirm: ['待人工确认离职', 'badge-warning'],
confirmed_leave: ['已确认离职', 'badge-error'],
deleted: ['已删除', 'badge-soft'],
};
// ---------------------------------------------------------------- 主题
function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000);
}
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换
function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === name);
});
if (name === 'overview') loadStatus();
if (name === 'overview') { loadStatus(); loadPending(); }
if (name === 'users') loadUsers();
if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() {
const errEl = $('#loginError');
errEl.classList.add('hidden');
try {
await api('/api/login', {
method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() {
try {
await api('/api/logout', { method: 'POST' });
} catch (_) {
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
} catch (_) { /* 登出失败也要回登录页 */ }
showLogin();
}
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() {
try {
const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers;
$('#statGroups').textContent = d.counts.groups;
$('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
: '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) {
showError(err.message);
}
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`;
}
// ---------------------------------------------------------------- 离职待确认
async function loadPending() {
try {
const d = await api('/api/resign/pending');
const rows = d.pending || [];
$('#pendingRows').innerHTML = rows.length
? rows.map((u) => `
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
<div>
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
</div>
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
</li>`).join('')
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
} catch (err) {
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
}
}
// ---------------------------------------------------------------- 快照
async function doSnapshot() {
const btn = $('#snapshotBtn');
btn.disabled = true;
btn.textContent = '快照中…';
try {
await api('/api/snapshot', { method: 'POST' });
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
$('#globalError').className = 'alert alert-success text-sm mb-4';
loadPending();
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = '立即快照';
}
}
// ---------------------------------------------------------------- 用户
let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users');
usersCache = d.users || [];
renderUsers();
hideError();
} catch (err) {
showError(err.message);
}
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
});
$('#userRows').innerHTML = rows.map((u) => `
<tr>
$('#userRows').innerHTML = rows.map((u) => {
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive'
? '<span class="badge badge-error badge-sm">离职</span>'
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
<td>${(u.deptIds || []).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
<td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
</tr>`).join('');
</tr>`;
}).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
}
// ---------------------------------------------------------------- 分组
// ---------------------------------------------------------------- 部门分组
async function loadGroups() {
try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => `
<tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td>${escapeHtml(g.DeptID || '—')}</td>
<td>${escapeHtml(g.ParentID || '—')}</td>
<td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden');
try {
const plan = await api('/api/plan');
currentPlan = plan;
renderPlan(plan);
hideError();
} catch (err) {
showError(err.message);
}
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) {
const s = plan.stats || {};
const cards = [
['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['删除用户', s.deleteUser, 'text-error'],
['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'],
@@ -300,7 +337,7 @@ function renderPlan(plan) {
const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => `
<tr>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-soft'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td>
</tr>`).join('');
@@ -308,17 +345,25 @@ function renderPlan(plan) {
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
}
async function applyPlan(force = false) {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
// FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
// 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
function openApplyModal() {
if (window.HSOverlay) window.HSOverlay.open('#applyModal');
}
function closeApplyModal() {
if (window.HSOverlay) window.HSOverlay.close('#applyModal');
}
async function applyPlan(force = false) {
closeApplyModal();
try {
const d = await api('/api/sync', {
method: 'POST',
body: JSON.stringify({ dryRun: false, force }),
});
const res = d.result || {};
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs');
} catch (err) {
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
}
}
// ---------------------------------------------------------------- 同步历史
// ---------------------------------------------------------------- 物化历史
async function loadRuns() {
try {
@@ -336,18 +381,17 @@ async function loadRuns() {
<tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run'
? '<span class="badge badge-ghost badge-sm">预览</span>'
? '<span class="badge badge-soft badge-sm">预览</span>'
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
<td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return;
const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) {
showError('没有需要应用的变更。');
return;
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = `
<div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`;
const modal = document.getElementById('applyModal');
if (modal && typeof modal.showModal === 'function') modal.showModal();
openApplyModal();
});
$('#applyCancel').addEventListener('click', () => {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
// 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面
+49 -17
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1>
</div>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
@@ -37,17 +37,17 @@
<div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
</div>
<div class="flex-none flex items-center gap-2">
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
<button id="themeToggle" class="btn btn-sm btn-soft" title="切换明暗主题">主题</button>
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
</div>
</header>
<main class="max-w-7xl mx-auto p-4">
<!-- 页签 -->
<div class="tabs tabs-border mb-4" role="tablist">
<div class="tabs tabs-bordered mb-4" role="tablist">
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
<button class="tab" data-view="users" role="tab">用户</button>
<button class="tab" data-view="groups" role="tab">部门分组</button>
@@ -68,8 +68,8 @@
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span>
<span id="statInactive" class="text-3xl font-semibold">–</span>
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statPending" class="text-3xl font-semibold">–</span>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body">
<h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div>
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
请到用户页查看并处理,避免他们一直没被同步。
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">上次同步</h2>
<h2 class="card-title text-base">上次物化</h2>
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div>
</div>
</div>
<!-- 离职待确认 -->
<div class="card bg-base-100 shadow-sm mt-4">
<div class="card-body">
<div class="flex items-center justify-between mb-1">
<h2 class="card-title text-base">离职待确认</h2>
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
</div>
<p class="text-sm text-base-content/60 mb-2">
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
</p>
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
</div>
</div>
</section>
<!-- 用户 -->
@@ -153,6 +171,7 @@
</p>
</div>
<div class="flex gap-2">
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div>
@@ -208,20 +227,33 @@
</main>
</div>
<!-- 应用变更的确认对话框 -->
<dialog id="applyModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
<!-- 应用变更的确认对话框。
必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
<div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
<button type="button" class="btn btn-text btn-circle btn-sm absolute end-3 top-3"
aria-label="关闭" data-overlay="#applyModal">✕</button>
</div>
<div class="modal-body">
<p class="text-sm text-base-content/70 mb-4">
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p>
<div id="applyModalBody" class="text-sm mb-4"></div>
<div class="modal-action">
<button id="applyCancel" class="btn btn-ghost">取消</button>
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
<div id="applyModalBody" class="text-sm"></div>
</div>
<div class="modal-footer">
<button id="applyCancel" type="button" class="btn btn-soft" data-overlay="#applyModal">取消</button>
<button id="applyConfirm" type="button" class="btn btn-primary">确认执行</button>
</div>
</div>
</div>
</div>
</dialog>
<script src="/static/flyonui.js"></script>
<script src="/static/app.js"></script>
+120 -83
View File
@@ -17,6 +17,7 @@ import (
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/server"
"idsync/internal/store"
"idsync/internal/syncer"
@@ -28,9 +29,10 @@ var version = "dev"
func main() {
var (
cfgPath = flag.String("config", "config.yml", "配置文件路径")
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP")
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)")
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏")
dryRun = flag.Bool("dry-run", false, "只计算并打印物化计划,不写入 LLDAP")
applyAll = flag.Bool("apply", false, "直接执行同步(快照+物化,默认只做 dry-run)")
force = flag.Bool("force", false, "忽略删除数量阈值等安全护栏")
snapshot = flag.Bool("snapshot", false, "只执行一轮快照(飞书→镜像+离职流程)后退出")
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
@@ -38,25 +40,15 @@ func main() {
flag.Parse()
if *genSecret {
s, err := server.GenerateSecret()
if err != nil {
fatal("生成密钥失败", err)
}
fmt.Println(s)
fmt.Println(mustGenSecret())
return
}
if *hashPass != "" {
h, err := server.HashPassword(*hashPass)
if err != nil {
fatal("生成哈希失败", err)
}
fmt.Println(h)
fmt.Println(mustHashPassword(*hashPass))
return
}
// 配置自检:把解析后的有效值打出来(凭据脱敏)。
// 用于在真正连接目录前确认关键项,尤其是分组形态——
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
if *checkCfg {
cfg, err := config.Load(*cfgPath)
if err != nil {
@@ -81,16 +73,20 @@ func main() {
fatal("创建数据目录失败", err)
}
// 读通道:LLDAP 的 LDAP(连接/搜索)。
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil {
fatal("初始化 LDAP 客户端失败", err)
fatal("初始化 LDAP 读客户端失败", err)
}
if err := ldapClient.Ping(); err != nil {
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
} else {
log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
log.Info("LDAP 读通道正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
}
// 写通道:LLDAP 的 GraphQL。
gqlClient := lldapgql.New(cfg.Lldap.BaseURL, log)
feishuClient, err := feishu.New(feishu.Options{
AppID: cfg.Feishu.AppID,
AppSecret: cfg.Feishu.AppSecret,
@@ -104,19 +100,28 @@ func main() {
db, err := store.Open(cfg.Database.Source)
if err != nil {
fatal("打开台账数据库失败", err)
fatal("打开镜像数据库失败", err)
}
defer db.Close()
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log)
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gqlClient, db, log, syncer.SnapshotOptions{
WebhookURL: cfg.Feishu.WebhookURL,
ConfirmBaseURL: cfg.Server.ConfirmBaseURL,
ReNotifyInterval: 24 * time.Hour,
TokenTTL: 72 * time.Hour,
})
// 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。
// 命令行模式:跑一轮后退出,便于放进 crontab 或手工验证。
if *snapshot {
runSnapshotOnce(runner, log)
return
}
if *dryRun || *applyAll {
runOnce(cfg, runner, *applyAll, *force, log)
return
}
// 服务模式
// 服务模式。
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
if err != nil {
fatal("初始化服务失败", err)
@@ -135,10 +140,10 @@ func main() {
}
}()
if cfg.Sync.Enable {
scheduleSync(cfg, runner, log)
if cfg.Schedule.Enable {
scheduleAuto(cfg, runner, log)
} else {
log.Info("定时同步未启用(sync.enable=false)")
log.Info("定时任务未启用(schedule.enable=false)")
}
quit := make(chan os.Signal, 1)
@@ -170,38 +175,24 @@ func printEffectiveConfig(cfg *config.Config) {
fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
fmt.Printf("飞书 离职卡片 webhook : %s\n", orDefault(cfg.Feishu.WebhookURL, "(未配置)"))
fmt.Println()
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL)
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN)
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN)
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS)
fmt.Printf("LDAP 读 url : %s\n", cfg.Ldap.URL)
fmt.Printf("LDAP 读 base-dn : %s\n", cfg.Ldap.BaseDN)
fmt.Printf("LDAP 读 admin-dn : %s\n", cfg.Ldap.AdminDN)
fmt.Printf("LDAP 读 admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
fmt.Printf("LLDAP 写 base-url : %s\n", cfg.Lldap.BaseURL)
fmt.Printf("LLDAP 写 admin-user : %s\n", cfg.Lldap.AdminUser)
fmt.Printf("LLDAP 写 admin-pass : %s\n", mask(cfg.Lldap.AdminPass))
fmt.Println()
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN())
fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN())
fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN())
fmt.Printf("用户 OU : %s\n", cfg.UserOUDN())
fmt.Printf("部门组 OU : %s\n", cfg.GroupRootDN())
fmt.Printf("离职确认端点对外 URL : %s\n", orDefault(cfg.Server.ConfirmBaseURL, "(未配置,离职卡片将无法提供确认链接)"))
fmt.Println()
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass)
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr)
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n",
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass)
fmt.Println()
fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)"))
fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n",
cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun)
fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n",
cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100)
// 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。
userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN()
if inactiveDN == userDN {
fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效")
} else if len(inactiveDN) > len(userDN) &&
inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户")
} else {
fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)")
}
fmt.Printf("定时任务 : enable=%v snapshot=%q sync=%q sync-dry-run=%v\n",
cfg.Schedule.Enable, cfg.Schedule.Snapshot, cfg.Schedule.Sync, cfg.Schedule.SyncDryRun)
fmt.Printf("删除阈值 : max=%d percent=%.0f%%\n",
cfg.Guard.MaxDeactivate, cfg.Guard.MaxDeactivatePercent*100)
}
func orDefault(v, def string) string {
@@ -211,26 +202,47 @@ func orDefault(v, def string) string {
return v
}
// runOnce 执行一次同步并打印计划,然后退出。
// runSnapshotOnce 只执行一轮快照并退出。
func runSnapshotOnce(runner *syncer.Runner, log *slog.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
res, err := runner.RunSnapshot(ctx)
if err != nil {
fatal("快照失败", err)
}
log.Info("快照完成",
"users", res.Users, "departments", res.Departments, "skipped", res.Skipped,
"leave_candidates", res.LeaveCandidates, "pending_confirm", res.PendingConfirm,
"reinstated", res.Reinstated, "cards_sent", res.CardsSent)
for _, w := range res.Warnings {
log.Warn("快照告警", "detail", w)
}
}
// runOnce 执行一轮「快照 + 物化」并打印计划,然后退出。
func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// 先快照(飞书→镜像),再物化(镜像→LLDAP)。
if _, err := runner.RunSnapshot(ctx); err != nil {
fatal("快照失败", err)
}
plan, res, err := runner.Run(ctx, !apply, force, "manual")
if plan != nil {
printPlanTo(plan)
}
if err != nil {
fatal("同步失败", err)
fatal("物化失败", err)
}
if !apply {
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
return
}
log.Info("同步完成",
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
log.Info("物化完成",
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
for _, e := range res.Errors {
log.Error("操作失败详情", "detail", e)
@@ -248,7 +260,6 @@ func buildServer(
) (*server.Server, error) {
secret := cfg.Server.SessionSecret
if secret == "" {
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
generated, err := server.GenerateSecret()
if err != nil {
return nil, fmt.Errorf("生成会话密钥失败: %w", err)
@@ -288,25 +299,36 @@ func buildServer(
}), nil
}
// scheduleSync 注册定时同步。
//
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
// scheduleAuto 注册两套定时任务:快照低频、物化略高频。输出到 runner 的互斥锁
// 保证两者绝不同时执行。
func scheduleAuto(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
c := cron.New(
cron.WithSeconds(),
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
)
_, err := c.AddFunc(cfg.Sync.Schedule, func() {
dry := cfg.Sync.DefaultDryRun
if _, err := c.AddFunc(cfg.Schedule.Snapshot, func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
log.Info("定时快照开始")
if res, err := runner.RunSnapshot(ctx); err != nil {
log.Error("定时快照失败", "err", err)
} else {
log.Info("定时快照完成",
"users", res.Users, "pending_confirm", res.PendingConfirm, "cards_sent", res.CardsSent)
}
}); err != nil {
log.Error("注册定时快照失败", "schedule", cfg.Schedule.Snapshot, "err", err)
}
log.Info("定时同步开始", "dry_run", dry)
if _, err := c.AddFunc(cfg.Schedule.Sync, func() {
dry := cfg.Schedule.SyncDryRun
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
log.Info("定时物化开始", "dry_run", dry)
plan, res, err := runner.Run(ctx, dry, false, "schedule")
if err != nil {
log.Error("定时同步失败", "err", err)
log.Error("定时物化失败", "err", err)
return
}
if dry {
@@ -314,34 +336,33 @@ func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
if plan != nil {
planned = len(plan.Ops)
}
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned)
log.Info("定时物化为 dry-run 模式,未写入变更", "planned_ops", planned)
return
}
log.Info("定时同步完成",
"created", res.Created, "updated", res.Updated,
"moved", res.Moved, "failed", res.Failed)
})
if err != nil {
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
return
log.Info("定时物化完成",
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
}); err != nil {
log.Error("注册定时物化失败", "schedule", cfg.Schedule.Sync, "err", err)
}
c.Start()
log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun)
log.Info("定时任务已启用",
"snapshot", cfg.Schedule.Snapshot, "sync", cfg.Schedule.Sync,
"sync_dry_run", cfg.Schedule.SyncDryRun)
}
func printPlanTo(p *syncer.Plan) {
fmt.Println("===== 同步计划 =====")
fmt.Println("===== 物化计划 =====")
s := p.Stats
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n",
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate)
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
fmt.Printf("新建用户 %d · 更新用户 %d · 删除用户 %d\n", s.CreateUser, s.UpdateUser, s.DeleteUser)
fmt.Printf("新建分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
s.CreateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
if len(p.Ops) > 0 {
fmt.Println("\n----- 操作明细 -----")
for _, op := range p.Ops {
fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail)
fmt.Printf(" [%-15s] %-22s %s\n", op.Kind, op.Target, op.Detail)
}
}
if len(p.Warnings) > 0 {
@@ -353,6 +374,22 @@ func printPlanTo(p *syncer.Plan) {
fmt.Println()
}
func mustGenSecret() string {
s, err := server.GenerateSecret()
if err != nil {
fatal("生成密钥失败", err)
}
return s
}
func mustHashPassword(plain string) string {
h, err := server.HashPassword(plain)
if err != nil {
fatal("生成哈希失败", err)
}
return h
}
func fatal(msg string, err error) {
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
os.Exit(1)
+106 -64
View File
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户',
update_user: '更新用户',
deactivate_user: '迁入离职 OU',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
delete_user: '删除用户',
add_member: '加入分组',
remove_member: '移出分组',
};
const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info',
update_group: 'badge-info',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
delete_user: 'badge-error',
add_member: 'badge-success',
remove_member: 'badge-error',
};
// 用户生命周期状态的中文标注。
const STATE_BADGES = {
active: ['在职', 'badge-success'],
leave_candidate: ['待发离职确认', 'badge-warning'],
pending_confirm: ['待人工确认离职', 'badge-warning'],
confirmed_leave: ['已确认离职', 'badge-error'],
deleted: ['已删除', 'badge-soft'],
};
// ---------------------------------------------------------------- 主题
function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000);
}
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换
function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === name);
});
if (name === 'overview') loadStatus();
if (name === 'overview') { loadStatus(); loadPending(); }
if (name === 'users') loadUsers();
if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() {
const errEl = $('#loginError');
errEl.classList.add('hidden');
try {
await api('/api/login', {
method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() {
try {
await api('/api/logout', { method: 'POST' });
} catch (_) {
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
} catch (_) { /* 登出失败也要回登录页 */ }
showLogin();
}
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() {
try {
const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers;
$('#statGroups').textContent = d.counts.groups;
$('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
: '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) {
showError(err.message);
}
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`;
}
// ---------------------------------------------------------------- 离职待确认
async function loadPending() {
try {
const d = await api('/api/resign/pending');
const rows = d.pending || [];
$('#pendingRows').innerHTML = rows.length
? rows.map((u) => `
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
<div>
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
</div>
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
</li>`).join('')
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
} catch (err) {
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
}
}
// ---------------------------------------------------------------- 快照
async function doSnapshot() {
const btn = $('#snapshotBtn');
btn.disabled = true;
btn.textContent = '快照中…';
try {
await api('/api/snapshot', { method: 'POST' });
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
$('#globalError').className = 'alert alert-success text-sm mb-4';
loadPending();
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = '立即快照';
}
}
// ---------------------------------------------------------------- 用户
let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users');
usersCache = d.users || [];
renderUsers();
hideError();
} catch (err) {
showError(err.message);
}
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
});
$('#userRows').innerHTML = rows.map((u) => `
<tr>
$('#userRows').innerHTML = rows.map((u) => {
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive'
? '<span class="badge badge-error badge-sm">离职</span>'
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
<td>${(u.deptIds || []).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
<td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
</tr>`).join('');
</tr>`;
}).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
}
// ---------------------------------------------------------------- 分组
// ---------------------------------------------------------------- 部门分组
async function loadGroups() {
try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => `
<tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td>${escapeHtml(g.DeptID || '—')}</td>
<td>${escapeHtml(g.ParentID || '—')}</td>
<td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden');
try {
const plan = await api('/api/plan');
currentPlan = plan;
renderPlan(plan);
hideError();
} catch (err) {
showError(err.message);
}
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) {
const s = plan.stats || {};
const cards = [
['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['删除用户', s.deleteUser, 'text-error'],
['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'],
@@ -300,7 +337,7 @@ function renderPlan(plan) {
const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => `
<tr>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-soft'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td>
</tr>`).join('');
@@ -308,17 +345,25 @@ function renderPlan(plan) {
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
}
async function applyPlan(force = false) {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
// FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
// 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
function openApplyModal() {
if (window.HSOverlay) window.HSOverlay.open('#applyModal');
}
function closeApplyModal() {
if (window.HSOverlay) window.HSOverlay.close('#applyModal');
}
async function applyPlan(force = false) {
closeApplyModal();
try {
const d = await api('/api/sync', {
method: 'POST',
body: JSON.stringify({ dryRun: false, force }),
});
const res = d.result || {};
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs');
} catch (err) {
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
}
}
// ---------------------------------------------------------------- 同步历史
// ---------------------------------------------------------------- 物化历史
async function loadRuns() {
try {
@@ -336,18 +381,17 @@ async function loadRuns() {
<tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run'
? '<span class="badge badge-ghost badge-sm">预览</span>'
? '<span class="badge badge-soft badge-sm">预览</span>'
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
<td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return;
const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) {
showError('没有需要应用的变更。');
return;
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = `
<div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`;
const modal = document.getElementById('applyModal');
if (modal && typeof modal.showModal === 'function') modal.showModal();
openApplyModal();
});
$('#applyCancel').addEventListener('click', () => {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
// 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面
+49 -17
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1>
</div>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
@@ -37,17 +37,17 @@
<div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
</div>
<div class="flex-none flex items-center gap-2">
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
<button id="themeToggle" class="btn btn-sm btn-soft" title="切换明暗主题">主题</button>
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
</div>
</header>
<main class="max-w-7xl mx-auto p-4">
<!-- 页签 -->
<div class="tabs tabs-border mb-4" role="tablist">
<div class="tabs tabs-bordered mb-4" role="tablist">
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
<button class="tab" data-view="users" role="tab">用户</button>
<button class="tab" data-view="groups" role="tab">部门分组</button>
@@ -68,8 +68,8 @@
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span>
<span id="statInactive" class="text-3xl font-semibold">–</span>
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statPending" class="text-3xl font-semibold">–</span>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body">
<h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div>
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
请到用户页查看并处理,避免他们一直没被同步。
</div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">上次同步</h2>
<h2 class="card-title text-base">上次物化</h2>
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div>
</div>
</div>
<!-- 离职待确认 -->
<div class="card bg-base-100 shadow-sm mt-4">
<div class="card-body">
<div class="flex items-center justify-between mb-1">
<h2 class="card-title text-base">离职待确认</h2>
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
</div>
<p class="text-sm text-base-content/60 mb-2">
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
</p>
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
</div>
</div>
</section>
<!-- 用户 -->
@@ -153,6 +171,7 @@
</p>
</div>
<div class="flex gap-2">
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div>
@@ -208,20 +227,33 @@
</main>
</div>
<!-- 应用变更的确认对话框 -->
<dialog id="applyModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
<!-- 应用变更的确认对话框。
必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
<div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
<button type="button" class="btn btn-text btn-circle btn-sm absolute end-3 top-3"
aria-label="关闭" data-overlay="#applyModal">✕</button>
</div>
<div class="modal-body">
<p class="text-sm text-base-content/70 mb-4">
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p>
<div id="applyModalBody" class="text-sm mb-4"></div>
<div class="modal-action">
<button id="applyCancel" class="btn btn-ghost">取消</button>
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
<div id="applyModalBody" class="text-sm"></div>
</div>
<div class="modal-footer">
<button id="applyCancel" type="button" class="btn btn-soft" data-overlay="#applyModal">取消</button>
<button id="applyConfirm" type="button" class="btn btn-primary">确认执行</button>
</div>
</div>
</div>
</div>
</dialog>
<script src="/static/flyonui.js"></script>
<script src="/static/app.js"></script>
+14
View File
@@ -42,6 +42,20 @@
--color-success-content: #071505;
}
/* FlyonUI 的 overlay 变体没有随 @plugin 自动注册:Tailwind v4 只会加载包内与插件同名的
* flyonui.css,而 overlay-open 等 @custom-variant 定义在包根的 variants.css 里,不会被引入。
* 不在这里补上,HTML 里的 overlay-open:opacity-100 就编译不出任何规则,modal 打开后仍是
* opacity:0 的透明层(表现为「页面变暗+点不了别处,但对话框不可见」)。 */
@custom-variant overlay-open {
&.open {
@slot;
}
.open & {
@slot;
}
}
/* 少量自定义样式:表格可横向滚动,长 DN 不撑破布局。 */
@layer components {
.idsync-dn {