# 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= 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=,` 。用部门 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='' \ -e LLDAP_LDAP_USER_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: 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` 全绿。