diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..490820d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,273 @@ +# AGENTS.md + +给在本仓库工作的编码 agent 的指导。**先读「硬约束」一节**——那里列的都是破坏后不会报错、 +只会静默失效的东西,是本项目最容易踩的坑。 + +## 项目定位 + +把飞书通讯录同步到 LLDAP,替代 `go-ldap-admin`。链路是两阶段:飞书 → SQLite 镜像 → LLDAP。 + +**它不在认证路径上。** 鉴权链是「业务系统 → Authelia → LLDAP bind」,本工具负责「飞书有 +什么数据、LDAP 该长什么样、两者差异是什么」。它不实现认证、会话、登录页、密码重置—— +这些由 Authelia 负责。唯一的例外是管理界面自身的单管理员登录(`internal/server/auth.go`), +那只是保护这个界面本身,与 LDAP 用户无关。 + +本工具会发送的**唯一对外通知**是「离职人工确认」飞书卡片(走自定义机器人 webhook 发到 +用户管理群),它属于同步业务流程而非认证链路,与 Authelia 无关。 + +判断某个需求是否属于本项目:如果它与「飞书有什么数据、LDAP 该长什么样、两者差异是什么」 +有关,属于本项目;如果与「谁有权限登录」有关,不属于。 + +## 架构 + +``` +飞书通讯录 ──快照(低频/可手动)──▶ SQLite 镜像 ──物化(略高频)──▶ LLDAP(只在职用户) + (全字段+部门关系 │ + +生命周期状态) │ 读走 LDAP / 写走 GraphQL + ▼ + Admin? 离职用户走「机器确认 + + 飞书卡片人工确认(一次性端点)」后才删 +``` + +数据单向流动,每一层职责单一: + +| 包 | 职责 | 关键约束 | +|---|---|---| +| `internal/config` | 配置加载、默认值、校验、DN 拼接 | 快照/物化两套 schedule、SMTP 与 webhook 配置在此归一 | +| `internal/feishu` | **只读**飞书:拉通讯录、确认离职、发离职确认卡片 | 不在这里做映射或写入决策 | +| `internal/store` | SQLite 镜像 + 台账 + 部门关系表 | 快照整体刷新、权限唯一来源 | +| `internal/mapping` | 镜像 → 期望 LDAP 状态(**纯函数**) | DN/组名构造规则集中于此 | +| `internal/ldaplib` | LLDAP **读**原语:连接/bind/搜索 | 不写任何属性 | +| `internal/lldapgql` | LLDAP **写**客户端(登录 + 手工 GraphQL 客户端 + 属性 schema 引导) | 见硬约束 8/9;GraphQL 端点为 `/api/graphql` | +| `internal/syncer` | diff 引擎(**纯函数**)+ 执行器 + 编排 | 见「硬约束」 | +| `internal/server` | HTTP 接口 + 单管理员会话 + 一次性确认端点 | 确认端点不经 Auth 中间件(token 即凭证) | +| `internal/webui` | `go:embed` 前端产物 + ETag | | + +**分层的意义**:`mapping` 与 `syncer.ComputePlan` 都是纯函数——输入镜像、输出状态/计划, +不触碰 IO。因此它们能被完整单测,且 **dry-run 与真正执行共用同一份代码**,预览即所得。 +改动它们时务必维持纯度,否则 dry-run 的可信度就没了。 + +### 迁移状态(写代码前先对号入座) + +以下模块仍是 OpenLDAP 单阶段时代的旧实现,正在迁移到本文档描述的目标架构: + +- `internal/ldaplib/ops.go` 的写路径(Add/Modify/ModifyDN)、`internal/password`(SSHA)、 + `config.yml` 的 `user-ou / inactive-ou / group-root-name / group-object-class / + group-member-attr / initial-password` 仍是旧概念,**迁移完成前不要把它们当目标行为**。 +- `internal/store` 现仅是轻量台账,尚未扩展为全字段镜像 + 部门关系表。 +- `internal/feishu` 尚无卡片发送能力,离职确认通道未落地。 + +改代码时以本文档的硬约束为准,逐步把旧实现收敛到目标架构。 + +## 硬约束 + +### 1. 真相源层级:飞书唯一,SQLite 只镜像 + +飞书是数据的唯一真相;SQLite 只做**镜像 + 生命周期标记**;LLDAP 只物化在职状态。 +SQLite 永不「发明」飞书没有的数据。任何写 SQLite 的逻辑都只能是「从飞书快照写入」或 +「生命周期状态转移」。禁止在 SQLite 里手改业务字段(那样会让下轮快照打回,出现幽灵漂移)。 + +### 2. 镜像刷新必须事务化 + +飞书全量快照要在**单个事务**里整体替换(用户/部门/关系三张表一致更新,或带版本号)。 +快照中途失败必须回滚到上一版镜像,绝不允许 SQLite 停留在「半新半旧」状态——物化阶段读 +到不一致镜像会污染 LDAP。物化与快照之间需要互斥,两者不能在单实例里并发执行。 + +### 3. LDAP 只在职;离职 = 双确认后才删 + +LLDAP 只有 `ou=people`(无 inactive OU、无禁用标志),所以「离职」= 从 LLDAP **物理删除**。 +在分配 `confirmed_leave` 状态前,任何人不得把用户移出删除保护;未确认的离职候选绝不入计划。 + +流程:缺席 → 飞书 `is_resigned` 机器确认(`feishu` 包,保留)→ 发飞书管理群卡片 +(含一次性「确认离职」按钮,跳转确认端点)→ 确认端点校验一次性 token → 标记 +`confirmed_leave` → 下一轮物化删除。两道闸缺一不可,任何路径都不得绕过。 + +- token 用 `crypto/rand` 生成,存 SQLite(带过期时间,如 72h),**一次使用**。 +- 确认端点挂在 `internal/server`,**不经 Auth 中间件**——token 本身即凭证;点击后回渲染 + 一个极简确认页,不要回显用户敏感信息。 +- 用户重新在飞书可见 → 撤销 `leave_candidate` 回 `active`,未用的 token 一并作废。 +- 未确认 = 不删除(安全侧默认对)。重复提醒要有节流,防止每轮都刷屏。 + +飞书卡片是单向通知(程序收不到点击回执),所以**确认必须落在我们自己的一次性 HTTP 端点**, +webhook 卡片按钮的 `url` 跳到它。不要尝试用卡片 callback 回执——那需要开放平台回调配置, +不值当。 + +### 4. 镜像台账 = 删除/修改权限的唯一来源 + +`store` 记录「哪些条目是本工具创建的」。任何不在镜像台账中的条目(服务账号、手工账号、 +`admin`)在计划里永不出现。改动 diff 逻辑时不要引入「靠属性嗅探判断归属」——那不如 +台账可靠。台账按期望状态整体登记,而不是只记录本次执行的操作。 + +### 5. 操作顺序是硬约束,不是偏好 + +`syncer/plan.go` 的 `opPhase` 决定执行顺序:分组 → 用户 → 恢复 → 加组 → 移组 → 删除。 +删除永远是最后一阶段(且只处理 `confirmed_leave`)。同阶段内按生成顺序(`Seq`)而非 +字母序排序(分组必须父先于子创建,曾因此出过 `No Such Object`)。 + +### 6. deleteUser 会级联清理成员关系 + +LLDAP 的 `memberships` 表外键是 `ON DELETE CASCADE`(源码已核实),删用户会自动把它从 +所有组移除,**不需要先移出组再删**;`deleteGroup` 同理。但删除仍应放在最后阶段, +且执行前先读一次镜像确认该用户仍在职视图外。 + +### 7. 读走 LDAP 的视图必须与 LLDAP 一致 + +需要跟 LLDAP 的 LDAP 视图对齐,否则比对会静默失效: + +- 用户 objectClass 是 `inetOrgPerson`(`person`/`posixAccount` 为别名),Authelia 官方模板 + 的 `users_filter` 用 `objectClass=person`——命中,**无需任何自定义属性**。 +- 组对内同时响应 `member` 与 `uniqueMember`,读取两种都认(`Members()` 现状保留)。 +- 组 CN 即 LLDAP 的 `displayName`;组名唯一。 +- GraphQL 写侧只配 LLDAP 内置 `addUserToGroup`,不要用自定义属性模拟成员关系。 + +Authelia 用官方 `implementation: lldap` + 官方模板(`users_filter`、`groups_filter`、 +`attributes: username=uid / display_name=cn / mail=mail / member_of=memberOf`), +**不得定制**。给 Authelia 建专用 bind 用户,授予 `lldap_strict_readonly` + +`lldap_password_manager`,别拿 admin 给它用。 + +### 8. GraphQL 写属性:省略即不改 + +LLDAP 的 `UpdateUserInput` 语义是「**省略的字段 = 不改变**」。genqlient 对可选输入字段 +生成指针、且 `nil` 会从请求中省略——这正好匹配语义,**不要用显式 null 覆盖字段**。 +- `insertAttributes` 对列表属性要**全量**提供整份列表;`removeAttributes` 只列要删的。 +- 对应旧约束「对不存在属性执行 Delete 会让整个 Modify 失败」:删除/替换前以镜像现状为准, + 只处理确认存在的属性。 +- 硬字段(uid/displayName/email)与自定义属性分开传,别混淆。 + +### 9. 密码:LLDAP 独占,程序不碰 + +GraphQL **没有**密码 mutation;LLDAP 自己管哈希。因此: + +- 创建用户**不设密码**,首次登录走 Authelia「忘记密码」(bind 用户需 `lldap_password_manager` + 权限即可写)。这比旧「随机初始密码 + SSHA」简单且更安全。 +- `internal/password` 包与 `initial-password` 配置项在迁移中删除,**不要复用它们**。 +- 任何路径都不得尝试写密码或注入预计算的哈希。 + +### 10. 自定义属性(若启用)不得撞硬编码名 + +若下游系统需要通过 LDAP 读自定义属性(如 `mobile`),才需要 `addUserAttribute` 声明(幂等 +引导、`isList=false`、editable+visible)。名称**不得与 LLDAP 硬编码属性冲突**(`uid`、`mail`、 +`cn`、`sn`、`givenName`、`avatar`、`creationDate`、`memberOf`、`objectClass`、`uuid` 等)。 +Authelia 官方模板不依赖任何自定义属性——**默认不建**。后续若要对接 ssh/pam(例如给用户 +打「可访问哪些服务器」的范围标记)再按需用自定义属性引入,届时同步工具负责声明与维护。 + +### 11. 登录名只用企业邮箱 + +`uid` 取自 `enterprise_email` 的 `@` 前缀。**不要回落到个人邮箱**:企业邮箱是组织内唯一且 +受控的标识。缺少企业邮箱的用户被跳过,且**必须产生告警**(进 SQLite 的 skipped 记录, +WebUI 要能列出来),让管理者发现有人没同步进来。 + +### 12. 组名以 dept_id 为稳定唯一标识 + +LLDAP 组不能嵌套(组里只能有用户),部门树在 LLDAP 中全部平铺在 `ou=groups` 下。 +LDAP 组名**直接用飞书部门 ID**(`cn=,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` 加页签与 `
` → `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='' -e LLDAP_LDAP_USER_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` 取收件地址,为空会失败。 + +## 代码风格 + +- 注释用中文,与现有代码一致。 +- **注释解释「为什么」,不解释「做什么」**。多数决策反直觉(如「为什么不回落个人邮箱」 + 「为什么读要同时认两种成员属性」「为什么离职要两道确认」),把理由写下来防止后人顺手 + 「优化」掉正确行为。不要给显而易见的代码加注释。 +- 错误信息带对象标识与原因,能直接定位。 +- 新增的安全约束尽量写成**结构性拦截**(代码主动检查并报错),而不只是写进文档。 \ No newline at end of file diff --git a/internal/lldapgql/client.go b/internal/lldapgql/client.go new file mode 100644 index 0000000..d132148 --- /dev/null +++ b/internal/lldapgql/client.go @@ -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":""}`;后续 GraphQL +// 请求带 `Authorization: Bearer `。 +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) +} \ No newline at end of file diff --git a/internal/lldapgql/integration_test.go b/internal/lldapgql/integration_test.go new file mode 100644 index 0000000..d8d9ee1 --- /dev/null +++ b/internal/lldapgql/integration_test.go @@ -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 } \ No newline at end of file diff --git a/internal/lldapgql/schema.graphql b/internal/lldapgql/schema.graphql new file mode 100644 index 0000000..e1bbeec --- /dev/null +++ b/internal/lldapgql/schema.graphql @@ -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!]! +}