Files
idsync/README.md
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

177 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# idsync
把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 `go-ldap-admin`。
## 它做什么,不做什么
**做**:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。
**不做**:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。
这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具**不在认证路径上**——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。
## 工作方式
```
飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP
▲ │
└─────────────── SQLite 台账(所有权记录)◀───────────┘
```
三个关键设计:
**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` | 仅创建时写入,**更新路径永不触碰** |
### 与 Authelia 的衔接
- **`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 # 本地运行
```
> **关于代理与网络**:若 `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`。
## 部署
```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
```
## 已知边界
- **不做密码同步下发**。Authelia 重置密码只写 LDAP,不发出任何事件或回调。因此对于「只认自己库里本地密码、既不吃 LDAP 也不读 HTTP 头」的服务,无法通过本工具同步密码。这类服务建议套一层 forward-auth(由 Authelia 注入 `Remote-User` / `Remote-Email` / `Remote-Groups`),或迁移到支持 LDAP 的同类方案。
- 部门改名会导致分组 DN 变化。此时**不会自动改名**(自动改名会牵动成员的 `uniqueMember` 引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。
- 飞书侧删除的部门不会被自动删除分组,同样只告警。