Files
caowangrenbo 6d31cba631 docs: 交付收尾(README/Dockerfile/Makefile 对齐 + 移除迁移状态节)
- README 重写:两阶段架构、LDAP 读/GraphQL 写、部门索引组名、离职双确认、
  部署(起 LLDAP + Authelia 官方 lldap 模板 + 配 idsync + 构建运行)、命令、测试
- Makefile:test-integration 改指本地 LLDAP(3890/17170),新增 make lldap 起容器,
  头部注释更新为 LLDAP
- AGENTS.md:移除「迁移状态」节(迁移已完成,旧实现已删除/收敛)
2026-09-29 23:58:29 +08:00

152 lines
7.2 KiB
Markdown
Raw Permalink 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 — 飞书通讯录 → LLDAP 同步器
把飞书通讯录同步到 [LLDAP](https://github.com/lldap/lldap),替代 OpenLDAP 时代的
`go-ldap-admin`。数据单向流动、两阶段:
```
飞书通讯录 ─快照(低频)─▶ SQLite 镜像 ─物化(略高频)─▶ LLDAP(只在职)
(全字段+部门关系+生命周期) │
▼
离职:机器确认(飞书 is_resigned) → 飞书卡片一次性端点
人工确认 → confirmed_leave → 物化时物理删除
```
- **读走 LDAP、写走 GraphQL**:LLDAP 的实际状态用 LDAP 读,所有写入(建/改/删用户、
建组、成员关系)走 GraphQL,密码由 LLDAP 独占(程序绝不写密码,首次登录走
Authelia「忘记密码」)。
- **本工具不参与认证链路**:登录、会话、密码重置、发信都由 Authelia 负责。它只决定
「飞书有什么数据、LLDAP 该长什么样、两者差异是什么」。它对外发出的唯一通知是
「离职人工确认」飞书卡片。
## 定位与边界
| 属于本项目 | 不属于本项目 |
|---|---|
| 飞书有什么数据、LDAP 该长什么样、两者差异是什么 | 谁有权限登录(Authelia) |
| 快照/物化调度、离职双确认状态机、离职卡片通知 | 认证、会话、登录页、密码重置 |
鉴权链是 `业务系统 → Authelia → LLDAP bind`;本工具只保证 LLDAP 里只在职、且离职
必须经人工确认后才删除。
## 架构
```
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(内容指纹)
```
### 关键约定
- **只在职**: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 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`。
### 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` 全绿。