diff --git a/AGENTS.md b/AGENTS.md index 490820d..bcaf94c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,18 +47,6 @@ 不触碰 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 只镜像 diff --git a/Makefile b/Makefile index e5c0e98..589cfd5 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index dd92d9f..22bfdd9 100644 --- a/README.md +++ b/README.md @@ -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 布局 - -``` - -├── ou=people 在职用户(Authelia 的 base_dn 指向这里) -│ └── uid=<邮箱前缀>,ou=people, -├── ou=Inactive 离职用户(位于 Authelia base_dn 之外 → 自动无法登录) -│ └── uid=<邮箱前缀>,ou=Inactive, -└── ou= 部门树 - └── cn=<部门拼音>,cn=<父部门拼音>,...,ou=, -``` - -部门用 `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,`。 -- **更新路径绝不写 `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= +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=,` + 。用部门 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='' \ + -e LLDAP_LDAP_USER_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: + 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://: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` 全绿。 \ No newline at end of file