- README 重写:两阶段架构、LDAP 读/GraphQL 写、部门索引组名、离职双确认、 部署(起 LLDAP + Authelia 官方 lldap 模板 + 配 idsync + 构建运行)、命令、测试 - Makefile:test-integration 改指本地 LLDAP(3890/17170),新增 make lldap 起容器, 头部注释更新为 LLDAP - AGENTS.md:移除「迁移状态」节(迁移已完成,旧实现已删除/收敛)
idsync — 飞书通讯录 → 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
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 模板,勿定制)
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):
make deps / make css / make build / make run
产物 bin/idsync 为静态二进制,内嵌 WebUI,可按 Dockerfile 打进镜像部署到
0.0.0.0:8899。镜像数据库在 data/(含离职状态与一次性 token,务必持久化)。
命令
./idsync -check-config # 打印有效配置(凭据脱敏)
./idsync -snapshot # 只跑一轮快照(飞书→镜像+离职流程)后退出
./idsync -dry-run # 快照+物化,只打印计划不写入
./idsync -apply # 快照+物化并实际写入
./idsync -apply -force # 跳过删除数量阈值护栏
./idsync -gen-secret # 生成会话密钥
./idsync -hash-password '…' # 生成管理员密码 bcrypt 哈希
管理界面 http://<host>:8899:概览(连通性/待确认离职/被跳过)、用户、部门分组、
变更预览(含「立即快照」「应用变更」)、物化历史。
测试
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 全绿。