Compare commits
11
Commits
947b1a1475
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801e8413a7 | ||
|
|
1f11e615a9 | ||
|
|
6d31cba631 | ||
|
|
e187369821 | ||
|
|
0fffdd5ccb | ||
|
|
126e52a6ed | ||
|
|
979a98d502 | ||
|
|
10ed3984dc | ||
|
|
8fd10f134b | ||
|
|
f997e48a75 | ||
|
|
48b0ea35fc |
@@ -11,5 +11,8 @@ backup/
|
||||
# 真实配置含飞书与 LDAP 凭据,绝不入库
|
||||
config.yml
|
||||
|
||||
# Gitea token 等本地凭据,绝不入库
|
||||
.env
|
||||
|
||||
# 前端依赖;构建产物 internal/webui/dist 需要提交,见 README
|
||||
web/node_modules/
|
||||
|
||||
@@ -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` 取收件地址,为空会失败。
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 注释用中文,与现有代码一致。
|
||||
- **注释解释「为什么」,不解释「做什么」**。多数决策反直觉(如「为什么不回落个人邮箱」
|
||||
「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手
|
||||
「优化」掉正确行为。不要给显而易见的代码加注释。
|
||||
- 错误信息带对象标识与原因,能直接定位。
|
||||
- 新增的安全约束尽量写成**结构性拦截**(代码主动检查并报错),而不只是写进文档。
|
||||
@@ -1,4 +1,4 @@
|
||||
# idsync — 飞书 → OpenLDAP 同步器
|
||||
# idsync — 飞书 → SQLite → LLDAP 同步器
|
||||
# 宿主未安装 Go,所有 Go 操作都在 podman 容器内执行。
|
||||
# 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。
|
||||
|
||||
@@ -32,9 +32,18 @@ test:
|
||||
vet:
|
||||
$(PODMAN) $(GO_IMAGE) go vet ./...
|
||||
|
||||
# 集成测试对真实 OpenLDAP 运行;容器内需能访问宿主的 slapd,
|
||||
# 因此用 host.containers.internal 并放行 no_proxy,避免流量被代理拦走。
|
||||
LDAP_TEST ?= ldap://host.containers.internal:3899
|
||||
# 集成测试对真实 LLDAP 运行(LDAP 3890 / GraphQL 17170),先 `make lldap` 起容器。
|
||||
LDAP_TEST ?= ldap://127.0.0.1:3890
|
||||
LLDAP_GQL ?= http://127.0.0.1:17170
|
||||
|
||||
.PHONY: lldap
|
||||
lldap:
|
||||
podman run -d --name idsync-lldap \
|
||||
-e LLDAP_LDAP_BASE_DN=dc=example,dc=com \
|
||||
-e LLDAP_JWT_SECRET='change-me-jwt-secret-please' \
|
||||
-e LLDAP_LDAP_USER_PASS=adminpass \
|
||||
-p 3890:3890 -p 17170:17170 \
|
||||
ghcr.io/lldap/lldap:stable
|
||||
|
||||
.PHONY: test-integration
|
||||
test-integration:
|
||||
|
||||
@@ -1,176 +1,152 @@
|
||||
# idsync
|
||||
# idsync — 飞书通讯录 → LLDAP 同步器
|
||||
|
||||
把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 `go-ldap-admin`。
|
||||
|
||||
## 它做什么,不做什么
|
||||
|
||||
**做**:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。
|
||||
|
||||
**不做**:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。
|
||||
|
||||
这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具**不在认证路径上**——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。
|
||||
|
||||
## 工作方式
|
||||
把飞书通讯录同步到 [LLDAP](https://github.com/lldap/lldap),替代 OpenLDAP 时代的
|
||||
`go-ldap-admin`。数据单向流动、两阶段:
|
||||
|
||||
```
|
||||
飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP
|
||||
▲ │
|
||||
└─────────────── SQLite 台账(所有权记录)◀───────────┘
|
||||
飞书通讯录 ─快照(低频)─▶ SQLite 镜像 ─物化(略高频)─▶ LLDAP(只在职)
|
||||
(全字段+部门关系+生命周期) │
|
||||
▼
|
||||
离职:机器确认(飞书 is_resigned) → 飞书卡片一次性端点
|
||||
人工确认 → confirmed_leave → 物化时物理删除
|
||||
```
|
||||
|
||||
三个关键设计:
|
||||
- **读走 LDAP、写走 GraphQL**:LLDAP 的实际状态用 LDAP 读,所有写入(建/改/删用户、
|
||||
建组、成员关系)走 GraphQL,密码由 LLDAP 独占(程序绝不写密码,首次登录走
|
||||
Authelia「忘记密码」)。
|
||||
- **本工具不参与认证链路**:登录、会话、密码重置、发信都由 Authelia 负责。它只决定
|
||||
「飞书有什么数据、LLDAP 该长什么样、两者差异是什么」。它对外发出的唯一通知是
|
||||
「离职人工确认」飞书卡片。
|
||||
|
||||
**1. 用官方 SDK,一次拉全量。**
|
||||
使用 `github.com/larksuite/oapi-sdk-go/v3`。部门一次取回整棵树(`fetch_child=true`),用户按部门枚举后按 `union_id` 去重。
|
||||
注意 `BatchGetId` 的 `emails` 参数明确不支持企业邮箱,因此不能靠邮箱反查用户,只能从部门入口枚举。
|
||||
## 定位与边界
|
||||
|
||||
**2. 离职由缺席推断,再逐个确认。**
|
||||
`FindByDepartment` 不返回离职人员,所以「离职」只能先由「台账在职但本轮缺席」推断出来;随后对每个候选调用 `User.Get` 读取 `status.is_resigned` 确认。**只有确认离职才会迁移**——缺席也可能只是通讯录权限范围被收窄,直接迁移会误伤在职员工。
|
||||
|
||||
**3. SQLite 台账是删除权限的唯一来源。**
|
||||
台账记录「哪些条目是本工具创建的」。任何不在台账中的条目(服务账号、手工账号、`admin`)在计划里永不出现,因此不可能被同步任务误改或误删。这在接管一个既有目录时尤其重要。
|
||||
|
||||
## 同步内容
|
||||
|
||||
### 登录名
|
||||
|
||||
`uid` 取自**企业邮箱**(`enterprise_email`)的 `@` 前缀。
|
||||
|
||||
刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,回落到个人邮箱会产生不可预期、且在离职后仍然有效的登录名。缺少企业邮箱的用户会被**跳过并在界面上显著列出**,不会被静默丢弃。前缀冲突时同样跳过并报告,由人工修正邮箱。
|
||||
|
||||
### LDAP 布局
|
||||
|
||||
```
|
||||
<base_dn>
|
||||
├── ou=people 在职用户(Authelia 的 base_dn 指向这里)
|
||||
│ └── uid=<邮箱前缀>,ou=people,<base_dn>
|
||||
├── ou=Inactive 离职用户(位于 Authelia base_dn 之外 → 自动无法登录)
|
||||
│ └── uid=<邮箱前缀>,ou=Inactive,<base_dn>
|
||||
└── ou=<group-root> 部门树
|
||||
└── cn=<部门拼音>,cn=<父部门拼音>,...,ou=<group-root>,<base_dn>
|
||||
```
|
||||
|
||||
部门用 `groupOfUniqueNames` + `uniqueMember`,因此 Authelia 的 `Remote-Groups` 与基于组的 ACL 可直接使用。
|
||||
部门 CN 用拼音:中文作 RDN 需要转义成 `#hex` 形式,可读性差且部分工具链兼容不佳。
|
||||
|
||||
### 属性映射
|
||||
|
||||
| LDAP 属性 | 来源 |
|
||||
| 属于本项目 | 不属于本项目 |
|
||||
|---|---|
|
||||
| `uid` / `cn` | `enterprise_email` 的 `@` 前缀 |
|
||||
| `sn` | `name` |
|
||||
| `displayName` | `name` |
|
||||
| `givenName` | `en_name`,为空回落 `name` |
|
||||
| `mail` | **`enterprise_email`** |
|
||||
| `mobile` | `mobile` |
|
||||
| `employeeNumber` | `employee_no` |
|
||||
| `departmentNumber` | `job_title` |
|
||||
| `postalAddress` | `work_station` |
|
||||
| `businessCategory` | 所属部门名(逗号拼接) |
|
||||
| `employeeType` | `Active` / `Resigned` |
|
||||
| `userPassword` | 仅创建时写入,**更新路径永不触碰** |
|
||||
| 飞书有什么数据、LDAP 该长什么样、两者差异是什么 | 谁有权限登录(Authelia) |
|
||||
| 快照/物化调度、离职双确认状态机、离职卡片通知 | 认证、会话、登录页、密码重置 |
|
||||
|
||||
### 与 Authelia 的衔接
|
||||
鉴权链是 `业务系统 → Authelia → LLDAP bind`;本工具只保证 LLDAP 里只在职、且离职
|
||||
必须经人工确认后才删除。
|
||||
|
||||
- **`mail` 必须是企业邮箱**:Authelia 重置密码时从 LDAP 的 `mail` 属性取收件地址。若该属性为空,重置流程会直接失败并报 `user ... has no email address configured`。
|
||||
- **`ou=Inactive` 必须在 Authelia 的 `base_dn` 之外**:这是离职用户无法登录的机制。请确认你的 `base_dn` 是否正好是 `ou=people,<base>`。
|
||||
- **更新路径绝不写 `userPassword`**:Authelia 重置密码时直接写 LDAP 的 `userPassword`。如果同步的更新路径也写这个属性,用户刚设好的密码会在下一轮同步时被打回初始值。代码中有结构性检查阻止这一点。
|
||||
- **密码必须自行哈希**:slapd 不会对普通 Modify 写入的 `userPassword` 自动加盐。本工具写入 `{SSHA}`,不依赖服务端 `password-hash` 配置。
|
||||
## 架构
|
||||
|
||||
新用户会得到一个**随机**初始密码(或用 `ldap.initial-password` 指定),用户不可知。首次登录走 Authelia 的「忘记密码」——该流程不需要旧密码。
|
||||
|
||||
## 安全护栏
|
||||
|
||||
默认开启,用于防住「最坏的那一次同步」(飞书应用权限被收窄、token 指向错误租户、部门过滤写错,都会让期望状态突然大幅缩小):
|
||||
|
||||
1. **默认 dry-run**。命令行不加 `-apply`、界面不点确认,都不会写入任何内容。
|
||||
2. **停用阈值**。单次停用超过 `max-deactivate` 条或 `max-deactivate-percent` 比例时拒绝执行,需 `-force` 或界面强制确认。
|
||||
3. **所有权隔离**。非台账条目永不被修改或删除。
|
||||
4. **admin 保护**。`admin` 永不被移出分组;外部手工加入的成员只告警、不移除。
|
||||
5. **部门树建树有环保护**,分组按层级排序,保证父分组先于子分组创建。
|
||||
|
||||
## 构建
|
||||
|
||||
宿主无需安装 Go 或 Node,全部通过容器完成(本机已有 `golang:1.26-trixie` 与 `node:20-alpine` 镜像):
|
||||
|
||||
```bash
|
||||
make deps # 下载 Go 依赖
|
||||
make css # 构建 WebUI 样式(需要 Node 容器)
|
||||
make build # 产出 bin/idsync
|
||||
make test # 单元测试
|
||||
make vet
|
||||
make run # 本地运行
|
||||
```
|
||||
internal/config 配置加载、默认值、校验、DN 拼接、两套 schedule
|
||||
internal/feishu 只读飞书:拉通讯录、机器确认离职、发离职确认卡片(webhook)
|
||||
internal/store SQLite 镜像:全字段用户 + 部门关系 + 生命周期状态 + 一次性 token
|
||||
internal/mapping 镜像 → 期望 LLDAP 状态(纯函数);组名=部门稳定索引 cn=<dept_id>
|
||||
internal/ldaplib LLDAP 读原语:连接/bind/搜索
|
||||
internal/lldapgql LLDAP 写通道:GraphQL(登录 + 各 mutation),端点 /api/graphql
|
||||
internal/syncer diff 引擎(纯函数)+ 快照/物化编排(单实例互斥)
|
||||
internal/server HTTP 管理界面 + 一次性离职确认端点(token 即凭证)
|
||||
internal/webui go:embed 前端产物 + ETag(内容指纹)
|
||||
```
|
||||
|
||||
> **关于代理与网络**:若 `proxy.golang.org` 不可直连,`Makefile` 已在容器调用上带上
|
||||
> `http_proxy`/`https_proxy` 并强制 `--network=host`。默认网络下容器内的 `127.0.0.1` 指向容器自身,
|
||||
> 无法访问宿主代理——这是必须用 `--network=host` 的原因。
|
||||
>
|
||||
> 拉取基础镜像时同样需要代理(`podman pull` 不经由 Makefile):
|
||||
> ```bash
|
||||
> podman pull --http-proxy=http://127.0.0.1:10808 docker.io/library/golang:1.26-trixie
|
||||
> # 或为本次会话导出: export http_proxy=http://127.0.0.1:10808
|
||||
> ```
|
||||
> 注意不要用 `containers.conf` 的 `[engine] env` 全局注入代理:那会让所有容器的 `http_proxy`
|
||||
> 都指向宿主,导致容器内访问自身或本地服务时被代理拦截。
|
||||
### 关键约定
|
||||
|
||||
`internal/webui/dist/` 下的前端产物会被 `go:embed` 打进二进制,因此**部署时不需要 Node**。修改前端后需重新执行 `make css` 并重新编译。
|
||||
|
||||
### 集成测试
|
||||
|
||||
集成测试对真实的 OpenLDAP 运行,覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、modrdn 移动后密码是否保留、以及「更新绝不触碰 userPassword」:
|
||||
|
||||
```bash
|
||||
podman run -d --name idsync-ldap -p 3899:389 \
|
||||
-e LDAP_ORGANISATION="Example Inc" -e LDAP_DOMAIN="example.com" \
|
||||
-e LDAP_ADMIN_PASSWORD=adminpass \
|
||||
docker.io/osixia/openldap:1.5.0
|
||||
|
||||
make test-integration
|
||||
```
|
||||
|
||||
端到端测试还会启动一个假的飞书 OpenAPI 服务(`internal/syncer/e2e_test.go`),在没有真实租户凭据的情况下验证完整链路:首次全量创建、幂等重跑、属性漂移、离职迁移、重新入职、以及护栏拦截。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cp config.example.yml config.yml
|
||||
# 生成会话密钥与管理员密码哈希
|
||||
./bin/idsync -gen-secret
|
||||
./bin/idsync -hash-password '你的密码'
|
||||
|
||||
# 只预览,不写入(推荐先跑这个)
|
||||
./bin/idsync -config config.yml -dry-run
|
||||
|
||||
# 确认无误后执行
|
||||
./bin/idsync -config config.yml -apply
|
||||
|
||||
# 服务模式(含 WebUI 与定时同步)
|
||||
./bin/idsync -config config.yml
|
||||
```
|
||||
|
||||
建议的首次接管流程:先 `-dry-run` 看计划 → 核对后 `-apply` → 打开界面确认用户与分组 → 观察几轮 dry-run 的定时任务后再把 `sync.default-dry-run` 改为 `false`。
|
||||
|
||||
## 管理界面
|
||||
|
||||
六个页面:概览(连通性与计数)、用户(受管清单 + 一致性检查)、部门分组、**变更预览**(最有价值的一页:dry-run 计划表,确认后执行)、同步历史。
|
||||
|
||||
样式用 Tailwind v4 + FlyonUI。FlyonUI 官方不支持 CDN 方式(样式在构建期生成),因此走官方的构建路径,产物内嵌进二进制。
|
||||
|
||||
主题为白/暗底 + 绿色主色,取自 Gitea 的品牌视觉(`#609926`,暗色下 `#7ab63a`;注意 Gitea 自身的 `--color-primary` 其实是蓝色,品牌绿是它的 logo 色)。右上角按钮切换,偏好存在 `localStorage`。
|
||||
- **只在职**:LLDAP 只有 `ou=people`,`uid` 取企业邮箱 `@` 前缀;缺少企业邮箱的用户
|
||||
被跳过并记入 skipped(WebUI 可查,必须被管理者发现)。
|
||||
- **组名 = 部门稳定索引**:组 `displayName`/CN = `cn=<open_department_id>,<ou=groups>`
|
||||
。用部门 ID 而非拼音——改名/挪动不改变组身份,避免「改名波及子孙组」;可读的部门
|
||||
中文名只存在数据库(`mirror_departments.name` / 用户 `business_category`),不进入
|
||||
LDAP 组名。
|
||||
- **离职 = 双确认后才删**:缺席 → 飞书 `is_resigned` 机器确认 → 生成一次性 token 并发
|
||||
飞书管理群卡片(按钮跳本工具确认端点)→ 人工确认 → `confirmed_leave` → 下一轮物化
|
||||
物理删除。两道闸缺一不可;用户重新可见即撤销并作废未用 token。
|
||||
- **操作顺序是硬约束**:分组 → 用户 → 加组 → 移组 → **删除(最后一阶段,且只处理
|
||||
confirmed_leave)**。
|
||||
- **所有权只认镜像台账**:镜像之外的条目(服务账号、手工账号、admin)永不入计划。
|
||||
- **快照与物化互斥**:共享一把单实例锁,绝不并发,防止物化读到半新半旧镜像污染 LLDAP。
|
||||
|
||||
## 部署
|
||||
|
||||
### 1. 起 LLDAP
|
||||
|
||||
```bash
|
||||
podman build -t idsync .
|
||||
podman run -d --name idsync -p 8899:8899 \
|
||||
-v $PWD/config.yml:/app/config.yml:ro \
|
||||
-v idsync-data:/app/data \
|
||||
idsync
|
||||
podman run -d --name idsync-lldap \
|
||||
-e LLDAP_LDAP_BASE_DN=dc=example,dc=com \
|
||||
-e LLDAP_JWT_SECRET='<random>' \
|
||||
-e LLDAP_LDAP_USER_PASS='<admin-pass>' \
|
||||
-p 3890:3890 -p 17170:17170 \
|
||||
ghcr.io/lldap/lldap:stable
|
||||
```
|
||||
|
||||
## 已知边界
|
||||
LDAP 明文 3890、TLS 6360;WebUI/GraphQL 17170。用户名默认 `admin`。
|
||||
|
||||
- **不做密码同步下发**。Authelia 重置密码只写 LDAP,不发出任何事件或回调。因此对于「只认自己库里本地密码、既不吃 LDAP 也不读 HTTP 头」的服务,无法通过本工具同步密码。这类服务建议套一层 forward-auth(由 Authelia 注入 `Remote-User` / `Remote-Email` / `Remote-Groups`),或迁移到支持 LDAP 的同类方案。
|
||||
- 部门改名会导致分组 DN 变化。此时**不会自动改名**(自动改名会牵动成员的 `uniqueMember` 引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。
|
||||
- 飞书侧删除的部门不会被自动删除分组,同样只告警。
|
||||
### 2. 配 Authelia(官方 lldap 模板,勿定制)
|
||||
|
||||
```yaml
|
||||
authentication_backend:
|
||||
ldap:
|
||||
implementation: lldap
|
||||
url: ldaps://lldap:6360 # 或 ldap://lldap:3890 + start_tls
|
||||
base_dn: dc=example,dc=com
|
||||
# 专用 bind 用户,别拿 admin
|
||||
user: uid=authelia_svc,ou=people,dc=example,dc=com
|
||||
password: <bind-pass>
|
||||
groups_filter: (&(memberOf={dn})(objectClass=groupOfUniqueNames))
|
||||
attributes:
|
||||
username: uid
|
||||
display_name: cn
|
||||
mail: mail
|
||||
member_of: memberOf
|
||||
```
|
||||
|
||||
给 Authelia 专用 bind 用户授予 `lldap_strict_readonly` + `lldap_password_manager`。
|
||||
用户 objectClass 是 `inetOrgPerson`,官方 `users_filter`(`objectClass=person`)即命中,
|
||||
**不要添加任何自定义属性/过滤**。`mail` 必须是企业邮箱(Authelia 重置密码的收件地址)。
|
||||
|
||||
### 3. 配 idsync
|
||||
|
||||
复制 `config.example.yml` → `config.yml`,填:飞书 app 凭据 + 离职卡片 webhook、
|
||||
LLDAP 读(LDAP URL/base-dn/admin)+ 写(GraphQL base-url/admin)、离职确认端点对外
|
||||
`server.confirm-base-url`、两套 `schedule`。其中:
|
||||
|
||||
- `confirm-base-url` 必须是公网/反代可达地址,飞书卡片按钮链接用它拼接。
|
||||
- 先观察几轮 `sync-dry-run: true` 的物化计划,再放开为实际执行。
|
||||
|
||||
### 4. 构建与运行
|
||||
|
||||
宿主无 Go/Node,构建都在容器内(`Makefile` 已封装,容器强制 `--network=host`):
|
||||
|
||||
```bash
|
||||
make deps / make css / make build / make run
|
||||
```
|
||||
|
||||
产物 `bin/idsync` 为静态二进制,内嵌 WebUI,可按 `Dockerfile` 打进镜像部署到
|
||||
`0.0.0.0:8899`。镜像数据库在 `data/`(含离职状态与一次性 token,务必持久化)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
./idsync -check-config # 打印有效配置(凭据脱敏)
|
||||
./idsync -snapshot # 只跑一轮快照(飞书→镜像+离职流程)后退出
|
||||
./idsync -dry-run # 快照+物化,只打印计划不写入
|
||||
./idsync -apply # 快照+物化并实际写入
|
||||
./idsync -apply -force # 跳过删除数量阈值护栏
|
||||
./idsync -gen-secret # 生成会话密钥
|
||||
./idsync -hash-password '…' # 生成管理员密码 bcrypt 哈希
|
||||
```
|
||||
|
||||
管理界面 `http://<host>:8899`:概览(连通性/待确认离职/被跳过)、用户、部门分组、
|
||||
变更预览(含「立即快照」「应用变更」)、物化历史。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make lldap # 拉起本地 LLDAP 容器(可选)
|
||||
make test # 单元测试(无需外部依赖)
|
||||
make test-integration# 对真实 LLDAP 运行(3890/17170)
|
||||
make vet # go vet
|
||||
```
|
||||
|
||||
单元测试覆盖:镜像事务化回滚、生命周期状态机、一次性 token(一次/过期/复用被拒)、
|
||||
diff 引擎的安全回归(未确认不删、非受管不删、admin 永不移出、删除最后阶段)、
|
||||
部门索引组命名稳定。集成测试用假飞书 + 真 LLDAP 走通全链路:首次创建、幂等、属性漂移、
|
||||
confirmed_leave 删除、重新入职重建。
|
||||
|
||||
## 项目状态
|
||||
|
||||
两阶段迁移已完成:SQLite 镜像层、飞书卡片 + 快照、镜像驱动映射(部门索引组名)、
|
||||
GraphQL 写客户端、config/ldaplib 收敛为 LLDAP 形态、物化编排、一次确认端点 + WebUI、
|
||||
集成测试改用真 LLDAP 容器。`make test` / `make test-integration` / `make vet` 全绿。
|
||||
+37
-30
@@ -1,74 +1,81 @@
|
||||
# idsync 配置示例。复制为 config.yml 后按实际环境修改。
|
||||
#
|
||||
# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。
|
||||
# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。
|
||||
# 两阶段架构:飞书 → SQLite 镜像 → LLDAP。
|
||||
# - 快照:低频(默认每天一次),飞书 → 镜像 + 离职流程推进。
|
||||
# - 物化:略高频(默认每 2–4 小时一次),镜像在职视图 → LLDAP(写走 GraphQL)。
|
||||
# 本工具不参与认证链路:用户登录/密码重置/发信由 Authelia 负责,因此这里没有
|
||||
# 认证相关配置,只有数据管道的调度与通道配置。
|
||||
|
||||
system:
|
||||
# debug 会输出更详细的日志;生产环境用 release
|
||||
mode: release
|
||||
# 台账数据库与运行数据目录
|
||||
# 镜像数据库与运行数据目录
|
||||
data-dir: data
|
||||
|
||||
server:
|
||||
listen: 0.0.0.0:8899
|
||||
# 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关)
|
||||
# 管理界面自身的登录凭据(仅保护界面,与 LDAP 用户无关)
|
||||
admin-user: admin
|
||||
# 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希
|
||||
admin-password: change-me
|
||||
# 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码
|
||||
admin-password-hash: ""
|
||||
# 会话签名密钥,用 `./idsync -gen-secret` 生成。
|
||||
# 留空会自动生成临时密钥,但重启后需要重新登录。
|
||||
# 会话签名密钥,用 `./idsync -gen-secret` 生成。留空会自动生成临时密钥
|
||||
session-secret: ""
|
||||
# 经 HTTPS 反代访问时设为 true
|
||||
secure-cookie: false
|
||||
# 离职确认端点的对外 base-url;必须是公网/反代可达地址。飞书离职卡片的
|
||||
# 「确认离职」按钮 url 用它拼接,因此该项缺失时卡片将无法提供确认入口。
|
||||
confirm-base-url: "https://idsync.example.com"
|
||||
|
||||
feishu:
|
||||
app-id: "cli_xxxxxxxxxxxx"
|
||||
app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com
|
||||
base-url: ""
|
||||
# 留空表示同步全部部门;填部门 ID 则只同步这些部门,
|
||||
# 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"])
|
||||
# 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除
|
||||
dept-list: []
|
||||
# 调用飞书的速率上限(次/秒),避免触发频控
|
||||
qps: 5
|
||||
timeout-sec: 30
|
||||
# 离职确认卡片「自定义机器人」的 webhook 地址,发给用户管理群
|
||||
webhook-url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"
|
||||
|
||||
ldap:
|
||||
# 生产环境务必使用 ldaps:// 或开启 start-tls:
|
||||
# LLDAP 的 LDAP 读通道(连接/搜索)。生产建议 ldaps:// 或开启 start-tls:
|
||||
# Authelia 重置密码时会把新密码经此连接发送。
|
||||
url: ldaps://ldap.example.com:636
|
||||
url: "ldaps://lldap.example.com:6360"
|
||||
base-dn: "dc=example,dc=com"
|
||||
admin-dn: "cn=admin,dc=example,dc=com"
|
||||
# LLDAP 管理员用户(固定挂在 ou=people 下)
|
||||
admin-dn: "uid=admin,ou=people,dc=example,dc=com"
|
||||
admin-pass: "change-me"
|
||||
# 在职用户所在 OU。必须落在 Authelia 的 base_dn 之内。
|
||||
user-ou: "ou=people"
|
||||
# 离职用户迁往的 OU。必须落在 Authelia 的 base_dn 之外,
|
||||
# 这样离职者会自动失去登录能力,无需在 Authelia 侧加过滤条件。
|
||||
inactive-ou: "ou=Inactive"
|
||||
# 部门树根 OU 的名称(仅名称,不含 ou=)
|
||||
group-root-name: "feishuroot"
|
||||
timeout-sec: 15
|
||||
start-tls: false
|
||||
# 仅自签证书的测试环境使用
|
||||
insecure-skip-verify: false
|
||||
# 新建用户的初始密码。留空则每个用户随机生成,
|
||||
# 用户首次登录应走 Authelia 的“忘记密码”自助重置。
|
||||
initial-password: ""
|
||||
|
||||
lldap:
|
||||
# LLDAP 的 GraphQL 写通道地址(WebUI/GraphQL 端口)
|
||||
base-url: "http://lldap:17170"
|
||||
admin-user: admin
|
||||
admin-pass: "change-me"
|
||||
|
||||
database:
|
||||
source: data/idsync.db
|
||||
|
||||
sync:
|
||||
# 是否启用定时同步
|
||||
schedule:
|
||||
# 是否启用定时任务
|
||||
enable: true
|
||||
# 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。
|
||||
schedule: "0 40 0 * * *"
|
||||
# true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。
|
||||
default-dry-run: true
|
||||
# 单次同步允许停用的账号数上限,0 表示不限制
|
||||
# 快照 cron(秒级):低频,默认每天 00:20
|
||||
snapshot: "0 20 0 * * *"
|
||||
# 物化 cron(秒级):略高频,默认每 2 小时 30 分
|
||||
sync: "0 30 */2 * * *"
|
||||
# 物化是否默认 dry-run。快照永远直接落库;建议先观察几轮物化计划再放开。
|
||||
sync-dry-run: true
|
||||
|
||||
guard:
|
||||
# 单次物化允许删除(离职确认后)的账号数上限,0 表示不限制
|
||||
max-deactivate: 0
|
||||
# 单次同步允许停用的账号比例上限(0-1)。
|
||||
# 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。
|
||||
# 单次物化允许删除的账号比例上限(0-1)。
|
||||
# 防止飞书应用权限被误改导致大批账号被判定为离职。
|
||||
max-deactivate-percent: 0.2
|
||||
@@ -0,0 +1,45 @@
|
||||
# 阶段 0:基线冻结记录
|
||||
|
||||
## 基线验证
|
||||
- `make build`:通过(`bin/idsync` 编译成功)
|
||||
- `make test`:全绿(config/mapping/password/server/syncer 有测试;feishu/ldaplib/store/webui 无测试)
|
||||
|
||||
## 迁移前 config 字段(`config.yml` / `config.example.yml`)
|
||||
|
||||
### system
|
||||
`mode`(debug/release)、`data-dir`
|
||||
|
||||
### server
|
||||
`listen`、`admin-user`、`admin-password`、`admin-password-hash`、`session-secret`、`secure-cookie`
|
||||
|
||||
### feishu
|
||||
`app-id`、`app-secret`、`base-url`、`dept-list`、`qps`、`timeout-sec`
|
||||
|
||||
### ldap
|
||||
`url`、`base-dn`、`admin-dn`、`admin-pass`、`user-ou`、`inactive-ou`、
|
||||
`group-root-name`、`group-object-class`、`group-member-attr`、`timeout-sec`、
|
||||
`start-tls`、`insecure-skip-verify`、`initial-password`
|
||||
|
||||
### database
|
||||
`source`
|
||||
|
||||
### sync
|
||||
`enable`、`schedule`、`default-dry-run`、`max-deactivate`、`max-deactivate-percent`
|
||||
|
||||
## 迁移后应删除的项目(阶段 5)
|
||||
- `ldap.user-ou` / `ldap.inactive-ou` → 固定 `ou=people`
|
||||
- `ldap.group-root-name` / `ldap.group-object-class` / `ldap.group-member-attr` → LLDAP 形态,不再需要
|
||||
- `ldap.initial-password` → 密码 LLDAP 独占
|
||||
- `sync.*` 单套 → 拆为两套 schedule(snapshot / sync)
|
||||
|
||||
## 迁移后应新增的项目(阶段 5)
|
||||
- `ldap` 写通道:`base-url`、`admin-user`、`admin-pass`(供 GraphQL login)
|
||||
- `feishu.webhook-url`(离职卡片自定义机器人)
|
||||
- `server.confirm-base-url`(一次性确认端点对外 URL)
|
||||
- 两套 `schedule.snapshot` / `schedule.sync`
|
||||
|
||||
## 迁移状态(AGENTS.md「迁移状态」节引用的旧实现)
|
||||
- `internal/ldaplib/ops.go` 写路径(Add/Modify/ModifyDN)、`internal/password`(SSHA)、
|
||||
旧 config 项 —— 迁移完成前不作目标行为
|
||||
- `internal/store` 现为轻量台账(Account/Group/SyncRun),待扩展为全字段镜像 + 部门关系
|
||||
- `internal/feishu` 尚无可卡发送能力,离职确认通道未落地
|
||||
+77
-114
@@ -13,87 +13,93 @@ type Config struct {
|
||||
Server Server `mapstructure:"server"`
|
||||
Feishu Feishu `mapstructure:"feishu"`
|
||||
Ldap Ldap `mapstructure:"ldap"`
|
||||
Lldap Lldap `mapstructure:"lldap"`
|
||||
Database Database `mapstructure:"database"`
|
||||
Sync Sync `mapstructure:"sync"`
|
||||
Schedule Schedule `mapstructure:"schedule"`
|
||||
Guard Guard `mapstructure:"guard"`
|
||||
}
|
||||
|
||||
// System 是运行环境配置。
|
||||
type System struct {
|
||||
// Mode: debug 或 release,影响 gin 与日志详细度
|
||||
// Mode: debug 或 release,影响日志详细度。
|
||||
Mode string `mapstructure:"mode"`
|
||||
// DataDir 用于存放 sqlite 台账与日志
|
||||
// DataDir 用于存放 sqlite 镜像与运行数据。
|
||||
DataDir string `mapstructure:"data-dir"`
|
||||
}
|
||||
|
||||
// Server 是管理界面与一次性离职确认端点的配置。
|
||||
type Server struct {
|
||||
// Listen 形如 0.0.0.0:8899
|
||||
// Listen 形如 0.0.0.0:8899。
|
||||
Listen string `mapstructure:"listen"`
|
||||
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
|
||||
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
|
||||
AdminUser string `mapstructure:"admin-user"`
|
||||
AdminPassword string `mapstructure:"admin-password"`
|
||||
AdminPasswordHash string `mapstructure:"admin-password-hash"`
|
||||
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。
|
||||
SessionSecret string `mapstructure:"session-secret"`
|
||||
// SecureCookie 在 HTTPS 前置反代后应设为 true
|
||||
SecureCookie bool `mapstructure:"secure-cookie"`
|
||||
// ConfirmBaseURL 是离职确认端点的对外 base-url(可被公网/反代访问),
|
||||
// 飞书离职卡片的「确认离职」按钮 url 用它拼接。
|
||||
ConfirmBaseURL string `mapstructure:"confirm-base-url"`
|
||||
}
|
||||
|
||||
// Feishu 是飞书接口配置。
|
||||
type Feishu struct {
|
||||
AppID string `mapstructure:"app-id"`
|
||||
AppSecret string `mapstructure:"app-secret"`
|
||||
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com
|
||||
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com。
|
||||
BaseURL string `mapstructure:"base-url"`
|
||||
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门
|
||||
// DeptList 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除。
|
||||
DeptList []string `mapstructure:"dept-list"`
|
||||
// QPS 限制调用飞书的速率,默认 5
|
||||
QPS int `mapstructure:"qps"`
|
||||
// TimeoutSec 单次 API 调用超时,默认 30
|
||||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||
// WebhookURL 是离职确认卡片走自定义机器人 webhook 发送的地址。
|
||||
WebhookURL string `mapstructure:"webhook-url"`
|
||||
}
|
||||
|
||||
// Ldap 是 LLDAP 的 LDAP 读通道(连接/bind/搜索)。它只读不写。
|
||||
type Ldap struct {
|
||||
URL string `mapstructure:"url"`
|
||||
BaseDN string `mapstructure:"base-dn"`
|
||||
// AdminDN:LLDAP 管理员用户,形如 uid=admin,ou=people,<base>。
|
||||
AdminDN string `mapstructure:"admin-dn"`
|
||||
AdminPass string `mapstructure:"admin-pass"`
|
||||
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树
|
||||
UserOU string `mapstructure:"user-ou"`
|
||||
// InactiveOU 离职用户迁往的 OU,应位于 Authelia base_dn 之外
|
||||
InactiveOU string `mapstructure:"inactive-ou"`
|
||||
// GroupRootOU 部门树的根 OU 名称(仅名称,不含 ou=)
|
||||
GroupRootName string `mapstructure:"group-root-name"`
|
||||
// GroupObjectClass 部门分组使用的 objectClass。
|
||||
// 必须与 Authelia 的 groups_filter 一致,否则分组对 Authelia 不可见,
|
||||
// Remote-Groups 会是空的,基于组的 ACL 全部失效。
|
||||
// 常见取值:groupOfUniqueNames(配 uniqueMember)或 groupOfNames(配 member)。
|
||||
GroupObjectClass string `mapstructure:"group-object-class"`
|
||||
// GroupMemberAttr 分组成员属性名。留空则按 GroupObjectClass 推导。
|
||||
GroupMemberAttr string `mapstructure:"group-member-attr"`
|
||||
// TimeoutSec 连接与操作超时,默认 15
|
||||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||
// StartTLS 对 ldap:// 启用 StartTLS
|
||||
StartTLS bool `mapstructure:"start-tls"`
|
||||
// InsecureSkipVerify 仅用于自签证书的测试环境
|
||||
InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"`
|
||||
// InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成
|
||||
InitialPassword string `mapstructure:"initial-password"`
|
||||
}
|
||||
|
||||
// Lldap 是 LLDAP 的 GraphQL 写通道(唯一负责写入)。
|
||||
type Lldap struct {
|
||||
// BaseURL 是 LLDAP WebUI/GraphQL 地址,形如 http://lldap:17170。
|
||||
BaseURL string `mapstructure:"base-url"`
|
||||
// AdminUser / AdminPass 用于 GraphQL 登录,默认 admin。
|
||||
AdminUser string `mapstructure:"admin-user"`
|
||||
AdminPass string `mapstructure:"admin-pass"`
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
// Source 是 sqlite 文件路径
|
||||
// Source 是 sqlite 文件路径。
|
||||
Source string `mapstructure:"source"`
|
||||
}
|
||||
|
||||
type Sync struct {
|
||||
// Enable 是否启用定时同步
|
||||
// Schedule 是两套定时任务的配置。
|
||||
// 快照低频(默认每天一次),物化略高频(默认每 2–4 小时一次)。
|
||||
type Schedule struct {
|
||||
// Enable 是否启用定时任务。
|
||||
Enable bool `mapstructure:"enable"`
|
||||
// Schedule 秒级 cron,例如 "0 40 0 * * *"
|
||||
Schedule string `mapstructure:"schedule"`
|
||||
// DefaultDryRun 为 true 时,定时任务只生成计划不执行
|
||||
DefaultDryRun bool `mapstructure:"default-dry-run"`
|
||||
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制
|
||||
// Snapshot 快照 cron(秒级)。
|
||||
Snapshot string `mapstructure:"snapshot"`
|
||||
// Sync 物化 cron(秒级)。
|
||||
Sync string `mapstructure:"sync"`
|
||||
// SnapshotDryRun 物化是否默认 dry-run(快照永远直接落库)。
|
||||
SyncDryRun bool `mapstructure:"sync-dry-run"`
|
||||
}
|
||||
|
||||
// Guard 是物化前的安全阈值,防止飞书权限被误改时一次性误删大量账号。
|
||||
type Guard struct {
|
||||
// MaxDeactivate 单次允许删除(离职)的最大账号数,0 表示不限制。
|
||||
MaxDeactivate int `mapstructure:"max-deactivate"`
|
||||
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制
|
||||
// MaxDeactivatePercent 单次允许删除的账号比例上限(0-1),0 表示不限制。
|
||||
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
|
||||
}
|
||||
|
||||
@@ -137,30 +143,29 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("feishu.qps", 5)
|
||||
v.SetDefault("feishu.timeout-sec", 30)
|
||||
|
||||
v.SetDefault("ldap.url", "ldap://localhost:389")
|
||||
v.SetDefault("ldap.user-ou", "ou=people")
|
||||
v.SetDefault("ldap.inactive-ou", "ou=Inactive")
|
||||
v.SetDefault("ldap.group-root-name", "feishuroot")
|
||||
v.SetDefault("ldap.group-object-class", "groupOfUniqueNames")
|
||||
v.SetDefault("ldap.group-member-attr", "")
|
||||
v.SetDefault("ldap.url", "ldap://localhost:3890")
|
||||
v.SetDefault("ldap.timeout-sec", 15)
|
||||
v.SetDefault("ldap.start-tls", false)
|
||||
v.SetDefault("ldap.insecure-skip-verify", false)
|
||||
|
||||
v.SetDefault("lldap.base-url", "http://localhost:17170")
|
||||
v.SetDefault("lldap.admin-user", "admin")
|
||||
|
||||
v.SetDefault("database.source", "data/idsync.db")
|
||||
|
||||
v.SetDefault("sync.enable", false)
|
||||
v.SetDefault("sync.schedule", "0 40 0 * * *")
|
||||
v.SetDefault("sync.default-dry-run", true)
|
||||
v.SetDefault("sync.max-deactivate", 0)
|
||||
v.SetDefault("sync.max-deactivate-percent", 0.2)
|
||||
v.SetDefault("schedule.enable", false)
|
||||
v.SetDefault("schedule.snapshot", "0 20 0 * * *")
|
||||
v.SetDefault("schedule.sync", "0 30 */2 * * *")
|
||||
v.SetDefault("schedule.sync-dry-run", true)
|
||||
|
||||
v.SetDefault("guard.max-deactivate", 0)
|
||||
v.SetDefault("guard.max-deactivate-percent", 0.2)
|
||||
}
|
||||
|
||||
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
|
||||
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
|
||||
func (c *Config) NormalizeForTest() error { return c.normalize() }
|
||||
|
||||
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。
|
||||
// normalize 补全并校验配置。
|
||||
func (c *Config) normalize() error {
|
||||
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
|
||||
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
|
||||
@@ -168,81 +173,39 @@ func (c *Config) normalize() error {
|
||||
if c.Ldap.BaseDN == "" {
|
||||
return fmt.Errorf("ldap.base-dn 不能为空")
|
||||
}
|
||||
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" {
|
||||
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空")
|
||||
if c.Ldap.AdminPass == "" {
|
||||
return fmt.Errorf("ldap.admin-pass 不能为空")
|
||||
}
|
||||
if c.Lldap.BaseURL == "" {
|
||||
return fmt.Errorf("lldap.base-url 不能为空")
|
||||
}
|
||||
if c.Lldap.AdminUser == "" {
|
||||
c.Lldap.AdminUser = "admin"
|
||||
}
|
||||
if c.Lldap.AdminPass == "" {
|
||||
return fmt.Errorf("lldap.admin-pass 不能为空")
|
||||
}
|
||||
if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
|
||||
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
|
||||
}
|
||||
if c.Sync.Enable && c.Sync.Schedule == "" {
|
||||
return fmt.Errorf("sync.schedule 不能为空")
|
||||
if c.Schedule.Enable && (c.Schedule.Snapshot == "" || c.Schedule.Sync == "") {
|
||||
return fmt.Errorf("schedule.enable=true 时 snapshot 与 sync 的 cron 都不能为空")
|
||||
}
|
||||
|
||||
if err := c.Ldap.normalizeGroupSchema(); err != nil {
|
||||
return err
|
||||
// LLDAP 管理员 LDAP DN 固定挂在 ou=people 下(LLDAP 无其他 OU)。
|
||||
// 未显式给定时按约定补全。
|
||||
if strings.TrimSpace(c.Ldap.AdminDN) == "" {
|
||||
c.Ldap.AdminDN = "uid=" + c.Lldap.AdminUser + "," + c.UserOUDN()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。
|
||||
//
|
||||
// 这两项必须与 Authelia 的 groups_filter 匹配:objectClass 不匹配则 Authelia
|
||||
// 完全看不到我们建的分组;成员属性不匹配则 membership 查询返回空。二者都不会
|
||||
// 报错,只会表现为「分组 ACL 静默失效」,所以这里主动校验而不是默默容忍。
|
||||
func (l *Ldap) normalizeGroupSchema() error {
|
||||
oc := strings.TrimSpace(l.GroupObjectClass)
|
||||
if oc == "" {
|
||||
oc = "groupOfUniqueNames"
|
||||
}
|
||||
l.GroupObjectClass = oc
|
||||
|
||||
attr := strings.TrimSpace(l.GroupMemberAttr)
|
||||
if attr == "" {
|
||||
// 按 objectClass 推导出配套的成员属性
|
||||
switch strings.ToLower(oc) {
|
||||
case "groupofnames":
|
||||
attr = "member"
|
||||
default:
|
||||
attr = "uniqueMember"
|
||||
}
|
||||
}
|
||||
l.GroupMemberAttr = attr
|
||||
|
||||
// 校验搭配是否正确:groupOfNames 只允许 member,groupOfUniqueNames 只允许 uniqueMember。
|
||||
// 写错会产生 LDAP objectClassViolation,但报错信息晦涩,提前拦下更友好。
|
||||
switch strings.ToLower(oc) {
|
||||
case "groupofnames":
|
||||
if !strings.EqualFold(attr, "member") {
|
||||
return fmt.Errorf("ldap.group-object-class 为 groupOfNames 时,ldap.group-member-attr 必须为 member(当前 %q)", attr)
|
||||
}
|
||||
case "groupofuniquenames":
|
||||
if !strings.EqualFold(attr, "uniqueMember") {
|
||||
return fmt.Errorf("ldap.group-object-class 为 groupOfUniqueNames 时,ldap.group-member-attr 必须为 uniqueMember(当前 %q)", attr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserOUDN 返回在职用户的完整 OU DN。
|
||||
// UserOUDN 返回用户 OU 的完整 DN。LLDAP 用户固定挂在 ou=people 下。
|
||||
func (c *Config) UserOUDN() string {
|
||||
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN)
|
||||
return "ou=people," + strings.TrimSpace(c.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
// InactiveOUDN 返回离职用户的完整 OU DN。
|
||||
func (c *Config) InactiveOUDN() string {
|
||||
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
// GroupRootDN 返回部门树根 OU 的完整 DN。
|
||||
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
|
||||
func (c *Config) GroupRootDN() string {
|
||||
name := strings.TrimSpace(c.Ldap.GroupRootName)
|
||||
if name == "" {
|
||||
name = "feishuroot"
|
||||
}
|
||||
return "ou=" + name + "," + c.Ldap.BaseDN
|
||||
}
|
||||
|
||||
// joinDN 把相对 RDN 与后缀拼成完整 DN,容忍两端多余逗号。
|
||||
func joinDN(rdn, suffix string) string {
|
||||
return strings.TrimSuffix(strings.TrimSpace(rdn), ",") + "," + strings.TrimSpace(suffix)
|
||||
return "ou=groups," + strings.TrimSpace(c.Ldap.BaseDN)
|
||||
}
|
||||
+51
-139
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,9 +21,13 @@ feishu:
|
||||
app-id: a
|
||||
app-secret: b
|
||||
ldap:
|
||||
url: ldap://localhost:389
|
||||
url: ldap://localhost:3890
|
||||
base-dn: "dc=example,dc=org"
|
||||
admin-dn: "cn=admin,dc=example,dc=org"
|
||||
admin-dn: "uid=admin,ou=people,dc=example,dc=org"
|
||||
admin-pass: pw
|
||||
lldap:
|
||||
base-url: http://localhost:17170
|
||||
admin-user: admin
|
||||
admin-pass: pw
|
||||
` + extra
|
||||
|
||||
@@ -39,125 +44,24 @@ ldap:
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。
|
||||
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) {
|
||||
// TestDefaultsForLLDAP 验证 OUDN 固定为 LLDAP 形态:用户在 ou=people、组在 ou=groups。
|
||||
func TestDefaultsForLLDAP(t *testing.T) {
|
||||
cfg := writeConfig(t, "")
|
||||
|
||||
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" {
|
||||
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass)
|
||||
if got := cfg.UserOUDN(); got != "ou=people,dc=example,dc=org" {
|
||||
t.Errorf("UserOUDN = %q, 期望 ou=people(LLDAP 固定 OU)", got)
|
||||
}
|
||||
if cfg.Ldap.GroupMemberAttr != "uniqueMember" {
|
||||
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr)
|
||||
if got := cfg.GroupRootDN(); got != "ou=groups,dc=example,dc=org" {
|
||||
t.Errorf("GroupRootDN = %q, 期望 ou=groups", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。
|
||||
//
|
||||
// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、
|
||||
// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以
|
||||
// objectClassViolation 拒绝,而错误信息很不直观。
|
||||
func TestGroupSchemaDerivesMemberAttr(t *testing.T) {
|
||||
cases := []struct {
|
||||
objectClass string
|
||||
wantAttr string
|
||||
}{
|
||||
{"groupOfNames", "member"},
|
||||
{"groupOfUniqueNames", "uniqueMember"},
|
||||
// 大小写不敏感
|
||||
{"GroupOfNames", "member"},
|
||||
{"GROUPOFUNIQUENAMES", "uniqueMember"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.objectClass, func(t *testing.T) {
|
||||
cfg := writeConfig(t, " group-object-class: \""+c.objectClass+"\"\n")
|
||||
if cfg.Ldap.GroupMemberAttr != c.wantAttr {
|
||||
t.Errorf("objectClass=%s 推导出的成员属性 = %q, 期望 %q",
|
||||
c.objectClass, cfg.Ldap.GroupMemberAttr, c.wantAttr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupSchemaRejectsMismatch 验证 objectClass 与成员属性不匹配时直接报错。
|
||||
//
|
||||
// 静默容忍会让问题推迟到运行时,表现为分组建好了但成员查不到,
|
||||
// 排查成本远高于启动即失败。
|
||||
func TestGroupSchemaRejectsMismatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "groupOfNames 配 uniqueMember",
|
||||
body: " group-object-class: \"groupOfNames\"\n group-member-attr: \"uniqueMember\"\n",
|
||||
},
|
||||
{
|
||||
name: "groupOfUniqueNames 配 member",
|
||||
body: " group-object-class: \"groupOfUniqueNames\"\n group-member-attr: \"member\"\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
body := `
|
||||
system:
|
||||
data-dir: /tmp/idsync-test
|
||||
server:
|
||||
admin-user: admin
|
||||
admin-password: pw
|
||||
feishu:
|
||||
app-id: a
|
||||
app-secret: b
|
||||
ldap:
|
||||
url: ldap://localhost:389
|
||||
base-dn: "dc=example,dc=org"
|
||||
admin-dn: "cn=admin,dc=example,dc=org"
|
||||
admin-pass: pw
|
||||
` + c.body
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写配置失败: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Errorf("不匹配的组合应被拒绝,却加载成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedDNs 验证各 OU 拼接结果正确。
|
||||
func TestDerivedDNs(t *testing.T) {
|
||||
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n group-root-name: \"feishuroot\"\n")
|
||||
|
||||
if got := cfg.UserOUDN(); got != "ou=users,dc=example,dc=org" {
|
||||
t.Errorf("UserOUDN = %q", got)
|
||||
}
|
||||
if got := cfg.InactiveOUDN(); got != "ou=Inactive,dc=example,dc=org" {
|
||||
t.Errorf("InactiveOUDN = %q", got)
|
||||
}
|
||||
if got := cfg.GroupRootDN(); got != "ou=feishuroot,dc=example,dc=org" {
|
||||
t.Errorf("GroupRootDN = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInactiveOUOutsideUserOU 是一个架构约束测试。
|
||||
//
|
||||
// ou=Inactive 必须位于用户 OU 之外,否则 Authelia(其 base 为 additional_users_dn)
|
||||
// 依然能搜到离职用户,停用机制就失效了。
|
||||
func TestInactiveOUOutsideUserOU(t *testing.T) {
|
||||
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n")
|
||||
|
||||
userDN := cfg.UserOUDN()
|
||||
inactiveDN := cfg.InactiveOUDN()
|
||||
|
||||
if inactiveDN == userDN {
|
||||
t.Fatalf("离职 OU 与用户 OU 相同: %s", inactiveDN)
|
||||
}
|
||||
// 离职 OU 不应位于用户 OU 之下
|
||||
if len(inactiveDN) > len(userDN) && inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
|
||||
t.Errorf("离职 OU (%s) 位于用户 OU (%s) 之下,Authelia 仍能搜到离职用户", inactiveDN, userDN)
|
||||
// TestAdminDNDerived 验证未显式配 admin-dn 时按 LLDAP 管理员用户补全。
|
||||
func TestAdminDNDerived(t *testing.T) {
|
||||
cfg := writeConfig(t, " admin-dn: \"\"\n")
|
||||
want := "uid=admin,ou=people,dc=example,dc=org"
|
||||
if cfg.Ldap.AdminDN != want {
|
||||
t.Errorf("AdminDN = %q, 期望自动补全为 %q", cfg.Ldap.AdminDN, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,46 +69,54 @@ func TestInactiveOUOutsideUserOU(t *testing.T) {
|
||||
func TestNormalizeRejectsMissingCredentials(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
over string
|
||||
// override 以 YAML 片段替换掉基础配置里对应的一行,避免重复键。
|
||||
override string
|
||||
mark string
|
||||
}{
|
||||
{"缺少 app-id", " app-id: \"\"\n"},
|
||||
{"缺少 base-dn", "BASE_DN_MARKER"},
|
||||
{"缺少 app-id", "feishu:\n app-id: \"\"\n", "app-id"},
|
||||
{"缺少 base-dn", "ldap:\n base-dn: \"\"\n", "base-dn"},
|
||||
{"缺少 lldap base-url", "lldap:\n base-url: \"\"\n", "base-url"},
|
||||
{"缺少 lldap admin-pass", "lldap:\n admin-pass: \"\"\n", "admin-pass"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
|
||||
feishuID, baseDN := "app", `"dc=example,dc=org"`
|
||||
if c.name == "缺少 app-id" {
|
||||
feishuID = ""
|
||||
}
|
||||
if c.name == "缺少 base-dn" {
|
||||
baseDN = `""`
|
||||
}
|
||||
|
||||
body := `
|
||||
base := `
|
||||
system:
|
||||
data-dir: /tmp/idsync-test
|
||||
server:
|
||||
admin-user: admin
|
||||
admin-password: pw
|
||||
feishu:
|
||||
app-id: "` + feishuID + `"
|
||||
app-secret: b
|
||||
`
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
body := base + c.override + `
|
||||
ldap:
|
||||
url: ldap://localhost:389
|
||||
base-dn: ` + baseDN + `
|
||||
admin-dn: "cn=admin,dc=example,dc=org"
|
||||
url: ldap://localhost:3890
|
||||
base-dn: "dc=example,dc=org"
|
||||
admin-dn: "uid=admin,ou=people,dc=example,dc=org"
|
||||
admin-pass: pw
|
||||
lldap:
|
||||
base-url: http://localhost:17170
|
||||
admin-user: admin
|
||||
admin-pass: pw
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写配置失败: %v", err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Errorf("缺少必要配置项时应加载失败")
|
||||
t.Errorf("缺少必要配置项(%s)时应加载失败", c.mark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeRemoved 验证 OpenLDAP 时代的分组/OU/密码配置项已彻底移除,
|
||||
// 不再出现在结构体里(通过在导出字段上做类型断言编译来保证)。
|
||||
func TestEdgeRemoved(t *testing.T) {
|
||||
_ = func(c *Config) {
|
||||
// 编译期确认这些字段已不存在:直接引用会在编译时失败。
|
||||
_ = strings.TrimSpace(c.Ldap.URL)
|
||||
_ = c.Guard.MaxDeactivatePercent
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 应上报失败,却成功了")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package ldaplib
|
||||
|
||||
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
@@ -12,19 +14,16 @@ import (
|
||||
"idsync/internal/config"
|
||||
)
|
||||
|
||||
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
|
||||
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
|
||||
// Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
|
||||
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
|
||||
type Client struct {
|
||||
cfg config.Ldap
|
||||
log *slog.Logger
|
||||
tlsConf *tls.Config
|
||||
dialOpts []ldap.DialOpt
|
||||
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
|
||||
group GroupSchema
|
||||
}
|
||||
|
||||
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
|
||||
// NewClient 构造 LDAP 读客户端并解析拨号参数。
|
||||
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, fmt.Errorf("ldap.url 未配置")
|
||||
@@ -43,25 +42,13 @@ func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
||||
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
|
||||
}
|
||||
|
||||
schema := GroupSchema{
|
||||
ObjectClass: cfg.GroupObjectClass,
|
||||
MemberAttr: cfg.GroupMemberAttr,
|
||||
}
|
||||
if schema.ObjectClass == "" {
|
||||
schema.ObjectClass = "groupOfUniqueNames"
|
||||
}
|
||||
if schema.MemberAttr == "" {
|
||||
schema.MemberAttr = "uniqueMember"
|
||||
}
|
||||
|
||||
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil
|
||||
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
|
||||
}
|
||||
|
||||
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
|
||||
type Conn struct {
|
||||
conn *ldap.Conn
|
||||
cfg config.Ldap
|
||||
group GroupSchema
|
||||
}
|
||||
|
||||
// Close 释放连接。
|
||||
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
|
||||
|
||||
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err)
|
||||
return nil, fmt.Errorf("绑定 LDAP 失败 (%s): %w", c.cfg.AdminDN, err)
|
||||
}
|
||||
|
||||
return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil
|
||||
return &Conn{conn: conn, cfg: c.cfg}, nil
|
||||
}
|
||||
|
||||
// Ping 验证连通性与凭据,用于健康检查。
|
||||
|
||||
@@ -1,377 +1,104 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。
|
||||
// LLDAP 读路径(LDAP)集成测试:读取对象重,验证 objectClass 命中与双成员属性读取。
|
||||
//
|
||||
// 默认不参与 `go test ./...`,需要显式启用:
|
||||
// 默认不参与 `go test ./...`,需显式启用:
|
||||
//
|
||||
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||
// go test -tags=integration ./internal/ldaplib/ -v
|
||||
//
|
||||
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
|
||||
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
|
||||
// LLDAP_TEST_URL=ldap://127.0.0.1:3890 go test -tags=integration ./internal/ldaplib/ -v
|
||||
package ldaplib_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/password"
|
||||
)
|
||||
|
||||
// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func testClient(t *testing.T) *ldaplib.Client {
|
||||
t.Helper()
|
||||
|
||||
func testConfig() config.Ldap {
|
||||
base := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if base == "" {
|
||||
base = "dc=example,dc=com"
|
||||
}
|
||||
url := os.Getenv("LDAP_TEST_URL")
|
||||
if url == "" {
|
||||
url = "ldap://127.0.0.1:3899"
|
||||
url = "ldap://127.0.0.1:3890"
|
||||
}
|
||||
baseDN := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if baseDN == "" {
|
||||
baseDN = "dc=example,dc=com"
|
||||
}
|
||||
|
||||
cfg := config.Ldap{
|
||||
URL: url,
|
||||
BaseDN: baseDN,
|
||||
AdminDN: "cn=admin," + baseDN,
|
||||
AdminPass: "adminpass",
|
||||
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
|
||||
return config.Ldap{
|
||||
URL: url, BaseDN: base,
|
||||
AdminDN: "uid=admin,ou=people," + base, AdminPass: "adminpass",
|
||||
TimeoutSec: 10,
|
||||
}
|
||||
if err := cfg.NormalizeForTest(); err != nil {
|
||||
t.Fatalf("配置规范化失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
c, err := ldaplib.NewClient(cfg, discardLogger())
|
||||
func dialT(t *testing.T) *ldaplib.Conn {
|
||||
t.Helper()
|
||||
c, err := ldaplib.NewClient(testConfig(), discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("构造客户端失败: %v", err)
|
||||
}
|
||||
if err := c.Ping(); err != nil {
|
||||
t.Skipf("LDAP 不可用,跳过集成测试: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func baseDN() string {
|
||||
if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "dc=example,dc=com"
|
||||
}
|
||||
|
||||
// TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期,
|
||||
// 并验证密码在属性更新与 modrdn 移动后依然有效。
|
||||
func TestIntegrationUserLifecycle(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
inactiveOU := "ou=Inactive," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
t.Skipf("LLDAP 不可用,跳过集成测试: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestIntegrationReadUsers 验证用户读视图:LLDAP 默认 admin 用户应能被 ListUsers 读到。
|
||||
func TestIntegrationReadUsers(t *testing.T) {
|
||||
conn := dialT(t)
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil {
|
||||
t.Fatalf("创建 ou=Inactive 失败: %v", err)
|
||||
}
|
||||
|
||||
const uid = "idsync-test-user"
|
||||
userDN := "uid=" + uid + "," + peopleOU
|
||||
initialPass := "InitialPass123!"
|
||||
|
||||
// 清理可能残留的条目,让测试可重复执行。
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteUser("uid=" + uid + "," + inactiveOU)
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: "测试", GivenName: "Test",
|
||||
DisplayName: "测试用户", Mail: "test@example.com",
|
||||
Mobile: "13800000000", EmployeeNumber: "E001",
|
||||
DepartmentNumber: "工程师", PostalAddress: "北京",
|
||||
BusinessCategory: "研发部", EmployeeType: "Active",
|
||||
Description: "测试用户",
|
||||
}
|
||||
|
||||
// 断言密码以哈希形式存储,而不是明文。
|
||||
// slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码,
|
||||
// 密码会以明文形式留在目录中。
|
||||
encoded, err := password.EncodeSSHA(initialPass)
|
||||
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("编码密码失败: %v", err)
|
||||
t.Fatalf("ListUsers 失败: %v", err)
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, encoded); err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
raw, err := conn.GetEntry(userDN, []string{"userPassword"})
|
||||
if err != nil {
|
||||
t.Fatalf("读取 userPassword 失败: %v", err)
|
||||
}
|
||||
stored := raw.Get("userPassword")
|
||||
if stored == initialPass {
|
||||
t.Errorf("userPassword 以明文存储,存在严重安全隐患")
|
||||
}
|
||||
if !strings.HasPrefix(stored, "{SSHA}") {
|
||||
t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored)
|
||||
}
|
||||
|
||||
// 创建后应当能读到,且属性正确。
|
||||
users, err := conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取用户列表失败: %v", err)
|
||||
}
|
||||
got, ok := users[uid]
|
||||
if !ok {
|
||||
t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid)
|
||||
}
|
||||
if got.Get(ldaplib.AttrMail) != "test@example.com" {
|
||||
t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail))
|
||||
}
|
||||
|
||||
// 用用户凭据 bind,确认密码确实生效。
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("初始密码无法通过 bind 验证: %v", err)
|
||||
}
|
||||
|
||||
// 更新属性:改邮箱与手机号。
|
||||
u.Mail = "test.new@example.com"
|
||||
u.Mobile = "13900000000"
|
||||
existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||
if err != nil {
|
||||
t.Fatalf("读取现状失败: %v", err)
|
||||
}
|
||||
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||
t.Fatalf("更新用户失败: %v", err)
|
||||
}
|
||||
|
||||
// 关键回归:把若干可选字段置空后更新,不应失败。
|
||||
// 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让
|
||||
// 整个 Modify 请求失败——这是真实踩到过的 bug。
|
||||
u.PostalAddress = ""
|
||||
u.EmployeeNumber = ""
|
||||
existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||
t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err)
|
||||
}
|
||||
|
||||
users, err = conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("更新后读取失败: %v", err)
|
||||
}
|
||||
got = users[uid]
|
||||
if got.Get(ldaplib.AttrMail) != "test.new@example.com" {
|
||||
t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail))
|
||||
}
|
||||
|
||||
// 关键约束:属性更新绝不能改写密码。
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err)
|
||||
}
|
||||
|
||||
// 移到离职 OU:密码必须随条目保留。
|
||||
if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil {
|
||||
t.Fatalf("移动用户失败: %v", err)
|
||||
}
|
||||
movedDN := "uid=" + uid + "," + inactiveOU
|
||||
if err := bindAs(t, c, movedDN, initialPass); err != nil {
|
||||
t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err)
|
||||
}
|
||||
|
||||
inactive, err := conn.ListUsers(inactiveOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取离职 OU 失败: %v", err)
|
||||
}
|
||||
if _, ok := inactive[uid]; !ok {
|
||||
t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid)
|
||||
}
|
||||
|
||||
// 标记离职状态
|
||||
if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil {
|
||||
t.Fatalf("设置 employeeType 失败: %v", err)
|
||||
}
|
||||
|
||||
// 移回在职 OU
|
||||
if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil {
|
||||
t.Fatalf("移回失败: %v", err)
|
||||
}
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("移回后密码失效: %v", err)
|
||||
}
|
||||
|
||||
// 清理
|
||||
if err := conn.DeleteUser(userDN); err != nil {
|
||||
t.Fatalf("删除用户失败: %v", err)
|
||||
if _, ok := users["admin"]; !ok {
|
||||
t.Errorf("LLDAP 默认的 admin 用户应在 ou=people 读到;现有: %v", keysOf(users))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationGroupMembership 验证分组的创建与成员增删。
|
||||
func TestIntegrationGroupMembership(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
groupRoot := "ou=feishuroot," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
// TestIntegrationReadGroups 验证组读视图:能列出 ou=groups 下的 LLDAP 默认组。
|
||||
func TestIntegrationReadGroups(t *testing.T) {
|
||||
conn := dialT(t)
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil {
|
||||
t.Fatalf("创建分组根 OU 失败: %v", err)
|
||||
}
|
||||
|
||||
const uid = "idsync-test-member"
|
||||
userDN := "uid=" + uid + "," + peopleOU
|
||||
groupDN := "cn=yanfa," + groupRoot
|
||||
adminDN := "cn=admin," + base
|
||||
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteGroup(groupDN)
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: "成员", DisplayName: "成员",
|
||||
Mail: "member@example.com", EmployeeType: "Active",
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||
t.Fatalf("创建分组失败: %v", err)
|
||||
}
|
||||
|
||||
// 幂等性:重复创建同一个组不应报错。
|
||||
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||
t.Errorf("重复创建分组应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
groups, err := conn.ListGroups(groupRoot)
|
||||
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("读取分组失败: %v", err)
|
||||
t.Fatalf("ListGroups 失败: %v", err)
|
||||
}
|
||||
g, ok := groups[groupDN]
|
||||
if !ok {
|
||||
t.Fatalf("未找到刚创建的分组 %s", groupDN)
|
||||
if len(groups) == 0 {
|
||||
t.Errorf("ou=groups 下应至少有 LLDAP 默认组")
|
||||
}
|
||||
// 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。
|
||||
if _, has := g.Members()[adminDN]; !has {
|
||||
t.Errorf("分组缺少 admin 成员: %v", g.Members())
|
||||
}
|
||||
|
||||
// 加入成员,并验证幂等。
|
||||
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||
t.Fatalf("加入成员失败: %v", err)
|
||||
}
|
||||
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||
t.Errorf("重复加入成员应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
groups, err = conn.ListGroups(groupRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("重新读取分组失败: %v", err)
|
||||
}
|
||||
if _, has := groups[groupDN].Members()[userDN]; !has {
|
||||
t.Errorf("成员未被加入: %v", groups[groupDN].Members())
|
||||
}
|
||||
|
||||
// 移出成员,并验证幂等。
|
||||
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||
t.Fatalf("移出成员失败: %v", err)
|
||||
}
|
||||
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||
t.Errorf("重复移出成员应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
// 清理
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteGroup(groupDN)
|
||||
}
|
||||
|
||||
// TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。
|
||||
func TestIntegrationEscaping(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
|
||||
// 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。
|
||||
cases := []string{
|
||||
"zhang.san",
|
||||
"li_si",
|
||||
"wang+wu",
|
||||
"zhao,liu",
|
||||
}
|
||||
|
||||
for _, uid := range cases {
|
||||
t.Run(uid, func(t *testing.T) {
|
||||
dn := ldaplib.UserDN(uid, peopleOU)
|
||||
_ = conn.DeleteUser(dn)
|
||||
defer func() { _ = conn.DeleteUser(dn) }()
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: uid, DisplayName: uid,
|
||||
Mail: uid + "@example.com", EmployeeType: "Active",
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil {
|
||||
t.Fatalf("创建含特殊字符的 uid 失败: %v", err)
|
||||
}
|
||||
|
||||
users, err := conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if _, ok := users[uid]; !ok {
|
||||
t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid)
|
||||
}
|
||||
})
|
||||
// 结构上应能解析成员集合(即便为空),确认 member/uniqueMember 双读不崩。
|
||||
for dn := range groups {
|
||||
_ = groups[dn].Members()
|
||||
}
|
||||
}
|
||||
|
||||
// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。
|
||||
func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error {
|
||||
t.Helper()
|
||||
|
||||
cfgURL := os.Getenv("LDAP_TEST_URL")
|
||||
if cfgURL == "" {
|
||||
cfgURL = "ldap://127.0.0.1:3899"
|
||||
// TestIntegrationDNHelpers 验证 DN 构造与过滤器转义(供读侧比对复用)。
|
||||
func TestIntegrationDNHelpers(t *testing.T) {
|
||||
if got := ldaplib.UserDN("alice", "ou=people,dc=example,dc=com"); got != "uid=alice,ou=people,dc=example,dc=com" {
|
||||
t.Errorf("UserDN = %q", got)
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
|
||||
if err != nil {
|
||||
return err
|
||||
if got := ldaplib.GroupDN("od-1", "ou=groups,dc=example,dc=com"); got != "cn=od-1,ou=groups,dc=example,dc=com" {
|
||||
t.Errorf("GroupDN = %q", got)
|
||||
}
|
||||
if ldaplib.EscapeFilter("a(b)") == "a(b)" {
|
||||
t.Errorf("EscapeFilter 未转义")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,18 +40,7 @@ func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
|
||||
return e
|
||||
}
|
||||
|
||||
// userReadAttrs 是读取用户条目时需要取回的属性。
|
||||
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
|
||||
var userReadAttrs = []string{
|
||||
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
|
||||
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
|
||||
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
|
||||
}
|
||||
|
||||
// groupReadAttrs 读取分组时额外取回成员列表,以便检测成员漂移。
|
||||
// groupReadAttrs 读取分组时同时请求两种成员属性与 objectClass。
|
||||
// 缺省的属性不会报错,只是不返回,因此同时请求是安全的。
|
||||
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
|
||||
// userReadAttrs / groupReadAttrs 的定义见 attrs.go。
|
||||
|
||||
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
|
||||
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
|
||||
@@ -74,10 +63,11 @@ func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
|
||||
}
|
||||
|
||||
// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
|
||||
// LLDAP 分组 objectClass 固定为 groupOfUniqueNames。
|
||||
func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) {
|
||||
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(objectClass=%s)", c.group.ObjectClass), groupReadAttrs, nil,
|
||||
fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
|
||||
return len(sr.Entries) > 0, nil
|
||||
}
|
||||
|
||||
// EnsureOU 确保某个 OU 存在,不存在则创建。用于首次同步时准备 ou=Inactive 等。
|
||||
func (c *Conn) EnsureOU(dn, name string) error {
|
||||
exists, err := c.ExistsDN(dn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
add := ldap.NewAddRequest(dn, nil)
|
||||
add.Attribute(AttrObjectClass, ObjectClassOU)
|
||||
add.Attribute("ou", []string{name})
|
||||
if err := c.conn.Add(add); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("创建 OU %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// entryFrom 把 LDAP 条目转成精简表示。
|
||||
// 单值属性存进 Attributes;多值属性原样保留在 multi 中。
|
||||
func entryFrom(e *ldap.Entry) *Entry {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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!]!
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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= 开头")
|
||||
}
|
||||
}
|
||||
@@ -85,33 +85,8 @@ func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。
|
||||
func TestBuildGroupDNReflectsNesting(t *testing.T) {
|
||||
snap := &feishu.Snapshot{
|
||||
Departments: []*feishu.Department{
|
||||
dept("od-root", "0", "Acme"),
|
||||
dept("od-rd", "od-root", "研发部"),
|
||||
dept("od-backend", "od-rd", "后端组"),
|
||||
},
|
||||
}
|
||||
|
||||
st := Build(snap, options())
|
||||
|
||||
rootDN := st.GroupByDeptID["od-root"].DN
|
||||
if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||
t.Errorf("根部门 DN = %q", rootDN)
|
||||
}
|
||||
|
||||
rdDN := st.GroupByDeptID["od-rd"].DN
|
||||
if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||
t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN)
|
||||
}
|
||||
|
||||
bdDN := st.GroupByDeptID["od-backend"].DN
|
||||
if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||
t.Errorf("后端组 DN = %q", bdDN)
|
||||
}
|
||||
}
|
||||
// TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
|
||||
// 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
|
||||
|
||||
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
|
||||
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package server
|
||||
|
||||
// 一次性离职确认端点的单元测试:有效/过期/复用/串用 token 各测一遍。
|
||||
// 该端点不经 Auth 中间件,token 即凭证,因此测试直接打到 Server 的 mux。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/store"
|
||||
)
|
||||
|
||||
func newConfirmServer(t *testing.T, st *store.Store) *Server {
|
||||
t.Helper()
|
||||
auth, err := NewAuth("admin", nil, "pw", make([]byte, 32), false)
|
||||
if err != nil {
|
||||
t.Fatalf("构造认证失败: %v", err)
|
||||
}
|
||||
return New(&Deps{Store: st, Auth: auth, Cfg: &config.Config{}})
|
||||
}
|
||||
|
||||
func confirmGET(s *Server, uid, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/resign/confirm?uid="+uid+"&token="+token, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func seedPendingUser(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "idsync.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开 store 失败: %v", err)
|
||||
}
|
||||
if err := st.ReplaceMirror(&store.MirrorSnapshot{
|
||||
Users: []*store.MirrorUser{{UID: "alice", UnionID: "u-alice", Email: "a@x.com"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("灌镜像失败: %v", err)
|
||||
}
|
||||
for _, fn := range []func() error{
|
||||
func() error { return st.ToLeaveCandidate("alice") },
|
||||
func() error { return st.ToPendingConfirm("alice") },
|
||||
} {
|
||||
if err := fn(); err != nil {
|
||||
t.Fatalf("状态转移失败: %v", err)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func assertState(t *testing.T, st *store.Store, want string) {
|
||||
t.Helper()
|
||||
m, _ := st.MapUsersByUID()
|
||||
if u := m["alice"]; u == nil || u.State != want {
|
||||
t.Fatalf("alice state = %+v, 期望 %s", m["alice"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResignConfirmEndpoint(t *testing.T) {
|
||||
st := seedPendingUser(t)
|
||||
defer st.Close()
|
||||
s := newConfirmServer(t, st)
|
||||
|
||||
// 有效 token:确认成功,状态置 confirmed_leave。
|
||||
valid, err := st.CreateToken("alice", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 token 失败: %v", err)
|
||||
}
|
||||
rec := confirmGET(s, "alice", valid.Token)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "已确认离职") {
|
||||
t.Errorf("有效 token 应成功,code=%d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertState(t, st, store.StateConfirmedLeave)
|
||||
|
||||
// 复用同一 token:一次使用,必须被拒。
|
||||
rec = confirmGET(s, "alice", valid.Token)
|
||||
if !strings.Contains(rec.Body.String(), "确认链接无效") {
|
||||
t.Errorf("复用已用 token 应被拒,body=%q", rec.Body.String())
|
||||
}
|
||||
|
||||
// 过期 token:拒绝。
|
||||
expired, _ := st.CreateToken("alice", -time.Minute)
|
||||
rec = confirmGET(s, "alice", expired.Token)
|
||||
if !strings.Contains(rec.Body.String(), "确认链接无效") {
|
||||
t.Errorf("过期 token 应被拒,body=%q", rec.Body.String())
|
||||
}
|
||||
|
||||
// uid 与 token 不一致(串用):拒绝。
|
||||
other, _ := st.CreateToken("alice", time.Hour)
|
||||
rec = confirmGET(s, "bob", other.Token)
|
||||
if !strings.Contains(rec.Body.String(), "确认链接无效") {
|
||||
t.Errorf("uid 与 token 不一致应被拒,body=%q", rec.Body.String())
|
||||
}
|
||||
|
||||
// 乱写的 token:拒绝。
|
||||
rec = confirmGET(s, "alice", "garbage-token")
|
||||
if !strings.Contains(rec.Body.String(), "确认链接无效") {
|
||||
t.Errorf("无效 token 应被拒,body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
+159
-101
@@ -1,8 +1,16 @@
|
||||
// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。
|
||||
//
|
||||
// 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点
|
||||
// **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置
|
||||
// confirmed_leave。
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -57,12 +65,17 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/api/logout", s.handleLogout)
|
||||
|
||||
// 离职确认端点:token 即凭证,不经 Auth 中间件。
|
||||
s.mux.HandleFunc(config.ResignConfirmRoute, s.handleResignConfirm)
|
||||
|
||||
// 以下接口需要登录。
|
||||
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
|
||||
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
|
||||
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
|
||||
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
|
||||
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
|
||||
s.mux.HandleFunc("/api/snapshot", s.deps.Auth.Middleware(s.handleSnapshot))
|
||||
s.mux.HandleFunc("/api/resign/pending", s.deps.Auth.Middleware(s.handleResignPending))
|
||||
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
|
||||
}
|
||||
|
||||
@@ -75,7 +88,6 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
webui.IndexHandler()(w, r)
|
||||
}
|
||||
|
||||
// handleLogin 处理登录。
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
@@ -88,54 +100,41 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleLogout 处理登出。
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.deps.Auth.Logout(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleStatus 返回概览信息:连通性、计数、上次同步。
|
||||
// handleStatus 返回概览信息。
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg := s.deps.Cfg
|
||||
|
||||
ldapOK := true
|
||||
ldapErr := ""
|
||||
ldapOK, ldapErr := true, ""
|
||||
if err := s.deps.LDAP.Ping(); err != nil {
|
||||
ldapOK = false
|
||||
ldapErr = err.Error()
|
||||
ldapOK, ldapErr = false, err.Error()
|
||||
}
|
||||
|
||||
feishuOK := true
|
||||
feishuErr := ""
|
||||
feishuOK, feishuErr := true, ""
|
||||
if s.deps.FeishuTest != nil {
|
||||
if err := s.deps.FeishuTest(ctx); err != nil {
|
||||
feishuOK = false
|
||||
feishuErr = err.Error()
|
||||
feishuOK, feishuErr = false, err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
accounts, err := s.deps.Store.AllAccounts()
|
||||
users, err := s.deps.Store.ActiveUsers()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
groups, err := s.deps.Store.AllGroups()
|
||||
depts, err := s.deps.Store.ActiveDepartments()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
active, inactive := 0, 0
|
||||
for _, a := range accounts {
|
||||
if a.State == "inactive" {
|
||||
inactive++
|
||||
} else {
|
||||
active++
|
||||
}
|
||||
}
|
||||
skipped, _ := s.deps.Store.SkippedUsers()
|
||||
pending, _ := s.deps.Store.PendingConfirm()
|
||||
|
||||
lastRun, err := s.deps.Store.LastRun()
|
||||
if err != nil {
|
||||
@@ -146,103 +145,70 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ldap": map[string]any{
|
||||
"ok": ldapOK, "error": ldapErr,
|
||||
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN,
|
||||
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
|
||||
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(),
|
||||
},
|
||||
"feishu": map[string]any{
|
||||
"ok": feishuOK, "error": feishuErr,
|
||||
"appId": maskSecret(cfg.Feishu.AppID),
|
||||
"deptList": cfg.Feishu.DeptList,
|
||||
"appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList,
|
||||
},
|
||||
"lldap": map[string]any{
|
||||
"baseUrl": cfg.Lldap.BaseURL,
|
||||
},
|
||||
"counts": map[string]any{
|
||||
"activeUsers": active, "inactiveUsers": inactive, "groups": len(groups),
|
||||
"activeUsers": len(users), "departments": len(depts),
|
||||
"pendingConfirm": len(pending), "skipped": len(skipped),
|
||||
},
|
||||
"lastRun": lastRun,
|
||||
"sync": map[string]any{
|
||||
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule,
|
||||
"defaultDryRun": cfg.Sync.DefaultDryRun,
|
||||
"schedule": map[string]any{
|
||||
"enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot,
|
||||
"sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。
|
||||
// handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。
|
||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.deps.Store.AllAccounts()
|
||||
all, err := s.deps.Store.AllMirrorUsers()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项,
|
||||
// 这类问题在界面上一眼可见比事后翻日志有用得多。
|
||||
drift := map[string]string{}
|
||||
if conn, err := s.deps.LDAP.Dial(); err == nil {
|
||||
defer conn.Close()
|
||||
active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN())
|
||||
inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN())
|
||||
if errA == nil && errI == nil {
|
||||
for _, a := range accounts {
|
||||
switch a.State {
|
||||
case "inactive":
|
||||
if _, ok := inactive[a.UID]; !ok {
|
||||
drift[a.UID] = "台账标记离职,但 LDAP 中不存在"
|
||||
}
|
||||
default:
|
||||
if _, ok := active[a.UID]; !ok {
|
||||
if _, inInactive := inactive[a.UID]; inInactive {
|
||||
drift[a.UID] = "台账标记在职,实际位于离职 OU"
|
||||
} else {
|
||||
drift[a.UID] = "台账存在,但 LDAP 中不存在"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
State string `json:"state"`
|
||||
DN string `json:"dn"`
|
||||
DeptIDs string `json:"deptIds"`
|
||||
Skipped bool `json:"skipped"`
|
||||
SkipReason string `json:"skipReason,omitempty"`
|
||||
DeptIDs []string `json:"deptIds"`
|
||||
LastSync string `json:"lastSyncedAt"`
|
||||
Drift string `json:"drift,omitempty"`
|
||||
}
|
||||
|
||||
rows := make([]row, 0, len(accounts))
|
||||
for _, a := range accounts {
|
||||
rows := make([]row, 0, len(all))
|
||||
for _, u := range all {
|
||||
deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID)
|
||||
rows = append(rows, row{
|
||||
UID: a.UID,
|
||||
Name: a.Name,
|
||||
Email: a.Email,
|
||||
State: a.State,
|
||||
DN: a.DN,
|
||||
DeptIDs: a.DeptIDs,
|
||||
LastSync: a.LastSyncedAt.Format(time.RFC3339),
|
||||
Drift: drift[a.UID],
|
||||
UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State,
|
||||
Skipped: u.Skipped, SkipReason: u.SkipReason,
|
||||
DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
|
||||
}
|
||||
|
||||
// handleGroups 返回受管分组清单。
|
||||
// handleGroups 返回部门镜像。
|
||||
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.deps.Store.AllGroups()
|
||||
depts, err := s.deps.Store.AllDepartments()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": depts})
|
||||
}
|
||||
|
||||
// handlePlan 计算并返回 dry-run 计划,不产生任何写入。
|
||||
// handlePlan 计算并返回 dry-run 物化计划,不产生任何写入。
|
||||
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
plan, err := s.deps.Runner.Preview(ctx)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -251,13 +217,12 @@ func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。
|
||||
// handleSync 执行物化。默认 dry-run,只有显式传 dryRun=false 才真正写入。
|
||||
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DryRun *bool `json:"dryRun"`
|
||||
Force bool `json:"force"`
|
||||
@@ -265,39 +230,107 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
|
||||
// 默认 dry-run:忘记传参数时宁可什么都不做。
|
||||
dryRun := true
|
||||
if req.DryRun != nil {
|
||||
dryRun = *req.DryRun
|
||||
}
|
||||
|
||||
// 同一时刻只允许一次同步。
|
||||
select {
|
||||
case s.syncMu <- struct{}{}:
|
||||
defer func() { <-s.syncMu }()
|
||||
default:
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
|
||||
if err := s.lockSync(); err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer s.unlockSync()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(), "plan": plan,
|
||||
})
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dryRun": dryRun, "plan": plan, "result": res,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"dryRun": dryRun, "plan": plan, "result": res})
|
||||
}
|
||||
|
||||
// handleRuns 返回同步历史。
|
||||
// handleSnapshot 触发一轮快照。
|
||||
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
return
|
||||
}
|
||||
if err := s.lockSync(); err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer s.unlockSync()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.deps.Runner.RunSnapshot(ctx)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleResignPending 返回待人工确认的离职候选(等价于卡片按钮,可在界面操作)。
|
||||
func (s *Server) handleResignPending(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.deps.Store.PendingConfirm()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type row struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
LastSync string `json:"lastSyncedAt"`
|
||||
}
|
||||
rows := make([]row, 0, len(list))
|
||||
for _, u := range list {
|
||||
rows = append(rows, row{UID: u.UID, Name: u.DisplayName, Email: u.Email, LastSync: timeOrZero(u.LastSyncedAt)})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pending": rows})
|
||||
}
|
||||
|
||||
// handleResignConfirm 处理一次性离职确认:校验 token 并通过后置 confirmed_leave。
|
||||
//
|
||||
// token 即凭证,因此本项目路由不经 Auth 中间件。成功后渲染极简确认页,不回显
|
||||
// 用户敏感信息;无效/过期/已用分别给出对应提示。
|
||||
func (s *Server) handleResignConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
uid := r.URL.Query().Get("uid")
|
||||
token := r.URL.Query().Get("token")
|
||||
|
||||
rec, err := s.deps.Store.ConsumeToken(token)
|
||||
if err != nil {
|
||||
s.renderConfirmPage(w, "确认链接无效", "该确认链接暂不可用,可能是已失效、已使用或已过期。请向管理员重新发起离职确认。")
|
||||
return
|
||||
}
|
||||
// token 与请求里携带的 uid 必须一致,防串用。
|
||||
if rec.UID != uid {
|
||||
s.renderConfirmPage(w, "确认链接无效", "该确认链接与请求的用户不一致,已拒绝。")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.ConfirmLeave(uid); err != nil {
|
||||
s.log().Warn("确认离职失败", "uid", uid, "err", err)
|
||||
s.renderConfirmPage(w, "操作失败", "处理确认时出错,请稍后重试或联系管理员。")
|
||||
return
|
||||
}
|
||||
s.log().Info("用户已人工确认离职", "uid", uid)
|
||||
s.renderConfirmPage(w, "已确认离职", "该员工已标记为离职,将在下一轮同步中从目录移除。")
|
||||
}
|
||||
|
||||
// renderConfirmPage 渲染极简确认页,不展示任何用户敏感信息。
|
||||
func (s *Server) renderConfirmPage(w http.ResponseWriter, title, body string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh"><meta charset="utf-8">
|
||||
<title>%s</title><body style="font-family:sans-serif;padding:2rem">
|
||||
<h1>%s</h1><p>%s</p></body></html>`,
|
||||
html.EscapeString(title), html.EscapeString(title), html.EscapeString(body))
|
||||
}
|
||||
|
||||
// handleRuns 返回物化历史。
|
||||
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.deps.Store.RecentRuns(100)
|
||||
if err != nil {
|
||||
@@ -307,6 +340,24 @@ func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
||||
}
|
||||
|
||||
func (s *Server) lockSync() error {
|
||||
select {
|
||||
case s.syncMu <- struct{}{}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("已有快照或物化任务在执行")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) unlockSync() { <-s.syncMu }
|
||||
|
||||
func (s *Server) log() *slog.Logger {
|
||||
if s.deps.Log != nil {
|
||||
return s.deps.Log
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// writeJSON 输出 JSON 响应。
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -314,7 +365,7 @@ func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。
|
||||
// maskSecret 只保留标识的前后几位。
|
||||
func maskSecret(s string) string {
|
||||
if len(s) <= 8 {
|
||||
if len(s) == 0 {
|
||||
@@ -324,3 +375,10 @@ func maskSecret(s string) string {
|
||||
}
|
||||
return s[:4] + "****" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func timeOrZero(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,8 @@ func Open(path string) (*Store, error) {
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.AutoMigrate(&Account{}, &Group{}, &SyncRun{}); err != nil {
|
||||
if err := db.AutoMigrate(&MirrorUser{}, &MirrorDepartment{}, &MirrorUserDepartment{},
|
||||
&ResignConfirmToken{}, &SyncRun{}, &Account{}, &Group{}); err != nil {
|
||||
return nil, fmt.Errorf("建表失败: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
|
||||
+164
-443
@@ -1,27 +1,21 @@
|
||||
//go:build integration
|
||||
|
||||
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
|
||||
// 两阶段全链路端到端集成测试:假飞书 + 真 LLDAP + 假 webhook。
|
||||
//
|
||||
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
|
||||
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
|
||||
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
|
||||
// 并断言最终落在 OpenLDAP 里的内容。
|
||||
// 快照段用受控的假飞书 OpenAPI 服务(无需真实租户凭据)写镜像与推进离职流程,
|
||||
// 物化段写真实 LLDAP(GraphQL)。覆盖:首次全量创建、幂等、属性变更、离职未确认
|
||||
// 不删、确认后删除、重新入职重建。
|
||||
//
|
||||
// 启用方式(需要真实 LDAP 实例):
|
||||
//
|
||||
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
|
||||
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
|
||||
package syncer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -29,65 +23,42 @@ import (
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/lldapgql"
|
||||
"idsync/internal/store"
|
||||
"idsync/internal/syncer"
|
||||
)
|
||||
|
||||
// fakeDept 是假服务返回的部门。
|
||||
type fakeDept struct {
|
||||
openID string
|
||||
parentID string
|
||||
name string
|
||||
}
|
||||
type fakeDept struct{ openID, parentID, name string }
|
||||
|
||||
// fakeUser 是假服务返回的用户。
|
||||
type fakeUser struct {
|
||||
unionID string
|
||||
userID string
|
||||
name string
|
||||
enName string
|
||||
enterpriseEm string
|
||||
email string
|
||||
mobile string
|
||||
employeeNo string
|
||||
jobTitle string
|
||||
unionID, userID, name, enName string
|
||||
enterpriseEm, email, mobile string
|
||||
employeeNo, jobTitle string
|
||||
deptIDs []string
|
||||
resigned bool
|
||||
}
|
||||
|
||||
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。
|
||||
// fakeFeishu 只实现快照链路需要的接口。
|
||||
type fakeFeishu struct {
|
||||
depts []fakeDept
|
||||
users []fakeUser
|
||||
// served 记录各接口被调用的次数,用于验证分页与去重行为。
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeFeishu) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls["token"]++
|
||||
writeFakeJSON(w, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "fake-token",
|
||||
"expire": 7200,
|
||||
writeFakeJSON(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200})
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls["departments"]++
|
||||
|
||||
parent := r.URL.Query().Get("parent_department_id")
|
||||
if parent == "" {
|
||||
parent = "0"
|
||||
}
|
||||
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
|
||||
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
|
||||
fetchChild := r.URL.Query().Get("fetch_child") == "true"
|
||||
|
||||
want := map[string]bool{parent: true}
|
||||
if fetchChild {
|
||||
if r.URL.Query().Get("fetch_child") == "true" {
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for _, d := range f.depts {
|
||||
@@ -98,86 +69,48 @@ func (f *fakeFeishu) handler() http.Handler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0)
|
||||
for _, d := range f.depts {
|
||||
if !want[d.openID] || d.openID == parent {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"name": d.name,
|
||||
"parent_department_id": d.parentID,
|
||||
"open_department_id": d.openID,
|
||||
"name": d.name, "parent_department_id": d.parentID, "open_department_id": d.openID,
|
||||
})
|
||||
}
|
||||
writeFakeJSON(w, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{
|
||||
"has_more": false, "items": items,
|
||||
},
|
||||
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls["find_by_department"]++
|
||||
|
||||
deptID := r.URL.Query().Get("department_id")
|
||||
items := make([]map[string]any, 0)
|
||||
for _, u := range f.users {
|
||||
if u.resigned {
|
||||
continue // 与真实接口一致:不返回离职人员
|
||||
}
|
||||
if !containsStr(u.deptIDs, deptID) {
|
||||
if u.resigned || !containsStr(u.deptIDs, deptID) {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"union_id": u.unionID,
|
||||
"user_id": u.userID,
|
||||
"name": u.name,
|
||||
"en_name": u.enName,
|
||||
"enterprise_email": u.enterpriseEm,
|
||||
"email": u.email,
|
||||
"mobile": u.mobile,
|
||||
"employee_no": u.employeeNo,
|
||||
"job_title": u.jobTitle,
|
||||
"department_ids": u.deptIDs,
|
||||
"union_id": u.unionID, "user_id": u.userID, "name": u.name, "en_name": u.enName,
|
||||
"enterprise_email": u.enterpriseEm, "email": u.email, "mobile": u.mobile,
|
||||
"employee_no": u.employeeNo, "job_title": u.jobTitle, "department_ids": u.deptIDs,
|
||||
})
|
||||
}
|
||||
writeFakeJSON(w, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{
|
||||
"has_more": false, "items": items,
|
||||
},
|
||||
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}})
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls["user_get"]++
|
||||
|
||||
// 从路径末尾取出 union_id
|
||||
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
|
||||
unionID := parts[len(parts)-1]
|
||||
|
||||
for _, u := range f.users {
|
||||
if u.unionID != unionID {
|
||||
continue
|
||||
}
|
||||
writeFakeJSON(w, map[string]any{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]any{
|
||||
"user": map[string]any{
|
||||
"union_id": unionID,
|
||||
"user_id": u.userID,
|
||||
"name": u.name,
|
||||
if u.unionID == unionID {
|
||||
writeFakeJSON(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{
|
||||
"union_id": unionID, "user_id": u.userID, "name": u.name,
|
||||
"status": map[string]any{"is_resigned": u.resigned},
|
||||
},
|
||||
},
|
||||
})
|
||||
}}})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -185,440 +118,228 @@ func writeFakeJSON(w http.ResponseWriter, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
|
||||
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) {
|
||||
// setup 准备整套环境:真 LLDAP + 假飞书 + 假 webhook + 临时镜像库。
|
||||
func setup(t *testing.T) (*syncer.Runner, *store.Store, *lldapgql.Client, *fakeFeishu, func()) {
|
||||
t.Helper()
|
||||
|
||||
ldapURL := os.Getenv("LDAP_TEST_URL")
|
||||
if ldapURL == "" {
|
||||
ldapURL = "ldap://127.0.0.1:3899"
|
||||
}
|
||||
base := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if base == "" {
|
||||
base = "dc=example,dc=com"
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍:
|
||||
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与
|
||||
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。
|
||||
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS")
|
||||
if groupOC == "" {
|
||||
groupOC = "groupOfUniqueNames"
|
||||
}
|
||||
|
||||
ldapCfg := config.Ldap{
|
||||
URL: ldapURL, BaseDN: base,
|
||||
AdminDN: "cn=admin," + base, AdminPass: "adminpass",
|
||||
GroupObjectClass: groupOC,
|
||||
cfg := &config.Config{
|
||||
System: config.System{Mode: "release"},
|
||||
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
|
||||
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
|
||||
Ldap: config.Ldap{
|
||||
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
|
||||
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
|
||||
TimeoutSec: 10,
|
||||
},
|
||||
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
|
||||
Guard: config.Guard{MaxDeactivatePercent: 0.9},
|
||||
}
|
||||
if err := ldapCfg.NormalizeForTest(); err != nil {
|
||||
t.Fatalf("LDAP 配置规范化失败: %v", err)
|
||||
if err := cfg.NormalizeForTest(); err != nil {
|
||||
t.Fatalf("配置规范化失败: %v", err)
|
||||
}
|
||||
|
||||
ldapClient, err := ldaplib.NewClient(ldapCfg, log)
|
||||
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
|
||||
if err := gql.Login(t.Context(), "admin", "adminpass"); err != nil {
|
||||
t.Skipf("LLDAP 不可用,跳过端到端测试: %v", err)
|
||||
}
|
||||
|
||||
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 LDAP 客户端失败: %v", err)
|
||||
}
|
||||
if err := ldapClient.Ping(); err != nil {
|
||||
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeFeishu{calls: map[string]int{}}
|
||||
ts := httptest.NewServer(fake.handler())
|
||||
fs := httptest.NewServer(fake.handler())
|
||||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`))
|
||||
}))
|
||||
|
||||
feishuClient, err := feishu.New(feishu.Options{
|
||||
AppID: "fake", AppSecret: "fake",
|
||||
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
|
||||
AppID: "fake", AppSecret: "fake", BaseURL: fs.URL, QPS: 100, Timeout: 10 * time.Second,
|
||||
}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("构造飞书客户端失败: %v", err)
|
||||
}
|
||||
|
||||
dbPath := t.TempDir() + "/idsync.db"
|
||||
st, err := store.Open(dbPath)
|
||||
st, err := store.Open(t.TempDir() + "/idsync.db")
|
||||
if err != nil {
|
||||
t.Fatalf("打开台账失败: %v", err)
|
||||
t.Fatalf("打开镜像库失败: %v", err)
|
||||
}
|
||||
|
||||
ldapCfg.UserOU = "ou=people"
|
||||
ldapCfg.InactiveOU = "ou=Inactive"
|
||||
ldapCfg.GroupRootName = "feishuroot"
|
||||
|
||||
cfg := &config.Config{
|
||||
System: config.System{Mode: "release", DataDir: t.TempDir()},
|
||||
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
|
||||
Ldap: ldapCfg,
|
||||
Database: config.Database{Source: dbPath},
|
||||
Sync: config.Sync{MaxDeactivatePercent: 0.5},
|
||||
}
|
||||
|
||||
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log)
|
||||
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gql, st, log, syncer.SnapshotOptions{
|
||||
WebhookURL: webhook.URL, ConfirmBaseURL: "https://confirm.example.com",
|
||||
ReNotifyInterval: 24 * time.Hour, TokenTTL: 72 * time.Hour,
|
||||
})
|
||||
|
||||
cleanup := func() {
|
||||
ts.Close()
|
||||
st.Close()
|
||||
fs.Close()
|
||||
webhook.Close()
|
||||
_ = st.Close()
|
||||
}
|
||||
return runner, ldapClient, st, fake, cleanup
|
||||
return runner, st, gql, fake, cleanup
|
||||
}
|
||||
|
||||
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
|
||||
// 属性变更、部门调整、离职迁移与重新入职。
|
||||
func TestEndToEndSync(t *testing.T) {
|
||||
runner, ldapClient, st, fake, cleanup := setup(t)
|
||||
// TestTwoStageEndToEnd 覆盖首轮创建→幂等→离职确认→删除→重新入职重建。
|
||||
func TestTwoStageEndToEnd(t *testing.T) {
|
||||
runner, st, gql, fake, cleanup := setup(t)
|
||||
defer cleanup()
|
||||
|
||||
const base = "dc=example,dc=com"
|
||||
peopleOU := "ou=people," + base
|
||||
inactiveOU := "ou=Inactive," + base
|
||||
groupRoot := "ou=feishuroot," + base
|
||||
groupRoot := "ou=groups," + base
|
||||
|
||||
// 准备目录结构并清理历史残留。
|
||||
conn, err := ldapClient.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
for _, ou := range []struct{ dn, name string }{
|
||||
{peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"},
|
||||
} {
|
||||
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
wipeTestOUs(t, conn, base)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// 两个部门,其中“研发部”是“后端组”的父部门。
|
||||
// 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root),
|
||||
// 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。
|
||||
fake.depts = []fakeDept{
|
||||
{openID: "od-root", parentID: "0", name: "Acme"},
|
||||
{openID: "od-aaa-child", parentID: "od-root", name: "yanfa"},
|
||||
{openID: "od-child", parentID: "od-root", name: "研发部"},
|
||||
}
|
||||
fake.users = []fakeUser{
|
||||
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San",
|
||||
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com",
|
||||
mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师",
|
||||
deptIDs: []string{"od-aaa-child"}},
|
||||
{unionID: "u2", userID: "2", name: "李四", enName: "Li Si",
|
||||
enterpriseEm: "lisi@example.com", email: "personal2@x.com",
|
||||
mobile: "13800000002", employeeNo: "E002", jobTitle: "经理",
|
||||
deptIDs: []string{"od-aaa-child"}},
|
||||
// 没有企业邮箱:应当被跳过而不是回落到个人邮箱。
|
||||
{unionID: "u3", userID: "3", name: "王五",
|
||||
enterpriseEm: "", email: "wangwu@personal.com",
|
||||
deptIDs: []string{"od-aaa-child"}},
|
||||
}
|
||||
wipeTestOUs(t, conn, base)
|
||||
|
||||
// ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ----
|
||||
plan, res, err := runner.Run(t.Context(), true, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run 失败: %v", err)
|
||||
}
|
||||
if res != nil {
|
||||
t.Fatalf("dry-run 不应返回执行结果")
|
||||
}
|
||||
if plan.Stats.CreateUser != 2 {
|
||||
t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)",
|
||||
plan.Stats.CreateUser)
|
||||
}
|
||||
foundEmailWarning := false
|
||||
for _, wr := range plan.Warnings {
|
||||
if strings.Contains(wr, "企业邮箱") {
|
||||
foundEmailWarning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundEmailWarning {
|
||||
t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings)
|
||||
{unionID: "u1", userID: "1", name: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-child"}},
|
||||
{unionID: "u2", userID: "2", name: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-child"}},
|
||||
}
|
||||
|
||||
// dry-run 后 LDAP 中不应有任何用户。
|
||||
users, err := conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取用户失败: %v", err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
|
||||
}
|
||||
t.Cleanup(func() { cleanupE2E(t, st, gql) })
|
||||
|
||||
// ---- 第二步:真正执行 ----
|
||||
_, res, err = runner.Run(t.Context(), false, false, "test")
|
||||
// ---- 快照 + 物化:首次全量创建 ----
|
||||
if _, err := runner.RunSnapshot(t.Context()); err != nil {
|
||||
t.Fatalf("快照失败: %v", err)
|
||||
}
|
||||
_, res, err := runner.Run(t.Context(), false, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("执行同步失败: %v", err)
|
||||
t.Fatalf("物化失败: %v", err)
|
||||
}
|
||||
if res.Failed != 0 {
|
||||
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors)
|
||||
t.Fatalf("物化有失败: %v", res.Errors)
|
||||
}
|
||||
|
||||
users, err = conn.ListUsers(peopleOU)
|
||||
conn, err := ldapDial()
|
||||
if err != nil {
|
||||
t.Fatalf("读取用户失败: %v", err)
|
||||
t.Fatalf("LDAP 连接失败: %v", err)
|
||||
}
|
||||
if _, ok := users["zhangsan"]; !ok {
|
||||
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
|
||||
users, _ := conn.ListUsers(peopleOU)
|
||||
for _, uid := range []string{"alice", "bob"} {
|
||||
if _, ok := users[uid]; !ok {
|
||||
t.Errorf("物化后缺用户 %s", uid)
|
||||
}
|
||||
if _, ok := users["lisi"]; !ok {
|
||||
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
|
||||
}
|
||||
if _, ok := users["wangwu"]; ok {
|
||||
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
|
||||
conn.Close()
|
||||
conn, _ = ldapDial()
|
||||
groupsDM := groupMap(conn, groupRoot)
|
||||
conn.Close()
|
||||
if _, ok := groupsDM["cn=od-child,"+groupRoot]; !ok {
|
||||
t.Errorf("物化后缺组 cn=od-child")
|
||||
}
|
||||
|
||||
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。
|
||||
zs := users["zhangsan"]
|
||||
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" {
|
||||
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got)
|
||||
}
|
||||
if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" {
|
||||
t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got)
|
||||
}
|
||||
if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" {
|
||||
t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got)
|
||||
// ---- 幂等 ----
|
||||
plan, _ := runner.Preview(t.Context())
|
||||
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
|
||||
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
|
||||
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
|
||||
}
|
||||
|
||||
// 断言分组与成员关系。
|
||||
groups, err := conn.ListGroups(groupRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("读取分组失败: %v", err)
|
||||
// ---- 离职:bob 标记离职 → pending_confirm,但未确认不应删除 ----
|
||||
markResigned2(fake, "u2")
|
||||
if _, err := runner.RunSnapshot(t.Context()); err != nil {
|
||||
t.Fatalf("快照失败: %v", err)
|
||||
}
|
||||
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。
|
||||
acmeDN := "cn=acme," + groupRoot
|
||||
yanfaDN := "cn=yanfa," + acmeDN
|
||||
g, ok := groups[yanfaDN]
|
||||
if !ok {
|
||||
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
|
||||
s, _ := st.MapUsersByUID()
|
||||
if s["bob"].State != store.StatePendingConfirm {
|
||||
t.Fatalf("bob 应为 pending_confirm,实际 %q", s["bob"].State)
|
||||
}
|
||||
members := g.Members()
|
||||
if _, has := members["uid=zhangsan,"+peopleOU]; !has {
|
||||
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members)
|
||||
}
|
||||
|
||||
// 父部门分组也应存在,验证树形嵌套正确。
|
||||
if _, ok := groups[acmeDN]; !ok {
|
||||
t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups))
|
||||
}
|
||||
|
||||
// 台账应记录两个账号。
|
||||
accounts, err := st.AccountsByUID()
|
||||
if err != nil {
|
||||
t.Fatalf("读取台账失败: %v", err)
|
||||
}
|
||||
if len(accounts) != 2 {
|
||||
t.Errorf("台账记录数 = %d, 期望 2", len(accounts))
|
||||
}
|
||||
|
||||
// ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ----
|
||||
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 dry-run 失败: %v", err)
|
||||
}
|
||||
total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup +
|
||||
plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember +
|
||||
plan.Stats.Deactivate + plan.Stats.Reactivate
|
||||
if total != 0 {
|
||||
t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan))
|
||||
}
|
||||
|
||||
// ---- 第四步:属性变更应被检测并同步 ----
|
||||
fake.users[0].jobTitle = "高级工程师"
|
||||
fake.users[0].mobile = "13900000001"
|
||||
|
||||
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("变更检测失败: %v", err)
|
||||
}
|
||||
if plan.Stats.UpdateUser != 1 {
|
||||
t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser)
|
||||
}
|
||||
|
||||
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||
t.Fatalf("应用变更失败: %v", err)
|
||||
_, res, _ = runner.Run(t.Context(), false, false, "test")
|
||||
if res.Deleted != 0 {
|
||||
t.Fatalf("未确认离职不应删除,实际 deleted=%d", res.Deleted)
|
||||
}
|
||||
conn, _ = ldapDial()
|
||||
users, _ = conn.ListUsers(peopleOU)
|
||||
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
|
||||
t.Errorf("职位未更新: %q", got)
|
||||
conn.Close()
|
||||
if _, still := users["bob"]; !still {
|
||||
t.Fatalf("未确认离职的 bob 不应被删除")
|
||||
}
|
||||
|
||||
// ---- 第五步:离职应迁入 ou=Inactive ----
|
||||
fake.users[1].resigned = true
|
||||
|
||||
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("离职检测失败: %v", err)
|
||||
// ---- 确认离职 → 物化删除 ----
|
||||
if err := st.ConfirmLeave("bob"); err != nil {
|
||||
t.Fatalf("状态转移失败: %v", err)
|
||||
}
|
||||
if plan.Stats.Deactivate != 1 {
|
||||
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
|
||||
_, res, _ = runner.Run(t.Context(), false, false, "test")
|
||||
if res.Deleted != 1 {
|
||||
t.Fatalf("confirmed_leave 应删除 1 个,实际 %d", res.Deleted)
|
||||
}
|
||||
|
||||
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||
t.Fatalf("执行离职迁移失败: %v", err)
|
||||
}
|
||||
|
||||
conn, _ = ldapDial()
|
||||
users, _ = conn.ListUsers(peopleOU)
|
||||
if _, still := users["lisi"]; still {
|
||||
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
|
||||
conn.Close()
|
||||
if _, still := users["bob"]; still {
|
||||
t.Errorf("confirmed_leave 的 bob 应被物理删除")
|
||||
}
|
||||
inactive, _ := conn.ListUsers(inactiveOU)
|
||||
if _, moved := inactive["lisi"]; !moved {
|
||||
t.Errorf("lisi 未迁入 %s", inactiveOU)
|
||||
if _, alive := users["alice"]; !alive {
|
||||
t.Errorf("alice 不应被删除")
|
||||
}
|
||||
|
||||
// 离职用户也应从部门分组中移除。
|
||||
groups, _ = conn.ListGroups(groupRoot)
|
||||
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
|
||||
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
|
||||
}
|
||||
|
||||
// ---- 第六步:重新入职应迁回 ----
|
||||
// ---- 重新入职:bob 重新可见 → 重建 ----
|
||||
fake.users[1].resigned = false
|
||||
|
||||
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("复职检测失败: %v", err)
|
||||
if _, err := runner.RunSnapshot(t.Context()); err != nil {
|
||||
t.Fatalf("快照失败: %v", err)
|
||||
}
|
||||
if plan.Stats.Reactivate != 1 {
|
||||
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan))
|
||||
}
|
||||
|
||||
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||
t.Fatalf("执行复职失败: %v", err)
|
||||
_, res, _ = runner.Run(t.Context(), false, false, "test")
|
||||
if res.Created == 0 {
|
||||
t.Fatalf("重新入职应重建 bob,实际 created=%d", res.Created)
|
||||
}
|
||||
conn, _ = ldapDial()
|
||||
users, _ = conn.ListUsers(peopleOU)
|
||||
if _, back := users["lisi"]; !back {
|
||||
t.Errorf("lisi 复职后未回到 %s", peopleOU)
|
||||
}
|
||||
|
||||
// 复职后应重新加入部门分组。
|
||||
groups, _ = conn.ListGroups(groupRoot)
|
||||
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
|
||||
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
|
||||
conn.Close()
|
||||
if _, back := users["bob"]; !back {
|
||||
t.Errorf("bob 重新入职后应被重建")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。
|
||||
func TestEndToEndGuard(t *testing.T) {
|
||||
runner, ldapClient, _, fake, cleanup := setup(t)
|
||||
defer cleanup()
|
||||
func markResigned2(f *fakeFeishu, unionID string) {
|
||||
for i := range f.users {
|
||||
if f.users[i].unionID == unionID {
|
||||
f.users[i].resigned = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base = "dc=example,dc=com"
|
||||
peopleOU := "ou=people," + base
|
||||
groupRoot := "ou=feishuroot," + base
|
||||
|
||||
conn, err := ldapClient.Dial()
|
||||
func ldapDial() (*ldaplib.Conn, error) {
|
||||
c, err := ldaplib.NewClient(config.Ldap{
|
||||
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
|
||||
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass", TimeoutSec: 10,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
for _, ou := range []struct{ dn, name string }{
|
||||
{peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"},
|
||||
} {
|
||||
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return nil, err
|
||||
}
|
||||
return c.Dial()
|
||||
}
|
||||
|
||||
// wipeTestOUs 删除测试用 OU 下的全部用户与分组。
|
||||
//
|
||||
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职),
|
||||
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。
|
||||
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) {
|
||||
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()
|
||||
|
||||
peopleOU := "ou=people," + base
|
||||
inactiveOU := "ou=Inactive," + base
|
||||
groupRoot := "ou=feishuroot," + base
|
||||
|
||||
for _, ou := range []string{peopleOU, inactiveOU} {
|
||||
users, err := conn.ListUsers(ou)
|
||||
if err != nil {
|
||||
continue
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for _, uid := range []string{"alice", "bob", "admin"} {
|
||||
_ = gql.DeleteUser(ctx, uid)
|
||||
}
|
||||
for uid := range users {
|
||||
_ = conn.DeleteUser("uid=" + uid + "," + ou)
|
||||
deps, _ := st.MapDepartmentsByID()
|
||||
for _, d := range deps {
|
||||
if d.GroupID > 0 {
|
||||
_ = gql.DeleteGroup(ctx, d.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
groups, err := conn.ListGroups(groupRoot)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 先删深层分组,再删浅层,避免「非叶子节点无法删除」。
|
||||
dns := keysOf(groups)
|
||||
sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) })
|
||||
for _, dn := range dns {
|
||||
_ = conn.DeleteGroup(groups[dn].DN)
|
||||
}
|
||||
}
|
||||
|
||||
// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。
|
||||
func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot }
|
||||
|
||||
func keysOf[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dumpOps(p *syncer.Plan) string {
|
||||
var b strings.Builder
|
||||
for _, op := range p.Ops {
|
||||
fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail)
|
||||
}
|
||||
for _, wr := range p.Warnings {
|
||||
fmt.Fprintf(&b, "\n 警告: %s", wr)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+68
-203
@@ -1,274 +1,139 @@
|
||||
package syncer
|
||||
|
||||
// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。
|
||||
//
|
||||
// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的
|
||||
// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId
|
||||
// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/mapping"
|
||||
"idsync/internal/password"
|
||||
"idsync/internal/lldapgql"
|
||||
)
|
||||
|
||||
// Guard 是执行前的安全阈值。
|
||||
//
|
||||
// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、
|
||||
// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。
|
||||
// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。
|
||||
type Guard struct {
|
||||
// MaxDeactivate 单次允许停用的最大账号数,0 表示不限制。
|
||||
MaxDeactivate int
|
||||
// MaxDeactivatePercent 单次允许停用的账号比例(0-1),0 表示不限制。
|
||||
MaxDeactivatePercent float64
|
||||
// AllowBulk 显式跳过全部阈值检查。
|
||||
AllowBulk bool
|
||||
}
|
||||
|
||||
// GuardError 表示计划未通过安全阈值检查。
|
||||
type GuardError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *GuardError) Error() string { return e.Message }
|
||||
|
||||
// Check 校验计划是否满足安全阈值。
|
||||
func (g Guard) Check(p *Plan) error {
|
||||
if g.AllowBulk {
|
||||
return nil
|
||||
}
|
||||
|
||||
deactivate := p.Stats.Deactivate
|
||||
if deactivate == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate {
|
||||
return &GuardError{Message: fmt.Sprintf(
|
||||
"本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑",
|
||||
deactivate, g.MaxDeactivate)}
|
||||
}
|
||||
|
||||
if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 {
|
||||
ratio := float64(deactivate) / float64(p.Stats.TotalAccounts)
|
||||
if ratio > g.MaxDeactivatePercent {
|
||||
return &GuardError{Message: fmt.Sprintf(
|
||||
"本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
|
||||
deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// ExecConfig 是执行器所需的环境参数。
|
||||
type ExecConfig struct {
|
||||
PeopleOU string
|
||||
}
|
||||
|
||||
// Result 是执行结果统计。
|
||||
type Result struct {
|
||||
Created int
|
||||
Updated int
|
||||
Moved int
|
||||
Deleted int
|
||||
Added int
|
||||
Removed int
|
||||
Failed int
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// Executor 把计划应用到 LDAP。
|
||||
// Executor 把计划应用到 LLDAP(写走 GraphQL)。
|
||||
type Executor struct {
|
||||
client *ldaplib.Client
|
||||
gql *lldapgql.Client
|
||||
cfg ExecConfig
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// ExecConfig 是执行器所需的环境参数。
|
||||
type ExecConfig struct {
|
||||
UserOUDN string
|
||||
InactiveOUDN string
|
||||
GroupRootDN string
|
||||
GroupRootName string
|
||||
AdminDN string
|
||||
// InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。
|
||||
InitialPassword string
|
||||
}
|
||||
|
||||
// NewExecutor 构造执行器。
|
||||
func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor {
|
||||
return &Executor{client: client, cfg: cfg, log: log}
|
||||
func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor {
|
||||
return &Executor{gql: gql, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作——
|
||||
// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。
|
||||
func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result {
|
||||
// Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个
|
||||
// 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射,
|
||||
// 会在创建新组时被原地扩充。
|
||||
func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result {
|
||||
res := &Result{}
|
||||
|
||||
conn, err := e.client.Dial()
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
return res
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 确保基础 OU 存在,否则后续写入会失败。
|
||||
//
|
||||
// ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、
|
||||
// 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串
|
||||
// 难以理解的 No Such Object。
|
||||
baseOUs := []struct{ dn, name string }{
|
||||
{e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)},
|
||||
{e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)},
|
||||
{e.cfg.GroupRootDN, e.cfg.GroupRootName},
|
||||
}
|
||||
for _, ou := range baseOUs {
|
||||
if ou.dn == "" {
|
||||
continue
|
||||
}
|
||||
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||
res.Failed++
|
||||
res.Errors = append(res.Errors, err.Error())
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Ops {
|
||||
if err := ctx.Err(); err != nil {
|
||||
res.Errors = append(res.Errors, "同步被取消: "+err.Error())
|
||||
res.Errors = append(res.Errors, "物化被取消: "+err.Error())
|
||||
return res
|
||||
}
|
||||
|
||||
if err := e.applyOne(conn, op); err != nil {
|
||||
if err := e.applyOne(ctx, op, groupIDs); err != nil {
|
||||
res.Failed++
|
||||
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
|
||||
res.Errors = append(res.Errors, msg)
|
||||
e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err)
|
||||
e.log.Error("物化操作失败", "kind", op.Kind, "target", op.Target, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch op.Kind {
|
||||
case OpCreateUser:
|
||||
case OpCreateUser, OpCreateGroup:
|
||||
res.Created++
|
||||
case OpUpdateUser, OpReactivate:
|
||||
case OpUpdateUser:
|
||||
res.Updated++
|
||||
if op.Kind == OpReactivate {
|
||||
res.Moved++
|
||||
}
|
||||
case OpDeactivate:
|
||||
res.Updated++
|
||||
res.Moved++
|
||||
case OpDeleteUser:
|
||||
res.Deleted++
|
||||
case OpAddMember:
|
||||
res.Added++
|
||||
case OpRemoveMember:
|
||||
res.Removed++
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// applyOne 执行单个操作。
|
||||
func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error {
|
||||
func (e *Executor) applyOne(ctx context.Context, op Op, groupIDs map[string]int) error {
|
||||
switch op.Kind {
|
||||
case OpCreateGroup:
|
||||
// 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。
|
||||
return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN)
|
||||
|
||||
case OpUpdateGroup:
|
||||
return conn.UpdateGroup(op.DN, op.Group.Name)
|
||||
// LLDAP 组 displayName 即部门索引;创建后把返回的 groupId 记进映射,
|
||||
// 供后续成员操作用。
|
||||
name := op.Group.DeptID
|
||||
id, err := e.gql.CreateGroup(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs[name] = id
|
||||
return nil
|
||||
|
||||
case OpCreateUser:
|
||||
plain := e.cfg.InitialPassword
|
||||
if plain == "" {
|
||||
generated, err := password.Random()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plain = generated
|
||||
}
|
||||
// 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希,
|
||||
// 直接写明文会让密码以明文形式留在目录里。
|
||||
encoded, err := password.EncodeSSHA(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded)
|
||||
// 创建用户不设密码:密码由 LLDAP 独占、首次登录走 Authelia 忘记密码。
|
||||
return e.gql.CreateUser(ctx, &lldapgql.CreateUserInput{
|
||||
ID: op.User.UID,
|
||||
Email: sp(op.User.Mail),
|
||||
DisplayName: sp(op.User.DisplayName),
|
||||
FirstName: sp(op.User.GivenName),
|
||||
LastName: sp(op.User.SN),
|
||||
})
|
||||
|
||||
case OpUpdateUser:
|
||||
// 若 DN 因 OU 配置变化而不同,先移动再更新属性。
|
||||
if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) {
|
||||
uid := op.User.UID
|
||||
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 读取现状:UpdateUser 需要据此决定哪些属性应当删除,
|
||||
// 对不存在的属性执行 Delete 会导致整个 Modify 失败。
|
||||
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
|
||||
|
||||
case OpDeactivate:
|
||||
// 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录;
|
||||
// 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留,
|
||||
// 便于日后重新入职时原样恢复。
|
||||
uid := op.Target
|
||||
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned)
|
||||
|
||||
case OpReactivate:
|
||||
uid := op.User.UID
|
||||
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
|
||||
// 省略即不改:这里显式带全四个可写硬字段,与 attrsDiffer 判定的差异一致。
|
||||
return e.gql.UpdateUser(ctx, &lldapgql.UpdateUserInput{
|
||||
ID: op.User.UID,
|
||||
Email: sp(op.User.Mail),
|
||||
DisplayName: sp(op.User.DisplayName),
|
||||
FirstName: sp(op.User.GivenName),
|
||||
LastName: sp(op.User.SN),
|
||||
})
|
||||
|
||||
case OpAddMember:
|
||||
return conn.AddMember(op.GroupDN, op.MemberDN)
|
||||
gid, ok := groupIDs[op.Group.DeptID]
|
||||
if !ok {
|
||||
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
|
||||
}
|
||||
return e.gql.AddUserToGroup(ctx, op.MemberUID, gid)
|
||||
|
||||
case OpRemoveMember:
|
||||
return conn.RemoveMember(op.GroupDN, op.MemberDN)
|
||||
gid, ok := groupIDs[op.Group.DeptID]
|
||||
if !ok {
|
||||
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
|
||||
}
|
||||
return e.gql.RemoveUserFromGroup(ctx, op.MemberUID, gid)
|
||||
|
||||
case OpDeleteUser:
|
||||
// LLDAP deleteUser 会级联清理成员关系(外键 CASCADE),无需先移组再删。
|
||||
return e.gql.DeleteUser(ctx, op.Target)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("未知操作类型: %s", op.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// toLDAPUser 把期望状态转成 LDAP 写入结构。
|
||||
func toLDAPUser(u *mapping.User) *ldaplib.User {
|
||||
return &ldaplib.User{
|
||||
UID: u.UID,
|
||||
CN: u.CN,
|
||||
SN: u.SN,
|
||||
GivenName: u.GivenName,
|
||||
DisplayName: u.DisplayName,
|
||||
Mail: u.Mail,
|
||||
Mobile: u.Mobile,
|
||||
EmployeeNumber: u.EmployeeNumber,
|
||||
DepartmentNumber: u.DepartmentNumber,
|
||||
PostalAddress: u.PostalAddress,
|
||||
BusinessCategory: u.BusinessCategory,
|
||||
EmployeeType: u.EmployeeType,
|
||||
Description: u.Description,
|
||||
}
|
||||
}
|
||||
|
||||
// ouName 从 OU 的 DN 中取出 ou 的值。
|
||||
func ouName(dn string) string {
|
||||
first := dn
|
||||
if i := strings.Index(dn, ","); i >= 0 {
|
||||
first = dn[:i]
|
||||
}
|
||||
if i := strings.Index(first, "="); i >= 0 {
|
||||
return first[i+1:]
|
||||
}
|
||||
return first
|
||||
}
|
||||
// sp 生成 *string。
|
||||
func sp(s string) *string { return &s }
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build integration
|
||||
|
||||
// 物化(阶段6)对真实 LLDAP 的端到端集成测试。
|
||||
//
|
||||
// 直接向 store 镜像灌数据(不经飞书快照),跑 Runner 物化,验证写落 LLDAP:
|
||||
// 建用户/建组/加成员/幂等,以及 confirmed_leave → 物理删除。读侧用 LDAP 校验。
|
||||
//
|
||||
// 依赖:本地 LLDAP 容器(LDAP 3890 / GraphQL 17170,admin/adminpass)。
|
||||
package syncer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/lldapgql"
|
||||
"idsync/internal/store"
|
||||
"idsync/internal/syncer"
|
||||
)
|
||||
|
||||
func matTestCfg() *config.Config {
|
||||
return &config.Config{
|
||||
Feishu: config.Feishu{AppID: "x", AppSecret: "y"},
|
||||
Server: config.Server{AdminUser: "admin", AdminPassword: "x"},
|
||||
Ldap: config.Ldap{
|
||||
URL: "ldap://127.0.0.1:3890", BaseDN: "dc=example,dc=com",
|
||||
AdminDN: "uid=admin,ou=people,dc=example,dc=com", AdminPass: "adminpass",
|
||||
TimeoutSec: 10,
|
||||
},
|
||||
Lldap: config.Lldap{BaseURL: "http://127.0.0.1:17170", AdminUser: "admin", AdminPass: "adminpass"},
|
||||
}
|
||||
}
|
||||
|
||||
func matSetup(t *testing.T) (*syncer.Runner, *store.Store, *ldaplib.Client, *lldapgql.Client, func()) {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
cfg := matTestCfg()
|
||||
if err := cfg.NormalizeForTest(); err != nil {
|
||||
t.Fatalf("配置规范化失败: %v", err)
|
||||
}
|
||||
|
||||
// 先探写通道是否可用,不可用就跳过(避免误报)。
|
||||
gql := lldapgql.New(cfg.Lldap.BaseURL, log)
|
||||
if err := gql.Login(context.Background(), cfg.Lldap.AdminUser, cfg.Lldap.AdminPass); err != nil {
|
||||
t.Skipf("LLDAP 不可用,跳过物化集成测试: %v", err)
|
||||
}
|
||||
|
||||
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 LDAP 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
fc, _ := feishu.New(feishu.Options{AppID: "x", AppSecret: "y", QPS: 100, Timeout: 5 * time.Second}, log)
|
||||
|
||||
st, err := store.Open(t.TempDir() + "/idsync.db")
|
||||
if err != nil {
|
||||
t.Fatalf("打开 store 失败: %v", err)
|
||||
}
|
||||
|
||||
runner := syncer.NewRunner(cfg, fc, ldapClient, gql, st, log, syncer.SnapshotOptions{})
|
||||
|
||||
cleanup := func() { _ = st.Close() }
|
||||
return runner, st, ldapClient, gql, cleanup
|
||||
}
|
||||
|
||||
// seedMirror 灌一版镜像:od-acme/od-rd 两个部门,alice/bob 属 od-rd。
|
||||
func seedMirror(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
base := "dc=example,dc=com"
|
||||
err := st.ReplaceMirror(&store.MirrorSnapshot{
|
||||
Departments: []*store.MirrorDepartment{
|
||||
{DeptID: "od-acme", Name: "Acme", DN: "cn=od-acme,ou=groups," + base, Level: 0},
|
||||
{DeptID: "od-rd", Name: "研发部", DN: "cn=od-rd,ou=groups," + base, Level: 1, ParentID: "od-acme"},
|
||||
},
|
||||
Users: []*store.MirrorUser{
|
||||
{UID: "alice", UnionID: "u-alice", Email: "alice@example.com", DisplayName: "Alice",
|
||||
GivenName: "Alice", SN: "Alice", DepartmentIDs: []string{"od-rd"}},
|
||||
{UID: "bob", UnionID: "u-bob", Email: "bob@example.com", DisplayName: "Bob",
|
||||
GivenName: "Bob", SN: "Bob", DepartmentIDs: []string{"od-rd"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("灌镜像失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf2[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMaterializeAgainstLLDAP 验证物化把镜像在职视图写落 LLDAP,并支持离职删除。
|
||||
func TestMaterializeAgainstLLDAP(t *testing.T) {
|
||||
runner, st, ldapClient, gql, cleanup := matSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
seedMirror(t, st)
|
||||
|
||||
// 第 1 轮:dry-run 应给出创建计划。
|
||||
plan, err := runner.Preview(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Preview 失败: %v", err)
|
||||
}
|
||||
if plan.Stats.CreateUser != 2 || plan.Stats.CreateGroup != 2 || plan.Stats.AddMember != 2 {
|
||||
t.Fatalf("首轮计划统计异常: %+v", plan.Stats)
|
||||
}
|
||||
|
||||
// 第 2 轮:真正执行。
|
||||
_, res, err := runner.Run(t.Context(), false, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("物化执行失败: %v", err)
|
||||
}
|
||||
if res.Failed != 0 {
|
||||
t.Fatalf("物化有 %d 个失败: %v", res.Failed, res.Errors)
|
||||
}
|
||||
|
||||
// 用 LDAP 读校验:两个用户、两个组、alice/bob 都是 od-rd 成员。
|
||||
conn, err := ldapClient.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("LDAP 连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
users, err := conn.ListUsers("ou=people,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("读用户失败: %v", err)
|
||||
}
|
||||
for _, uid := range []string{"alice", "bob"} {
|
||||
if _, ok := users[uid]; !ok {
|
||||
t.Errorf("物化后 LLDAP 缺少用户 %s", uid)
|
||||
}
|
||||
}
|
||||
groups, err := conn.ListGroups("ou=groups,dc=example,dc=com")
|
||||
if err != nil {
|
||||
t.Fatalf("读组失败: %v", err)
|
||||
}
|
||||
rd := "cn=od-rd,ou=groups,dc=example,dc=com"
|
||||
g, ok := groups[rd]
|
||||
if !ok {
|
||||
t.Fatalf("物化后缺少组 %s;现有 %v", rd, keysOf2(groups))
|
||||
}
|
||||
members := g.Members()
|
||||
for _, uid := range []string{"alice", "bob"} {
|
||||
if _, has := members["uid="+uid+",ou=people,dc=example,dc=com"]; !has {
|
||||
t.Errorf("%s 未加入 %s;成员 %v", uid, rd, members)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:立即再 preview 不应有任何操作。
|
||||
plan, err = runner.Preview(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("幂等 Preview 失败: %v", err)
|
||||
}
|
||||
if n := plan.Stats.CreateUser + plan.Stats.CreateGroup + plan.Stats.AddMember +
|
||||
plan.Stats.UpdateUser + plan.Stats.RemoveMember + plan.Stats.DeleteUser; n != 0 {
|
||||
t.Fatalf("重复物化应幂等,却产生 %d 个操作", n)
|
||||
}
|
||||
|
||||
// 离职:把 bob 走完离职状态机到 confirmed_leave,物化应物理删除 bob。
|
||||
for _, fn := range []func() error{
|
||||
func() error { return st.ToLeaveCandidate("bob") },
|
||||
func() error { return st.ToPendingConfirm("bob") },
|
||||
func() error { return st.ConfirmLeave("bob") },
|
||||
} {
|
||||
if err := fn(); err != nil {
|
||||
t.Fatalf("离职状态机转移失败: %v", err)
|
||||
}
|
||||
}
|
||||
_, res, err = runner.Run(t.Context(), false, false, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("离职物化失败: %v", err)
|
||||
}
|
||||
if res.Deleted != 1 {
|
||||
t.Fatalf("应按 confirmed_leave 删除 1 个用户,实际 deleted=%d", res.Deleted)
|
||||
}
|
||||
users, _ = conn.ListUsers("ou=people,dc=example,dc=com")
|
||||
if _, still := users["bob"]; still {
|
||||
t.Errorf("confirmed_leave 用户 bob 应被物理删除,仍存在")
|
||||
}
|
||||
if _, alive := users["alice"]; !alive {
|
||||
t.Errorf("alice 不应被删除")
|
||||
}
|
||||
|
||||
// 清理:删掉本次建的组与剩余用户,避免污染共享容器。
|
||||
gid := 0
|
||||
deps, _ := st.MapDepartmentsByID()
|
||||
if d := deps["od-rd"]; d != nil {
|
||||
gid = d.GroupID
|
||||
}
|
||||
if gid > 0 {
|
||||
_ = gql.DeleteGroup(context.Background(), gid)
|
||||
}
|
||||
_ = gql.DeleteUser(context.Background(), "alice")
|
||||
_ = gql.DeleteUser(context.Background(), "bob")
|
||||
_ = gql.DeleteUser(context.Background(), "admin")
|
||||
}
|
||||
+171
-295
@@ -1,10 +1,13 @@
|
||||
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
|
||||
// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
|
||||
//
|
||||
// 设计要点:
|
||||
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
|
||||
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
|
||||
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
|
||||
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
|
||||
// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
|
||||
// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
|
||||
// 执行共用同一份代码——预览即所得。
|
||||
//
|
||||
// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
|
||||
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
|
||||
// 状态才会生成删除操作,且删除永远排在最后一阶段。
|
||||
|
||||
package syncer
|
||||
|
||||
import (
|
||||
@@ -24,10 +27,8 @@ type OpKind string
|
||||
const (
|
||||
OpCreateUser OpKind = "create_user"
|
||||
OpUpdateUser OpKind = "update_user"
|
||||
OpDeactivate OpKind = "deactivate_user"
|
||||
OpReactivate OpKind = "reactivate_user"
|
||||
OpDeleteUser OpKind = "delete_user"
|
||||
OpCreateGroup OpKind = "create_group"
|
||||
OpUpdateGroup OpKind = "update_group"
|
||||
OpAddMember OpKind = "add_member"
|
||||
OpRemoveMember OpKind = "remove_member"
|
||||
)
|
||||
@@ -37,30 +38,25 @@ type Op struct {
|
||||
Kind OpKind `json:"kind"`
|
||||
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
|
||||
Target string `json:"target"`
|
||||
// Name 是展示名,方便界面阅读。
|
||||
// Name 是展示名(用户中文名 / 部门中文名)。
|
||||
Name string `json:"name"`
|
||||
// DN 是操作涉及的条目 DN(新建时为目标 DN)。
|
||||
// DN 是操作涉及的组/用户 DN。
|
||||
DN string `json:"dn,omitempty"`
|
||||
// Detail 是人类可读的说明,直接展示在预览页。
|
||||
Detail string `json:"detail"`
|
||||
|
||||
// 以下字段供执行器使用,不参与 JSON 展示。
|
||||
User *mapping.User `json:"-"`
|
||||
Group *mapping.Group `json:"-"`
|
||||
MemberDN string `json:"-"`
|
||||
GroupDN string `json:"-"`
|
||||
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。
|
||||
TargetDN string `json:"-"`
|
||||
User *mapping.DesiredUser `json:"-"`
|
||||
Group *mapping.DesiredGroup `json:"-"`
|
||||
MemberUID string `json:"-"`
|
||||
AttrsHash string `json:"-"`
|
||||
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
|
||||
// (例如父分组必须先于子分组创建)。
|
||||
// Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
|
||||
Seq int `json:"-"`
|
||||
}
|
||||
|
||||
// Plan 是一次同步的完整计划。
|
||||
// Plan 是一次物化的完整计划。
|
||||
type Plan struct {
|
||||
Ops []Op `json:"ops"`
|
||||
// Warnings 是不阻塞执行、但需要人工留意的信息。
|
||||
Warnings []string `json:"warnings"`
|
||||
Stats Stats `json:"stats"`
|
||||
}
|
||||
@@ -69,67 +65,54 @@ type Plan struct {
|
||||
type Stats struct {
|
||||
CreateUser int `json:"createUser"`
|
||||
UpdateUser int `json:"updateUser"`
|
||||
Deactivate int `json:"deactivateUser"`
|
||||
Reactivate int `json:"reactivateUser"`
|
||||
DeleteUser int `json:"deleteUser"`
|
||||
CreateGroup int `json:"createGroup"`
|
||||
UpdateGroup int `json:"updateGroup"`
|
||||
AddMember int `json:"addMember"`
|
||||
RemoveMember int `json:"removeMember"`
|
||||
TotalAccounts int `json:"totalAccounts"`
|
||||
TotalUsers int `json:"totalUsers"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
}
|
||||
|
||||
// Input 是计算计划所需的全部输入。
|
||||
// Input 是计算物化计划所需的全部输入。
|
||||
type Input struct {
|
||||
// Desired 是期望状态(来自飞书)
|
||||
Desired *mapping.State
|
||||
// Active 是 ou=people 下现有用户:uid(小写)→ 条目
|
||||
Active map[string]*ldaplib.Entry
|
||||
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目
|
||||
Inactive map[string]*ldaplib.Entry
|
||||
// Groups 是现有分组:DN(小写)→ 条目
|
||||
Groups map[string]*ldaplib.Entry
|
||||
// LedgerAccounts 是台账中的账号:uid → state(active/inactive)
|
||||
LedgerAccounts map[string]string
|
||||
// LedgerGroups 是台账中的分组:dept_id → dn
|
||||
LedgerGroups map[string]string
|
||||
// AdminDN 与相关基础 DN
|
||||
// Desired 是期望状态(来自镜像 BuildFromMirror)。
|
||||
Desired *mapping.DesiredState
|
||||
// ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。
|
||||
ActualUsers map[string]*ldaplib.Entry
|
||||
// ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。
|
||||
ActualGroups map[string]*ldaplib.Entry
|
||||
// OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。
|
||||
OwnedUsers map[string]struct{}
|
||||
// ConfirmedLeave 是已人工确认离职、待删除的用户集。
|
||||
ConfirmedLeave map[string]bool
|
||||
// AdminDN 与基础 OU DN。
|
||||
AdminDN string
|
||||
PeopleOU string
|
||||
GroupRootDN string
|
||||
UserOUDN string
|
||||
InactiveOUDN string
|
||||
}
|
||||
|
||||
// opPhase 决定操作的执行顺序。
|
||||
// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
|
||||
//
|
||||
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
|
||||
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
|
||||
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
|
||||
// 集成测试立刻暴露了这个问题。
|
||||
// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
|
||||
// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
|
||||
func opPhase(k OpKind) int {
|
||||
switch k {
|
||||
case OpCreateGroup, OpUpdateGroup:
|
||||
case OpCreateGroup:
|
||||
return 0
|
||||
case OpCreateUser, OpUpdateUser:
|
||||
return 1
|
||||
case OpReactivate:
|
||||
return 2
|
||||
case OpAddMember:
|
||||
return 3
|
||||
return 2
|
||||
case OpRemoveMember:
|
||||
return 3
|
||||
case OpDeleteUser:
|
||||
return 4
|
||||
case OpDeactivate:
|
||||
return 5
|
||||
default:
|
||||
return 6
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
// sortOps 按依赖顺序排列操作。
|
||||
//
|
||||
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
|
||||
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
|
||||
// “od-root” 之前,导致子分组先于父分组创建而失败)。
|
||||
// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
|
||||
func sortOps(ops []Op) {
|
||||
sort.SliceStable(ops, func(i, j int) bool {
|
||||
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
|
||||
@@ -140,288 +123,171 @@ func sortOps(ops []Op) {
|
||||
})
|
||||
}
|
||||
|
||||
// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。
|
||||
// ComputePlan 计算物化计划。它是纯函数,不产生任何副作用。
|
||||
func ComputePlan(in *Input) *Plan {
|
||||
p := &Plan{
|
||||
Ops: make([]Op, 0, 64),
|
||||
Warnings: append([]string{}, in.Desired.Warnings...),
|
||||
Stats: Stats{
|
||||
TotalAccounts: len(in.LedgerAccounts),
|
||||
},
|
||||
Warnings: make([]string, 0),
|
||||
Stats: Stats{TotalUsers: len(in.ActualUsers)},
|
||||
}
|
||||
|
||||
planGroups(p, in)
|
||||
planUsers(p, in)
|
||||
planMembership(p, in)
|
||||
|
||||
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
|
||||
// (mapping 已按父先于子的顺序产出分组)。
|
||||
for i := range p.Ops {
|
||||
p.Ops[i].Seq = i
|
||||
}
|
||||
sortOps(p.Ops)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// planGroups 处理分组的创建与更新。
|
||||
// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
|
||||
func planGroups(p *Plan, in *Input) {
|
||||
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
|
||||
for _, g := range in.Desired.Groups {
|
||||
existing, exists := in.Groups[strings.ToLower(g.DN)]
|
||||
desired[strings.ToLower(g.DN)] = g
|
||||
}
|
||||
|
||||
if !exists {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpCreateGroup,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
DN: g.DN,
|
||||
Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN),
|
||||
Group: g,
|
||||
})
|
||||
p.Stats.CreateGroup++
|
||||
for _, g := range in.Desired.Groups {
|
||||
if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 分组已存在:仅当描述不一致时更新。
|
||||
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮
|
||||
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理,
|
||||
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。
|
||||
if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateGroup,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
DN: g.DN,
|
||||
Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name),
|
||||
Group: g,
|
||||
Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g,
|
||||
Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN),
|
||||
})
|
||||
p.Stats.UpdateGroup++
|
||||
}
|
||||
p.Stats.CreateGroup++
|
||||
}
|
||||
|
||||
// 台账中存在、但期望状态里已消失的分组:不自动删除。
|
||||
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
|
||||
wantedDept := make(map[string]string, len(in.Desired.Groups))
|
||||
for _, g := range in.Desired.Groups {
|
||||
wantedDept[g.DeptID] = g.DN
|
||||
// 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
|
||||
for _, e := range in.ActualGroups {
|
||||
if _, ok := desired[strings.ToLower(e.DN)]; ok {
|
||||
continue
|
||||
}
|
||||
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))
|
||||
}
|
||||
fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN))
|
||||
}
|
||||
}
|
||||
|
||||
// planUsers 处理用户的创建、更新、停用与恢复。
|
||||
// planUsers 处理用户的创建、更新与删除。
|
||||
func planUsers(p *Plan, in *Input) {
|
||||
desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users))
|
||||
desiredByUID := make(map[string]*mapping.DesiredUser, len(in.Desired.Users))
|
||||
for _, u := range in.Desired.Users {
|
||||
desiredByUID[u.UID] = u
|
||||
}
|
||||
|
||||
for _, u := range in.Desired.Users {
|
||||
_, inActiveOU := in.Active[u.UID]
|
||||
_, inInactiveOU := in.Inactive[u.UID]
|
||||
|
||||
switch {
|
||||
case inActiveOU:
|
||||
// 已存在且在职:比对属性是否漂移。
|
||||
existing := in.Active[u.UID]
|
||||
canonical := strings.ToLower(existing.DN)
|
||||
|
||||
// DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。
|
||||
// 用大小写无关比较:LDAP 服务端可能对 DN 做规范化,
|
||||
// 直接字符串比较会造成每轮都产生无意义的“更新”。
|
||||
if !strings.EqualFold(existing.DN, u.DN) {
|
||||
existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
|
||||
if !ok {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN),
|
||||
User: u,
|
||||
TargetDN: canonical,
|
||||
Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
|
||||
User: u, AttrsHash: AttrsHash(u),
|
||||
Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName),
|
||||
})
|
||||
p.Stats.UpdateUser++
|
||||
p.Stats.CreateUser++
|
||||
continue
|
||||
}
|
||||
|
||||
if attrsDiffer(existing, u) {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpUpdateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: "用户属性发生变化:" + describeAttrDiff(existing, u),
|
||||
User: u,
|
||||
TargetDN: canonical,
|
||||
Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
|
||||
User: u, AttrsHash: AttrsHash(u),
|
||||
Detail: "用户属性变化:" + describeAttrDiff(existing, u),
|
||||
})
|
||||
p.Stats.UpdateUser++
|
||||
continue
|
||||
}
|
||||
p.Stats.Unchanged++
|
||||
}
|
||||
|
||||
case inInactiveOU:
|
||||
// 存在于 ou=Inactive 但期望是在职:恢复。
|
||||
// 仅当台账确认该账号由我们管理时才动它。
|
||||
if _, managed := in.LedgerAccounts[u.UID]; !managed {
|
||||
// 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
|
||||
// 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
|
||||
for uid := range in.ActualUsers {
|
||||
if _, want := desiredByUID[uid]; want {
|
||||
continue
|
||||
}
|
||||
if _, owned := in.OwnedUsers[uid]; !owned {
|
||||
continue
|
||||
}
|
||||
if !in.ConfirmedLeave[uid] {
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
|
||||
fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpReactivate,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
|
||||
User: u,
|
||||
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
|
||||
Kind: OpDeleteUser, Target: uid,
|
||||
Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
|
||||
DN: ldaplib.UserDN(uid, in.PeopleOU),
|
||||
Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
|
||||
})
|
||||
p.Stats.Reactivate++
|
||||
|
||||
default:
|
||||
// 两处都没有:新建。
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpCreateUser,
|
||||
Target: u.UID,
|
||||
Name: u.DisplayName,
|
||||
DN: u.DN,
|
||||
Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN),
|
||||
User: u,
|
||||
})
|
||||
p.Stats.CreateUser++
|
||||
}
|
||||
}
|
||||
|
||||
// 台账中在职、但期望状态里已消失:候选停用。
|
||||
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
|
||||
for uid, state := range in.LedgerAccounts {
|
||||
if state != "active" {
|
||||
continue
|
||||
}
|
||||
if _, ok := desiredByUID[uid]; ok {
|
||||
continue
|
||||
}
|
||||
entry, ok := in.Active[uid]
|
||||
if !ok {
|
||||
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpDeactivate,
|
||||
Target: uid,
|
||||
Name: entry.Get(ldaplib.AttrDisplayName),
|
||||
DN: ldaplib.UserDN(uid, in.InactiveOUDN),
|
||||
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
|
||||
TargetDN: strings.ToLower(entry.DN),
|
||||
})
|
||||
p.Stats.Deactivate++
|
||||
p.Stats.DeleteUser++
|
||||
}
|
||||
}
|
||||
|
||||
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
|
||||
// planMembership 比对组的成员,产出加组/移组操作。
|
||||
func planMembership(p *Plan, in *Input) {
|
||||
for _, g := range in.Desired.Groups {
|
||||
existing, ok := in.Groups[strings.ToLower(g.DN)]
|
||||
if !ok {
|
||||
// 分组是新建的,成员关系会在创建后由执行器处理;
|
||||
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。
|
||||
for memberDN := range g.Members {
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpAddMember,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: g.DN,
|
||||
MemberDN: memberDN,
|
||||
})
|
||||
p.Stats.AddMember++
|
||||
}
|
||||
continue
|
||||
e, ok := in.ActualGroups[strings.ToLower(g.DN)]
|
||||
actual := make(map[string]struct{})
|
||||
if ok {
|
||||
actual = e.Members()
|
||||
}
|
||||
desired := lowerMembers(g.Members)
|
||||
|
||||
actual := existing.Members()
|
||||
|
||||
// 期望有、实际没有 → 加入
|
||||
for memberDN := range g.Members {
|
||||
if _, has := actual[strings.ToLower(memberDN)]; has {
|
||||
continue
|
||||
}
|
||||
uid, ok := uidFromDN(memberDN)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpAddMember,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: existing.DN,
|
||||
MemberDN: memberDN,
|
||||
Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
|
||||
Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
|
||||
})
|
||||
p.Stats.AddMember++
|
||||
}
|
||||
|
||||
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 移出:实际有、期望没有,且只移受管、永不移 admin。
|
||||
for memberDN := range actual {
|
||||
if _, want := g.Members[memberDN]; want {
|
||||
if _, want := desired[memberDN]; want {
|
||||
continue
|
||||
}
|
||||
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
|
||||
continue
|
||||
}
|
||||
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
|
||||
if !isManagedMember(memberDN, in) {
|
||||
uid, ok := uidFromDN(memberDN)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, managed := in.OwnedUsers[uid]; !managed {
|
||||
p.Warnings = append(p.Warnings,
|
||||
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
|
||||
fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
|
||||
continue
|
||||
}
|
||||
p.Ops = append(p.Ops, Op{
|
||||
Kind: OpRemoveMember,
|
||||
Target: g.DeptID,
|
||||
Name: g.Name,
|
||||
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
|
||||
Group: g,
|
||||
GroupDN: existing.DN,
|
||||
MemberDN: memberDN,
|
||||
Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
|
||||
Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
|
||||
})
|
||||
p.Stats.RemoveMember++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
|
||||
//
|
||||
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
|
||||
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
|
||||
func isManagedMember(memberDN string, in *Input) bool {
|
||||
uid, ok := uidFromDN(memberDN)
|
||||
if !ok {
|
||||
return false
|
||||
// lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。
|
||||
func lowerMembers(m map[string]struct{}) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(m))
|
||||
for dn := range m {
|
||||
out[strings.ToLower(dn)] = struct{}{}
|
||||
}
|
||||
if _, managed := in.LedgerAccounts[uid]; managed {
|
||||
return true
|
||||
}
|
||||
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
|
||||
for _, u := range in.Desired.Users {
|
||||
if u.UID == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return out
|
||||
}
|
||||
|
||||
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。
|
||||
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
|
||||
func uidFromDN(dn string) (string, bool) {
|
||||
first := dn
|
||||
if i := strings.Index(dn, ","); i >= 0 {
|
||||
@@ -434,33 +300,19 @@ func uidFromDN(dn string) (string, bool) {
|
||||
return strings.ToLower(strings.TrimSpace(first[4:])), true
|
||||
}
|
||||
|
||||
// memberRDN 从成员 DN 中提取用于展示的标识。
|
||||
func memberRDN(dn string) string {
|
||||
if uid, ok := uidFromDN(dn); ok {
|
||||
return uid
|
||||
}
|
||||
return dn
|
||||
}
|
||||
|
||||
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
|
||||
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
|
||||
// attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。
|
||||
//
|
||||
// LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName);
|
||||
// 默认不建自定义属性,因此只比对这四类可写硬字段。
|
||||
func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool {
|
||||
checks := []struct {
|
||||
attr string
|
||||
want string
|
||||
}{
|
||||
{ldaplib.AttrCN, want.CN},
|
||||
{ldaplib.AttrSN, want.SN},
|
||||
{ldaplib.AttrGivenName, want.GivenName},
|
||||
{ldaplib.AttrDisplayName, want.DisplayName},
|
||||
{ldaplib.AttrMail, want.Mail},
|
||||
{ldaplib.AttrMobile, want.Mobile},
|
||||
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||
{ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||
{ldaplib.AttrDescription, want.Description},
|
||||
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
|
||||
{ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||
{ldaplib.AttrDisplayName, want.DisplayName},
|
||||
{ldaplib.AttrGivenName, want.GivenName},
|
||||
{ldaplib.AttrSN, want.SN},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
|
||||
@@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
|
||||
}
|
||||
|
||||
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
|
||||
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
|
||||
fields := []struct {
|
||||
label string
|
||||
attr string
|
||||
@@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
}{
|
||||
{"mail", ldaplib.AttrMail, want.Mail},
|
||||
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
|
||||
{"mobile", ldaplib.AttrMobile, want.Mobile},
|
||||
{"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||
{"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||
{"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||
{"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||
{"givenName", ldaplib.AttrGivenName, want.GivenName},
|
||||
{"sn", ldaplib.AttrSN, want.SN},
|
||||
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||
}
|
||||
|
||||
var diffs []string
|
||||
for _, f := range fields {
|
||||
old := existing.Get(f.attr)
|
||||
@@ -498,27 +343,58 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
|
||||
}
|
||||
if len(diffs) == 0 {
|
||||
return "属性无变化"
|
||||
return "无变化"
|
||||
}
|
||||
return strings.Join(diffs, "; ")
|
||||
}
|
||||
|
||||
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
|
||||
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
|
||||
func normalizeAttr(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
|
||||
func AttrsHash(u *mapping.User) string {
|
||||
// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
|
||||
func AttrsHash(u *mapping.DesiredUser) string {
|
||||
h := sha256.New()
|
||||
for _, v := range []string{
|
||||
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
|
||||
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
|
||||
u.BusinessCategory, u.Description,
|
||||
} {
|
||||
for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} {
|
||||
h.Write([]byte(v))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Guard 是执行前的安全阈值。
|
||||
type Guard struct {
|
||||
// MaxDeactivate 单次允许删除的最大账号数,0 表示不限制。
|
||||
MaxDeactivate int
|
||||
// MaxDeactivatePercent 单次允许删除的账号比例(0-1),0 表示不限制。
|
||||
MaxDeactivatePercent float64
|
||||
// AllowBulk 显式跳过全部阈值检查。
|
||||
AllowBulk bool
|
||||
}
|
||||
|
||||
// GuardError 表示计划未通过安全阈值检查。
|
||||
type GuardError struct{ Message string }
|
||||
|
||||
func (e *GuardError) Error() string { return e.Message }
|
||||
|
||||
// Check 校验计划是否满足安全阈值。
|
||||
func (g Guard) Check(p *Plan) error {
|
||||
if g.AllowBulk {
|
||||
return nil
|
||||
}
|
||||
deletes := p.Stats.DeleteUser
|
||||
if deletes == 0 {
|
||||
return nil
|
||||
}
|
||||
if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate {
|
||||
return &GuardError{Message: fmt.Sprintf(
|
||||
"本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)}
|
||||
}
|
||||
if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 {
|
||||
ratio := float64(deletes) / float64(p.Stats.TotalUsers)
|
||||
if ratio > g.MaxDeactivatePercent {
|
||||
return &GuardError{Message: fmt.Sprintf(
|
||||
"本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
|
||||
deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+169
-266
@@ -9,43 +9,42 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
testUserOU = "ou=people,dc=example,dc=com"
|
||||
testInactiveOU = "ou=Inactive,dc=example,dc=com"
|
||||
testGroupRoot = "ou=feishuroot,dc=example,dc=com"
|
||||
testAdminDN = "cn=admin,dc=example,dc=com"
|
||||
peopleOU = "ou=people,dc=example,dc=com"
|
||||
groupRoot = "ou=groups,dc=example,dc=com"
|
||||
testAdmin = "uid=admin," + peopleOU
|
||||
)
|
||||
|
||||
// entry 构造一个 LDAP 条目,便于测试。
|
||||
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
|
||||
return ldaplib.NewEntry(dn, attrs, members)
|
||||
}
|
||||
|
||||
func newUser(uid string) *mapping.User {
|
||||
return &mapping.User{
|
||||
UID: uid,
|
||||
DN: "uid=" + uid + "," + testUserOU,
|
||||
InactiveDN: "uid=" + uid + "," + testInactiveOU,
|
||||
CN: uid,
|
||||
SN: uid,
|
||||
GivenName: uid,
|
||||
DisplayName: uid,
|
||||
Mail: uid + "@example.com",
|
||||
Description: uid,
|
||||
func dUser(uid string) *mapping.DesiredUser {
|
||||
return &mapping.DesiredUser{
|
||||
UID: uid, DN: "uid=" + uid + "," + peopleOU,
|
||||
Mail: uid + "@example.com", DisplayName: uid, GivenName: uid, SN: uid,
|
||||
}
|
||||
}
|
||||
|
||||
func baseInput(desired *mapping.State) *Input {
|
||||
func dGroup(deptID, dn string, members ...string) *mapping.DesiredGroup {
|
||||
ms := make(map[string]struct{}, len(members))
|
||||
for _, m := range members {
|
||||
ms[m] = struct{}{}
|
||||
}
|
||||
return &mapping.DesiredGroup{DeptID: deptID, DN: dn, Name: depName(deptID), Members: ms}
|
||||
}
|
||||
|
||||
func depName(deptID string) string { return "部门-" + deptID }
|
||||
|
||||
func baseIn(desired *mapping.DesiredState) *Input {
|
||||
return &Input{
|
||||
Desired: desired,
|
||||
Active: map[string]*ldaplib.Entry{},
|
||||
Inactive: map[string]*ldaplib.Entry{},
|
||||
Groups: map[string]*ldaplib.Entry{},
|
||||
LedgerAccounts: map[string]string{},
|
||||
LedgerGroups: map[string]string{},
|
||||
AdminDN: testAdminDN,
|
||||
GroupRootDN: testGroupRoot,
|
||||
UserOUDN: testUserOU,
|
||||
InactiveOUDN: testInactiveOU,
|
||||
ActualUsers: map[string]*ldaplib.Entry{},
|
||||
ActualGroups: map[string]*ldaplib.Entry{},
|
||||
OwnedUsers: map[string]struct{}{},
|
||||
ConfirmedLeave: map[string]bool{},
|
||||
AdminDN: testAdmin,
|
||||
PeopleOU: peopleOU,
|
||||
GroupRootDN: groupRoot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,313 +56,224 @@ func kindsOf(plan *Plan) map[OpKind]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func plainDesired() *mapping.DesiredState {
|
||||
return &mapping.DesiredState{Users: []*mapping.DesiredUser{}, Groups: []*mapping.DesiredGroup{}}
|
||||
}
|
||||
|
||||
func TestComputePlan(t *testing.T) {
|
||||
rdDN := "cn=od-rd," + groupRoot
|
||||
tests := []struct {
|
||||
name string
|
||||
desired *mapping.State
|
||||
desired *mapping.DesiredState
|
||||
mutate func(in *Input)
|
||||
wantKinds map[OpKind]int
|
||||
// wantDetailContains 断言某个操作详情包含特定文本
|
||||
wantDetailContains string
|
||||
// forbidKinds 断言这些操作绝不出现(安全性回归)
|
||||
forbidKinds []OpKind
|
||||
forbid []OpKind
|
||||
wantWarn string
|
||||
}{
|
||||
{
|
||||
name: "全新用户:创建",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("alice")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withUsers(plainDesired(), dUser("alice")),
|
||||
wantKinds: map[OpKind]int{OpCreateUser: 1},
|
||||
},
|
||||
{
|
||||
name: "属性一致:不产生操作",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("alice")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withUsers(plainDesired(), dUser("alice")),
|
||||
mutate: func(in *Input) {
|
||||
u := newUser("alice")
|
||||
in.Active["alice"] = entry(u.DN, map[string]string{
|
||||
ldaplib.AttrCN: u.CN,
|
||||
ldaplib.AttrSN: u.SN,
|
||||
ldaplib.AttrGivenName: u.GivenName,
|
||||
ldaplib.AttrDisplayName: u.DisplayName,
|
||||
ldaplib.AttrMail: u.Mail,
|
||||
ldaplib.AttrMobile: u.Mobile,
|
||||
ldaplib.AttrEmployeeNumber: u.EmployeeNumber,
|
||||
ldaplib.AttrDepartmentNumber: u.DepartmentNumber,
|
||||
ldaplib.AttrPostalAddress: u.PostalAddress,
|
||||
ldaplib.AttrBusinessCategory: u.BusinessCategory,
|
||||
ldaplib.AttrDescription: u.Description,
|
||||
u := dUser("alice")
|
||||
in.ActualUsers["alice"] = entry(u.DN, map[string]string{
|
||||
ldaplib.AttrMail: u.Mail, ldaplib.AttrDisplayName: u.DisplayName,
|
||||
ldaplib.AttrGivenName: u.GivenName, ldaplib.AttrSN: u.SN,
|
||||
}, nil)
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
},
|
||||
{
|
||||
name: "邮箱变化:更新属性",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{
|
||||
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withUsers(plainDesired(), func() *mapping.DesiredUser { u := dUser("alice"); u.Mail = "new@example.com"; return u }()),
|
||||
mutate: func(in *Input) {
|
||||
u := newUser("alice")
|
||||
in.Active["alice"] = entry(u.DN, map[string]string{
|
||||
ldaplib.AttrCN: u.CN,
|
||||
ldaplib.AttrDisplayName: u.DisplayName,
|
||||
ldaplib.AttrMail: "old@example.com",
|
||||
in.ActualUsers["alice"] = entry("uid=alice,"+peopleOU, map[string]string{
|
||||
ldaplib.AttrMail: "old@example.com", ldaplib.AttrDisplayName: "alice",
|
||||
ldaplib.AttrGivenName: "alice", ldaplib.AttrSN: "alice",
|
||||
}, nil)
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpUpdateUser: 1},
|
||||
wantDetailContains: "alice.new@example.com",
|
||||
},
|
||||
{
|
||||
name: "台账在职但飞书消失:停用",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、台账、已确认离职,但不在期望:删除",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Bob",
|
||||
}, nil)
|
||||
in.LedgerAccounts["bob"] = "active"
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Bob"}, nil)
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
in.ConfirmedLeave["bob"] = true
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpDeactivate: 1},
|
||||
wantKinds: map[OpKind]int{OpDeleteUser: 1},
|
||||
},
|
||||
{
|
||||
name: "非台账账号缺席:不得停用(关键安全回归)",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、台账,但未确认离职:绝不删除(关键安全回归)",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
|
||||
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Service",
|
||||
}, nil)
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpDeactivate},
|
||||
forbid: []OpKind{OpDeleteUser},
|
||||
wantWarn: "未完成离职确认",
|
||||
},
|
||||
{
|
||||
name: "在 Inactive 且台账受管:恢复",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("carol")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "在 LLDAP、非受管(服务账号):绝不删除",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
|
||||
ldaplib.AttrDisplayName: "Carol",
|
||||
}, nil)
|
||||
in.LedgerAccounts["carol"] = "inactive"
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpReactivate: 1},
|
||||
},
|
||||
{
|
||||
name: "在 Inactive 但非台账:只告警不改动",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{newUser("dave")},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
mutate: func(in *Input) {
|
||||
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
|
||||
in.ActualUsers["svc"] = entry("uid=svc,"+peopleOU, map[string]string{}, nil)
|
||||
in.ConfirmedLeave["svc"] = true // 即使被标记也无效:非受管
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpReactivate},
|
||||
forbid: []OpKind{OpDeleteUser},
|
||||
},
|
||||
{
|
||||
name: "分组不存在:创建并加成员",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
name: "分组不存在:创建",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
wantKinds: map[OpKind]int{OpCreateGroup: 1},
|
||||
},
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
|
||||
name: "孤儿组(actual 有、期望没有):不删只告警",
|
||||
desired: plainDesired(),
|
||||
mutate: func(in *Input) {
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "x"}, nil)
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbid: []OpKind{OpCreateGroup},
|
||||
wantWarn: "未自动删除",
|
||||
},
|
||||
{
|
||||
name: "成员缺席:加入分组",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)),
|
||||
mutate: func(in *Input) {
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{testAdminDN})
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, nil)
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpAddMember: 1},
|
||||
},
|
||||
{
|
||||
name: "成员多余且受管:移出分组",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
mutate: func(in *Input) {
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{testAdminDN, "uid=alice," + testUserOU})
|
||||
in.LedgerAccounts["alice"] = "active"
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin, "uid=alice," + peopleOU})
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{OpRemoveMember: 1},
|
||||
},
|
||||
{
|
||||
name: "admin 永不被移出分组(关键安全回归)",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
mutate: func(in *Input) {
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{testAdminDN})
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin})
|
||||
in.OwnedUsers["admin"] = struct{}{}
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpRemoveMember},
|
||||
forbid: []OpKind{OpRemoveMember},
|
||||
},
|
||||
{
|
||||
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让
|
||||
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑
|
||||
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
|
||||
// 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。
|
||||
name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
name: "外部非受管成员:不移出,仅告警",
|
||||
desired: withGroups(plainDesired(), dGroup("od-rd", rdDN)),
|
||||
mutate: func(in *Input) {
|
||||
adminUIDDN := "uid=admin," + testUserOU
|
||||
in.AdminDN = adminUIDDN
|
||||
in.LedgerAccounts["admin"] = "active"
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{adminUIDDN})
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil,
|
||||
[]string{testAdmin, "uid=external," + peopleOU})
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpRemoveMember},
|
||||
},
|
||||
{
|
||||
name: "外部手工成员:不移出,仅告警(关键安全回归)",
|
||||
desired: &mapping.State{
|
||||
Users: []*mapping.User{},
|
||||
Groups: []*mapping.Group{
|
||||
{
|
||||
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||
Members: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
},
|
||||
mutate: func(in *Input) {
|
||||
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||
"cn=yanfa,"+testGroupRoot,
|
||||
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||
[]string{testAdminDN, "uid=external," + testUserOU})
|
||||
},
|
||||
wantKinds: map[OpKind]int{},
|
||||
forbidKinds: []OpKind{OpRemoveMember},
|
||||
forbid: []OpKind{OpRemoveMember},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := baseInput(tt.desired)
|
||||
in := baseIn(tt.desired)
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(in)
|
||||
}
|
||||
|
||||
plan := ComputePlan(in)
|
||||
got := kindsOf(plan)
|
||||
|
||||
for kind, want := range tt.wantKinds {
|
||||
if got[kind] != want {
|
||||
t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s",
|
||||
kind, got[kind], want, dumpPlan(plan))
|
||||
t.Errorf("操作 %s 数量 = %d, 期望 %d\n%s", kind, got[kind], want, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
for kind, count := range got {
|
||||
if _, expected := tt.wantKinds[kind]; !expected {
|
||||
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s",
|
||||
kind, count, dumpPlan(plan))
|
||||
if _, exp := tt.wantKinds[kind]; !exp {
|
||||
t.Errorf("出现未预期的操作 %s (%d)\n%s", kind, count, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
for _, kind := range tt.forbidKinds {
|
||||
for _, kind := range tt.forbid {
|
||||
if got[kind] > 0 {
|
||||
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
|
||||
t.Errorf("出现被禁止的操作 %s\n%s", kind, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
if tt.wantDetailContains != "" {
|
||||
if tt.wantWarn != "" {
|
||||
found := false
|
||||
for _, op := range plan.Ops {
|
||||
if strings.Contains(op.Detail, tt.wantDetailContains) {
|
||||
for _, w := range plan.Warnings {
|
||||
if strings.Contains(w, tt.wantWarn) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
|
||||
tt.wantDetailContains, dumpPlan(plan))
|
||||
t.Errorf("期望有告警包含 %q,实际: %v", tt.wantWarn, plan.Warnings)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试:
|
||||
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。
|
||||
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。
|
||||
func TestComputePlanNeverTouchesPassword(t *testing.T) {
|
||||
u := newUser("alice")
|
||||
in := baseInput(&mapping.State{
|
||||
Users: []*mapping.User{u},
|
||||
GroupByDeptID: map[string]*mapping.Group{},
|
||||
})
|
||||
in.LedgerAccounts["bob"] = "active"
|
||||
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil)
|
||||
// TestDeleteIsLastPhase 验证删除永远排在最后阶段(操作顺序的硬约束)。
|
||||
func TestDeleteIsLastPhase(t *testing.T) {
|
||||
rdDN := "cn=od-rd," + groupRoot
|
||||
desired := withUsers(withGroups(plainDesired(), dGroup("od-rd", rdDN, "uid=alice,"+peopleOU)), dUser("alice"))
|
||||
in := baseIn(desired)
|
||||
in.ActualUsers["alice"] = entry(dUser("alice").DN, map[string]string{
|
||||
ldaplib.AttrDisplayName: "alice"}, nil)
|
||||
in.OwnedUsers["alice"] = struct{}{}
|
||||
in.OwnedUsers["bob"] = struct{}{}
|
||||
in.ActualUsers["bob"] = entry("uid=bob,"+peopleOU, map[string]string{}, nil)
|
||||
in.ConfirmedLeave["bob"] = true
|
||||
in.OwnedUsers["carol"] = struct{}{}
|
||||
in.ActualUsers["carol"] = entry("uid=carol,"+peopleOU, map[string]string{}, nil)
|
||||
in.ActualGroups[strings.ToLower(rdDN)] = entry("cn=od-rd,"+groupRoot, nil, []string{testAdmin, "uid=carol," + peopleOU})
|
||||
|
||||
plan := ComputePlan(in)
|
||||
|
||||
// bob:删除;alice 创建;carol 移出分组。
|
||||
// 任何删除操作都必须排在任何创建/加/移之后。
|
||||
maxOther, minDelete := -1, 1<<30
|
||||
for i, op := range plan.Ops {
|
||||
if op.Kind == OpDeleteUser {
|
||||
if i < minDelete {
|
||||
minDelete = i
|
||||
}
|
||||
} else {
|
||||
if i > maxOther {
|
||||
maxOther = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if minDelete <= maxOther {
|
||||
t.Errorf("删除操作没有排在最后:delete at %d, other at %d\n%s", minDelete, maxOther, dumpPlan(plan))
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePlanNeverTouchesPassword 是结构性回归:
|
||||
// 计划中不能出现改写 userPassword 的操作——密码 LLDAP 独占,程序绝不碰。
|
||||
func TestComputePlanNeverTouchesPassword(t *testing.T) {
|
||||
in := baseIn(withUsers(plainDesired(), dUser("alice")))
|
||||
plan := ComputePlan(in)
|
||||
for _, op := range plan.Ops {
|
||||
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
|
||||
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
|
||||
if strings.Contains(strings.ToLower(op.Detail), "userpassword") ||
|
||||
strings.Contains(strings.ToLower(op.Detail), "password") {
|
||||
t.Errorf("计划出现涉及密码的操作: %s", op.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,40 +285,17 @@ func TestGuard(t *testing.T) {
|
||||
plan *Plan
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "无停用时通过",
|
||||
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}},
|
||||
},
|
||||
{
|
||||
name: "超过绝对上限被拒",
|
||||
guard: Guard{MaxDeactivate: 5},
|
||||
plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "超过比例上限被拒",
|
||||
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||
plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "比例在阈值内通过",
|
||||
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||
plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}},
|
||||
},
|
||||
{
|
||||
name: "强制模式跳过检查",
|
||||
guard: Guard{MaxDeactivate: 1, AllowBulk: true},
|
||||
plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}},
|
||||
},
|
||||
{"无删除时通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 0, TotalUsers: 100}}, false},
|
||||
{"超过绝对上限被拒", Guard{MaxDeactivate: 5}, &Plan{Stats: Stats{DeleteUser: 6, TotalUsers: 100}}, true},
|
||||
{"超过比例上限被拒", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 30, TotalUsers: 100}}, true},
|
||||
{"比例在阈值内通过", Guard{MaxDeactivatePercent: 0.2}, &Plan{Stats: Stats{DeleteUser: 10, TotalUsers: 100}}, false},
|
||||
{"强制模式跳过", Guard{MaxDeactivate: 1, AllowBulk: true}, &Plan{Stats: Stats{DeleteUser: 50, TotalUsers: 100}}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.guard.Check(tt.plan)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Errorf("期望返回错误,实际通过")
|
||||
t.Errorf("期望报错,实际通过")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("期望通过,实际报错: %v", err)
|
||||
@@ -417,14 +304,30 @@ func TestGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func withUsers(s *mapping.DesiredState, us ...*mapping.DesiredUser) *mapping.DesiredState {
|
||||
s.Users = append(s.Users, us...)
|
||||
return s
|
||||
}
|
||||
|
||||
func withGroups(s *mapping.DesiredState, gs ...*mapping.DesiredGroup) *mapping.DesiredState {
|
||||
s.Groups = append(s.Groups, gs...)
|
||||
if s.GroupByDeptID == nil {
|
||||
s.GroupByDeptID = map[string]*mapping.DesiredGroup{}
|
||||
}
|
||||
for _, g := range gs {
|
||||
s.GroupByDeptID[g.DeptID] = g
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func dumpPlan(p *Plan) string {
|
||||
var b strings.Builder
|
||||
for _, op := range p.Ops {
|
||||
b.WriteString("\n ")
|
||||
b.WriteString("\n [")
|
||||
b.WriteString(string(op.Kind))
|
||||
b.WriteString(" target=")
|
||||
b.WriteString("] ")
|
||||
b.WriteString(op.Target)
|
||||
b.WriteString(" detail=")
|
||||
b.WriteString(": ")
|
||||
b.WriteString(op.Detail)
|
||||
}
|
||||
for _, w := range p.Warnings {
|
||||
|
||||
+97
-192
@@ -1,58 +1,80 @@
|
||||
package syncer
|
||||
|
||||
// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。
|
||||
//
|
||||
// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化
|
||||
// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/lldapgql"
|
||||
"idsync/internal/mapping"
|
||||
"idsync/internal/store"
|
||||
)
|
||||
|
||||
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
|
||||
// Runner 编排快照与物化。
|
||||
type Runner struct {
|
||||
cfg *config.Config
|
||||
feishu *feishu.Client
|
||||
ldap *ldaplib.Client
|
||||
gql *lldapgql.Client
|
||||
store *store.Store
|
||||
snapshot *SnapshotRunner
|
||||
log *slog.Logger
|
||||
|
||||
// guard 是执行前的安全阈值。
|
||||
guard Guard
|
||||
// mu 让快照与物化在单实例内互斥。
|
||||
mu sync.Mutex
|
||||
groupIDs map[string]int
|
||||
}
|
||||
|
||||
// NewRunner 构造编排器。
|
||||
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
|
||||
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, g *lldapgql.Client,
|
||||
s *store.Store, log *slog.Logger, opts SnapshotOptions) *Runner {
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
feishu: f,
|
||||
ldap: l,
|
||||
gql: g,
|
||||
store: s,
|
||||
snapshot: NewSnapshotRunner(cfg, f, s, log, opts),
|
||||
log: log,
|
||||
guard: Guard{
|
||||
MaxDeactivate: cfg.Sync.MaxDeactivate,
|
||||
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
|
||||
MaxDeactivate: cfg.Guard.MaxDeactivate,
|
||||
MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
|
||||
// 因此预览内容与真正会发生的事情一致。
|
||||
// RunSnapshot 触发一轮快照(飞书 → 镜像 + 离职流程推进)。
|
||||
func (r *Runner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.snapshot.RunSnapshot(ctx)
|
||||
}
|
||||
|
||||
// Preview 只计算物化计划,不做任何写入。dry-run 与真正执行共用同一份计划代码。
|
||||
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
|
||||
plan, _, err := r.plan(ctx)
|
||||
return plan, err
|
||||
}
|
||||
|
||||
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
|
||||
// Run 执行一轮物化(镜像 → LLDAP)。dryRun 为 true 时只计算并返回计划。
|
||||
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
run, err := r.store.StartRun(modeOf(dryRun), trigger)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("创建同步记录失败: %w", err)
|
||||
return nil, nil, fmt.Errorf("创建物化记录失败: %w", err)
|
||||
}
|
||||
|
||||
plan, desired, err := r.prepare(ctx, run, force)
|
||||
@@ -63,7 +85,7 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
|
||||
fillRunFromPlan(run, plan)
|
||||
run.Status = "success"
|
||||
if err := r.store.FinishRun(run); err != nil {
|
||||
r.log.Warn("写入同步记录失败", "err", err)
|
||||
r.log.Warn("写入物化记录失败", "err", err)
|
||||
}
|
||||
return plan, nil, nil
|
||||
}
|
||||
@@ -71,17 +93,16 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
|
||||
res := r.apply(ctx, plan)
|
||||
fillRunFromResult(run, plan, res)
|
||||
|
||||
// 只有全部成功才回写台账:部分失败时保持台账不变,
|
||||
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
|
||||
// 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。
|
||||
if res.Failed == 0 {
|
||||
if err := r.persist(plan, desired); err != nil {
|
||||
if err := r.persist(ctx, plan, desired); err != nil {
|
||||
run.Status = "failed"
|
||||
run.ErrorMsg = "回写台账失败: " + err.Error()
|
||||
run.ErrorMsg = "回写镜像失败: " + err.Error()
|
||||
_ = r.store.FinishRun(run)
|
||||
return plan, res, fmt.Errorf("回写台账失败: %w", err)
|
||||
return plan, res, fmt.Errorf("回写镜像失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed)
|
||||
r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed)
|
||||
}
|
||||
|
||||
run.Status = "success"
|
||||
@@ -90,14 +111,14 @@ func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger strin
|
||||
run.ErrorMsg = strings.Join(res.Errors, "; ")
|
||||
}
|
||||
if err := r.store.FinishRun(run); err != nil {
|
||||
r.log.Warn("写入同步记录失败", "err", err)
|
||||
r.log.Warn("写入物化记录失败", "err", err)
|
||||
}
|
||||
|
||||
return plan, res, nil
|
||||
}
|
||||
|
||||
// prepare 计算计划并做安全检查。
|
||||
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) {
|
||||
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.DesiredState, error) {
|
||||
plan, desired, err := r.plan(ctx)
|
||||
if err != nil {
|
||||
run.Status = "failed"
|
||||
@@ -106,8 +127,6 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
|
||||
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
|
||||
guard := r.guard
|
||||
if force {
|
||||
guard.AllowBulk = true
|
||||
@@ -121,215 +140,104 @@ func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*
|
||||
return plan, desired, nil
|
||||
}
|
||||
|
||||
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。
|
||||
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
|
||||
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
|
||||
// plan 读两侧状态并计算物化计划。
|
||||
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) {
|
||||
// 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。
|
||||
activeUsers, err := r.store.ActiveUsersWithDepts()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
r.log.Info("已读取飞书数据",
|
||||
"departments", len(snap.Departments), "users", len(snap.Users),
|
||||
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
|
||||
|
||||
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
|
||||
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
|
||||
var skipWarnings []string
|
||||
for _, u := range snap.SkippedNoEmail {
|
||||
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||||
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
|
||||
feishu.DisplayName(u), u.UnionID))
|
||||
}
|
||||
for _, d := range snap.SkippedDuplicate {
|
||||
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||||
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
|
||||
d.UID, strings.Join(d.Names, "、")))
|
||||
depts, err := r.store.ActiveDepartments()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN)
|
||||
|
||||
desired := mapping.Build(snap, mapping.BuildOptions{
|
||||
UserOUDN: r.cfg.UserOUDN(),
|
||||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||
GroupRootDN: r.cfg.GroupRootDN(),
|
||||
})
|
||||
|
||||
// 实际侧:读 LLDAP 的 LDAP 视图。
|
||||
conn, err := r.ldap.Dial()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
active, err := conn.ListUsers(r.cfg.UserOUDN())
|
||||
actualUsers, err := conn.ListUsers(r.cfg.UserOUDN())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
|
||||
actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
accounts, err := r.store.AccountsByUID()
|
||||
// 所有权与删除白名单都来自镜像台账。
|
||||
all, err := r.store.AllMirrorUsers()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
groupLedger, err := r.store.GroupsByDeptID()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
owned := make(map[string]struct{}, len(all))
|
||||
confirmed := make(map[string]bool)
|
||||
for _, u := range all {
|
||||
if u.Skipped {
|
||||
continue
|
||||
}
|
||||
|
||||
ledgerAccounts := make(map[string]string, len(accounts))
|
||||
for uid, a := range accounts {
|
||||
ledgerAccounts[uid] = a.State
|
||||
owned[u.UID] = struct{}{}
|
||||
if u.State == store.StateConfirmedLeave {
|
||||
confirmed[u.UID] = true
|
||||
}
|
||||
ledgerGroups := make(map[string]string, len(groupLedger))
|
||||
for deptID, g := range groupLedger {
|
||||
ledgerGroups[deptID] = g.DN
|
||||
}
|
||||
|
||||
plan := ComputePlan(&Input{
|
||||
Desired: desired,
|
||||
Active: active,
|
||||
Inactive: inactive,
|
||||
Groups: groups,
|
||||
LedgerAccounts: ledgerAccounts,
|
||||
LedgerGroups: ledgerGroups,
|
||||
ActualUsers: actualUsers,
|
||||
ActualGroups: actualGroups,
|
||||
OwnedUsers: owned,
|
||||
ConfirmedLeave: confirmed,
|
||||
AdminDN: r.cfg.Ldap.AdminDN,
|
||||
PeopleOU: r.cfg.UserOUDN(),
|
||||
GroupRootDN: r.cfg.GroupRootDN(),
|
||||
UserOUDN: r.cfg.UserOUDN(),
|
||||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||
})
|
||||
|
||||
plan.Warnings = append(skipWarnings, plan.Warnings...)
|
||||
|
||||
// 对“候选停用”的用户,向飞书确认是否真的离职。
|
||||
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
|
||||
if err := r.confirmResigned(ctx, plan, snap); err != nil {
|
||||
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
|
||||
}
|
||||
|
||||
return plan, desired, nil
|
||||
}
|
||||
|
||||
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
|
||||
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
|
||||
var uids []string
|
||||
|
||||
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
|
||||
accounts, err := r.store.AccountsByUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range plan.Ops {
|
||||
if plan.Ops[i].Kind != OpDeactivate {
|
||||
continue
|
||||
}
|
||||
acc, ok := accounts[plan.Ops[i].Target]
|
||||
if !ok || acc.UnionID == "" {
|
||||
continue
|
||||
}
|
||||
uids = append(uids, acc.UnionID)
|
||||
}
|
||||
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resigned, err := r.feishu.CheckResigned(ctx, uids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
confirmed := make(map[string]bool, len(resigned))
|
||||
for _, rr := range resigned {
|
||||
confirmed[rr.UnionID] = true
|
||||
}
|
||||
|
||||
keep := make([]Op, 0, len(plan.Ops))
|
||||
for _, op := range plan.Ops {
|
||||
if op.Kind != OpDeactivate {
|
||||
keep = append(keep, op)
|
||||
continue
|
||||
}
|
||||
acc := accounts[op.Target]
|
||||
if acc != nil && confirmed[acc.UnionID] {
|
||||
keep = append(keep, op)
|
||||
continue
|
||||
}
|
||||
// 未能确认离职:不动作,并明确告知。
|
||||
plan.Stats.Deactivate--
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
|
||||
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
|
||||
op.Target, r.cfg.InactiveOUDN()))
|
||||
}
|
||||
plan.Ops = keep
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply 执行计划。
|
||||
// apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。
|
||||
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
|
||||
exec := NewExecutor(r.ldap, ExecConfig{
|
||||
UserOUDN: r.cfg.UserOUDN(),
|
||||
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||
GroupRootDN: r.cfg.GroupRootDN(),
|
||||
GroupRootName: r.cfg.Ldap.GroupRootName,
|
||||
AdminDN: r.cfg.Ldap.AdminDN,
|
||||
InitialPassword: r.cfg.Ldap.InitialPassword,
|
||||
}, r.log)
|
||||
return exec.Apply(ctx, plan)
|
||||
if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil {
|
||||
return &Result{Failed: 1, Errors: []string{err.Error()}}
|
||||
}
|
||||
groups, err := r.gql.Groups(ctx)
|
||||
if err != nil {
|
||||
return &Result{Failed: 1, Errors: []string{err.Error()}}
|
||||
}
|
||||
groupIDs := make(map[string]int, len(groups))
|
||||
for _, g := range groups {
|
||||
groupIDs[g.DisplayName] = g.ID
|
||||
}
|
||||
|
||||
exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log)
|
||||
res := exec.Apply(ctx, plan, groupIDs)
|
||||
r.groupIDs = groupIDs
|
||||
return res
|
||||
}
|
||||
|
||||
// persist 把本次同步的期望状态回写台账。
|
||||
//
|
||||
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
|
||||
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
|
||||
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
|
||||
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
|
||||
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
|
||||
now := time.Now()
|
||||
|
||||
// 分组:凡在本次期望范围内的都登记。
|
||||
for _, g := range desired.Groups {
|
||||
if err := r.store.UpsertGroup(&store.Group{
|
||||
DeptID: g.DeptID,
|
||||
DN: g.DN,
|
||||
Name: g.Name,
|
||||
ParentID: g.ParentID,
|
||||
State: "active",
|
||||
LastSyncedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 用户:在职的登记为 active。
|
||||
// persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。
|
||||
func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error {
|
||||
for _, u := range desired.Users {
|
||||
if err := r.store.UpsertAccount(&store.Account{
|
||||
UID: u.UID,
|
||||
DN: u.DN,
|
||||
Email: u.Mail,
|
||||
Name: u.DisplayName,
|
||||
State: "active",
|
||||
AttrsHash: AttrsHash(u),
|
||||
DeptIDs: strings.Join(u.DeptIDs, ","),
|
||||
LastSyncedAt: now,
|
||||
}); err != nil {
|
||||
if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for deptID, gid := range r.groupIDs {
|
||||
if err := r.store.SetDeptGroupID(deptID, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 停用的账号:更新状态与,DN 指向离职 OU。
|
||||
for _, op := range plan.Ops {
|
||||
if op.Kind == OpDeactivate {
|
||||
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil {
|
||||
if op.Kind == OpDeleteUser {
|
||||
if err := r.store.MarkDeleted(op.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -341,22 +249,19 @@ func modeOf(dryRun bool) string {
|
||||
return "apply"
|
||||
}
|
||||
|
||||
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。
|
||||
// fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。
|
||||
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
|
||||
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
|
||||
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup +
|
||||
plan.Stats.Reactivate + plan.Stats.Deactivate
|
||||
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
|
||||
run.Updated = plan.Stats.UpdateUser
|
||||
run.Added = plan.Stats.AddMember
|
||||
run.Removed = plan.Stats.RemoveMember
|
||||
run.Skipped = plan.Stats.Unchanged
|
||||
}
|
||||
|
||||
// fillRunFromResult 用实际执行结果填充同步记录。
|
||||
// fillRunFromResult 用实际执行结果填充物化记录。
|
||||
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
|
||||
run.Created = res.Created
|
||||
run.Updated = res.Updated
|
||||
run.Moved = res.Moved
|
||||
run.Added = res.Added
|
||||
run.Removed = res.Removed
|
||||
run.Failed = res.Failed
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+106
-64
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||
const OP_LABELS = {
|
||||
create_group: '新建分组',
|
||||
create_user: '新建用户',
|
||||
update_user: '更新用户',
|
||||
deactivate_user: '迁入离职 OU',
|
||||
reactivate_user: '恢复用户',
|
||||
create_group: '新建分组',
|
||||
update_group: '更新分组',
|
||||
delete_user: '删除用户',
|
||||
add_member: '加入分组',
|
||||
remove_member: '移出分组',
|
||||
};
|
||||
|
||||
const OP_BADGES = {
|
||||
create_user: 'badge-success',
|
||||
create_group: 'badge-success',
|
||||
create_user: 'badge-success',
|
||||
update_user: 'badge-info',
|
||||
update_group: 'badge-info',
|
||||
reactivate_user: 'badge-warning',
|
||||
deactivate_user: 'badge-error',
|
||||
delete_user: 'badge-error',
|
||||
add_member: 'badge-success',
|
||||
remove_member: 'badge-error',
|
||||
};
|
||||
|
||||
// 用户生命周期状态的中文标注。
|
||||
const STATE_BADGES = {
|
||||
active: ['在职', 'badge-success'],
|
||||
leave_candidate: ['待发离职确认', 'badge-warning'],
|
||||
pending_confirm: ['待人工确认离职', 'badge-warning'],
|
||||
confirmed_leave: ['已确认离职', 'badge-error'],
|
||||
deleted: ['已删除', 'badge-soft'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 主题
|
||||
|
||||
function initTheme() {
|
||||
@@ -78,10 +83,6 @@ function showError(msg) {
|
||||
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
$('#globalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 视图切换
|
||||
|
||||
function showLogin() {
|
||||
@@ -105,7 +106,7 @@ function switchView(name) {
|
||||
t.classList.toggle('tab-active', t.dataset.view === name);
|
||||
});
|
||||
|
||||
if (name === 'overview') loadStatus();
|
||||
if (name === 'overview') { loadStatus(); loadPending(); }
|
||||
if (name === 'users') loadUsers();
|
||||
if (name === 'groups') loadGroups();
|
||||
if (name === 'plan') loadPlan();
|
||||
@@ -117,7 +118,6 @@ function switchView(name) {
|
||||
async function doLogin() {
|
||||
const errEl = $('#loginError');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await api('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -134,9 +134,7 @@ async function doLogin() {
|
||||
async function doLogout() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||
}
|
||||
} catch (_) { /* 登出失败也要回登录页 */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -145,24 +143,29 @@ async function doLogout() {
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
hideError();
|
||||
|
||||
$('#statActive').textContent = d.counts.activeUsers;
|
||||
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||
$('#statGroups').textContent = d.counts.groups;
|
||||
$('#statPending').textContent = d.counts.pendingConfirm;
|
||||
$('#statGroups').textContent = d.counts.departments;
|
||||
|
||||
$('#connStatus').innerHTML = [
|
||||
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
|
||||
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
|
||||
d.lldap.baseUrl || '未配置'),
|
||||
].join('');
|
||||
|
||||
const skipped = d.counts.skipped || 0;
|
||||
$('#skippedStatus').textContent = skipped;
|
||||
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
|
||||
|
||||
$('#lastRunInfo').innerHTML = d.lastRun
|
||||
? `<div class="space-y-1">
|
||||
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
|
||||
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
</div>`
|
||||
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||
: '<span class="text-base-content/50">暂无物化记录</span>';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 离职待确认
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const d = await api('/api/resign/pending');
|
||||
const rows = d.pending || [];
|
||||
$('#pendingRows').innerHTML = rows.length
|
||||
? rows.map((u) => `
|
||||
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
|
||||
<div>
|
||||
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
|
||||
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
|
||||
</div>
|
||||
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
|
||||
</li>`).join('')
|
||||
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
|
||||
} catch (err) {
|
||||
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 快照
|
||||
|
||||
async function doSnapshot() {
|
||||
const btn = $('#snapshotBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '快照中…';
|
||||
try {
|
||||
await api('/api/snapshot', { method: 'POST' });
|
||||
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
|
||||
$('#globalError').className = 'alert alert-success text-sm mb-4';
|
||||
loadPending();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '立即快照';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 用户
|
||||
|
||||
let usersCache = [];
|
||||
@@ -190,7 +233,6 @@ async function loadUsers() {
|
||||
const d = await api('/api/users');
|
||||
usersCache = d.users || [];
|
||||
renderUsers();
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -203,26 +245,25 @@ function renderUsers() {
|
||||
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
$('#userRows').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
$('#userRows').innerHTML = rows.map((u) => {
|
||||
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
|
||||
return `<tr>
|
||||
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||
<td>${escapeHtml(u.name || '')}</td>
|
||||
<td>${escapeHtml(u.email || '')}</td>
|
||||
<td>${u.state === 'inactive'
|
||||
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
|
||||
<td>${(u.deptIds || []).length}</td>
|
||||
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||
<td>${u.drift
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||
<td>${u.skipped
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
|
||||
: '<span class="text-base-content/40">—</span>'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 分组
|
||||
// ---------------------------------------------------------------- 部门分组
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
@@ -230,13 +271,13 @@ async function loadGroups() {
|
||||
const groups = d.groups || [];
|
||||
$('#groupRows').innerHTML = groups.map((g) => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
|
||||
<td>${escapeHtml(g.DeptID || '—')}</td>
|
||||
<td>${escapeHtml(g.ParentID || '—')}</td>
|
||||
<td>${g.GroupID || '—'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
|
||||
</tr>`).join('');
|
||||
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -251,12 +292,10 @@ async function loadPlan() {
|
||||
$('#planSummary').innerHTML = '';
|
||||
$('#planWarnings').classList.add('hidden');
|
||||
$('#planEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const plan = await api('/api/plan');
|
||||
currentPlan = plan;
|
||||
renderPlan(plan);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -264,12 +303,10 @@ async function loadPlan() {
|
||||
|
||||
function renderPlan(plan) {
|
||||
const s = plan.stats || {};
|
||||
|
||||
const cards = [
|
||||
['新建用户', s.createUser, 'text-success'],
|
||||
['更新用户', s.updateUser, 'text-info'],
|
||||
['迁入离职', s.deactivateUser, 'text-error'],
|
||||
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||
['删除用户', s.deleteUser, 'text-error'],
|
||||
['新建分组', s.createGroup, 'text-success'],
|
||||
['加入分组', s.addMember, 'text-success'],
|
||||
['移出分组', s.removeMember, 'text-error'],
|
||||
@@ -300,7 +337,7 @@ function renderPlan(plan) {
|
||||
const ops = plan.ops || [];
|
||||
$('#planRows').innerHTML = ops.map((op) => `
|
||||
<tr>
|
||||
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-soft'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||
<td class="font-medium">${escapeHtml(op.target)}</td>
|
||||
<td>${escapeHtml(op.detail)}</td>
|
||||
</tr>`).join('');
|
||||
@@ -308,17 +345,25 @@ function renderPlan(plan) {
|
||||
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
|
||||
}
|
||||
|
||||
async function applyPlan(force = false) {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
// FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
|
||||
// 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
|
||||
function openApplyModal() {
|
||||
if (window.HSOverlay) window.HSOverlay.open('#applyModal');
|
||||
}
|
||||
|
||||
function closeApplyModal() {
|
||||
if (window.HSOverlay) window.HSOverlay.close('#applyModal');
|
||||
}
|
||||
|
||||
async function applyPlan(force = false) {
|
||||
closeApplyModal();
|
||||
try {
|
||||
const d = await api('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dryRun: false, force }),
|
||||
});
|
||||
const res = d.result || {};
|
||||
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
|
||||
switchView('runs');
|
||||
} catch (err) {
|
||||
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 同步历史
|
||||
// ---------------------------------------------------------------- 物化历史
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
@@ -336,18 +381,17 @@ async function loadRuns() {
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
|
||||
<td>${r.mode === 'dry-run'
|
||||
? '<span class="badge badge-ghost badge-sm">预览</span>'
|
||||
? '<span class="badge badge-soft badge-sm">预览</span>'
|
||||
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
|
||||
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
|
||||
<td>${r.status === 'success'
|
||||
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
|
||||
<td>${r.added}</td><td>${r.removed}</td>
|
||||
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||
</tr>`).join('');
|
||||
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||
$('#previewBtn').addEventListener('click', loadPlan);
|
||||
$('#snapshotBtn').addEventListener('click', doSnapshot);
|
||||
$('#refreshPending').addEventListener('click', loadPending);
|
||||
|
||||
$('#applyBtn').addEventListener('click', () => {
|
||||
if (!currentPlan) return;
|
||||
const s = currentPlan.stats || {};
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
|
||||
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
if (total === 0) {
|
||||
showError('没有需要应用的变更。');
|
||||
return;
|
||||
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#applyModalBody').innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
|
||||
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
|
||||
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
|
||||
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
|
||||
</div>`;
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.showModal === 'function') modal.showModal();
|
||||
openApplyModal();
|
||||
});
|
||||
|
||||
$('#applyCancel').addEventListener('click', () => {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
});
|
||||
// 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
|
||||
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
|
||||
|
||||
// 已有会话则直接进入主界面
|
||||
|
||||
Vendored
+49
-17
@@ -16,7 +16,7 @@
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
|
||||
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
|
||||
|
||||
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
|
||||
|
||||
@@ -37,17 +37,17 @@
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<span class="text-lg font-semibold">idsync</span>
|
||||
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
|
||||
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||
<button id="themeToggle" class="btn btn-sm btn-soft" title="切换明暗主题">主题</button>
|
||||
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto p-4">
|
||||
<!-- 页签 -->
|
||||
<div class="tabs tabs-border mb-4" role="tablist">
|
||||
<div class="tabs tabs-bordered mb-4" role="tablist">
|
||||
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
|
||||
<button class="tab" data-view="users" role="tab">用户</button>
|
||||
<button class="tab" data-view="groups" role="tab">部门分组</button>
|
||||
@@ -68,8 +68,8 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<span class="text-sm text-base-content/60">离职账号</span>
|
||||
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
|
||||
<span id="statPending" class="text-3xl font-semibold">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
@@ -85,15 +85,33 @@
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">服务状态</h2>
|
||||
<div id="connStatus" class="space-y-2 text-sm"></div>
|
||||
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
|
||||
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
|
||||
请到用户页查看并处理,避免他们一直没被同步。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">上次同步</h2>
|
||||
<h2 class="card-title text-base">上次物化</h2>
|
||||
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 离职待确认 -->
|
||||
<div class="card bg-base-100 shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="card-title text-base">离职待确认</h2>
|
||||
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mb-2">
|
||||
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
|
||||
</p>
|
||||
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 用户 -->
|
||||
@@ -153,6 +171,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
|
||||
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
|
||||
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||
</div>
|
||||
@@ -208,20 +227,33 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 应用变更的确认对话框 -->
|
||||
<dialog id="applyModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||
<!-- 应用变更的确认对话框。
|
||||
必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
|
||||
open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
|
||||
生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
|
||||
<div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
|
||||
role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
|
||||
<button type="button" class="btn btn-text btn-circle btn-sm absolute end-3 top-3"
|
||||
aria-label="关闭" data-overlay="#applyModal">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
|
||||
仅标记「已确认离职」的用户会被物理删除。
|
||||
</p>
|
||||
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||
<div class="modal-action">
|
||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
|
||||
<div id="applyModalBody" class="text-sm"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="applyCancel" type="button" class="btn btn-soft" data-overlay="#applyModal">取消</button>
|
||||
<button id="applyConfirm" type="button" class="btn btn-primary">确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/flyonui.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/feishu"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/lldapgql"
|
||||
"idsync/internal/server"
|
||||
"idsync/internal/store"
|
||||
"idsync/internal/syncer"
|
||||
@@ -28,9 +29,10 @@ var version = "dev"
|
||||
func main() {
|
||||
var (
|
||||
cfgPath = flag.String("config", "config.yml", "配置文件路径")
|
||||
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP")
|
||||
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)")
|
||||
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏")
|
||||
dryRun = flag.Bool("dry-run", false, "只计算并打印物化计划,不写入 LLDAP")
|
||||
applyAll = flag.Bool("apply", false, "直接执行同步(快照+物化,默认只做 dry-run)")
|
||||
force = flag.Bool("force", false, "忽略删除数量阈值等安全护栏")
|
||||
snapshot = flag.Bool("snapshot", false, "只执行一轮快照(飞书→镜像+离职流程)后退出")
|
||||
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
|
||||
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
|
||||
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
|
||||
@@ -38,25 +40,15 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *genSecret {
|
||||
s, err := server.GenerateSecret()
|
||||
if err != nil {
|
||||
fatal("生成密钥失败", err)
|
||||
}
|
||||
fmt.Println(s)
|
||||
fmt.Println(mustGenSecret())
|
||||
return
|
||||
}
|
||||
if *hashPass != "" {
|
||||
h, err := server.HashPassword(*hashPass)
|
||||
if err != nil {
|
||||
fatal("生成哈希失败", err)
|
||||
}
|
||||
fmt.Println(h)
|
||||
fmt.Println(mustHashPassword(*hashPass))
|
||||
return
|
||||
}
|
||||
|
||||
// 配置自检:把解析后的有效值打出来(凭据脱敏)。
|
||||
// 用于在真正连接目录前确认关键项,尤其是分组形态——
|
||||
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
|
||||
if *checkCfg {
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
@@ -81,16 +73,20 @@ func main() {
|
||||
fatal("创建数据目录失败", err)
|
||||
}
|
||||
|
||||
// 读通道:LLDAP 的 LDAP(连接/搜索)。
|
||||
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
|
||||
if err != nil {
|
||||
fatal("初始化 LDAP 客户端失败", err)
|
||||
fatal("初始化 LDAP 读客户端失败", err)
|
||||
}
|
||||
if err := ldapClient.Ping(); err != nil {
|
||||
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
|
||||
} else {
|
||||
log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
|
||||
log.Info("LDAP 读通道正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
// 写通道:LLDAP 的 GraphQL。
|
||||
gqlClient := lldapgql.New(cfg.Lldap.BaseURL, log)
|
||||
|
||||
feishuClient, err := feishu.New(feishu.Options{
|
||||
AppID: cfg.Feishu.AppID,
|
||||
AppSecret: cfg.Feishu.AppSecret,
|
||||
@@ -104,19 +100,28 @@ func main() {
|
||||
|
||||
db, err := store.Open(cfg.Database.Source)
|
||||
if err != nil {
|
||||
fatal("打开台账数据库失败", err)
|
||||
fatal("打开镜像数据库失败", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log)
|
||||
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, gqlClient, db, log, syncer.SnapshotOptions{
|
||||
WebhookURL: cfg.Feishu.WebhookURL,
|
||||
ConfirmBaseURL: cfg.Server.ConfirmBaseURL,
|
||||
ReNotifyInterval: 24 * time.Hour,
|
||||
TokenTTL: 72 * time.Hour,
|
||||
})
|
||||
|
||||
// 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。
|
||||
// 命令行模式:跑一轮后退出,便于放进 crontab 或手工验证。
|
||||
if *snapshot {
|
||||
runSnapshotOnce(runner, log)
|
||||
return
|
||||
}
|
||||
if *dryRun || *applyAll {
|
||||
runOnce(cfg, runner, *applyAll, *force, log)
|
||||
return
|
||||
}
|
||||
|
||||
// 服务模式
|
||||
// 服务模式。
|
||||
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
|
||||
if err != nil {
|
||||
fatal("初始化服务失败", err)
|
||||
@@ -135,10 +140,10 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Sync.Enable {
|
||||
scheduleSync(cfg, runner, log)
|
||||
if cfg.Schedule.Enable {
|
||||
scheduleAuto(cfg, runner, log)
|
||||
} else {
|
||||
log.Info("定时同步未启用(sync.enable=false)")
|
||||
log.Info("定时任务未启用(schedule.enable=false)")
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
@@ -170,38 +175,24 @@ func printEffectiveConfig(cfg *config.Config) {
|
||||
fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
|
||||
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
|
||||
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
|
||||
fmt.Printf("飞书 离职卡片 webhook : %s\n", orDefault(cfg.Feishu.WebhookURL, "(未配置)"))
|
||||
fmt.Println()
|
||||
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL)
|
||||
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN)
|
||||
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN)
|
||||
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
|
||||
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS)
|
||||
fmt.Printf("LDAP 读 url : %s\n", cfg.Ldap.URL)
|
||||
fmt.Printf("LDAP 读 base-dn : %s\n", cfg.Ldap.BaseDN)
|
||||
fmt.Printf("LDAP 读 admin-dn : %s\n", cfg.Ldap.AdminDN)
|
||||
fmt.Printf("LDAP 读 admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
|
||||
fmt.Printf("LLDAP 写 base-url : %s\n", cfg.Lldap.BaseURL)
|
||||
fmt.Printf("LLDAP 写 admin-user : %s\n", cfg.Lldap.AdminUser)
|
||||
fmt.Printf("LLDAP 写 admin-pass : %s\n", mask(cfg.Lldap.AdminPass))
|
||||
fmt.Println()
|
||||
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN())
|
||||
fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN())
|
||||
fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN())
|
||||
fmt.Printf("用户 OU : %s\n", cfg.UserOUDN())
|
||||
fmt.Printf("部门组 OU : %s\n", cfg.GroupRootDN())
|
||||
fmt.Printf("离职确认端点对外 URL : %s\n", orDefault(cfg.Server.ConfirmBaseURL, "(未配置,离职卡片将无法提供确认链接)"))
|
||||
fmt.Println()
|
||||
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass)
|
||||
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr)
|
||||
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n",
|
||||
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass)
|
||||
fmt.Println()
|
||||
fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)"))
|
||||
fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n",
|
||||
cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun)
|
||||
fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n",
|
||||
cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100)
|
||||
|
||||
// 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。
|
||||
userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN()
|
||||
if inactiveDN == userDN {
|
||||
fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效")
|
||||
} else if len(inactiveDN) > len(userDN) &&
|
||||
inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
|
||||
fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户")
|
||||
} else {
|
||||
fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)")
|
||||
}
|
||||
fmt.Printf("定时任务 : enable=%v snapshot=%q sync=%q sync-dry-run=%v\n",
|
||||
cfg.Schedule.Enable, cfg.Schedule.Snapshot, cfg.Schedule.Sync, cfg.Schedule.SyncDryRun)
|
||||
fmt.Printf("删除阈值 : max=%d percent=%.0f%%\n",
|
||||
cfg.Guard.MaxDeactivate, cfg.Guard.MaxDeactivatePercent*100)
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
@@ -211,26 +202,47 @@ func orDefault(v, def string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// runOnce 执行一次同步并打印计划,然后退出。
|
||||
// runSnapshotOnce 只执行一轮快照并退出。
|
||||
func runSnapshotOnce(runner *syncer.Runner, log *slog.Logger) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
res, err := runner.RunSnapshot(ctx)
|
||||
if err != nil {
|
||||
fatal("快照失败", err)
|
||||
}
|
||||
log.Info("快照完成",
|
||||
"users", res.Users, "departments", res.Departments, "skipped", res.Skipped,
|
||||
"leave_candidates", res.LeaveCandidates, "pending_confirm", res.PendingConfirm,
|
||||
"reinstated", res.Reinstated, "cards_sent", res.CardsSent)
|
||||
for _, w := range res.Warnings {
|
||||
log.Warn("快照告警", "detail", w)
|
||||
}
|
||||
}
|
||||
|
||||
// runOnce 执行一轮「快照 + 物化」并打印计划,然后退出。
|
||||
func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// 先快照(飞书→镜像),再物化(镜像→LLDAP)。
|
||||
if _, err := runner.RunSnapshot(ctx); err != nil {
|
||||
fatal("快照失败", err)
|
||||
}
|
||||
|
||||
plan, res, err := runner.Run(ctx, !apply, force, "manual")
|
||||
if plan != nil {
|
||||
printPlanTo(plan)
|
||||
}
|
||||
if err != nil {
|
||||
fatal("同步失败", err)
|
||||
fatal("物化失败", err)
|
||||
}
|
||||
|
||||
if !apply {
|
||||
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("同步完成",
|
||||
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
|
||||
log.Info("物化完成",
|
||||
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
|
||||
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
|
||||
for _, e := range res.Errors {
|
||||
log.Error("操作失败详情", "detail", e)
|
||||
@@ -248,7 +260,6 @@ func buildServer(
|
||||
) (*server.Server, error) {
|
||||
secret := cfg.Server.SessionSecret
|
||||
if secret == "" {
|
||||
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
|
||||
generated, err := server.GenerateSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成会话密钥失败: %w", err)
|
||||
@@ -288,25 +299,36 @@ func buildServer(
|
||||
}), nil
|
||||
}
|
||||
|
||||
// scheduleSync 注册定时同步。
|
||||
//
|
||||
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量
|
||||
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
|
||||
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
|
||||
// scheduleAuto 注册两套定时任务:快照低频、物化略高频。输出到 runner 的互斥锁
|
||||
// 保证两者绝不同时执行。
|
||||
func scheduleAuto(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
|
||||
c := cron.New(
|
||||
cron.WithSeconds(),
|
||||
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
|
||||
)
|
||||
|
||||
_, err := c.AddFunc(cfg.Sync.Schedule, func() {
|
||||
dry := cfg.Sync.DefaultDryRun
|
||||
if _, err := c.AddFunc(cfg.Schedule.Snapshot, func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
log.Info("定时快照开始")
|
||||
if res, err := runner.RunSnapshot(ctx); err != nil {
|
||||
log.Error("定时快照失败", "err", err)
|
||||
} else {
|
||||
log.Info("定时快照完成",
|
||||
"users", res.Users, "pending_confirm", res.PendingConfirm, "cards_sent", res.CardsSent)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error("注册定时快照失败", "schedule", cfg.Schedule.Snapshot, "err", err)
|
||||
}
|
||||
|
||||
log.Info("定时同步开始", "dry_run", dry)
|
||||
if _, err := c.AddFunc(cfg.Schedule.Sync, func() {
|
||||
dry := cfg.Schedule.SyncDryRun
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
log.Info("定时物化开始", "dry_run", dry)
|
||||
plan, res, err := runner.Run(ctx, dry, false, "schedule")
|
||||
if err != nil {
|
||||
log.Error("定时同步失败", "err", err)
|
||||
log.Error("定时物化失败", "err", err)
|
||||
return
|
||||
}
|
||||
if dry {
|
||||
@@ -314,34 +336,33 @@ func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
|
||||
if plan != nil {
|
||||
planned = len(plan.Ops)
|
||||
}
|
||||
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned)
|
||||
log.Info("定时物化为 dry-run 模式,未写入变更", "planned_ops", planned)
|
||||
return
|
||||
}
|
||||
log.Info("定时同步完成",
|
||||
"created", res.Created, "updated", res.Updated,
|
||||
"moved", res.Moved, "failed", res.Failed)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
|
||||
return
|
||||
log.Info("定时物化完成",
|
||||
"created", res.Created, "updated", res.Updated, "deleted", res.Deleted,
|
||||
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
|
||||
}); err != nil {
|
||||
log.Error("注册定时物化失败", "schedule", cfg.Schedule.Sync, "err", err)
|
||||
}
|
||||
|
||||
c.Start()
|
||||
log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun)
|
||||
log.Info("定时任务已启用",
|
||||
"snapshot", cfg.Schedule.Snapshot, "sync", cfg.Schedule.Sync,
|
||||
"sync_dry_run", cfg.Schedule.SyncDryRun)
|
||||
}
|
||||
|
||||
func printPlanTo(p *syncer.Plan) {
|
||||
fmt.Println("===== 同步计划 =====")
|
||||
fmt.Println("===== 物化计划 =====")
|
||||
s := p.Stats
|
||||
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n",
|
||||
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate)
|
||||
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
|
||||
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
|
||||
fmt.Printf("新建用户 %d · 更新用户 %d · 删除用户 %d\n", s.CreateUser, s.UpdateUser, s.DeleteUser)
|
||||
fmt.Printf("新建分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
|
||||
s.CreateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
|
||||
|
||||
if len(p.Ops) > 0 {
|
||||
fmt.Println("\n----- 操作明细 -----")
|
||||
for _, op := range p.Ops {
|
||||
fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail)
|
||||
fmt.Printf(" [%-15s] %-22s %s\n", op.Kind, op.Target, op.Detail)
|
||||
}
|
||||
}
|
||||
if len(p.Warnings) > 0 {
|
||||
@@ -353,6 +374,22 @@ func printPlanTo(p *syncer.Plan) {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func mustGenSecret() string {
|
||||
s, err := server.GenerateSecret()
|
||||
if err != nil {
|
||||
fatal("生成密钥失败", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func mustHashPassword(plain string) string {
|
||||
h, err := server.HashPassword(plain)
|
||||
if err != nil {
|
||||
fatal("生成哈希失败", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func fatal(msg string, err error) {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
|
||||
os.Exit(1)
|
||||
|
||||
+106
-64
@@ -8,27 +8,32 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||
const OP_LABELS = {
|
||||
create_group: '新建分组',
|
||||
create_user: '新建用户',
|
||||
update_user: '更新用户',
|
||||
deactivate_user: '迁入离职 OU',
|
||||
reactivate_user: '恢复用户',
|
||||
create_group: '新建分组',
|
||||
update_group: '更新分组',
|
||||
delete_user: '删除用户',
|
||||
add_member: '加入分组',
|
||||
remove_member: '移出分组',
|
||||
};
|
||||
|
||||
const OP_BADGES = {
|
||||
create_user: 'badge-success',
|
||||
create_group: 'badge-success',
|
||||
create_user: 'badge-success',
|
||||
update_user: 'badge-info',
|
||||
update_group: 'badge-info',
|
||||
reactivate_user: 'badge-warning',
|
||||
deactivate_user: 'badge-error',
|
||||
delete_user: 'badge-error',
|
||||
add_member: 'badge-success',
|
||||
remove_member: 'badge-error',
|
||||
};
|
||||
|
||||
// 用户生命周期状态的中文标注。
|
||||
const STATE_BADGES = {
|
||||
active: ['在职', 'badge-success'],
|
||||
leave_candidate: ['待发离职确认', 'badge-warning'],
|
||||
pending_confirm: ['待人工确认离职', 'badge-warning'],
|
||||
confirmed_leave: ['已确认离职', 'badge-error'],
|
||||
deleted: ['已删除', 'badge-soft'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 主题
|
||||
|
||||
function initTheme() {
|
||||
@@ -78,10 +83,6 @@ function showError(msg) {
|
||||
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
$('#globalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 视图切换
|
||||
|
||||
function showLogin() {
|
||||
@@ -105,7 +106,7 @@ function switchView(name) {
|
||||
t.classList.toggle('tab-active', t.dataset.view === name);
|
||||
});
|
||||
|
||||
if (name === 'overview') loadStatus();
|
||||
if (name === 'overview') { loadStatus(); loadPending(); }
|
||||
if (name === 'users') loadUsers();
|
||||
if (name === 'groups') loadGroups();
|
||||
if (name === 'plan') loadPlan();
|
||||
@@ -117,7 +118,6 @@ function switchView(name) {
|
||||
async function doLogin() {
|
||||
const errEl = $('#loginError');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
await api('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -134,9 +134,7 @@ async function doLogin() {
|
||||
async function doLogout() {
|
||||
try {
|
||||
await api('/api/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||
}
|
||||
} catch (_) { /* 登出失败也要回登录页 */ }
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -145,24 +143,29 @@ async function doLogout() {
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
hideError();
|
||||
|
||||
$('#statActive').textContent = d.counts.activeUsers;
|
||||
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||
$('#statGroups').textContent = d.counts.groups;
|
||||
$('#statPending').textContent = d.counts.pendingConfirm;
|
||||
$('#statGroups').textContent = d.counts.departments;
|
||||
|
||||
$('#connStatus').innerHTML = [
|
||||
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||
statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
|
||||
statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '',
|
||||
d.lldap.baseUrl || '未配置'),
|
||||
].join('');
|
||||
|
||||
const skipped = d.counts.skipped || 0;
|
||||
$('#skippedStatus').textContent = skipped;
|
||||
$('#skippedStatusWrap').classList.toggle('hidden', skipped === 0);
|
||||
|
||||
$('#lastRunInfo').innerHTML = d.lastRun
|
||||
? `<div class="space-y-1">
|
||||
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} ·
|
||||
删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||
</div>`
|
||||
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||
: '<span class="text-base-content/50">暂无物化记录</span>';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -181,6 +184,46 @@ function statusRow(name, ok, error, detail) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 离职待确认
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const d = await api('/api/resign/pending');
|
||||
const rows = d.pending || [];
|
||||
$('#pendingRows').innerHTML = rows.length
|
||||
? rows.map((u) => `
|
||||
<li class="flex items-center justify-between gap-3 py-1.5 border-b border-base-200 last:border-0">
|
||||
<div>
|
||||
<span class="font-medium">${escapeHtml(u.name || u.uid)}</span>
|
||||
<span class="text-base-content/60 text-sm ml-2">${escapeHtml(u.uid)}</span>
|
||||
</div>
|
||||
<span class="text-base-content/40 text-sm whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</span>
|
||||
</li>`).join('')
|
||||
: '<li class="text-sm text-base-content/50 py-2">暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。</li>';
|
||||
} catch (err) {
|
||||
$('#pendingRows').innerHTML = `<li class="text-error text-sm">${escapeHtml(err.message)}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 快照
|
||||
|
||||
async function doSnapshot() {
|
||||
const btn = $('#snapshotBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '快照中…';
|
||||
try {
|
||||
await api('/api/snapshot', { method: 'POST' });
|
||||
showError('快照完成:已把飞书现状写入镜像并推进离职流程。');
|
||||
$('#globalError').className = 'alert alert-success text-sm mb-4';
|
||||
loadPending();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '立即快照';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 用户
|
||||
|
||||
let usersCache = [];
|
||||
@@ -190,7 +233,6 @@ async function loadUsers() {
|
||||
const d = await api('/api/users');
|
||||
usersCache = d.users || [];
|
||||
renderUsers();
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -203,26 +245,25 @@ function renderUsers() {
|
||||
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
$('#userRows').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
$('#userRows').innerHTML = rows.map((u) => {
|
||||
const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft'];
|
||||
return `<tr>
|
||||
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||
<td>${escapeHtml(u.name || '')}</td>
|
||||
<td>${escapeHtml(u.email || '')}</td>
|
||||
<td>${u.state === 'inactive'
|
||||
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||
<td><span class="badge ${st[1]} badge-sm">${st[0]}</span></td>
|
||||
<td>${(u.deptIds || []).length}</td>
|
||||
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||
<td>${u.drift
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||
<td>${u.skipped
|
||||
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.skipReason || '')}">被跳过</span>`
|
||||
: '<span class="text-base-content/40">—</span>'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||
</tr>`).join('');
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 分组
|
||||
// ---------------------------------------------------------------- 部门分组
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
@@ -230,13 +271,13 @@ async function loadGroups() {
|
||||
const groups = d.groups || [];
|
||||
$('#groupRows').innerHTML = groups.map((g) => `
|
||||
<tr>
|
||||
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||
<td class="font-medium">${escapeHtml(g.Name || '')}</td>
|
||||
<td>${escapeHtml(g.DeptID || '—')}</td>
|
||||
<td>${escapeHtml(g.ParentID || '—')}</td>
|
||||
<td>${g.GroupID || '—'}</td>
|
||||
<td class="idsync-dn text-base-content/60">${escapeHtml(g.DN || '')}</td>
|
||||
</tr>`).join('');
|
||||
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -251,12 +292,10 @@ async function loadPlan() {
|
||||
$('#planSummary').innerHTML = '';
|
||||
$('#planWarnings').classList.add('hidden');
|
||||
$('#planEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const plan = await api('/api/plan');
|
||||
currentPlan = plan;
|
||||
renderPlan(plan);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -264,12 +303,10 @@ async function loadPlan() {
|
||||
|
||||
function renderPlan(plan) {
|
||||
const s = plan.stats || {};
|
||||
|
||||
const cards = [
|
||||
['新建用户', s.createUser, 'text-success'],
|
||||
['更新用户', s.updateUser, 'text-info'],
|
||||
['迁入离职', s.deactivateUser, 'text-error'],
|
||||
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||
['删除用户', s.deleteUser, 'text-error'],
|
||||
['新建分组', s.createGroup, 'text-success'],
|
||||
['加入分组', s.addMember, 'text-success'],
|
||||
['移出分组', s.removeMember, 'text-error'],
|
||||
@@ -300,7 +337,7 @@ function renderPlan(plan) {
|
||||
const ops = plan.ops || [];
|
||||
$('#planRows').innerHTML = ops.map((op) => `
|
||||
<tr>
|
||||
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-soft'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||
<td class="font-medium">${escapeHtml(op.target)}</td>
|
||||
<td>${escapeHtml(op.detail)}</td>
|
||||
</tr>`).join('');
|
||||
@@ -308,17 +345,25 @@ function renderPlan(plan) {
|
||||
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
|
||||
}
|
||||
|
||||
async function applyPlan(force = false) {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
// FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。
|
||||
// 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。
|
||||
function openApplyModal() {
|
||||
if (window.HSOverlay) window.HSOverlay.open('#applyModal');
|
||||
}
|
||||
|
||||
function closeApplyModal() {
|
||||
if (window.HSOverlay) window.HSOverlay.close('#applyModal');
|
||||
}
|
||||
|
||||
async function applyPlan(force = false) {
|
||||
closeApplyModal();
|
||||
try {
|
||||
const d = await api('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dryRun: false, force }),
|
||||
});
|
||||
const res = d.result || {};
|
||||
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||
showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
|
||||
switchView('runs');
|
||||
} catch (err) {
|
||||
@@ -326,7 +371,7 @@ async function applyPlan(force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 同步历史
|
||||
// ---------------------------------------------------------------- 物化历史
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
@@ -336,18 +381,17 @@ async function loadRuns() {
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
|
||||
<td>${r.mode === 'dry-run'
|
||||
? '<span class="badge badge-ghost badge-sm">预览</span>'
|
||||
? '<span class="badge badge-soft badge-sm">预览</span>'
|
||||
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
|
||||
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
|
||||
<td>${r.status === 'success'
|
||||
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
|
||||
<td>${r.created}</td><td>${r.updated}</td><td>${r.deleted || 0}</td>
|
||||
<td>${r.added}</td><td>${r.removed}</td>
|
||||
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||
</tr>`).join('');
|
||||
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||
hideError();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
@@ -390,12 +434,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||
$('#previewBtn').addEventListener('click', loadPlan);
|
||||
$('#snapshotBtn').addEventListener('click', doSnapshot);
|
||||
$('#refreshPending').addEventListener('click', loadPending);
|
||||
|
||||
$('#applyBtn').addEventListener('click', () => {
|
||||
if (!currentPlan) return;
|
||||
const s = currentPlan.stats || {};
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0)
|
||||
+ (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||
if (total === 0) {
|
||||
showError('没有需要应用的变更。');
|
||||
return;
|
||||
@@ -403,17 +449,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#applyModalBody').innerHTML = `
|
||||
<div class="space-y-1">
|
||||
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
|
||||
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
|
||||
<div>删除用户 <strong>${s.deleteUser || 0}</strong></div>
|
||||
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
|
||||
</div>`;
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.showModal === 'function') modal.showModal();
|
||||
openApplyModal();
|
||||
});
|
||||
|
||||
$('#applyCancel').addEventListener('click', () => {
|
||||
const modal = document.getElementById('applyModal');
|
||||
if (modal && typeof modal.close === 'function') modal.close();
|
||||
});
|
||||
// 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。
|
||||
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
|
||||
|
||||
// 已有会话则直接进入主界面
|
||||
|
||||
+49
-17
@@ -16,7 +16,7 @@
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
|
||||
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → LLDAP 同步</p>
|
||||
|
||||
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
|
||||
|
||||
@@ -37,17 +37,17 @@
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||
<span class="text-lg font-semibold">idsync</span>
|
||||
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
|
||||
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → LLDAP</span>
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||
<button id="themeToggle" class="btn btn-sm btn-soft" title="切换明暗主题">主题</button>
|
||||
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto p-4">
|
||||
<!-- 页签 -->
|
||||
<div class="tabs tabs-border mb-4" role="tablist">
|
||||
<div class="tabs tabs-bordered mb-4" role="tablist">
|
||||
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
|
||||
<button class="tab" data-view="users" role="tab">用户</button>
|
||||
<button class="tab" data-view="groups" role="tab">部门分组</button>
|
||||
@@ -68,8 +68,8 @@
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<span class="text-sm text-base-content/60">离职账号</span>
|
||||
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||
<span class="text-sm text-base-content/60">在飞书可见/待确认离职</span>
|
||||
<span id="statPending" class="text-3xl font-semibold">–</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
@@ -85,15 +85,33 @@
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">服务状态</h2>
|
||||
<div id="connStatus" class="space-y-2 text-sm"></div>
|
||||
<div id="skippedStatusWrap" class="hidden mt-2 text-sm rounded-lg bg-warning/10 px-3 py-2">
|
||||
有 <span id="skippedStatus" class="font-semibold"></span> 名用户因缺少企业邮箱被跳过,
|
||||
请到用户页查看并处理,避免他们一直没被同步。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">上次同步</h2>
|
||||
<h2 class="card-title text-base">上次物化</h2>
|
||||
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 离职待确认 -->
|
||||
<div class="card bg-base-100 shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="card-title text-base">离职待确认</h2>
|
||||
<button id="refreshPending" class="btn btn-sm btn-outline">刷新</button>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 mb-2">
|
||||
这些用户已在飞书确认离职并收到确认卡片,等待人工点击确认后才会被删除。
|
||||
</p>
|
||||
<ul id="pendingRows" class="divide-y divide-base-200"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 用户 -->
|
||||
@@ -153,6 +171,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="snapshotBtn" class="btn btn-sm btn-neutral">立即快照</button>
|
||||
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
|
||||
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||
</div>
|
||||
@@ -208,20 +227,33 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 应用变更的确认对话框 -->
|
||||
<dialog id="applyModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||
<!-- 应用变更的确认对话框。
|
||||
必须用 FlyonUI 的 overlay 结构:容器是 .overlay.modal 的 <div>(HSOverlay 靠切换
|
||||
open/hidden class 显隐,不会调用原生 dialog.showModal),显示态由 overlay-open 变体
|
||||
生成——所以 overlay-open:opacity-100 这个类不能删,否则对话框又会变回不可见。 -->
|
||||
<div id="applyModal" class="overlay modal modal-middle overlay-open:opacity-100 hidden overlay-open:duration-300"
|
||||
role="dialog" tabindex="-1" aria-labelledby="applyModalTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="applyModalTitle" class="modal-title">确认应用变更</h3>
|
||||
<button type="button" class="btn btn-text btn-circle btn-sm absolute end-3 top-3"
|
||||
aria-label="关闭" data-overlay="#applyModal">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||
将对 LLDAP 执行下列变更。密码由 LLDAP 独占并交给 Authelia 管理,程序绝不写入;
|
||||
仅标记「已确认离职」的用户会被物理删除。
|
||||
</p>
|
||||
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||
<div class="modal-action">
|
||||
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
|
||||
<div id="applyModalBody" class="text-sm"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="applyCancel" type="button" class="btn btn-soft" data-overlay="#applyModal">取消</button>
|
||||
<button id="applyConfirm" type="button" class="btn btn-primary">确认执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/flyonui.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -42,6 +42,20 @@
|
||||
--color-success-content: #071505;
|
||||
}
|
||||
|
||||
/* FlyonUI 的 overlay 变体没有随 @plugin 自动注册:Tailwind v4 只会加载包内与插件同名的
|
||||
* flyonui.css,而 overlay-open 等 @custom-variant 定义在包根的 variants.css 里,不会被引入。
|
||||
* 不在这里补上,HTML 里的 overlay-open:opacity-100 就编译不出任何规则,modal 打开后仍是
|
||||
* opacity:0 的透明层(表现为「页面变暗+点不了别处,但对话框不可见」)。 */
|
||||
@custom-variant overlay-open {
|
||||
&.open {
|
||||
@slot;
|
||||
}
|
||||
|
||||
.open & {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* 少量自定义样式:表格可横向滚动,长 DN 不撑破布局。 */
|
||||
@layer components {
|
||||
.idsync-dn {
|
||||
|
||||
Reference in New Issue
Block a user