Compare commits

..
11 Commits
Author SHA1 Message Date
caowangrenbo 801e8413a7 chore: .gitignore 忽略本地 .env 凭据 2026-09-30 00:51:39 +08:00
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
46 changed files with 5082 additions and 3187 deletions
+3
View File
@@ -11,5 +11,8 @@ backup/
# 真实配置含飞书与 LDAP 凭据,绝不入库 # 真实配置含飞书与 LDAP 凭据,绝不入库
config.yml config.yml
# Gitea token 等本地凭据,绝不入库
.env
# 前端依赖;构建产物 internal/webui/dist 需要提交,见 README # 前端依赖;构建产物 internal/webui/dist 需要提交,见 README
web/node_modules/ web/node_modules/
+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 容器内执行。 # 宿主未安装 Go,所有 Go 操作都在 podman 容器内执行。
# 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。 # 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。
@@ -32,9 +32,18 @@ test:
vet: vet:
$(PODMAN) $(GO_IMAGE) go vet ./... $(PODMAN) $(GO_IMAGE) go vet ./...
# 集成测试对真实 OpenLDAP 运行;容器内需能访问宿主的 slapd, # 集成测试对真实 LLDAP 运行(LDAP 3890 / GraphQL 17170),先 `make lldap` 起容器。
# 因此用 host.containers.internal 并放行 no_proxy,避免流量被代理拦走。 LDAP_TEST ?= ldap://127.0.0.1:3890
LDAP_TEST ?= ldap://host.containers.internal:3899 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 .PHONY: test-integration
test-integration: test-integration:
+131 -155
View File
@@ -1,176 +1,152 @@
# idsync # idsync — 飞书通讯录 → LLDAP 同步器
把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 `go-ldap-admin`。 把飞书通讯录同步到 [LLDAP](https://github.com/lldap/lldap),替代 OpenLDAP 时代的
`go-ldap-admin`。数据单向流动、两阶段:
## 它做什么,不做什么
**做**:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。
**不做**:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。
这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具**不在认证路径上**——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。
## 工作方式
``` ```
飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP 飞书通讯录 ─快照(低频)─▶ SQLite 镜像 ─物化(略高频)─▶ LLDAP(只在职)
▲ │ (全字段+部门关系+生命周期) │
└─────────────── SQLite 台账(所有权记录)◀───────────┘ ▼
离职:机器确认(飞书 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` 的 `@` 前缀 | | 飞书有什么数据、LDAP 该长什么样、两者差异是什么 | 谁有权限登录(Authelia) |
| `sn` | `name` | | 快照/物化调度、离职双确认状态机、离职卡片通知 | 认证、会话、登录页、密码重置 |
| `displayName` | `name` |
| `givenName` | `en_name`,为空回落 `name` |
| `mail` | **`enterprise_email`** |
| `mobile` | `mobile` |
| `employeeNumber` | `employee_no` |
| `departmentNumber` | `job_title` |
| `postalAddress` | `work_station` |
| `businessCategory` | 所属部门名(逗号拼接) |
| `employeeType` | `Active` / `Resigned` |
| `userPassword` | 仅创建时写入,**更新路径永不触碰** |
### 与 Authelia 的衔接 鉴权链是 `业务系统 → 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 的「忘记密码」——该流程不需要旧密码。 ```
internal/config 配置加载、默认值、校验、DN 拼接、两套 schedule
## 安全护栏 internal/feishu 只读飞书:拉通讯录、机器确认离职、发离职确认卡片(webhook)
internal/store SQLite 镜像:全字段用户 + 部门关系 + 生命周期状态 + 一次性 token
默认开启,用于防住「最坏的那一次同步」(飞书应用权限被收窄、token 指向错误租户、部门过滤写错,都会让期望状态突然大幅缩小): internal/mapping 镜像 → 期望 LLDAP 状态(纯函数);组名=部门稳定索引 cn=<dept_id>
internal/ldaplib LLDAP 读原语:连接/bind/搜索
1. **默认 dry-run**。命令行不加 `-apply`、界面不点确认,都不会写入任何内容。 internal/lldapgql LLDAP 写通道:GraphQL(登录 + 各 mutation),端点 /api/graphql
2. **停用阈值**。单次停用超过 `max-deactivate` 条或 `max-deactivate-percent` 比例时拒绝执行,需 `-force` 或界面强制确认。 internal/syncer diff 引擎(纯函数)+ 快照/物化编排(单实例互斥)
3. **所有权隔离**。非台账条目永不被修改或删除。 internal/server HTTP 管理界面 + 一次性离职确认端点(token 即凭证)
4. **admin 保护**。`admin` 永不被移出分组;外部手工加入的成员只告警、不移除。 internal/webui go:embed 前端产物 + ETag(内容指纹)
5. **部门树建树有环保护**,分组按层级排序,保证父分组先于子分组创建。
## 构建
宿主无需安装 Go 或 Node,全部通过容器完成(本机已有 `golang:1.26-trixie` 与 `node:20-alpine` 镜像):
```bash
make deps # 下载 Go 依赖
make css # 构建 WebUI 样式(需要 Node 容器)
make build # 产出 bin/idsync
make test # 单元测试
make vet
make run # 本地运行
``` ```
> **关于代理与网络**:若 `proxy.golang.org` 不可直连,`Makefile` 已在容器调用上带上 ### 关键约定
> `http_proxy`/`https_proxy` 并强制 `--network=host`。默认网络下容器内的 `127.0.0.1` 指向容器自身,
> 无法访问宿主代理——这是必须用 `--network=host` 的原因。
>
> 拉取基础镜像时同样需要代理(`podman pull` 不经由 Makefile):
> ```bash
> podman pull --http-proxy=http://127.0.0.1:10808 docker.io/library/golang:1.26-trixie
> # 或为本次会话导出: export http_proxy=http://127.0.0.1:10808
> ```
> 注意不要用 `containers.conf` 的 `[engine] env` 全局注入代理:那会让所有容器的 `http_proxy`
> 都指向宿主,导致容器内访问自身或本地服务时被代理拦截。
`internal/webui/dist/` 下的前端产物会被 `go:embed` 打进二进制,因此**部署时不需要 Node**。修改前端后需重新执行 `make css` 并重新编译。 - **只在职**:LLDAP 只有 `ou=people`,`uid` 取企业邮箱 `@` 前缀;缺少企业邮箱的用户
被跳过并记入 skipped(WebUI 可查,必须被管理者发现)。
### 集成测试 - **组名 = 部门稳定索引**:组 `displayName`/CN = `cn=<open_department_id>,<ou=groups>`
。用部门 ID 而非拼音——改名/挪动不改变组身份,避免「改名波及子孙组」;可读的部门
集成测试对真实的 OpenLDAP 运行,覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、modrdn 移动后密码是否保留、以及「更新绝不触碰 userPassword」: 中文名只存在数据库(`mirror_departments.name` / 用户 `business_category`),不进入
LDAP 组名。
```bash - **离职 = 双确认后才删**:缺席 → 飞书 `is_resigned` 机器确认 → 生成一次性 token 并发
podman run -d --name idsync-ldap -p 3899:389 \ 飞书管理群卡片(按钮跳本工具确认端点)→ 人工确认 → `confirmed_leave` → 下一轮物化
-e LDAP_ORGANISATION="Example Inc" -e LDAP_DOMAIN="example.com" \ 物理删除。两道闸缺一不可;用户重新可见即撤销并作废未用 token。
-e LDAP_ADMIN_PASSWORD=adminpass \ - **操作顺序是硬约束**:分组 → 用户 → 加组 → 移组 → **删除(最后一阶段,且只处理
docker.io/osixia/openldap:1.5.0 confirmed_leave)**。
- **所有权只认镜像台账**:镜像之外的条目(服务账号、手工账号、admin)永不入计划。
make test-integration - **快照与物化互斥**:共享一把单实例锁,绝不并发,防止物化读到半新半旧镜像污染 LLDAP。
```
端到端测试还会启动一个假的飞书 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`。
## 部署 ## 部署
### 1. 起 LLDAP
```bash ```bash
podman build -t idsync . podman run -d --name idsync-lldap \
podman run -d --name idsync -p 8899:8899 \ -e LLDAP_LDAP_BASE_DN=dc=example,dc=com \
-v $PWD/config.yml:/app/config.yml:ro \ -e LLDAP_JWT_SECRET='<random>' \
-v idsync-data:/app/data \ -e LLDAP_LDAP_USER_PASS='<admin-pass>' \
idsync -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 的同类方案。 ### 2. 配 Authelia(官方 lldap 模板,勿定制)
- 部门改名会导致分组 DN 变化。此时**不会自动改名**(自动改名会牵动成员的 `uniqueMember` 引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。
- 飞书侧删除的部门不会被自动删除分组,同样只告警。 ```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 后按实际环境修改。 # idsync 配置示例。复制为 config.yml 后按实际环境修改。
# #
# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。 # 两阶段架构:飞书 → SQLite 镜像 → LLDAP。
# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。 # - 快照:低频(默认每天一次),飞书 → 镜像 + 离职流程推进。
# - 物化:略高频(默认每 2–4 小时一次),镜像在职视图 → LLDAP(写走 GraphQL)。
# 本工具不参与认证链路:用户登录/密码重置/发信由 Authelia 负责,因此这里没有
# 认证相关配置,只有数据管道的调度与通道配置。
system: system:
# debug 会输出更详细的日志;生产环境用 release # debug 会输出更详细的日志;生产环境用 release
mode: release mode: release
# 台账数据库与运行数据目录 # 镜像数据库与运行数据目录
data-dir: data data-dir: data
server: server:
listen: 0.0.0.0:8899 listen: 0.0.0.0:8899
# 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关) # 管理界面自身的登录凭据(仅保护界面,与 LDAP 用户无关)
admin-user: admin admin-user: admin
# 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希 # 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希
admin-password: change-me admin-password: change-me
# 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码 # 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码
admin-password-hash: "" admin-password-hash: ""
# 会话签名密钥,用 `./idsync -gen-secret` 生成。 # 会话签名密钥,用 `./idsync -gen-secret` 生成。留空会自动生成临时密钥
# 留空会自动生成临时密钥,但重启后需要重新登录。
session-secret: "" session-secret: ""
# 经 HTTPS 反代访问时设为 true # 经 HTTPS 反代访问时设为 true
secure-cookie: false secure-cookie: false
# 离职确认端点的对外 base-url;必须是公网/反代可达地址。飞书离职卡片的
# 「确认离职」按钮 url 用它拼接,因此该项缺失时卡片将无法提供确认入口。
confirm-base-url: "https://idsync.example.com"
feishu: feishu:
app-id: "cli_xxxxxxxxxxxx" app-id: "cli_xxxxxxxxxxxx"
app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx" app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
# 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com # 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com
base-url: "" base-url: ""
# 留空表示同步全部部门;填部门 ID 则只同步这些部门, # 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除
# 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"])
dept-list: [] dept-list: []
# 调用飞书的速率上限(次/秒),避免触发频控 # 调用飞书的速率上限(次/秒),避免触发频控
qps: 5 qps: 5
timeout-sec: 30 timeout-sec: 30
# 离职确认卡片「自定义机器人」的 webhook 地址,发给用户管理群
webhook-url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"
ldap: ldap:
# 生产环境务必使用 ldaps:// 或开启 start-tls: # LLDAP 的 LDAP 读通道(连接/搜索)。生产建议 ldaps:// 或开启 start-tls:
# Authelia 重置密码时会把新密码经此连接发送。 # Authelia 重置密码时会把新密码经此连接发送。
url: ldaps://ldap.example.com:636 url: "ldaps://lldap.example.com:6360"
base-dn: "dc=example,dc=com" 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" 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 timeout-sec: 15
start-tls: false start-tls: false
# 仅自签证书的测试环境使用 # 仅自签证书的测试环境使用
insecure-skip-verify: false insecure-skip-verify: false
# 新建用户的初始密码。留空则每个用户随机生成,
# 用户首次登录应走 Authelia 的“忘记密码”自助重置。 lldap:
initial-password: "" # LLDAP 的 GraphQL 写通道地址(WebUI/GraphQL 端口)
base-url: "http://lldap:17170"
admin-user: admin
admin-pass: "change-me"
database: database:
source: data/idsync.db source: data/idsync.db
sync: schedule:
# 是否启用定时同步 # 是否启用定时任务
enable: true enable: true
# 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。 # 快照 cron(秒级):低频,默认每天 00:20
schedule: "0 40 0 * * *" snapshot: "0 20 0 * * *"
# true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。 # 物化 cron(秒级):略高频,默认每 2 小时 30 分
default-dry-run: true sync: "0 30 */2 * * *"
# 单次同步允许停用的账号数上限,0 表示不限制 # 物化是否默认 dry-run。快照永远直接落库;建议先观察几轮物化计划再放开。
sync-dry-run: true
guard:
# 单次物化允许删除(离职确认后)的账号数上限,0 表示不限制
max-deactivate: 0 max-deactivate: 0
# 单次同步允许停用的账号比例上限(0-1)。 # 单次物化允许删除的账号比例上限(0-1)。
# 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。 # 防止飞书应用权限被误改导致大批账号被判定为离职。
max-deactivate-percent: 0.2 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` 尚无可卡发送能力,离职确认通道未落地
+84 -121
View File
@@ -13,87 +13,93 @@ type Config struct {
Server Server `mapstructure:"server"` Server Server `mapstructure:"server"`
Feishu Feishu `mapstructure:"feishu"` Feishu Feishu `mapstructure:"feishu"`
Ldap Ldap `mapstructure:"ldap"` Ldap Ldap `mapstructure:"ldap"`
Lldap Lldap `mapstructure:"lldap"`
Database Database `mapstructure:"database"` Database Database `mapstructure:"database"`
Sync Sync `mapstructure:"sync"` Schedule Schedule `mapstructure:"schedule"`
Guard Guard `mapstructure:"guard"`
} }
// System 是运行环境配置。
type System struct { type System struct {
// Mode: debug 或 release,影响 gin 与日志详细度 // Mode: debug 或 release,影响日志详细度。
Mode string `mapstructure:"mode"` Mode string `mapstructure:"mode"`
// DataDir 用于存放 sqlite 台账与日志 // DataDir 用于存放 sqlite 镜像与运行数据。
DataDir string `mapstructure:"data-dir"` DataDir string `mapstructure:"data-dir"`
} }
// Server 是管理界面与一次性离职确认端点的配置。
type Server struct { type Server struct {
// Listen 形如 0.0.0.0:8899 // Listen 形如 0.0.0.0:8899。
Listen string `mapstructure:"listen"` Listen string `mapstructure:"listen"`
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。 // AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
AdminUser string `mapstructure:"admin-user"` AdminUser string `mapstructure:"admin-user"`
AdminPassword string `mapstructure:"admin-password"` AdminPassword string `mapstructure:"admin-password"`
AdminPasswordHash string `mapstructure:"admin-password-hash"` AdminPasswordHash string `mapstructure:"admin-password-hash"`
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。 SessionSecret string `mapstructure:"session-secret"`
SessionSecret string `mapstructure:"session-secret"` SecureCookie bool `mapstructure:"secure-cookie"`
// SecureCookie 在 HTTPS 前置反代后应设为 true // ConfirmBaseURL 是离职确认端点的对外 base-url(可被公网/反代访问),
SecureCookie bool `mapstructure:"secure-cookie"` // 飞书离职卡片的「确认离职」按钮 url 用它拼接。
ConfirmBaseURL string `mapstructure:"confirm-base-url"`
} }
// Feishu 是飞书接口配置。
type Feishu struct { type Feishu struct {
AppID string `mapstructure:"app-id"` AppID string `mapstructure:"app-id"`
AppSecret string `mapstructure:"app-secret"` 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"` BaseURL string `mapstructure:"base-url"`
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门 // DeptList 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除。
DeptList []string `mapstructure:"dept-list"` DeptList []string `mapstructure:"dept-list"`
// QPS 限制调用飞书的速率,默认 5 QPS int `mapstructure:"qps"`
QPS int `mapstructure:"qps"` TimeoutSec int `mapstructure:"timeout-sec"`
// TimeoutSec 单次 API 调用超时,默认 30 // WebhookURL 是离职确认卡片走自定义机器人 webhook 发送的地址。
TimeoutSec int `mapstructure:"timeout-sec"` WebhookURL string `mapstructure:"webhook-url"`
} }
// Ldap 是 LLDAP 的 LDAP 读通道(连接/bind/搜索)。它只读不写。
type Ldap struct { type Ldap struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
BaseDN string `mapstructure:"base-dn"` BaseDN string `mapstructure:"base-dn"`
// AdminDN:LLDAP 管理员用户,形如 uid=admin,ou=people,<base>。
AdminDN string `mapstructure:"admin-dn"` AdminDN string `mapstructure:"admin-dn"`
AdminPass string `mapstructure:"admin-pass"` AdminPass string `mapstructure:"admin-pass"`
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树 TimeoutSec int `mapstructure:"timeout-sec"`
UserOU string `mapstructure:"user-ou"` StartTLS bool `mapstructure:"start-tls"`
// 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"` 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 { type Database struct {
// Source 是 sqlite 文件路径 // Source 是 sqlite 文件路径。
Source string `mapstructure:"source"` Source string `mapstructure:"source"`
} }
type Sync struct { // Schedule 是两套定时任务的配置。
// Enable 是否启用定时同步 // 快照低频(默认每天一次),物化略高频(默认每 2–4 小时一次)。
type Schedule struct {
// Enable 是否启用定时任务。
Enable bool `mapstructure:"enable"` Enable bool `mapstructure:"enable"`
// Schedule 秒级 cron,例如 "0 40 0 * * *" // Snapshot 快照 cron(秒级)。
Schedule string `mapstructure:"schedule"` Snapshot string `mapstructure:"snapshot"`
// DefaultDryRun 为 true 时,定时任务只生成计划不执行 // Sync 物化 cron(秒级)。
DefaultDryRun bool `mapstructure:"default-dry-run"` Sync string `mapstructure:"sync"`
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制 // SnapshotDryRun 物化是否默认 dry-run(快照永远直接落库)。
SyncDryRun bool `mapstructure:"sync-dry-run"`
}
// Guard 是物化前的安全阈值,防止飞书权限被误改时一次性误删大量账号。
type Guard struct {
// MaxDeactivate 单次允许删除(离职)的最大账号数,0 表示不限制。
MaxDeactivate int `mapstructure:"max-deactivate"` MaxDeactivate int `mapstructure:"max-deactivate"`
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制 // MaxDeactivatePercent 单次允许删除的账号比例上限(0-1),0 表示不限制。
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"` MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
} }
@@ -137,30 +143,29 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("feishu.qps", 5) v.SetDefault("feishu.qps", 5)
v.SetDefault("feishu.timeout-sec", 30) v.SetDefault("feishu.timeout-sec", 30)
v.SetDefault("ldap.url", "ldap://localhost:389") v.SetDefault("ldap.url", "ldap://localhost:3890")
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.timeout-sec", 15) v.SetDefault("ldap.timeout-sec", 15)
v.SetDefault("ldap.start-tls", false) v.SetDefault("ldap.start-tls", false)
v.SetDefault("ldap.insecure-skip-verify", 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("database.source", "data/idsync.db")
v.SetDefault("sync.enable", false) v.SetDefault("schedule.enable", false)
v.SetDefault("sync.schedule", "0 40 0 * * *") v.SetDefault("schedule.snapshot", "0 20 0 * * *")
v.SetDefault("sync.default-dry-run", true) v.SetDefault("schedule.sync", "0 30 */2 * * *")
v.SetDefault("sync.max-deactivate", 0) v.SetDefault("schedule.sync-dry-run", true)
v.SetDefault("sync.max-deactivate-percent", 0.2)
v.SetDefault("guard.max-deactivate", 0)
v.SetDefault("guard.max-deactivate-percent", 0.2)
} }
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。 // NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
func (c *Config) NormalizeForTest() error { return c.normalize() } func (c *Config) NormalizeForTest() error { return c.normalize() }
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。 // normalize 补全并校验配置。
func (c *Config) normalize() error { func (c *Config) normalize() error {
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" { if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空") return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
@@ -168,81 +173,39 @@ func (c *Config) normalize() error {
if c.Ldap.BaseDN == "" { if c.Ldap.BaseDN == "" {
return fmt.Errorf("ldap.base-dn 不能为空") return fmt.Errorf("ldap.base-dn 不能为空")
} }
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" { if c.Ldap.AdminPass == "" {
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空") 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 == "" { if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一") return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
} }
if c.Sync.Enable && c.Sync.Schedule == "" { if c.Schedule.Enable && (c.Schedule.Snapshot == "" || c.Schedule.Sync == "") {
return fmt.Errorf("sync.schedule 不能为空") return fmt.Errorf("schedule.enable=true 时 snapshot 与 sync 的 cron 都不能为空")
} }
if err := c.Ldap.normalizeGroupSchema(); err != nil { // LLDAP 管理员 LDAP DN 固定挂在 ou=people 下(LLDAP 无其他 OU)。
return err // 未显式给定时按约定补全。
if strings.TrimSpace(c.Ldap.AdminDN) == "" {
c.Ldap.AdminDN = "uid=" + c.Lldap.AdminUser + "," + c.UserOUDN()
} }
return nil return nil
} }
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。 // UserOUDN 返回用户 OU 的完整 DN。LLDAP 用户固定挂在 ou=people 下。
//
// 这两项必须与 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。
func (c *Config) UserOUDN() string { func (c *Config) UserOUDN() string {
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN) return "ou=people," + strings.TrimSpace(c.Ldap.BaseDN)
} }
// InactiveOUDN 返回离职用户的完整 OU DN。 // GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func (c *Config) InactiveOUDN() string {
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
}
// GroupRootDN 返回部门树根 OU 的完整 DN。
func (c *Config) GroupRootDN() string { func (c *Config) GroupRootDN() string {
name := strings.TrimSpace(c.Ldap.GroupRootName) return "ou=groups," + strings.TrimSpace(c.Ldap.BaseDN)
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)
} }
+51 -139
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -20,9 +21,13 @@ feishu:
app-id: a app-id: a
app-secret: b app-secret: b
ldap: ldap:
url: ldap://localhost:389 url: ldap://localhost:3890
base-dn: "dc=example,dc=org" 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 admin-pass: pw
` + extra ` + extra
@@ -39,125 +44,24 @@ ldap:
return cfg return cfg
} }
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。 // TestDefaultsForLLDAP 验证 OUDN 固定为 LLDAP 形态:用户在 ou=people、组在 ou=groups。
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) { func TestDefaultsForLLDAP(t *testing.T) {
cfg := writeConfig(t, "") cfg := writeConfig(t, "")
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" { if got := cfg.UserOUDN(); got != "ou=people,dc=example,dc=org" {
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass) t.Errorf("UserOUDN = %q, 期望 ou=people(LLDAP 固定 OU)", got)
} }
if cfg.Ldap.GroupMemberAttr != "uniqueMember" { if got := cfg.GroupRootDN(); got != "ou=groups,dc=example,dc=org" {
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr) t.Errorf("GroupRootDN = %q, 期望 ou=groups", got)
} }
} }
// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。 // TestAdminDNDerived 验证未显式配 admin-dn 时按 LLDAP 管理员用户补全。
// func TestAdminDNDerived(t *testing.T) {
// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、 cfg := writeConfig(t, " admin-dn: \"\"\n")
// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以 want := "uid=admin,ou=people,dc=example,dc=org"
// objectClassViolation 拒绝,而错误信息很不直观。 if cfg.Ldap.AdminDN != want {
func TestGroupSchemaDerivesMemberAttr(t *testing.T) { t.Errorf("AdminDN = %q, 期望自动补全为 %q", cfg.Ldap.AdminDN, want)
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)
} }
} }
@@ -165,46 +69,54 @@ func TestInactiveOUOutsideUserOU(t *testing.T) {
func TestNormalizeRejectsMissingCredentials(t *testing.T) { func TestNormalizeRejectsMissingCredentials(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
over string // override 以 YAML 片段替换掉基础配置里对应的一行,避免重复键。
override string
mark string
}{ }{
{"缺少 app-id", " app-id: \"\"\n"}, {"缺少 app-id", "feishu:\n app-id: \"\"\n", "app-id"},
{"缺少 base-dn", "BASE_DN_MARKER"}, {"缺少 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 { base := `
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 := `
system: system:
data-dir: /tmp/idsync-test data-dir: /tmp/idsync-test
server: server:
admin-user: admin admin-user: admin
admin-password: pw admin-password: pw
feishu: `
app-id: "` + feishuID + `" for _, c := range cases {
app-secret: b t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
body := base + c.override + `
ldap: ldap:
url: ldap://localhost:389 url: ldap://localhost:3890
base-dn: ` + baseDN + ` 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 admin-pass: pw
` `
if err := os.WriteFile(path, []byte(body), 0o600); err != nil { if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写配置失败: %v", err) t.Fatalf("写配置失败: %v", err)
} }
if _, err := Load(path); err == nil { 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
}
+9 -22
View File
@@ -1,5 +1,7 @@
package ldaplib package ldaplib
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
@@ -12,19 +14,16 @@ import (
"idsync/internal/config" "idsync/internal/config"
) )
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低, // Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。 // 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
type Client struct { type Client struct {
cfg config.Ldap cfg config.Ldap
log *slog.Logger log *slog.Logger
tlsConf *tls.Config tlsConf *tls.Config
dialOpts []ldap.DialOpt dialOpts []ldap.DialOpt
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
group GroupSchema
} }
// NewClient 构造 LDAP 客户端,并解析出拨号参数。 // NewClient 构造 LDAP 读客户端并解析拨号参数。
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) { func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
if cfg.URL == "" { if cfg.URL == "" {
return nil, fmt.Errorf("ldap.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)) dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
} }
schema := GroupSchema{ return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
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
} }
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。 // Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
type Conn struct { type Conn struct {
conn *ldap.Conn conn *ldap.Conn
cfg config.Ldap cfg config.Ldap
group GroupSchema
} }
// Close 释放连接。 // Close 释放连接。
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil { if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
_ = conn.Close() _ = 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 验证连通性与凭据,用于健康检查。 // Ping 验证连通性与凭据,用于健康检查。
+55 -328
View File
@@ -1,377 +1,104 @@
//go:build integration //go:build integration
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。 // LLDAP 读路径(LDAP)集成测试:读取对象重,验证 objectClass 命中与双成员属性读取。
// //
// 默认不参与 `go test ./...`,需要显式启用: // 默认不参与 `go test ./...`,需显式启用:
// //
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \ // LLDAP_TEST_URL=ldap://127.0.0.1:3890 go test -tags=integration ./internal/ldaplib/ -v
// go test -tags=integration ./internal/ldaplib/ -v
//
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
package ldaplib_test package ldaplib_test
import ( import (
"io" "io"
"log/slog" "log/slog"
"net"
"os" "os"
"strings"
"testing" "testing"
"time"
"github.com/go-ldap/ldap/v3"
"idsync/internal/config" "idsync/internal/config"
"idsync/internal/ldaplib" "idsync/internal/ldaplib"
"idsync/internal/password"
) )
// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。
func discardLogger() *slog.Logger { func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil)) return slog.New(slog.NewTextHandler(io.Discard, nil))
} }
func testClient(t *testing.T) *ldaplib.Client { func testConfig() config.Ldap {
t.Helper() base := os.Getenv("LDAP_TEST_BASE_DN")
if base == "" {
base = "dc=example,dc=com"
}
url := os.Getenv("LDAP_TEST_URL") url := os.Getenv("LDAP_TEST_URL")
if url == "" { if url == "" {
url = "ldap://127.0.0.1:3899" url = "ldap://127.0.0.1:3890"
} }
baseDN := os.Getenv("LDAP_TEST_BASE_DN") return config.Ldap{
if baseDN == "" { URL: url, BaseDN: base,
baseDN = "dc=example,dc=com" AdminDN: "uid=admin,ou=people," + base, AdminPass: "adminpass",
TimeoutSec: 10,
} }
}
cfg := config.Ldap{ func dialT(t *testing.T) *ldaplib.Conn {
URL: url, t.Helper()
BaseDN: baseDN, c, err := ldaplib.NewClient(testConfig(), discardLogger())
AdminDN: "cn=admin," + baseDN,
AdminPass: "adminpass",
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
TimeoutSec: 10,
}
if err := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
}
c, err := ldaplib.NewClient(cfg, discardLogger())
if err != nil { if err != nil {
t.Fatalf("构造客户端失败: %v", err) 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() conn, err := c.Dial()
if err != nil { 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() defer conn.Close()
if err := conn.EnsureOU(peopleOU, "people"); err != nil { users, err := conn.ListUsers("ou=people,dc=example,dc=com")
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)
if err != nil { if err != nil {
t.Fatalf("编码密码失败: %v", err) t.Fatalf("ListUsers 失败: %v", err)
} }
if err := conn.AddUser(u, peopleOU, encoded); err != nil { if _, ok := users["admin"]; !ok {
t.Fatalf("创建用户失败: %v", err) t.Errorf("LLDAP 默认的 admin 用户应在 ou=people 读到;现有: %v", keysOf(users))
}
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)
} }
} }
// TestIntegrationGroupMembership 验证分组的创建与成员增删。 // TestIntegrationReadGroups 验证组读视图:能列出 ou=groups 下的 LLDAP 默认组。
func TestIntegrationGroupMembership(t *testing.T) { func TestIntegrationReadGroups(t *testing.T) {
c := testClient(t) conn := dialT(t)
base := baseDN()
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := c.Dial()
if err != nil {
t.Fatalf("连接失败: %v", err)
}
defer conn.Close() defer conn.Close()
if err := conn.EnsureOU(peopleOU, "people"); err != nil { groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
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)
if err != nil { if err != nil {
t.Fatalf("读取分组失败: %v", err) t.Fatalf("ListGroups 失败: %v", err)
} }
g, ok := groups[groupDN] if len(groups) == 0 {
if !ok { t.Errorf("ou=groups 下应至少有 LLDAP 默认组")
t.Fatalf("未找到刚创建的分组 %s", groupDN)
} }
// 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。 // 结构上应能解析成员集合(即便为空),确认 member/uniqueMember 双读不崩。
if _, has := g.Members()[adminDN]; !has { for dn := range groups {
t.Errorf("分组缺少 admin 成员: %v", g.Members()) _ = groups[dn].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)
}
})
} }
} }
// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。 // TestIntegrationDNHelpers 验证 DN 构造与过滤器转义(供读侧比对复用)。
func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error { func TestIntegrationDNHelpers(t *testing.T) {
t.Helper() 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)
cfgURL := os.Getenv("LDAP_TEST_URL")
if cfgURL == "" {
cfgURL = "ldap://127.0.0.1:3899"
} }
if got := ldaplib.GroupDN("od-1", "ou=groups,dc=example,dc=com"); got != "cn=od-1,ou=groups,dc=example,dc=com" {
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second})) t.Errorf("GroupDN = %q", got)
if err != nil { }
return err if ldaplib.EscapeFilter("a(b)") == "a(b)" {
t.Errorf("EscapeFilter 未转义")
} }
defer conn.Close() }
return conn.Bind(dn, password)
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
} }
-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 return e
} }
// userReadAttrs 是读取用户条目时需要取回的属性。 // userReadAttrs / groupReadAttrs 的定义见 attrs.go。
// 刻意不读 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}
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。 // ListUsers 读取某个 OU 下的全部用户条目(不递归)。
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) { 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 小写为键。 // ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
// LLDAP 分组 objectClass 固定为 groupOfUniqueNames。
func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) { func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) {
sr, err := c.conn.Search(ldap.NewSearchRequest( sr, err := c.conn.Search(ldap.NewSearchRequest(
rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, 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 err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
return len(sr.Entries) > 0, nil 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 条目转成精简表示。 // entryFrom 把 LDAP 条目转成精简表示。
// 单值属性存进 Attributes;多值属性原样保留在 multi 中。 // 单值属性存进 Attributes;多值属性原样保留在 multi 中。
func entryFrom(e *ldap.Entry) *Entry { 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 正确表达部门层级。 // TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
func TestBuildGroupDNReflectsNesting(t *testing.T) { // 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
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)
}
}
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。 // TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) { 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())
}
}
+164 -106
View File
@@ -1,8 +1,16 @@
// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。
//
// 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点
// **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置
// confirmed_leave。
package server package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"html"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
@@ -57,12 +65,17 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/login", s.handleLogin) s.mux.HandleFunc("/api/login", s.handleLogin)
s.mux.HandleFunc("/api/logout", s.handleLogout) 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/status", s.deps.Auth.Middleware(s.handleStatus))
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers)) 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/groups", s.deps.Auth.Middleware(s.handleGroups))
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan)) 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/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)) 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) webui.IndexHandler()(w, r)
} }
// handleLogin 处理登录。
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) 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}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// handleLogout 处理登出。
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.deps.Auth.Logout(w) s.deps.Auth.Logout(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// handleStatus 返回概览信息:连通性、计数、上次同步。 // handleStatus 返回概览信息。
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel() defer cancel()
cfg := s.deps.Cfg cfg := s.deps.Cfg
ldapOK := true ldapOK, ldapErr := true, ""
ldapErr := ""
if err := s.deps.LDAP.Ping(); err != nil { if err := s.deps.LDAP.Ping(); err != nil {
ldapOK = false ldapOK, ldapErr = false, err.Error()
ldapErr = err.Error()
} }
feishuOK, feishuErr := true, ""
feishuOK := true
feishuErr := ""
if s.deps.FeishuTest != nil { if s.deps.FeishuTest != nil {
if err := s.deps.FeishuTest(ctx); err != nil { if err := s.deps.FeishuTest(ctx); err != nil {
feishuOK = false feishuOK, feishuErr = false, err.Error()
feishuErr = err.Error()
} }
} }
accounts, err := s.deps.Store.AllAccounts() users, err := s.deps.Store.ActiveUsers()
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
groups, err := s.deps.Store.AllGroups() depts, err := s.deps.Store.ActiveDepartments()
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
skipped, _ := s.deps.Store.SkippedUsers()
active, inactive := 0, 0 pending, _ := s.deps.Store.PendingConfirm()
for _, a := range accounts {
if a.State == "inactive" {
inactive++
} else {
active++
}
}
lastRun, err := s.deps.Store.LastRun() lastRun, err := s.deps.Store.LastRun()
if err != nil { if err != nil {
@@ -146,103 +145,70 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"ldap": map[string]any{ "ldap": map[string]any{
"ok": ldapOK, "error": ldapErr, "ok": ldapOK, "error": ldapErr,
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(),
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
}, },
"feishu": map[string]any{ "feishu": map[string]any{
"ok": feishuOK, "error": feishuErr, "ok": feishuOK, "error": feishuErr,
"appId": maskSecret(cfg.Feishu.AppID), "appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList,
"deptList": cfg.Feishu.DeptList, },
"lldap": map[string]any{
"baseUrl": cfg.Lldap.BaseURL,
}, },
"counts": map[string]any{ "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, "lastRun": lastRun,
"sync": map[string]any{ "schedule": map[string]any{
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule, "enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot,
"defaultDryRun": cfg.Sync.DefaultDryRun, "sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun,
}, },
}) })
} }
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。 // handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return 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 { type row struct {
UID string `json:"uid"` UID string `json:"uid"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
State string `json:"state"` State string `json:"state"`
DN string `json:"dn"` Skipped bool `json:"skipped"`
DeptIDs string `json:"deptIds"` SkipReason string `json:"skipReason,omitempty"`
LastSync string `json:"lastSyncedAt"` DeptIDs []string `json:"deptIds"`
Drift string `json:"drift,omitempty"` LastSync string `json:"lastSyncedAt"`
} }
rows := make([]row, 0, len(all))
rows := make([]row, 0, len(accounts)) for _, u := range all {
for _, a := range accounts { deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID)
rows = append(rows, row{ rows = append(rows, row{
UID: a.UID, UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State,
Name: a.Name, Skipped: u.Skipped, SkipReason: u.SkipReason,
Email: a.Email, DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt),
State: a.State,
DN: a.DN,
DeptIDs: a.DeptIDs,
LastSync: a.LastSyncedAt.Format(time.RFC3339),
Drift: drift[a.UID],
}) })
} }
writeJSON(w, http.StatusOK, map[string]any{"users": rows}) writeJSON(w, http.StatusOK, map[string]any{"users": rows})
} }
// handleGroups 返回受管分组清单。 // handleGroups 返回部门镜像。
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return 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) { func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel() defer cancel()
plan, err := s.deps.Runner.Preview(ctx) plan, err := s.deps.Runner.Preview(ctx)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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) 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) { func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return return
} }
var req struct { var req struct {
DryRun *bool `json:"dryRun"` DryRun *bool `json:"dryRun"`
Force bool `json:"force"` Force bool `json:"force"`
@@ -265,39 +230,107 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
if r.Body != nil { if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
} }
// 默认 dry-run:忘记传参数时宁可什么都不做。
dryRun := true dryRun := true
if req.DryRun != nil { if req.DryRun != nil {
dryRun = *req.DryRun dryRun = *req.DryRun
} }
// 同一时刻只允许一次同步。 if err := s.lockSync(); err != nil {
select { writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case s.syncMu <- struct{}{}:
defer func() { <-s.syncMu }()
default:
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
return return
} }
defer s.unlockSync()
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel() defer cancel()
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual") plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{ writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan})
"error": err.Error(), "plan": plan,
})
return 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) { func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.deps.Store.RecentRuns(100) runs, err := s.deps.Store.RecentRuns(100)
if err != nil { 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}) 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 响应。 // writeJSON 输出 JSON 响应。
func writeJSON(w http.ResponseWriter, code int, body any) { func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") 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) _ = json.NewEncoder(w).Encode(body)
} }
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。 // maskSecret 只保留标识的前后几位。
func maskSecret(s string) string { func maskSecret(s string) string {
if len(s) <= 8 { if len(s) <= 8 {
if len(s) == 0 { if len(s) == 0 {
@@ -324,3 +375,10 @@ func maskSecret(s string) string {
} }
return s[:4] + "****" + s[len(s)-4:] 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) 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 nil, fmt.Errorf("建表失败: %w", err)
} }
return &Store{db: db}, nil return &Store{db: db}, nil
+170 -449
View File
@@ -1,27 +1,21 @@
//go:build integration //go:build integration
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。 // 两阶段全链路端到端集成测试:假飞书 + 真 LLDAP + 假 webhook。
// //
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、 // 快照段用受控的假飞书 OpenAPI 服务(无需真实租户凭据)写镜像与推进离职流程,
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。 // 物化段写真实 LLDAP(GraphQL)。覆盖:首次全量创建、幂等、属性变更、离职未确认
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通, // 不删、确认后删除、重新入职重建。
// 并断言最终落在 OpenLDAP 里的内容。
// //
// 启用方式(需要真实 LDAP 实例): // 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
//
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
package syncer_test package syncer_test
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"sort"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -29,65 +23,42 @@ import (
"idsync/internal/config" "idsync/internal/config"
"idsync/internal/feishu" "idsync/internal/feishu"
"idsync/internal/ldaplib" "idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/store" "idsync/internal/store"
"idsync/internal/syncer" "idsync/internal/syncer"
) )
// fakeDept 是假服务返回的部门。 type fakeDept struct{ openID, parentID, name string }
type fakeDept struct {
openID string
parentID string
name string
}
// fakeUser 是假服务返回的用户。
type fakeUser struct { type fakeUser struct {
unionID string unionID, userID, name, enName string
userID string enterpriseEm, email, mobile string
name string employeeNo, jobTitle string
enName string deptIDs []string
enterpriseEm string resigned bool
email string
mobile string
employeeNo string
jobTitle string
deptIDs []string
resigned bool
} }
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。 // fakeFeishu 只实现快照链路需要的接口。
type fakeFeishu struct { type fakeFeishu struct {
depts []fakeDept depts []fakeDept
users []fakeUser users []fakeUser
// served 记录各接口被调用的次数,用于验证分页与去重行为。
calls map[string]int calls map[string]int
} }
func (f *fakeFeishu) handler() http.Handler { func (f *fakeFeishu) handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.calls["token"]++ f.calls["token"]++
writeFakeJSON(w, map[string]any{ writeFakeJSON(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
"code": 0, "msg": "ok",
"tenant_access_token": "fake-token",
"expire": 7200,
})
}) })
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
f.calls["departments"]++ f.calls["departments"]++
parent := r.URL.Query().Get("parent_department_id") parent := r.URL.Query().Get("parent_department_id")
if parent == "" { if parent == "" {
parent = "0" parent = "0"
} }
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
fetchChild := r.URL.Query().Get("fetch_child") == "true"
want := map[string]bool{parent: true} want := map[string]bool{parent: true}
if fetchChild { if r.URL.Query().Get("fetch_child") == "true" {
for changed := true; changed; { for changed := true; changed; {
changed = false changed = false
for _, d := range f.depts { for _, d := range f.depts {
@@ -98,86 +69,48 @@ func (f *fakeFeishu) handler() http.Handler {
} }
} }
} }
items := make([]map[string]any, 0) items := make([]map[string]any, 0)
for _, d := range f.depts { for _, d := range f.depts {
if !want[d.openID] || d.openID == parent { if !want[d.openID] || d.openID == parent {
continue continue
} }
items = append(items, map[string]any{ items = append(items, map[string]any{
"name": d.name, "name": d.name, "parent_department_id": d.parentID, "open_department_id": d.openID,
"parent_department_id": d.parentID,
"open_department_id": d.openID,
}) })
} }
writeFakeJSON(w, map[string]any{ writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
"code": 0, "msg": "ok",
"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) { mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
f.calls["find_by_department"]++ f.calls["find_by_department"]++
deptID := r.URL.Query().Get("department_id") deptID := r.URL.Query().Get("department_id")
items := make([]map[string]any, 0) items := make([]map[string]any, 0)
for _, u := range f.users { for _, u := range f.users {
if u.resigned { if u.resigned || !containsStr(u.deptIDs, deptID) {
continue // 与真实接口一致:不返回离职人员
}
if !containsStr(u.deptIDs, deptID) {
continue continue
} }
items = append(items, map[string]any{ items = append(items, map[string]any{
"union_id": u.unionID, "union_id": u.unionID, "user_id": u.userID, "name": u.name, "en_name": u.enName,
"user_id": u.userID, "enterprise_email": u.enterpriseEm, "email": u.email, "mobile": u.mobile,
"name": u.name, "employee_no": u.employeeNo, "job_title": u.jobTitle, "department_ids": u.deptIDs,
"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{ writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
"code": 0, "msg": "ok",
"data": map[string]any{
"has_more": false, "items": items,
},
})
}) })
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
f.calls["user_get"]++ f.calls["user_get"]++
// 从路径末尾取出 union_id
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/") parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
unionID := parts[len(parts)-1] unionID := parts[len(parts)-1]
for _, u := range f.users { for _, u := range f.users {
if u.unionID != unionID { if u.unionID == unionID {
continue 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": 0, "msg": "ok",
"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"}) writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
}) })
return mux return mux
} }
@@ -185,440 +118,228 @@ func writeFakeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body) _ = json.NewEncoder(w).Encode(body)
} }
func containsStr(list []string, s string) bool {
func containsStr(list []string, want string) bool {
for _, v := range list { for _, v := range list {
if v == want { if v == s {
return true return true
} }
} }
return false return false
} }
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。 // setup 准备整套环境:真 LLDAP + 假飞书 + 假 webhook + 临时镜像库。
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) { func setup(t *testing.T) (*syncer.Runner, *store.Store, *lldapgql.Client, *fakeFeishu, func()) {
t.Helper() 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)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍: cfg := &config.Config{
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与 System: config.System{Mode: "release"},
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。 Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS") Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
if groupOC == "" { Ldap: config.Ldap{
groupOC = "groupOfUniqueNames" 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 := cfg.NormalizeForTest(); err != nil {
t.Fatalf("配置规范化失败: %v", err)
} }
ldapCfg := config.Ldap{ gql := lldapgql.New(cfg.Lldap.BaseURL, log)
URL: ldapURL, BaseDN: base, if err := gql.Login(t.Context(), "admin", "adminpass"); err != nil {
AdminDN: "cn=admin," + base, AdminPass: "adminpass", t.Skipf("LLDAP 不可用,跳过端到端测试: %v", err)
GroupObjectClass: groupOC,
TimeoutSec: 10,
}
if err := ldapCfg.NormalizeForTest(); err != nil {
t.Fatalf("LDAP 配置规范化失败: %v", err)
} }
ldapClient, err := ldaplib.NewClient(ldapCfg, log) ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil { if err != nil {
t.Fatalf("构造 LDAP 客户端失败: %v", err) t.Fatalf("构造 LDAP 客户端失败: %v", err)
} }
if err := ldapClient.Ping(); err != nil {
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
}
fake := &fakeFeishu{calls: map[string]int{}} 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{ feishuClient, err := feishu.New(feishu.Options{
AppID: "fake", AppSecret: "fake", AppID: "fake", AppSecret: "fake", BaseURL: fs.URL, QPS: 100, Timeout: 10 * time.Second,
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
}, log) }, log)
if err != nil { if err != nil {
t.Fatalf("构造飞书客户端失败: %v", err) t.Fatalf("构造飞书客户端失败: %v", err)
} }
dbPath := t.TempDir() + "/idsync.db" st, err := store.Open(t.TempDir() + "/idsync.db")
st, err := store.Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("打开台账失败: %v", err) t.Fatalf("打开镜像库失败: %v", err)
} }
ldapCfg.UserOU = "ou=people" runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gql, st, log, syncer.SnapshotOptions{
ldapCfg.InactiveOU = "ou=Inactive" WebhookURL: webhook.URL, ConfirmBaseURL: "https://confirm.example.com",
ldapCfg.GroupRootName = "feishuroot" ReNotifyInterval: 24 * time.Hour, TokenTTL: 72 * time.Hour,
})
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)
cleanup := func() { cleanup := func() {
ts.Close() fs.Close()
st.Close() webhook.Close()
_ = st.Close()
} }
return runner, ldapClient, st, fake, cleanup return runner, st, gql, fake, cleanup
} }
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、 // TestTwoStageEndToEnd 覆盖首轮创建→幂等→离职确认→删除→重新入职重建。
// 属性变更、部门调整、离职迁移与重新入职。 func TestTwoStageEndToEnd(t *testing.T) {
func TestEndToEndSync(t *testing.T) { runner, st, gql, fake, cleanup := setup(t)
runner, ldapClient, st, fake, cleanup := setup(t)
defer cleanup() defer cleanup()
const base = "dc=example,dc=com" const base = "dc=example,dc=com"
peopleOU := "ou=people," + base peopleOU := "ou=people," + base
inactiveOU := "ou=Inactive," + base groupRoot := "ou=groups," + base
groupRoot := "ou=feishuroot," + 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{ fake.depts = []fakeDept{
{openID: "od-root", parentID: "0", name: "Acme"}, {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{ fake.users = []fakeUser{
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San", {unionID: "u1", userID: "1", name: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-child"}},
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com", {unionID: "u2", userID: "2", name: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-child"}},
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)
} }
// dry-run 后 LDAP 中不应有任何用户。 t.Cleanup(func() { cleanupE2E(t, st, gql) })
users, err := conn.ListUsers(peopleOU)
if err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if len(users) != 0 {
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
}
// ---- 第二步:真正执行 ---- // ---- 快照 + 物化:首次全量创建 ----
_, 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 { if err != nil {
t.Fatalf("执行同步失败: %v", err) t.Fatalf("物化失败: %v", err)
} }
if res.Failed != 0 { if res.Failed != 0 {
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors) t.Fatalf("物化有失败: %v", res.Errors)
} }
conn, err := ldapDial()
users, err = conn.ListUsers(peopleOU)
if err != nil { if err != nil {
t.Fatalf("读取用户失败: %v", err) t.Fatalf("LDAP 连接失败: %v", err)
} }
if _, ok := users["zhangsan"]; !ok { users, _ := conn.ListUsers(peopleOU)
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users)) for _, uid := range []string{"alice", "bob"} {
if _, ok := users[uid]; !ok {
t.Errorf("物化后缺用户 %s", uid)
}
} }
if _, ok := users["lisi"]; !ok { conn.Close()
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users)) conn, _ = ldapDial()
} groupsDM := groupMap(conn, groupRoot)
if _, ok := users["wangwu"]; ok { conn.Close()
t.Errorf("wangwu 缺少企业邮箱,不应被创建") if _, ok := groupsDM["cn=od-child,"+groupRoot]; !ok {
t.Errorf("物化后缺组 cn=od-child")
} }
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。 // ---- 幂等 ----
zs := users["zhangsan"] plan, _ := runner.Preview(t.Context())
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" { if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got) plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
} t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
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)
} }
// 断言分组与成员关系。 // ---- 离职:bob 标记离职 → pending_confirm,但未确认不应删除 ----
groups, err := conn.ListGroups(groupRoot) markResigned2(fake, "u2")
if err != nil { if _, err := runner.RunSnapshot(t.Context()); err != nil {
t.Fatalf("读取分组失败: %v", err) t.Fatalf("快照失败: %v", err)
} }
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。 s, _ := st.MapUsersByUID()
acmeDN := "cn=acme," + groupRoot if s["bob"].State != store.StatePendingConfirm {
yanfaDN := "cn=yanfa," + acmeDN t.Fatalf("bob 应为 pending_confirm,实际 %q", s["bob"].State)
g, ok := groups[yanfaDN]
if !ok {
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
} }
members := g.Members() _, res, _ = runner.Run(t.Context(), false, false, "test")
if _, has := members["uid=zhangsan,"+peopleOU]; !has { if res.Deleted != 0 {
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members) t.Fatalf("未确认离职不应删除,实际 deleted=%d", res.Deleted)
}
// 父部门分组也应存在,验证树形嵌套正确。
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)
} }
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU) users, _ = conn.ListUsers(peopleOU)
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" { conn.Close()
t.Errorf("职位未更新: %q", got) if _, still := users["bob"]; !still {
t.Fatalf("未确认离职的 bob 不应被删除")
} }
// ---- 第五步:离职应迁入 ou=Inactive ---- // ---- 确认离职 → 物化删除 ----
fake.users[1].resigned = true if err := st.ConfirmLeave("bob"); err != nil {
t.Fatalf("状态转移失败: %v", err)
plan, _, err = runner.Run(t.Context(), true, false, "test")
if err != nil {
t.Fatalf("离职检测失败: %v", err)
} }
if plan.Stats.Deactivate != 1 { _, res, _ = runner.Run(t.Context(), false, false, "test")
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan)) if res.Deleted != 1 {
t.Fatalf("confirmed_leave 应删除 1 个,实际 %d", res.Deleted)
} }
conn, _ = ldapDial()
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行离职迁移失败: %v", err)
}
users, _ = conn.ListUsers(peopleOU) users, _ = conn.ListUsers(peopleOU)
if _, still := users["lisi"]; still { conn.Close()
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU) if _, still := users["bob"]; still {
t.Errorf("confirmed_leave 的 bob 应被物理删除")
} }
inactive, _ := conn.ListUsers(inactiveOU) if _, alive := users["alice"]; !alive {
if _, moved := inactive["lisi"]; !moved { t.Errorf("alice 不应被删除")
t.Errorf("lisi 未迁入 %s", inactiveOU)
} }
// 离职用户也应从部门分组中移除。 // ---- 重新入职:bob 重新可见 → 重建 ----
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
}
// ---- 第六步:重新入职应迁回 ----
fake.users[1].resigned = false fake.users[1].resigned = false
if _, err := runner.RunSnapshot(t.Context()); err != nil {
plan, _, err = runner.Run(t.Context(), true, false, "test") t.Fatalf("快照失败: %v", err)
if err != nil {
t.Fatalf("复职检测失败: %v", err)
} }
if plan.Stats.Reactivate != 1 { _, res, _ = runner.Run(t.Context(), false, false, "test")
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan)) if res.Created == 0 {
} t.Fatalf("重新入职应重建 bob,实际 created=%d", res.Created)
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
t.Fatalf("执行复职失败: %v", err)
} }
conn, _ = ldapDial()
users, _ = conn.ListUsers(peopleOU) users, _ = conn.ListUsers(peopleOU)
if _, back := users["lisi"]; !back { conn.Close()
t.Errorf("lisi 复职后未回到 %s", peopleOU) if _, back := users["bob"]; !back {
} t.Errorf("bob 重新入职后应被重建")
// 复职后应重新加入部门分组。
groups, _ = conn.ListGroups(groupRoot)
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
} }
} }
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。 func markResigned2(f *fakeFeishu, unionID string) {
func TestEndToEndGuard(t *testing.T) { for i := range f.users {
runner, ldapClient, _, fake, cleanup := setup(t) if f.users[i].unionID == unionID {
defer cleanup() f.users[i].resigned = true
const base = "dc=example,dc=com"
peopleOU := "ou=people," + base
groupRoot := "ou=feishuroot," + base
conn, err := ldapClient.Dial()
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)
} }
} }
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"},
})
}
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 下的全部用户与分组。 func ldapDial() (*ldaplib.Conn, error) {
// c, err := ldaplib.NewClient(config.Ldap{
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职), URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。 AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass", TimeoutSec: 10,
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) { }, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
return nil, err
}
return c.Dial()
}
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
}
// cleanupE2E 用 GraphQL 清掉本次测试在 LLDAP 里留下的用户与组。
func cleanupE2E(t *testing.T, st *store.Store, gql *lldapgql.Client) {
t.Helper() t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
peopleOU := "ou=people," + base defer cancel()
inactiveOU := "ou=Inactive," + base for _, uid := range []string{"alice", "bob", "admin"} {
groupRoot := "ou=feishuroot," + base _ = gql.DeleteUser(ctx, uid)
}
for _, ou := range []string{peopleOU, inactiveOU} { deps, _ := st.MapDepartmentsByID()
users, err := conn.ListUsers(ou) for _, d := range deps {
if err != nil { if d.GroupID > 0 {
continue _ = gql.DeleteGroup(ctx, d.GroupID)
}
for uid := range users {
_ = conn.DeleteUser("uid=" + uid + "," + ou)
} }
} }
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()
} }
+70 -205
View File
@@ -1,274 +1,139 @@
package syncer package syncer
// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。
//
// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的
// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId
// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"idsync/internal/ldaplib" "idsync/internal/lldapgql"
"idsync/internal/mapping"
"idsync/internal/password"
) )
// Guard 是执行前的安全阈值。 // ExecConfig 是执行器所需的环境参数。
// type ExecConfig struct {
// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、 PeopleOU string
// 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
} }
// Result 是执行结果统计。 // Result 是执行结果统计。
type Result struct { type Result struct {
Created int Created int
Updated int Updated int
Moved int Deleted int
Added int Added int
Removed int Removed int
Failed int Failed int
Errors []string Errors []string
} }
// Executor 把计划应用到 LDAP。 // Executor 把计划应用到 LLDAP(写走 GraphQL)。
type Executor struct { type Executor struct {
client *ldaplib.Client gql *lldapgql.Client
cfg ExecConfig cfg ExecConfig
log *slog.Logger log *slog.Logger
}
// ExecConfig 是执行器所需的环境参数。
type ExecConfig struct {
UserOUDN string
InactiveOUDN string
GroupRootDN string
GroupRootName string
AdminDN string
// InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。
InitialPassword string
} }
// NewExecutor 构造执行器。 // NewExecutor 构造执行器。
func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor { func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor {
return &Executor{client: client, cfg: cfg, log: log} return &Executor{gql: gql, cfg: cfg, log: log}
} }
// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作—— // Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个
// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。 // 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射,
func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result { // 会在创建新组时被原地扩充。
func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result {
res := &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 { for _, op := range plan.Ops {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
res.Errors = append(res.Errors, "同步被取消: "+err.Error()) res.Errors = append(res.Errors, "物化被取消: "+err.Error())
return res return res
} }
if err := e.applyOne(conn, op); err != nil { if err := e.applyOne(ctx, op, groupIDs); err != nil {
res.Failed++ res.Failed++
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error()) msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
res.Errors = append(res.Errors, msg) 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 continue
} }
switch op.Kind { switch op.Kind {
case OpCreateUser: case OpCreateUser, OpCreateGroup:
res.Created++ res.Created++
case OpUpdateUser, OpReactivate: case OpUpdateUser:
res.Updated++ res.Updated++
if op.Kind == OpReactivate { case OpDeleteUser:
res.Moved++ res.Deleted++
}
case OpDeactivate:
res.Updated++
res.Moved++
case OpAddMember: case OpAddMember:
res.Added++ res.Added++
case OpRemoveMember: case OpRemoveMember:
res.Removed++ res.Removed++
} }
} }
return res return res
} }
// applyOne 执行单个操作。 // 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 { switch op.Kind {
case OpCreateGroup: case OpCreateGroup:
// 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。 // LLDAP 组 displayName 即部门索引;创建后把返回的 groupId 记进映射,
return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN) // 供后续成员操作用。
name := op.Group.DeptID
case OpUpdateGroup: id, err := e.gql.CreateGroup(ctx, name)
return conn.UpdateGroup(op.DN, op.Group.Name) if err != nil {
return err
}
groupIDs[name] = id
return nil
case OpCreateUser: case OpCreateUser:
plain := e.cfg.InitialPassword // 创建用户不设密码:密码由 LLDAP 独占、首次登录走 Authelia 忘记密码。
if plain == "" { return e.gql.CreateUser(ctx, &lldapgql.CreateUserInput{
generated, err := password.Random() ID: op.User.UID,
if err != nil { Email: sp(op.User.Mail),
return err DisplayName: sp(op.User.DisplayName),
} FirstName: sp(op.User.GivenName),
plain = generated LastName: sp(op.User.SN),
} })
// 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希,
// 直接写明文会让密码以明文形式留在目录里。
encoded, err := password.EncodeSSHA(plain)
if err != nil {
return err
}
return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded)
case OpUpdateUser: case OpUpdateUser:
// 若 DN 因 OU 配置变化而不同,先移动再更新属性。 // 省略即不改:这里显式带全四个可写硬字段,与 attrsDiffer 判定的差异一致。
if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) { return e.gql.UpdateUser(ctx, &lldapgql.UpdateUserInput{
uid := op.User.UID ID: op.User.UID,
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil { Email: sp(op.User.Mail),
return err DisplayName: sp(op.User.DisplayName),
} FirstName: sp(op.User.GivenName),
} LastName: sp(op.User.SN),
// 读取现状: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)
case OpAddMember: 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: 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: default:
return fmt.Errorf("未知操作类型: %s", op.Kind) return fmt.Errorf("未知操作类型: %s", op.Kind)
} }
} }
// toLDAPUser 把期望状态转成 LDAP 写入结构。 // sp 生成 *string。
func toLDAPUser(u *mapping.User) *ldaplib.User { func sp(s string) *string { return &s }
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
}
@@ -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")
}
+188 -312
View File
@@ -1,10 +1,13 @@
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。 // Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
// //
// 设计要点: // 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。 // 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。 // 执行共用同一份代码——预览即所得。
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的 //
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。 // 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
// 状态才会生成删除操作,且删除永远排在最后一阶段。
package syncer package syncer
import ( import (
@@ -24,10 +27,8 @@ type OpKind string
const ( const (
OpCreateUser OpKind = "create_user" OpCreateUser OpKind = "create_user"
OpUpdateUser OpKind = "update_user" OpUpdateUser OpKind = "update_user"
OpDeactivate OpKind = "deactivate_user" OpDeleteUser OpKind = "delete_user"
OpReactivate OpKind = "reactivate_user"
OpCreateGroup OpKind = "create_group" OpCreateGroup OpKind = "create_group"
OpUpdateGroup OpKind = "update_group"
OpAddMember OpKind = "add_member" OpAddMember OpKind = "add_member"
OpRemoveMember OpKind = "remove_member" OpRemoveMember OpKind = "remove_member"
) )
@@ -37,99 +38,81 @@ type Op struct {
Kind OpKind `json:"kind"` Kind OpKind `json:"kind"`
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。 // Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
Target string `json:"target"` Target string `json:"target"`
// Name 是展示名,方便界面阅读。 // Name 是展示名(用户中文名 / 部门中文名)。
Name string `json:"name"` Name string `json:"name"`
// DN 是操作涉及的条目 DN(新建时为目标 DN)。 // DN 是操作涉及的组/用户 DN。
DN string `json:"dn,omitempty"` DN string `json:"dn,omitempty"`
// Detail 是人类可读的说明,直接展示在预览页。 // Detail 是人类可读的说明,直接展示在预览页。
Detail string `json:"detail"` Detail string `json:"detail"`
// 以下字段供执行器使用,不参与 JSON 展示。 // 以下字段供执行器使用,不参与 JSON 展示。
User *mapping.User `json:"-"` User *mapping.DesiredUser `json:"-"`
Group *mapping.Group `json:"-"` Group *mapping.DesiredGroup `json:"-"`
MemberDN string `json:"-"` MemberUID string `json:"-"`
GroupDN string `json:"-"` AttrsHash string `json:"-"`
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。 // Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
TargetDN string `json:"-"`
AttrsHash string `json:"-"`
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
// (例如父分组必须先于子分组创建)。
Seq int `json:"-"` Seq int `json:"-"`
} }
// Plan 是一次同步的完整计划。 // Plan 是一次物化的完整计划。
type Plan struct { type Plan struct {
Ops []Op `json:"ops"` Ops []Op `json:"ops"`
// Warnings 是不阻塞执行、但需要人工留意的信息。 Warnings []string `json:"warnings"`
Warnings []string `json:"warnings"` Stats Stats `json:"stats"`
Stats Stats `json:"stats"`
} }
// Stats 是计划的分类统计。 // Stats 是计划的分类统计。
type Stats struct { type Stats struct {
CreateUser int `json:"createUser"` CreateUser int `json:"createUser"`
UpdateUser int `json:"updateUser"` UpdateUser int `json:"updateUser"`
Deactivate int `json:"deactivateUser"` DeleteUser int `json:"deleteUser"`
Reactivate int `json:"reactivateUser"` CreateGroup int `json:"createGroup"`
CreateGroup int `json:"createGroup"` AddMember int `json:"addMember"`
UpdateGroup int `json:"updateGroup"` RemoveMember int `json:"removeMember"`
AddMember int `json:"addMember"` TotalUsers int `json:"totalUsers"`
RemoveMember int `json:"removeMember"` Unchanged int `json:"unchanged"`
TotalAccounts int `json:"totalAccounts"`
Unchanged int `json:"unchanged"`
} }
// Input 是计算计划所需的全部输入。 // Input 是计算物化计划所需的全部输入。
type Input struct { type Input struct {
// Desired 是期望状态(来自飞书) // Desired 是期望状态(来自镜像 BuildFromMirror)。
Desired *mapping.State Desired *mapping.DesiredState
// Active 是 ou=people 下现有用户:uid(小写)→ 条目 // ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。
Active map[string]*ldaplib.Entry ActualUsers map[string]*ldaplib.Entry
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目 // ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。
Inactive map[string]*ldaplib.Entry ActualGroups map[string]*ldaplib.Entry
// Groups 是现有分组:DN(小写)→ 条目 // OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。
Groups map[string]*ldaplib.Entry OwnedUsers map[string]struct{}
// LedgerAccounts 是台账中的账号:uid → state(active/inactive) // ConfirmedLeave 是已人工确认离职、待删除的用户集。
LedgerAccounts map[string]string ConfirmedLeave map[string]bool
// LedgerGroups 是台账中的分组:dept_id → dn // AdminDN 与基础 OU DN。
LedgerGroups map[string]string AdminDN string
// AdminDN 与相关基础 DN PeopleOU string
AdminDN string GroupRootDN string
GroupRootDN string
UserOUDN string
InactiveOUDN string
} }
// opPhase 决定操作的执行顺序。 // opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
// //
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系 // 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。 // 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
// 集成测试立刻暴露了这个问题。
func opPhase(k OpKind) int { func opPhase(k OpKind) int {
switch k { switch k {
case OpCreateGroup, OpUpdateGroup: case OpCreateGroup:
return 0 return 0
case OpCreateUser, OpUpdateUser: case OpCreateUser, OpUpdateUser:
return 1 return 1
case OpReactivate:
return 2
case OpAddMember: case OpAddMember:
return 3 return 2
case OpRemoveMember: case OpRemoveMember:
return 3
case OpDeleteUser:
return 4 return 4
case OpDeactivate:
return 5
default: default:
return 6 return 5
} }
} }
// sortOps 按依赖顺序排列操作。 // sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
//
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
// “od-root” 之前,导致子分组先于父分组创建而失败)。
func sortOps(ops []Op) { func sortOps(ops []Op) {
sort.SliceStable(ops, func(i, j int) bool { sort.SliceStable(ops, func(i, j int) bool {
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind) 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 { func ComputePlan(in *Input) *Plan {
p := &Plan{ p := &Plan{
Ops: make([]Op, 0, 64), Ops: make([]Op, 0, 64),
Warnings: append([]string{}, in.Desired.Warnings...), Warnings: make([]string, 0),
Stats: Stats{ Stats: Stats{TotalUsers: len(in.ActualUsers)},
TotalAccounts: len(in.LedgerAccounts),
},
} }
planGroups(p, in) planGroups(p, in)
planUsers(p, in) planUsers(p, in)
planMembership(p, in) planMembership(p, in)
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
// (mapping 已按父先于子的顺序产出分组)。
for i := range p.Ops { for i := range p.Ops {
p.Ops[i].Seq = i p.Ops[i].Seq = i
} }
sortOps(p.Ops) sortOps(p.Ops)
return p return p
} }
// planGroups 处理分组的创建与更新。 // planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
func planGroups(p *Plan, in *Input) { func planGroups(p *Plan, in *Input) {
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
for _, g := range 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 { for _, g := range in.Desired.Groups {
p.Ops = append(p.Ops, Op{ if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
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++
continue continue
} }
p.Ops = append(p.Ops, Op{
// 分组已存在:仅当描述不一致时更新。 Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g,
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮 Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN),
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理, })
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。 p.Stats.CreateGroup++
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,
})
p.Stats.UpdateGroup++
}
} }
// 台账中存在、但期望状态里已消失的分组:不自动删除。 // 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。 for _, e := range in.ActualGroups {
wantedDept := make(map[string]string, len(in.Desired.Groups)) if _, ok := desired[strings.ToLower(e.DN)]; ok {
for _, g := range in.Desired.Groups { continue
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))
} }
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN))
} }
} }
// planUsers 处理用户的创建、更新、停用与恢复。 // planUsers 处理用户的创建、更新与删除。
func planUsers(p *Plan, in *Input) { 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 { for _, u := range in.Desired.Users {
desiredByUID[u.UID] = u desiredByUID[u.UID] = u
} }
for _, u := range in.Desired.Users { for _, u := range in.Desired.Users {
_, inActiveOU := in.Active[u.UID] existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
_, inInactiveOU := in.Inactive[u.UID] if !ok {
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) {
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,
})
p.Stats.UpdateUser++
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,
})
p.Stats.UpdateUser++
continue
}
p.Stats.Unchanged++
case inInactiveOU:
// 存在于 ou=Inactive 但期望是在职:恢复。
// 仅当台账确认该账号由我们管理时才动它。
if _, managed := in.LedgerAccounts[u.UID]; !managed {
p.Warnings = append(p.Warnings,
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
continue
}
p.Ops = append(p.Ops, Op{ p.Ops = append(p.Ops, Op{
Kind: OpReactivate, Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
Target: u.UID, User: u, AttrsHash: AttrsHash(u),
Name: u.DisplayName, Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName),
DN: u.DN,
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
User: u,
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
})
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.CreateUser++
continue
} }
if attrsDiffer(existing, u) {
p.Ops = append(p.Ops, Op{
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++
} }
// 台账中在职、但期望状态里已消失:候选停用。 // 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。 // 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
for uid, state := range in.LedgerAccounts { for uid := range in.ActualUsers {
if state != "active" { if _, want := desiredByUID[uid]; want {
continue continue
} }
if _, ok := desiredByUID[uid]; ok { if _, owned := in.OwnedUsers[uid]; !owned {
continue continue
} }
entry, ok := in.Active[uid] if !in.ConfirmedLeave[uid] {
if !ok {
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
p.Warnings = append(p.Warnings, p.Warnings = append(p.Warnings,
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN)) fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
continue continue
} }
p.Ops = append(p.Ops, Op{ p.Ops = append(p.Ops, Op{
Kind: OpDeactivate, Kind: OpDeleteUser, Target: uid,
Target: uid, Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
Name: entry.Get(ldaplib.AttrDisplayName), DN: ldaplib.UserDN(uid, in.PeopleOU),
DN: ldaplib.UserDN(uid, in.InactiveOUDN), Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
TargetDN: strings.ToLower(entry.DN),
}) })
p.Stats.Deactivate++ p.Stats.DeleteUser++
} }
} }
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。 // planMembership 比对组的成员,产出加组/移组操作。
func planMembership(p *Plan, in *Input) { func planMembership(p *Plan, in *Input) {
for _, g := range in.Desired.Groups { for _, g := range in.Desired.Groups {
existing, ok := in.Groups[strings.ToLower(g.DN)] e, ok := in.ActualGroups[strings.ToLower(g.DN)]
if !ok { actual := make(map[string]struct{})
// 分组是新建的,成员关系会在创建后由执行器处理; if ok {
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。 actual = e.Members()
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
} }
desired := lowerMembers(g.Members)
actual := existing.Members()
// 期望有、实际没有 → 加入
for memberDN := range g.Members { for memberDN := range g.Members {
if _, has := actual[strings.ToLower(memberDN)]; has { if _, has := actual[strings.ToLower(memberDN)]; has {
continue continue
} }
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
p.Ops = append(p.Ops, Op{ p.Ops = append(p.Ops, Op{
Kind: OpAddMember, Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Target: g.DeptID, Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
Name: g.Name,
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
}) })
p.Stats.AddMember++ p.Stats.AddMember++
} }
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。 if !ok {
continue
}
// 移出:实际有、期望没有,且只移受管、永不移 admin。
for memberDN := range actual { for memberDN := range actual {
if _, want := g.Members[memberDN]; want { if _, want := desired[memberDN]; want {
continue continue
} }
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) { if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
continue continue
} }
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。 uid, ok := uidFromDN(memberDN)
if !isManagedMember(memberDN, in) { if !ok {
continue
}
if _, managed := in.OwnedUsers[uid]; !managed {
p.Warnings = append(p.Warnings, p.Warnings = append(p.Warnings,
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN))) fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
continue continue
} }
p.Ops = append(p.Ops, Op{ p.Ops = append(p.Ops, Op{
Kind: OpRemoveMember, Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Target: g.DeptID, Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
Name: g.Name,
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
}) })
p.Stats.RemoveMember++ p.Stats.RemoveMember++
} }
} }
} }
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。 // lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。
// func lowerMembers(m map[string]struct{}) map[string]struct{} {
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源, out := make(map[string]struct{}, len(m))
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。 for dn := range m {
func isManagedMember(memberDN string, in *Input) bool { out[strings.ToLower(dn)] = struct{}{}
uid, ok := uidFromDN(memberDN)
if !ok {
return false
} }
if _, managed := in.LedgerAccounts[uid]; managed { return out
return true
}
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
for _, u := range in.Desired.Users {
if u.UID == uid {
return true
}
}
return false
} }
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。 // uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
func uidFromDN(dn string) (string, bool) { func uidFromDN(dn string) (string, bool) {
first := dn first := dn
if i := strings.Index(dn, ","); i >= 0 { 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 return strings.ToLower(strings.TrimSpace(first[4:])), true
} }
// memberRDN 从成员 DN 中提取用于展示的标识。 // attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。
func memberRDN(dn string) string { //
if uid, ok := uidFromDN(dn); ok { // LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName);
return uid // 默认不建自定义属性,因此只比对这四类可写硬字段。
} func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool {
return dn
}
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
checks := []struct { checks := []struct {
attr string attr string
want string want string
}{ }{
{ldaplib.AttrCN, want.CN},
{ldaplib.AttrSN, want.SN},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrMail, want.Mail}, {ldaplib.AttrMail, want.Mail},
{ldaplib.AttrMobile, want.Mobile}, {ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber}, {ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber}, {ldaplib.AttrSN, want.SN},
{ldaplib.AttrPostalAddress, want.PostalAddress},
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
{ldaplib.AttrDescription, want.Description},
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
{ldaplib.AttrEmployeeType, want.EmployeeType},
} }
for _, c := range checks { for _, c := range checks {
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) { if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
@@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
} }
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。 // describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string { func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
fields := []struct { fields := []struct {
label string label string
attr string attr string
@@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
}{ }{
{"mail", ldaplib.AttrMail, want.Mail}, {"mail", ldaplib.AttrMail, want.Mail},
{"displayName", ldaplib.AttrDisplayName, want.DisplayName}, {"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}, {"givenName", ldaplib.AttrGivenName, want.GivenName},
{"sn", ldaplib.AttrSN, want.SN}, {"sn", ldaplib.AttrSN, want.SN},
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
} }
var diffs []string var diffs []string
for _, f := range fields { for _, f := range fields {
old := existing.Get(f.attr) 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)) diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
} }
if len(diffs) == 0 { if len(diffs) == 0 {
return "属性无变化" return "无变化"
} }
return strings.Join(diffs, "; ") 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 计算用户属性的指纹,供台账记录“上次写入的内容”。 // AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
func AttrsHash(u *mapping.User) string { func AttrsHash(u *mapping.DesiredUser) string {
h := sha256.New() h := sha256.New()
for _, v := range []string{ for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} {
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
u.BusinessCategory, u.Description,
} {
h.Write([]byte(v)) h.Write([]byte(v))
h.Write([]byte{0}) h.Write([]byte{0})
} }
return hex.EncodeToString(h.Sum(nil)) 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
}
+181 -278
View File
@@ -9,43 +9,42 @@ import (
) )
const ( const (
testUserOU = "ou=people,dc=example,dc=com" peopleOU = "ou=people,dc=example,dc=com"
testInactiveOU = "ou=Inactive,dc=example,dc=com" groupRoot = "ou=groups,dc=example,dc=com"
testGroupRoot = "ou=feishuroot,dc=example,dc=com" testAdmin = "uid=admin," + peopleOU
testAdminDN = "cn=admin,dc=example,dc=com"
) )
// entry 构造一个 LDAP 条目,便于测试。
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry { func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
return ldaplib.NewEntry(dn, attrs, members) return ldaplib.NewEntry(dn, attrs, members)
} }
func newUser(uid string) *mapping.User { func dUser(uid string) *mapping.DesiredUser {
return &mapping.User{ return &mapping.DesiredUser{
UID: uid, UID: uid, DN: "uid=" + uid + "," + peopleOU,
DN: "uid=" + uid + "," + testUserOU, Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid,
InactiveDN: "uid=" + uid + "," + testInactiveOU,
CN: uid,
SN: uid,
GivenName: uid,
DisplayName: uid,
Mail: uid + "@example.com",
Description: 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{ return &Input{
Desired: desired, Desired: desired,
Active: map[string]*ldaplib.Entry{}, ActualUsers: map[string]*ldaplib.Entry{},
Inactive: map[string]*ldaplib.Entry{}, ActualGroups: map[string]*ldaplib.Entry{},
Groups: map[string]*ldaplib.Entry{}, OwnedUsers: map[string]struct{}{},
LedgerAccounts: map[string]string{}, ConfirmedLeave: map[string]bool{},
LedgerGroups: map[string]string{}, AdminDN: testAdmin,
AdminDN: testAdminDN, PeopleOU: peopleOU,
GroupRootDN: testGroupRoot, GroupRootDN: groupRoot,
UserOUDN: testUserOU,
InactiveOUDN: testInactiveOU,
} }
} }
@@ -57,313 +56,224 @@ func kindsOf(plan *Plan) map[OpKind]int {
return out return out
} }
func plainDesired() *mapping.DesiredState {
return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}}
}
func TestComputePlan(t *testing.T) { func TestComputePlan(t *testing.T) {
rdDN := "cn=od-rd," + groupRoot
tests := []struct { tests := []struct {
name string name string
desired *mapping.State desired *mapping.DesiredState
mutate func(in *Input) mutate func(in *Input)
wantKinds map[OpKind]int wantKinds map[OpKind]int
// wantDetailContains 断言某个操作详情包含特定文本 forbid []OpKind
wantDetailContains string wantWarn string
// forbidKinds 断言这些操作绝不出现(安全性回归)
forbidKinds []OpKind
}{ }{
{ {
name: "全新用户:创建", name: "全新用户:创建",
desired: &mapping.State{ desired: withUsers(plainDesired(), dUser("alice")),
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
wantKinds: map[OpKind]int{OpCreateUser: 1}, wantKinds: map[OpKind]int{OpCreateUser: 1},
}, },
{ {
name: "属性一致:不产生操作", name: "属性一致:不产生操作",
desired: &mapping.State{ desired: withUsers(plainDesired(), dUser("alice")),
Users: []*mapping.User{newUser("alice")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
u := newUser("alice") u := dUser("alice")
in.Active["alice"] = entry(u.DN, map[string]string{ in.ActualUsers["alice"] = entry(u.DN, map[string]string{
ldaplib.AttrCN: u.CN, ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrSN: u.SN, ldaplib.AttrGivenName: u.GivenName, 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,
}, nil) }, nil)
in.LedgerAccounts["alice"] = "active" in.OwnedUsers["alice"] = struct{}{}
}, },
wantKinds: map[OpKind]int{}, wantKinds: map[OpKind]int{},
}, },
{ {
name: "邮箱变化:更新属性", name: "邮箱变化:更新属性",
desired: &mapping.State{ desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
Users: []*mapping.User{
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
u := newUser("alice") in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{
in.Active["alice"] = entry(u.DN, map[string]string{ ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice",
ldaplib.AttrCN: u.CN, ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice",
ldaplib.AttrDisplayName: u.DisplayName,
ldaplib.AttrMail: "old@example.com",
}, nil) }, nil)
in.LedgerAccounts["alice"] = "active" in.OwnedUsers["alice"] = struct{}{}
}, },
wantKinds: map[OpKind]int{OpUpdateUser: 1}, wantKinds: map[OpKind]int{OpUpdateUser: 1},
wantDetailContains: "alice.new@example.com",
}, },
{ {
name: "台账在职但飞书消失:停用", name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
desired: &mapping.State{ desired: plainDesired(),
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{ in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{
ldaplib.AttrDisplayName: "Bob", ldaplib.AttrDisplayName: "Bob"}, nil)
}, nil) in.OwnedUsers["bob"] = struct{}{}
in.LedgerAccounts["bob"] = "active" in.ConfirmedLeave["bob"] = true
}, },
wantKinds: map[OpKind]int{OpDeactivate: 1}, wantKinds: map[OpKind]int{OpDeleteUser: 1},
}, },
{ {
name: "非台账账号缺席:不得停用(关键安全回归)", name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
desired: &mapping.State{ desired: plainDesired(),
Users: []*mapping.User{},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。 in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{ in.OwnedUsers["bob"] = struct{}{}
ldaplib.AttrDisplayName: "Service",
}, nil)
}, },
wantKinds: map[OpKind]int{}, wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpDeactivate}, forbid: []OpKind{OpDeleteUser},
wantWarn: "未完成离职确认",
}, },
{ {
name: "在 Inactive 且台账受管:恢复", name: "在 LLDAP、非受管(服务账号):绝不删除",
desired: &mapping.State{ desired: plainDesired(),
Users: []*mapping.User{newUser("carol")},
GroupByDeptID: map[string]*mapping.Group{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{ in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
ldaplib.AttrDisplayName: "Carol", in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
}, nil)
in.LedgerAccounts["carol"] = "inactive"
}, },
wantKinds: map[OpKind]int{OpReactivate: 1}, wantKinds: map[OpKind]int{},
forbid: []OpKind{OpDeleteUser},
}, },
{ {
name: "在 Inactive 但非台账:只告警不改动", name: "分组不存在:创建",
desired: &mapping.State{ desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
Users: []*mapping.User{newUser("dave")}, wantKinds: map[OpKind]int{OpCreateGroup: 1},
GroupByDeptID: map[string]*mapping.Group{}, },
}, {
name: "孤儿组(actual 有、期望没有):不删只告警",
desired: plainDesired(),
mutate: func(in *Input) { mutate: func(in *Input) {
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil) in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
map[string]string{ldaplib.AttrDescription: "x"}, nil)
}, },
wantKinds: map[OpKind]int{}, wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpReactivate}, forbid: []OpKind{OpCreateGroup},
wantWarn: "未自动删除",
}, },
{ {
name: "分组不存在:创建并加成员", name: "成员缺席:加入分组",
desired: &mapping.State{ desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
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{},
},
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
},
{
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{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
"cn=yanfa,"+testGroupRoot, in.OwnedUsers["alice"] = struct{}{}
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{testAdminDN})
in.LedgerAccounts["alice"] = "active"
}, },
wantKinds: map[OpKind]int{OpAddMember: 1}, wantKinds: map[OpKind]int{OpAddMember: 1},
}, },
{ {
name: "成员多余且受管:移出分组", name: "成员多余且受管:移出分组",
desired: &mapping.State{ desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
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) { mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
"cn=yanfa,"+testGroupRoot, []string{testAdmin, "uid=alice," + peopleOU})
map[string]string{ldaplib.AttrDescription: "研发部"}, in.OwnedUsers["alice"] = struct{}{}
[]string{testAdminDN, "uid=alice," + testUserOU})
in.LedgerAccounts["alice"] = "active"
}, },
wantKinds: map[OpKind]int{OpRemoveMember: 1}, wantKinds: map[OpKind]int{OpRemoveMember: 1},
}, },
{ {
name: "admin 永不被移出分组(关键安全回归)", name: "admin 永不被移出分组(关键安全回归)",
desired: &mapping.State{ desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
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) { mutate: func(in *Input) {
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
"cn=yanfa,"+testGroupRoot, []string{testAdmin})
map[string]string{ldaplib.AttrDescription: "研发部"}, in.OwnedUsers["admin"] = struct{}{}
[]string{testAdminDN})
}, },
wantKinds: map[OpKind]int{}, wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember}, forbid: []OpKind{OpRemoveMember},
}, },
{ {
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让 name: "外部非受管成员:不移出,仅告警",
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑 desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
// 前面的 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{},
},
mutate: func(in *Input) { mutate: func(in *Input) {
adminUIDDN := "uid=admin," + testUserOU in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
in.AdminDN = adminUIDDN []string{testAdmin, "uid=external," + peopleOU})
in.LedgerAccounts["admin"] = "active"
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
"cn=yanfa,"+testGroupRoot,
map[string]string{ldaplib.AttrDescription: "研发部"},
[]string{adminUIDDN})
}, },
wantKinds: map[OpKind]int{}, wantKinds: map[OpKind]int{},
forbidKinds: []OpKind{OpRemoveMember}, forbid: []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},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
in := baseInput(tt.desired) in := baseIn(tt.desired)
if tt.mutate != nil { if tt.mutate != nil {
tt.mutate(in) tt.mutate(in)
} }
plan := ComputePlan(in) plan := ComputePlan(in)
got := kindsOf(plan) got := kindsOf(plan)
for kind, want := range tt.wantKinds { for kind, want := range tt.wantKinds {
if got[kind] != want { if got[kind] != want {
t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s", t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan))
kind, got[kind], want, dumpPlan(plan))
} }
} }
for kind, count := range got { for kind, count := range got {
if _, expected := tt.wantKinds[kind]; !expected { if _, exp := tt.wantKinds[kind]; !exp {
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s", t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
kind, count, dumpPlan(plan))
} }
} }
for _, kind := range tt.forbidKinds { for _, kind := range tt.forbid {
if got[kind] > 0 { 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 found := false
for _, op := range plan.Ops { for _, w := range plan.Warnings {
if strings.Contains(op.Detail, tt.wantDetailContains) { if strings.Contains(w, tt.wantWarn) {
found = true found = true
break break
} }
} }
if !found { if !found {
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s", t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
tt.wantDetailContains, dumpPlan(plan))
} }
} }
}) })
} }
} }
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试: // TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。 func TestDeleteIsLastPhase(t *testing.T) {
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。 rdDN := "cn=od-rd," + groupRoot
func TestComputePlanNeverTouchesPassword(t *testing.T) { desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice"))
u := newUser("alice") in := baseIn(desired)
in := baseInput(&mapping.State{ in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{
Users: []*mapping.User{u}, ldaplib.AttrDisplayName: "alice"}, nil)
GroupByDeptID: map[string]*mapping.Group{}, in.OwnedUsers["alice"] = struct{}{}
}) in.OwnedUsers["bob"] = struct{}{}
in.LedgerAccounts["bob"] = "active" in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
in.Active["bob"] = entry("uid=bob,"+testUserOU, 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) plan := ComputePlan(in)
for _, op := range plan.Ops { for _, op := range plan.Ops {
if strings.Contains(strings.ToLower(op.Detail), "userpassword") { if strings.Contains(strings.ToLower(op.Detail), "userpassword") ||
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail) strings.Contains(strings.ToLower(op.Detail), "password") {
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
} }
} }
} }
@@ -375,40 +285,17 @@ func TestGuard(t *testing.T) {
plan *Plan plan *Plan
wantErr bool wantErr bool
}{ }{
{ {"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false},
name: "无停用时通过", {"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true},
guard: Guard{MaxDeactivatePercent: 0.2}, {"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true},
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}}, {"比例在阈值内通过", 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},
{
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}},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := tt.guard.Check(tt.plan) err := tt.guard.Check(tt.plan)
if tt.wantErr && err == nil { if tt.wantErr && err == nil {
t.Errorf("期望返回错误,实际通过") t.Errorf("期望报错,实际通过")
} }
if !tt.wantErr && err != nil { if !tt.wantErr && err != nil {
t.Errorf("期望通过,实际报错: %v", err) 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 { func dumpPlan(p *Plan) string {
var b strings.Builder var b strings.Builder
for _, op := range p.Ops { for _, op := range p.Ops {
b.WriteString("\n ") b.WriteString("\n [")
b.WriteString(string(op.Kind)) b.WriteString(string(op.Kind))
b.WriteString(" target=") b.WriteString("] ")
b.WriteString(op.Target) b.WriteString(op.Target)
b.WriteString(" detail=") b.WriteString(": ")
b.WriteString(op.Detail) b.WriteString(op.Detail)
} }
for _, w := range p.Warnings { for _, w := range p.Warnings {
+109 -204
View File
@@ -1,58 +1,80 @@
package syncer package syncer
// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。
//
// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化
// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"time" "sync"
"idsync/internal/config" "idsync/internal/config"
"idsync/internal/feishu" "idsync/internal/feishu"
"idsync/internal/ldaplib" "idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/mapping" "idsync/internal/mapping"
"idsync/internal/store" "idsync/internal/store"
) )
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。 // Runner 编排快照与物化。
type Runner struct { type Runner struct {
cfg *config.Config cfg *config.Config
feishu *feishu.Client feishu *feishu.Client
ldap *ldaplib.Client ldap *ldaplib.Client
store *store.Store gql *lldapgql.Client
log *slog.Logger store *store.Store
snapshot *SnapshotRunner
log *slog.Logger
// guard 是执行前的安全阈值。
guard Guard guard Guard
// mu 让快照与物化在单实例内互斥。
mu sync.Mutex
groupIDs map[string]int
} }
// NewRunner 构造编排器。 // 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{ return &Runner{
cfg: cfg, cfg: cfg,
feishu: f, feishu: f,
ldap: l, ldap: l,
store: s, gql: g,
log: log, store: s,
snapshot: NewSnapshotRunner(cfg, f, s, log, opts),
log: log,
guard: Guard{ guard: Guard{
MaxDeactivate: cfg.Sync.MaxDeactivate, MaxDeactivate: cfg.Guard.MaxDeactivate,
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent, 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) { func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
plan, _, err := r.plan(ctx) plan, _, err := r.plan(ctx)
return plan, err 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) { 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) run, err := r.store.StartRun(modeOf(dryRun), trigger)
if err != nil { 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) 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) fillRunFromPlan(run, plan)
run.Status = "success" run.Status = "success"
if err := r.store.FinishRun(run); err != nil { if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err) r.log.Warn("写入物化记录失败", "err", err)
} }
return plan, nil, nil 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) res := r.apply(ctx, plan)
fillRunFromResult(run, plan, res) fillRunFromResult(run, plan, res)
// 只有全部成功才回写台账:部分失败时保持台账不变, // 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
if res.Failed == 0 { 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.Status = "failed"
run.ErrorMsg = "回写台账失败: " + err.Error() run.ErrorMsg = "回写镜像失败: " + err.Error()
_ = r.store.FinishRun(run) _ = r.store.FinishRun(run)
return plan, res, fmt.Errorf("回写台账失败: %w", err) return plan, res, fmt.Errorf("回写镜像失败: %w", err)
} }
} else { } else {
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed) r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed)
} }
run.Status = "success" 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, "; ") run.ErrorMsg = strings.Join(res.Errors, "; ")
} }
if err := r.store.FinishRun(run); err != nil { if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入同步记录失败", "err", err) r.log.Warn("写入物化记录失败", "err", err)
} }
return plan, res, nil return plan, res, nil
} }
// prepare 计算计划并做安全检查。 // 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) plan, desired, err := r.plan(ctx)
if err != nil { if err != nil {
run.Status = "failed" run.Status = "failed"
@@ -106,8 +127,6 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
return nil, nil, err return nil, nil, err
} }
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
guard := r.guard guard := r.guard
if force { if force {
guard.AllowBulk = true guard.AllowBulk = true
@@ -121,215 +140,104 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
return plan, desired, nil return plan, desired, nil
} }
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。 // plan 读两侧状态并计算物化计划。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) { func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) {
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList) // 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。
activeUsers, err := r.store.ActiveUsersWithDepts()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err) return nil, nil, err
} }
r.log.Info("已读取飞书数据", depts, err := r.store.ActiveDepartments()
"departments", len(snap.Departments), "users", len(snap.Users), if err != nil {
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate)) return nil, nil, err
// 把“被跳过的用户”显式转成告警:这些人不会出现在 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, "、")))
} }
desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN)
desired := mapping.Build(snap, mapping.BuildOptions{ // 实际侧:读 LLDAP 的 LDAP 视图。
UserOUDN: r.cfg.UserOUDN(),
InactiveOUDN: r.cfg.InactiveOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
})
conn, err := r.ldap.Dial() conn, err := r.ldap.Dial()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
defer conn.Close() defer conn.Close()
active, err := conn.ListUsers(r.cfg.UserOUDN()) actualUsers, err := conn.ListUsers(r.cfg.UserOUDN())
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN()) actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil {
return nil, nil, err
}
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
accounts, err := r.store.AccountsByUID() // 所有权与删除白名单都来自镜像台账。
all, err := r.store.AllMirrorUsers()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
groupLedger, err := r.store.GroupsByDeptID() owned := make(map[string]struct{}, len(all))
if err != nil { confirmed := make(map[string]bool)
return nil, nil, err for _, u := range all {
} if u.Skipped {
continue
ledgerAccounts := make(map[string]string, len(accounts)) }
for uid, a := range accounts { owned[u.UID] = struct{}{}
ledgerAccounts[uid] = a.State 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{ plan := ComputePlan(&Input{
Desired: desired, Desired: desired,
Active: active, ActualUsers: actualUsers,
Inactive: inactive, ActualGroups: actualGroups,
Groups: groups, OwnedUsers: owned,
LedgerAccounts: ledgerAccounts, ConfirmedLeave: confirmed,
LedgerGroups: ledgerGroups,
AdminDN: r.cfg.Ldap.AdminDN, AdminDN: r.cfg.Ldap.AdminDN,
PeopleOU: r.cfg.UserOUDN(),
GroupRootDN: r.cfg.GroupRootDN(), 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 return plan, desired, nil
} }
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。 // apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。
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 执行计划。
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result { func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
exec := NewExecutor(r.ldap, ExecConfig{ if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil {
UserOUDN: r.cfg.UserOUDN(), return &Result{Failed: 1, Errors: []string{err.Error()}}
InactiveOUDN: r.cfg.InactiveOUDN(), }
GroupRootDN: r.cfg.GroupRootDN(), groups, err := r.gql.Groups(ctx)
GroupRootName: r.cfg.Ldap.GroupRootName, if err != nil {
AdminDN: r.cfg.Ldap.AdminDN, return &Result{Failed: 1, Errors: []string{err.Error()}}
InitialPassword: r.cfg.Ldap.InitialPassword, }
}, r.log) groupIDs := make(map[string]int, len(groups))
return exec.Apply(ctx, plan) for _, g := range groups {
groupIDs[g.DisplayName] = g.ID
}
exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log)
res := exec.Apply(ctx, plan, groupIDs)
r.groupIDs = groupIDs
return res
} }
// persist 把本次同步的期望状态回写台账。 // persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。
// func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error {
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
// 否则会出现一个隐蔽问题——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
}
}
// 用户:在职的登记为 active。
for _, u := range desired.Users { for _, u := range desired.Users {
if err := r.store.UpsertAccount(&store.Account{ if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil {
UID: u.UID, return err
DN: u.DN, }
Email: u.Mail, }
Name: u.DisplayName, for deptID, gid := range r.groupIDs {
State: "active", if err := r.store.SetDeptGroupID(deptID, gid); err != nil {
AttrsHash: AttrsHash(u),
DeptIDs: strings.Join(u.DeptIDs, ","),
LastSyncedAt: now,
}); err != nil {
return err return err
} }
} }
// 停用的账号:更新状态与,DN 指向离职 OU。
for _, op := range plan.Ops { for _, op := range plan.Ops {
if op.Kind == OpDeactivate { if op.Kind == OpDeleteUser {
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil { if err := r.store.MarkDeleted(op.Target); err != nil {
return err return err
} }
} }
} }
return nil return nil
} }
@@ -341,22 +249,19 @@ func modeOf(dryRun bool) string {
return "apply" return "apply"
} }
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。 // fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。
func fillRunFromPlan(run *store.SyncRun, plan *Plan) { func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup + run.Updated = plan.Stats.UpdateUser
plan.Stats.Reactivate + plan.Stats.Deactivate
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
run.Added = plan.Stats.AddMember run.Added = plan.Stats.AddMember
run.Removed = plan.Stats.RemoveMember run.Removed = plan.Stats.RemoveMember
run.Skipped = plan.Stats.Unchanged run.Skipped = plan.Stats.Unchanged
} }
// fillRunFromResult 用实际执行结果填充同步记录。 // fillRunFromResult 用实际执行结果填充物化记录。
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) { func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
run.Created = res.Created run.Created = res.Created
run.Updated = res.Updated run.Updated = res.Updated
run.Moved = res.Moved
run.Added = res.Added run.Added = res.Added
run.Removed = res.Removed run.Removed = res.Removed
run.Failed = res.Failed 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 对应。 // 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = { const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户', create_user: '新建用户',
update_user: '更新用户', update_user: '更新用户',
deactivate_user: '迁入离职 OU', delete_user: '删除用户',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组', add_member: '加入分组',
remove_member: '移出分组', remove_member: '移出分组',
}; };
const OP_BADGES = { const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success', create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info', update_user: 'badge-info',
update_group: 'badge-info', delete_user: 'badge-error',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success', add_member: 'badge-success',
remove_member: 'badge-error', 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() { function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000); setTimeout(() => el.classList.add('hidden'), 8000);
} }
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换 // ---------------------------------------------------------------- 视图切换
function showLogin() { function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === 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 === 'users') loadUsers();
if (name === 'groups') loadGroups(); if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan(); if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() { async function doLogin() {
const errEl = $('#loginError'); const errEl = $('#loginError');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
await api('/api/login', { await api('/api/login', {
method: 'POST', method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() { async function doLogout() {
try { try {
await api('/api/logout', { method: 'POST' }); await api('/api/logout', { method: 'POST' });
} catch (_) { } catch (_) { /* 登出失败也要回登录页 */ }
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
showLogin(); showLogin();
} }
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() { async function loadStatus() {
try { try {
const d = await api('/api/status'); const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers; $('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers; $('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.groups; $('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [ $('#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('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join(''); ].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun $('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1"> ? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div> <div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</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>` </div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>'; : '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`; </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 = []; let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users'); const d = await api('/api/users');
usersCache = d.users || []; usersCache = d.users || [];
renderUsers(); renderUsers();
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
}); });
$('#userRows').innerHTML = rows.map((u) => ` $('#userRows').innerHTML = rows.map((u) => {
<tr> const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td> <td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td> <td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td> <td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive' <td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
? '<span class="badge badge-error badge-sm">离职</span>' <td>${(u.deptIds || []).length}</td>
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td> <td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift <td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>` ? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td> : '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td> </tr>`;
</tr>`).join(''); }).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0); $('#userEmpty').classList.toggle('hidden', rows.length > 0);
} }
// ---------------------------------------------------------------- 分组 // ---------------------------------------------------------------- 部门分组
async function loadGroups() { async function loadGroups() {
try { try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || []; const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => ` $('#groupRows').innerHTML = groups.map((g) => `
<tr> <tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td> <td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td> <td>${escapeHtml(g.DeptID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td> <td>${escapeHtml(g.ParentID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td> <td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join(''); </tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0); $('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = ''; $('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden'); $('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden'); $('#planEmpty').classList.add('hidden');
try { try {
const plan = await api('/api/plan'); const plan = await api('/api/plan');
currentPlan = plan; currentPlan = plan;
renderPlan(plan); renderPlan(plan);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) { function renderPlan(plan) {
const s = plan.stats || {}; const s = plan.stats || {};
const cards = [ const cards = [
['新建用户', s.createUser, 'text-success'], ['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'], ['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'], ['删除用户', s.deleteUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'], ['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'], ['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'], ['移出分组', s.removeMember, 'text-error'],
@@ -300,7 +337,7 @@ function renderPlan(plan) {
const ops = plan.ops || []; const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => ` $('#planRows').innerHTML = ops.map((op) => `
<tr> <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 class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td> <td>${escapeHtml(op.detail)}</td>
</tr>`).join(''); </tr>`).join('');
@@ -308,17 +345,25 @@ function renderPlan(plan) {
$('#planEmpty').classList.toggle('hidden', ops.length > 0); $('#planEmpty').classList.toggle('hidden', ops.length > 0);
} }
async function applyPlan(force = false) { // FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
const modal = document.getElementById('applyModal'); // 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
if (modal && typeof modal.close === 'function') modal.close(); 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 { try {
const d = await api('/api/sync', { const d = await api('/api/sync', {
method: 'POST', method: 'POST',
body: JSON.stringify({ dryRun: false, force }), body: JSON.stringify({ dryRun: false, force }),
}); });
const res = d.result || {}; 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'; $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs'); switchView('runs');
} catch (err) { } catch (err) {
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
} }
} }
// ---------------------------------------------------------------- 同步历史 // ---------------------------------------------------------------- 物化历史
async function loadRuns() { async function loadRuns() {
try { try {
@@ -336,18 +381,17 @@ async function loadRuns() {
<tr> <tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td> <td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run' <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> : '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td> <td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success' <td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>' ? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td> : '<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.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td> <td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join(''); </tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0); $('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers); $('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns); $('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan); $('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => { $('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return; if (!currentPlan) return;
const s = currentPlan.stats || {}; const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) { if (total === 0) {
showError('没有需要应用的变更。'); showError('没有需要应用的变更。');
return; return;
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = ` $('#applyModalBody').innerHTML = `
<div class="space-y-1"> <div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div> <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>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`; </div>`;
const modal = document.getElementById('applyModal'); openApplyModal();
if (modal && typeof modal.showModal === 'function') modal.showModal();
}); });
$('#applyCancel').addEventListener('click', () => { // 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
$('#applyConfirm').addEventListener('click', () => applyPlan(false)); $('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面 // 已有会话则直接进入主界面
+51 -19
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1> <h1 class="card-title text-lg m-0">idsync</h1>
</div> </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> <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"> <div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</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>
<div class="flex-none flex items-center gap-2"> <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> <button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
</div> </div>
</header> </header>
<main class="max-w-7xl mx-auto p-4"> <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 tab-active" data-view="overview" role="tab">概览</button>
<button class="tab" data-view="users" role="tab">用户</button> <button class="tab" data-view="users" role="tab">用户</button>
<button class="tab" data-view="groups" role="tab">部门分组</button> <button class="tab" data-view="groups" role="tab">部门分组</button>
@@ -68,8 +68,8 @@
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span> <span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statInactive" class="text-3xl font-semibold">–</span> <span id="statPending" class="text-3xl font-semibold">–</span>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">服务状态</h2> <h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div> <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> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <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 id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div> </div>
</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> </section>
<!-- 用户 --> <!-- 用户 -->
@@ -153,6 +171,7 @@
</p> </p>
</div> </div>
<div class="flex gap-2"> <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="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button> <button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div> </div>
@@ -208,20 +227,33 @@
</main> </main>
</div> </div>
<!-- 应用变更的确认对话框 --> <!-- 应用变更的确认对话框。
<dialog id="applyModal" class="modal"> 必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
<div class="modal-box"> open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3> 生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
<p class="text-sm text-base-content/70 mb-4"> <div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。 role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
</p> <div class="modal-dialog">
<div id="applyModalBody" class="text-sm mb-4"></div> <div class="modal-content">
<div class="modal-action"> <div class="modal-header">
<button id="applyCancel" class="btn btn-ghost">取消</button> <h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
<button id="applyConfirm" class="btn btn-primary">确认执行</button> <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">
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p>
<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> </div>
</dialog> </div>
<script src="/static/flyonui.js"></script> <script src="/static/flyonui.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
+120 -83
View File
@@ -17,6 +17,7 @@ import (
"idsync/internal/config" "idsync/internal/config"
"idsync/internal/feishu" "idsync/internal/feishu"
"idsync/internal/ldaplib" "idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/server" "idsync/internal/server"
"idsync/internal/store" "idsync/internal/store"
"idsync/internal/syncer" "idsync/internal/syncer"
@@ -28,9 +29,10 @@ var version = "dev"
func main() { func main() {
var ( var (
cfgPath = flag.String("config", "config.yml", "配置文件路径") cfgPath = flag.String("config", "config.yml", "配置文件路径")
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP") dryRun = flag.Bool("dry-run", false, "只计算并打印物化计划,不写入 LLDAP")
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)") applyAll = flag.Bool("apply", false, "直接执行同步(快照+物化,默认只做 dry-run)")
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏") force = flag.Bool("force", false, "忽略删除数量阈值等安全护栏")
snapshot = flag.Bool("snapshot", false, "只执行一轮快照(飞书→镜像+离职流程)后退出")
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出") genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出") hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接") checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
@@ -38,25 +40,15 @@ func main() {
flag.Parse() flag.Parse()
if *genSecret { if *genSecret {
s, err := server.GenerateSecret() fmt.Println(mustGenSecret())
if err != nil {
fatal("生成密钥失败", err)
}
fmt.Println(s)
return return
} }
if *hashPass != "" { if *hashPass != "" {
h, err := server.HashPassword(*hashPass) fmt.Println(mustHashPassword(*hashPass))
if err != nil {
fatal("生成哈希失败", err)
}
fmt.Println(h)
return return
} }
// 配置自检:把解析后的有效值打出来(凭据脱敏)。 // 配置自检:把解析后的有效值打出来(凭据脱敏)。
// 用于在真正连接目录前确认关键项,尤其是分组形态——
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
if *checkCfg { if *checkCfg {
cfg, err := config.Load(*cfgPath) cfg, err := config.Load(*cfgPath)
if err != nil { if err != nil {
@@ -81,16 +73,20 @@ func main() {
fatal("创建数据目录失败", err) fatal("创建数据目录失败", err)
} }
// 读通道:LLDAP 的 LDAP(连接/搜索)。
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log) ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
if err != nil { if err != nil {
fatal("初始化 LDAP 客户端失败", err) fatal("初始化 LDAP 读客户端失败", err)
} }
if err := ldapClient.Ping(); err != nil { if err := ldapClient.Ping(); err != nil {
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err) log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
} else { } 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{ feishuClient, err := feishu.New(feishu.Options{
AppID: cfg.Feishu.AppID, AppID: cfg.Feishu.AppID,
AppSecret: cfg.Feishu.AppSecret, AppSecret: cfg.Feishu.AppSecret,
@@ -104,19 +100,28 @@ func main() {
db, err := store.Open(cfg.Database.Source) db, err := store.Open(cfg.Database.Source)
if err != nil { if err != nil {
fatal("打开台账数据库失败", err) fatal("打开镜像数据库失败", err)
} }
defer db.Close() 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 { if *dryRun || *applyAll {
runOnce(cfg, runner, *applyAll, *force, log) runOnce(cfg, runner, *applyAll, *force, log)
return return
} }
// 服务模式 // 服务模式。
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log) srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
if err != nil { if err != nil {
fatal("初始化服务失败", err) fatal("初始化服务失败", err)
@@ -135,10 +140,10 @@ func main() {
} }
}() }()
if cfg.Sync.Enable { if cfg.Schedule.Enable {
scheduleSync(cfg, runner, log) scheduleAuto(cfg, runner, log)
} else { } else {
log.Info("定时同步未启用(sync.enable=false)") log.Info("定时任务未启用(schedule.enable=false)")
} }
quit := make(chan os.Signal, 1) 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("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)")) fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList) fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
fmt.Printf("飞书 离职卡片 webhook : %s\n", orDefault(cfg.Feishu.WebhookURL, "(未配置)"))
fmt.Println() fmt.Println()
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL) fmt.Printf("LDAP 读 url : %s\n", cfg.Ldap.URL)
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN) fmt.Printf("LDAP 读 base-dn : %s\n", cfg.Ldap.BaseDN)
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN) fmt.Printf("LDAP 读 admin-dn : %s\n", cfg.Ldap.AdminDN)
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass)) fmt.Printf("LDAP 读 admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS) 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.Println()
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN()) 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.GroupRootDN()) fmt.Printf("离职确认端点对外 URL : %s\n", orDefault(cfg.Server.ConfirmBaseURL, "(未配置,离职卡片将无法提供确认链接)"))
fmt.Println() fmt.Println()
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass) fmt.Printf("定时任务 : enable=%v snapshot=%q sync=%q sync-dry-run=%v\n",
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr) cfg.Schedule.Enable, cfg.Schedule.Snapshot, cfg.Schedule.Sync, cfg.Schedule.SyncDryRun)
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n", fmt.Printf("删除阈值 : max=%d percent=%.0f%%\n",
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass) cfg.Guard.MaxDeactivate, cfg.Guard.MaxDeactivatePercent*100)
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 搜不到离职用户)")
}
} }
func orDefault(v, def string) string { func orDefault(v, def string) string {
@@ -211,26 +202,47 @@ func orDefault(v, def string) string {
return v 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) { func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer cancel()
// 先快照(飞书→镜像),再物化(镜像→LLDAP)。
if _, err := runner.RunSnapshot(ctx); err != nil {
fatal("快照失败", err)
}
plan, res, err := runner.Run(ctx, !apply, force, "manual") plan, res, err := runner.Run(ctx, !apply, force, "manual")
if plan != nil { if plan != nil {
printPlanTo(plan) printPlanTo(plan)
} }
if err != nil { if err != nil {
fatal("同步失败", err) fatal("物化失败", err)
} }
if !apply { if !apply {
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。") log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
return return
} }
log.Info("物化完成",
log.Info("同步完成", "created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
"added", res.Added, "removed", res.Removed, "failed", res.Failed) "added", res.Added, "removed", res.Removed, "failed", res.Failed)
for _, e := range res.Errors { for _, e := range res.Errors {
log.Error("操作失败详情", "detail", e) log.Error("操作失败详情", "detail", e)
@@ -248,7 +260,6 @@ func buildServer(
) (*server.Server, error) { ) (*server.Server, error) {
secret := cfg.Server.SessionSecret secret := cfg.Server.SessionSecret
if secret == "" { if secret == "" {
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
generated, err := server.GenerateSecret() generated, err := server.GenerateSecret()
if err != nil { if err != nil {
return nil, fmt.Errorf("生成会话密钥失败: %w", err) return nil, fmt.Errorf("生成会话密钥失败: %w", err)
@@ -288,25 +299,36 @@ func buildServer(
}), nil }), nil
} }
// scheduleSync 注册定时同步。 // scheduleAuto 注册两套定时任务:快照低频、物化略高频。输出到 runner 的互斥锁
// // 保证两者绝不同时执行。
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量 func scheduleAuto(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
c := cron.New( c := cron.New(
cron.WithSeconds(), cron.WithSeconds(),
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)), cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
) )
_, err := c.AddFunc(cfg.Sync.Schedule, func() { if _, err := c.AddFunc(cfg.Schedule.Snapshot, func() {
dry := cfg.Sync.DefaultDryRun
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() 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") plan, res, err := runner.Run(ctx, dry, false, "schedule")
if err != nil { if err != nil {
log.Error("定时同步失败", "err", err) log.Error("定时物化失败", "err", err)
return return
} }
if dry { if dry {
@@ -314,34 +336,33 @@ func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
if plan != nil { if plan != nil {
planned = len(plan.Ops) planned = len(plan.Ops)
} }
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned) log.Info("定时物化为 dry-run 模式,未写入变更", "planned_ops", planned)
return return
} }
log.Info("定时同步完成", log.Info("定时物化完成",
"created", res.Created, "updated", res.Updated, "created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
"moved", res.Moved, "failed", res.Failed) "added", res.Added, "removed", res.Removed, "failed", res.Failed)
}) }); err != nil {
if err != nil { log.Error("注册定时物化失败", "schedule", cfg.Schedule.Sync, "err", err)
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
return
} }
c.Start() 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) { func printPlanTo(p *syncer.Plan) {
fmt.Println("===== 同步计划 =====") fmt.Println("===== 物化计划 =====")
s := p.Stats s := p.Stats
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n", fmt.Printf("新建用户 %d · 更新用户 %d · 删除用户 %d\n", s.CreateUser, s.UpdateUser, s.DeleteUser)
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate) fmt.Printf("新建分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n", s.CreateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
if len(p.Ops) > 0 { if len(p.Ops) > 0 {
fmt.Println("\n----- 操作明细 -----") fmt.Println("\n----- 操作明细 -----")
for _, op := range p.Ops { 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 { if len(p.Warnings) > 0 {
@@ -353,6 +374,22 @@ func printPlanTo(p *syncer.Plan) {
fmt.Println() 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) { func fatal(msg string, err error) {
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
os.Exit(1) os.Exit(1)
+106 -64
View File
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。 // 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = { const OP_LABELS = {
create_group: '新建分组',
create_user: '新建用户', create_user: '新建用户',
update_user: '更新用户', update_user: '更新用户',
deactivate_user: '迁入离职 OU', delete_user: '删除用户',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组', add_member: '加入分组',
remove_member: '移出分组', remove_member: '移出分组',
}; };
const OP_BADGES = { const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success', create_group: 'badge-success',
create_user: 'badge-success',
update_user: 'badge-info', update_user: 'badge-info',
update_group: 'badge-info', delete_user: 'badge-error',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success', add_member: 'badge-success',
remove_member: 'badge-error', 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() { function initTheme() {
@@ -78,10 +83,6 @@ function showError(msg) {
setTimeout(() => el.classList.add('hidden'), 8000); setTimeout(() => el.classList.add('hidden'), 8000);
} }
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换 // ---------------------------------------------------------------- 视图切换
function showLogin() { function showLogin() {
@@ -105,7 +106,7 @@ function switchView(name) {
t.classList.toggle('tab-active', t.dataset.view === 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 === 'users') loadUsers();
if (name === 'groups') loadGroups(); if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan(); if (name === 'plan') loadPlan();
@@ -117,7 +118,6 @@ function switchView(name) {
async function doLogin() { async function doLogin() {
const errEl = $('#loginError'); const errEl = $('#loginError');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
try { try {
await api('/api/login', { await api('/api/login', {
method: 'POST', method: 'POST',
@@ -134,9 +134,7 @@ async function doLogin() {
async function doLogout() { async function doLogout() {
try { try {
await api('/api/logout', { method: 'POST' }); await api('/api/logout', { method: 'POST' });
} catch (_) { } catch (_) { /* 登出失败也要回登录页 */ }
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
showLogin(); showLogin();
} }
@@ -145,24 +143,29 @@ async function doLogout() {
async function loadStatus() { async function loadStatus() {
try { try {
const d = await api('/api/status'); const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers; $('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers; $('#statPending').textContent = d.counts.pendingConfirm;
$('#statGroups').textContent = d.counts.groups; $('#statGroups').textContent = d.counts.departments;
$('#connStatus').innerHTML = [ $('#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('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
d.lldap.baseUrl || '未配置'),
].join(''); ].join('');
const skipped = d.counts.skipped || 0;
$('#skippedStatus').textContent = skipped;
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
$('#lastRunInfo').innerHTML = d.lastRun $('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1"> ? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div> <div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</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>` </div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>'; : '<span class="text-base-content/50">暂无物化记录</span>';
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
</div>`; </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 = []; let usersCache = [];
@@ -190,7 +233,6 @@ async function loadUsers() {
const d = await api('/api/users'); const d = await api('/api/users');
usersCache = d.users || []; usersCache = d.users || [];
renderUsers(); renderUsers();
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -203,26 +245,25 @@ function renderUsers() {
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
}); });
$('#userRows').innerHTML = rows.map((u) => ` $('#userRows').innerHTML = rows.map((u) => {
<tr> const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
return `<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td> <td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td> <td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td> <td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive' <td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
? '<span class="badge badge-error badge-sm">离职</span>' <td>${(u.deptIds || []).length}</td>
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td> <td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift <td>${u.skipped
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>` ? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
: '<span class="text-base-content/40">—</span>'}</td> : '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td> </tr>`;
</tr>`).join(''); }).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0); $('#userEmpty').classList.toggle('hidden', rows.length > 0);
} }
// ---------------------------------------------------------------- 分组 // ---------------------------------------------------------------- 部门分组
async function loadGroups() { async function loadGroups() {
try { try {
@@ -230,13 +271,13 @@ async function loadGroups() {
const groups = d.groups || []; const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => ` $('#groupRows').innerHTML = groups.map((g) => `
<tr> <tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td> <td class="font-medium">${escapeHtml(g.Name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td> <td>${escapeHtml(g.DeptID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td> <td>${escapeHtml(g.ParentID || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td> <td>${g.GroupID || '—'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
</tr>`).join(''); </tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0); $('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -251,12 +292,10 @@ async function loadPlan() {
$('#planSummary').innerHTML = ''; $('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden'); $('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden'); $('#planEmpty').classList.add('hidden');
try { try {
const plan = await api('/api/plan'); const plan = await api('/api/plan');
currentPlan = plan; currentPlan = plan;
renderPlan(plan); renderPlan(plan);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -264,12 +303,10 @@ async function loadPlan() {
function renderPlan(plan) { function renderPlan(plan) {
const s = plan.stats || {}; const s = plan.stats || {};
const cards = [ const cards = [
['新建用户', s.createUser, 'text-success'], ['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'], ['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'], ['删除用户', s.deleteUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'], ['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'], ['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'], ['移出分组', s.removeMember, 'text-error'],
@@ -300,7 +337,7 @@ function renderPlan(plan) {
const ops = plan.ops || []; const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => ` $('#planRows').innerHTML = ops.map((op) => `
<tr> <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 class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td> <td>${escapeHtml(op.detail)}</td>
</tr>`).join(''); </tr>`).join('');
@@ -308,17 +345,25 @@ function renderPlan(plan) {
$('#planEmpty').classList.toggle('hidden', ops.length > 0); $('#planEmpty').classList.toggle('hidden', ops.length > 0);
} }
async function applyPlan(force = false) { // FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
const modal = document.getElementById('applyModal'); // 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
if (modal && typeof modal.close === 'function') modal.close(); 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 { try {
const d = await api('/api/sync', { const d = await api('/api/sync', {
method: 'POST', method: 'POST',
body: JSON.stringify({ dryRun: false, force }), body: JSON.stringify({ dryRun: false, force }),
}); });
const res = d.result || {}; 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'; $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs'); switchView('runs');
} catch (err) { } catch (err) {
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
} }
} }
// ---------------------------------------------------------------- 同步历史 // ---------------------------------------------------------------- 物化历史
async function loadRuns() { async function loadRuns() {
try { try {
@@ -336,18 +381,17 @@ async function loadRuns() {
<tr> <tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td> <td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run' <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> : '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td> <td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success' <td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>' ? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td> : '<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.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td> <td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join(''); </tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0); $('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
$('#reloadUsers').addEventListener('click', loadUsers); $('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns); $('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan); $('#previewBtn').addEventListener('click', loadPlan);
$('#snapshotBtn').addEventListener('click', doSnapshot);
$('#refreshPending').addEventListener('click', loadPending);
$('#applyBtn').addEventListener('click', () => { $('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return; if (!currentPlan) return;
const s = currentPlan.stats || {}; const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) { if (total === 0) {
showError('没有需要应用的变更。'); showError('没有需要应用的变更。');
return; return;
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
$('#applyModalBody').innerHTML = ` $('#applyModalBody').innerHTML = `
<div class="space-y-1"> <div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div> <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>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`; </div>`;
const modal = document.getElementById('applyModal'); openApplyModal();
if (modal && typeof modal.showModal === 'function') modal.showModal();
}); });
$('#applyCancel').addEventListener('click', () => { // 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
$('#applyConfirm').addEventListener('click', () => applyPlan(false)); $('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面 // 已有会话则直接进入主界面
+51 -19
View File
@@ -16,7 +16,7 @@
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1> <h1 class="card-title text-lg m-0">idsync</h1>
</div> </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> <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"> <div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span> <span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</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>
<div class="flex-none flex items-center gap-2"> <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> <button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
</div> </div>
</header> </header>
<main class="max-w-7xl mx-auto p-4"> <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 tab-active" data-view="overview" role="tab">概览</button>
<button class="tab" data-view="users" role="tab">用户</button> <button class="tab" data-view="users" role="tab">用户</button>
<button class="tab" data-view="groups" role="tab">部门分组</button> <button class="tab" data-view="groups" role="tab">部门分组</button>
@@ -68,8 +68,8 @@
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span> <span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
<span id="statInactive" class="text-3xl font-semibold">–</span> <span id="statPending" class="text-3xl font-semibold">–</span>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
@@ -85,15 +85,33 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-base">服务状态</h2> <h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div> <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> </div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <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 id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div> </div>
</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> </section>
<!-- 用户 --> <!-- 用户 -->
@@ -153,6 +171,7 @@
</p> </p>
</div> </div>
<div class="flex gap-2"> <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="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button> <button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div> </div>
@@ -208,20 +227,33 @@
</main> </main>
</div> </div>
<!-- 应用变更的确认对话框 --> <!-- 应用变更的确认对话框。
<dialog id="applyModal" class="modal"> 必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
<div class="modal-box"> open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3> 生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
<p class="text-sm text-base-content/70 mb-4"> <div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。 role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
</p> <div class="modal-dialog">
<div id="applyModalBody" class="text-sm mb-4"></div> <div class="modal-content">
<div class="modal-action"> <div class="modal-header">
<button id="applyCancel" class="btn btn-ghost">取消</button> <h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
<button id="applyConfirm" class="btn btn-primary">确认执行</button> <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">
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
仅标记「已确认离职」的用户会被物理删除。
</p>
<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> </div>
</dialog> </div>
<script src="/static/flyonui.js"></script> <script src="/static/flyonui.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
+14
View File
@@ -42,6 +42,20 @@
--color-success-content: #071505; --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 不撑破布局。 */ /* 少量自定义样式:表格可横向滚动,长 DN 不撑破布局。 */
@layer components { @layer components {
.idsync-dn { .idsync-dn {