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/)不入库。
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
bin
|
||||||
|
data
|
||||||
|
web/node_modules
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# 构建产物
|
||||||
|
bin/
|
||||||
|
idsync
|
||||||
|
|
||||||
|
# 运行时数据(台账、日志)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# 运维导出的 LDIF 备份含真实用户条目、口令哈希与手机号,绝不入库
|
||||||
|
backup/
|
||||||
|
|
||||||
|
# 真实配置含飞书与 LDAP 凭据,绝不入库
|
||||||
|
config.yml
|
||||||
|
|
||||||
|
# 前端依赖;构建产物 internal/webui/dist 需要提交,见 README
|
||||||
|
web/node_modules/
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# 多阶段构建:编译 Go 二进制(内嵌 WebUI),运行在极小的基础镜像里。
|
||||||
|
FROM docker.io/library/golang:1.26-trixie AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# 先只拷贝依赖清单,让依赖下载层可以复用缓存。
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# CGO_ENABLED=0 让产物是静态链接的,可直接跑在 scratch/alpine 上。
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/idsync .
|
||||||
|
|
||||||
|
FROM docker.io/library/alpine:3.20
|
||||||
|
|
||||||
|
# ca-certificates 供访问飞书 HTTPS 接口使用;tzdata 让日志时间正确。
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata && \
|
||||||
|
adduser -D -u 10001 idsync
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/idsync /app/idsync
|
||||||
|
|
||||||
|
# 台账与配置放在挂载卷里,容器重建不丢数据。
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
USER idsync
|
||||||
|
|
||||||
|
EXPOSE 8899
|
||||||
|
|
||||||
|
# 默认以服务模式启动;定时同步由配置中的 sync.enable 控制。
|
||||||
|
ENTRYPOINT ["/app/idsync"]
|
||||||
|
CMD ["-config", "/app/config.yml"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# idsync — 飞书 → OpenLDAP 同步器
|
||||||
|
# 宿主未安装 Go,所有 Go 操作都在 podman 容器内执行。
|
||||||
|
# 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。
|
||||||
|
|
||||||
|
GO_IMAGE := docker.io/library/golang:1.26-trixie
|
||||||
|
NODE_IMAGE := docker.io/library/node:20-alpine
|
||||||
|
PROXY := http://127.0.0.1:10808
|
||||||
|
|
||||||
|
PODMAN := podman run --rm --pull=never --network=host \
|
||||||
|
-e http_proxy=$(PROXY) -e https_proxy=$(PROXY) \
|
||||||
|
-v $(PWD):/app -w /app \
|
||||||
|
-v idsync-gomod:/go/pkg/mod \
|
||||||
|
-v idsync-gocache:/root/.cache/go-build
|
||||||
|
|
||||||
|
.PHONY: default
|
||||||
|
default: help
|
||||||
|
|
||||||
|
.PHONY: deps
|
||||||
|
deps:
|
||||||
|
$(PODMAN) $(GO_IMAGE) go mod download
|
||||||
|
$(PODMAN) $(GO_IMAGE) go mod tidy
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
$(PODMAN) -e CGO_ENABLED=0 $(GO_IMAGE) go build -trimpath -o bin/idsync .
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
$(PODMAN) $(GO_IMAGE) go test ./...
|
||||||
|
|
||||||
|
.PHONY: vet
|
||||||
|
vet:
|
||||||
|
$(PODMAN) $(GO_IMAGE) go vet ./...
|
||||||
|
|
||||||
|
# 集成测试对真实 OpenLDAP 运行;容器内需能访问宿主的 slapd,
|
||||||
|
# 因此用 host.containers.internal 并放行 no_proxy,避免流量被代理拦走。
|
||||||
|
LDAP_TEST ?= ldap://host.containers.internal:3899
|
||||||
|
|
||||||
|
.PHONY: test-integration
|
||||||
|
test-integration:
|
||||||
|
podman run --rm --pull=never --network=host \
|
||||||
|
--add-host host.containers.internal:host-gateway \
|
||||||
|
-e no_proxy=127.0.0.1,localhost,host.containers.internal \
|
||||||
|
-e NO_PROXY=127.0.0.1,localhost,host.containers.internal \
|
||||||
|
-e LDAP_TEST_URL=$(LDAP_TEST) \
|
||||||
|
-v $(PWD):/app -w /app \
|
||||||
|
-v idsync-gomod:/go/pkg/mod \
|
||||||
|
-v idsync-gocache:/root/.cache/go-build \
|
||||||
|
$(GO_IMAGE) go test -tags=integration ./internal/... -v
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
$(PODMAN) $(GO_IMAGE) gofmt -l -w .
|
||||||
|
|
||||||
|
.PHONY: css
|
||||||
|
css:
|
||||||
|
sh scripts/build-webui.sh
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
$(PODMAN) $(GO_IMAGE) go run . -config config.yml
|
||||||
|
|
||||||
|
.PHONY: shell
|
||||||
|
shell:
|
||||||
|
podman run --rm -it --network=host \
|
||||||
|
-e http_proxy=$(PROXY) -e https_proxy=$(PROXY) \
|
||||||
|
-v $(PWD):/app -w /app \
|
||||||
|
-v idsync-gomod:/go/pkg/mod \
|
||||||
|
-v idsync-gocache:/root/.cache/go-build \
|
||||||
|
$(GO_IMAGE) bash
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -rf bin
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "目标:"
|
||||||
|
@echo " deps 下载并整理 Go 依赖"
|
||||||
|
@echo " build 构建 bin/idsync"
|
||||||
|
@echo " test 运行单元测试"
|
||||||
|
@echo " test-integration 对真实 OpenLDAP 运行集成测试"
|
||||||
|
@echo " vet go vet"
|
||||||
|
@echo " fmt 格式化"
|
||||||
|
@echo " css 构建 WebUI 样式(node 容器)"
|
||||||
|
@echo " run 本地运行"
|
||||||
|
@echo " shell 进入构建容器"
|
||||||
|
@echo " clean 清理产物"
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# 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),而是在预览页告警,交由人工处理。
|
||||||
|
- 飞书侧删除的部门不会被自动删除分组,同样只告警。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# idsync 配置示例。复制为 config.yml 后按实际环境修改。
|
||||||
|
#
|
||||||
|
# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。
|
||||||
|
# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。
|
||||||
|
|
||||||
|
system:
|
||||||
|
# debug 会输出更详细的日志;生产环境用 release
|
||||||
|
mode: release
|
||||||
|
# 台账数据库与运行数据目录
|
||||||
|
data-dir: data
|
||||||
|
|
||||||
|
server:
|
||||||
|
listen: 0.0.0.0:8899
|
||||||
|
# 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关)
|
||||||
|
admin-user: admin
|
||||||
|
# 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希
|
||||||
|
admin-password: change-me
|
||||||
|
# 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码
|
||||||
|
admin-password-hash: ""
|
||||||
|
# 会话签名密钥,用 `./idsync -gen-secret` 生成。
|
||||||
|
# 留空会自动生成临时密钥,但重启后需要重新登录。
|
||||||
|
session-secret: ""
|
||||||
|
# 经 HTTPS 反代访问时设为 true
|
||||||
|
secure-cookie: false
|
||||||
|
|
||||||
|
feishu:
|
||||||
|
app-id: "cli_xxxxxxxxxxxx"
|
||||||
|
app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
# 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com
|
||||||
|
base-url: ""
|
||||||
|
# 留空表示同步全部部门;填部门 ID 则只同步这些部门,
|
||||||
|
# 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"])
|
||||||
|
dept-list: []
|
||||||
|
# 调用飞书的速率上限(次/秒),避免触发频控
|
||||||
|
qps: 5
|
||||||
|
timeout-sec: 30
|
||||||
|
|
||||||
|
ldap:
|
||||||
|
# 生产环境务必使用 ldaps:// 或开启 start-tls:
|
||||||
|
# Authelia 重置密码时会把新密码经此连接发送。
|
||||||
|
url: ldaps://ldap.example.com:636
|
||||||
|
base-dn: "dc=example,dc=com"
|
||||||
|
admin-dn: "cn=admin,dc=example,dc=com"
|
||||||
|
admin-pass: "change-me"
|
||||||
|
# 在职用户所在 OU。必须落在 Authelia 的 base_dn 之内。
|
||||||
|
user-ou: "ou=people"
|
||||||
|
# 离职用户迁往的 OU。必须落在 Authelia 的 base_dn 之外,
|
||||||
|
# 这样离职者会自动失去登录能力,无需在 Authelia 侧加过滤条件。
|
||||||
|
inactive-ou: "ou=Inactive"
|
||||||
|
# 部门树根 OU 的名称(仅名称,不含 ou=)
|
||||||
|
group-root-name: "feishuroot"
|
||||||
|
timeout-sec: 15
|
||||||
|
start-tls: false
|
||||||
|
# 仅自签证书的测试环境使用
|
||||||
|
insecure-skip-verify: false
|
||||||
|
# 新建用户的初始密码。留空则每个用户随机生成,
|
||||||
|
# 用户首次登录应走 Authelia 的“忘记密码”自助重置。
|
||||||
|
initial-password: ""
|
||||||
|
|
||||||
|
database:
|
||||||
|
source: data/idsync.db
|
||||||
|
|
||||||
|
sync:
|
||||||
|
# 是否启用定时同步
|
||||||
|
enable: true
|
||||||
|
# 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。
|
||||||
|
schedule: "0 40 0 * * *"
|
||||||
|
# true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。
|
||||||
|
default-dry-run: true
|
||||||
|
# 单次同步允许停用的账号数上限,0 表示不限制
|
||||||
|
max-deactivate: 0
|
||||||
|
# 单次同步允许停用的账号比例上限(0-1)。
|
||||||
|
# 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。
|
||||||
|
max-deactivate-percent: 0.2
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
module idsync
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.14
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.12.0
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.12.0 h1:H8NP6YIgfEX0RBhKse25npdeZoiaDv8mrw9nCnCVFRc=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.12.0/go.mod h1:F1nwLfYBSKvD8mS/OFw90Gr+V4RIugPBR44m/ThA5jg=
|
||||||
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是 idsync 的完整配置。
|
||||||
|
type Config struct {
|
||||||
|
System System `mapstructure:"system"`
|
||||||
|
Server Server `mapstructure:"server"`
|
||||||
|
Feishu Feishu `mapstructure:"feishu"`
|
||||||
|
Ldap Ldap `mapstructure:"ldap"`
|
||||||
|
Database Database `mapstructure:"database"`
|
||||||
|
Sync Sync `mapstructure:"sync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type System struct {
|
||||||
|
// Mode: debug 或 release,影响 gin 与日志详细度
|
||||||
|
Mode string `mapstructure:"mode"`
|
||||||
|
// DataDir 用于存放 sqlite 台账与日志
|
||||||
|
DataDir string `mapstructure:"data-dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
// Listen 形如 0.0.0.0:8899
|
||||||
|
Listen string `mapstructure:"listen"`
|
||||||
|
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
|
||||||
|
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
|
||||||
|
AdminUser string `mapstructure:"admin-user"`
|
||||||
|
AdminPassword string `mapstructure:"admin-password"`
|
||||||
|
AdminPasswordHash string `mapstructure:"admin-password-hash"`
|
||||||
|
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。
|
||||||
|
SessionSecret string `mapstructure:"session-secret"`
|
||||||
|
// SecureCookie 在 HTTPS 前置反代后应设为 true
|
||||||
|
SecureCookie bool `mapstructure:"secure-cookie"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feishu struct {
|
||||||
|
AppID string `mapstructure:"app-id"`
|
||||||
|
AppSecret string `mapstructure:"app-secret"`
|
||||||
|
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com
|
||||||
|
BaseURL string `mapstructure:"base-url"`
|
||||||
|
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门
|
||||||
|
DeptList []string `mapstructure:"dept-list"`
|
||||||
|
// QPS 限制调用飞书的速率,默认 5
|
||||||
|
QPS int `mapstructure:"qps"`
|
||||||
|
// TimeoutSec 单次 API 调用超时,默认 30
|
||||||
|
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ldap struct {
|
||||||
|
URL string `mapstructure:"url"`
|
||||||
|
BaseDN string `mapstructure:"base-dn"`
|
||||||
|
AdminDN string `mapstructure:"admin-dn"`
|
||||||
|
AdminPass string `mapstructure:"admin-pass"`
|
||||||
|
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树
|
||||||
|
UserOU string `mapstructure:"user-ou"`
|
||||||
|
// InactiveOU 离职用户迁往的 OU,应位于 Authelia base_dn 之外
|
||||||
|
InactiveOU string `mapstructure:"inactive-ou"`
|
||||||
|
// GroupRootOU 部门树的根 OU 名称(仅名称,不含 ou=)
|
||||||
|
GroupRootName string `mapstructure:"group-root-name"`
|
||||||
|
// GroupObjectClass 部门分组使用的 objectClass。
|
||||||
|
// 必须与 Authelia 的 groups_filter 一致,否则分组对 Authelia 不可见,
|
||||||
|
// Remote-Groups 会是空的,基于组的 ACL 全部失效。
|
||||||
|
// 常见取值:groupOfUniqueNames(配 uniqueMember)或 groupOfNames(配 member)。
|
||||||
|
GroupObjectClass string `mapstructure:"group-object-class"`
|
||||||
|
// GroupMemberAttr 分组成员属性名。留空则按 GroupObjectClass 推导。
|
||||||
|
GroupMemberAttr string `mapstructure:"group-member-attr"`
|
||||||
|
// TimeoutSec 连接与操作超时,默认 15
|
||||||
|
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||||
|
// StartTLS 对 ldap:// 启用 StartTLS
|
||||||
|
StartTLS bool `mapstructure:"start-tls"`
|
||||||
|
// InsecureSkipVerify 仅用于自签证书的测试环境
|
||||||
|
InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"`
|
||||||
|
// InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成
|
||||||
|
InitialPassword string `mapstructure:"initial-password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
// Source 是 sqlite 文件路径
|
||||||
|
Source string `mapstructure:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sync struct {
|
||||||
|
// Enable 是否启用定时同步
|
||||||
|
Enable bool `mapstructure:"enable"`
|
||||||
|
// Schedule 秒级 cron,例如 "0 40 0 * * *"
|
||||||
|
Schedule string `mapstructure:"schedule"`
|
||||||
|
// DefaultDryRun 为 true 时,定时任务只生成计划不执行
|
||||||
|
DefaultDryRun bool `mapstructure:"default-dry-run"`
|
||||||
|
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制
|
||||||
|
MaxDeactivate int `mapstructure:"max-deactivate"`
|
||||||
|
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制
|
||||||
|
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 读取配置文件并应用默认值与校验。
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
setDefaults(v)
|
||||||
|
|
||||||
|
if path != "" {
|
||||||
|
v.SetConfigFile(path)
|
||||||
|
} else {
|
||||||
|
v.SetConfigName("config")
|
||||||
|
v.SetConfigType("yaml")
|
||||||
|
v.AddConfigPath(".")
|
||||||
|
v.AddConfigPath("/etc/idsync")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := v.Unmarshal(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaults(v *viper.Viper) {
|
||||||
|
v.SetDefault("system.mode", "release")
|
||||||
|
v.SetDefault("system.data-dir", "data")
|
||||||
|
|
||||||
|
v.SetDefault("server.listen", "0.0.0.0:8899")
|
||||||
|
v.SetDefault("server.admin-user", "admin")
|
||||||
|
v.SetDefault("server.secure-cookie", false)
|
||||||
|
|
||||||
|
v.SetDefault("feishu.qps", 5)
|
||||||
|
v.SetDefault("feishu.timeout-sec", 30)
|
||||||
|
|
||||||
|
v.SetDefault("ldap.url", "ldap://localhost:389")
|
||||||
|
v.SetDefault("ldap.user-ou", "ou=people")
|
||||||
|
v.SetDefault("ldap.inactive-ou", "ou=Inactive")
|
||||||
|
v.SetDefault("ldap.group-root-name", "feishuroot")
|
||||||
|
v.SetDefault("ldap.group-object-class", "groupOfUniqueNames")
|
||||||
|
v.SetDefault("ldap.group-member-attr", "")
|
||||||
|
v.SetDefault("ldap.timeout-sec", 15)
|
||||||
|
v.SetDefault("ldap.start-tls", false)
|
||||||
|
v.SetDefault("ldap.insecure-skip-verify", false)
|
||||||
|
|
||||||
|
v.SetDefault("database.source", "data/idsync.db")
|
||||||
|
|
||||||
|
v.SetDefault("sync.enable", false)
|
||||||
|
v.SetDefault("sync.schedule", "0 40 0 * * *")
|
||||||
|
v.SetDefault("sync.default-dry-run", true)
|
||||||
|
v.SetDefault("sync.max-deactivate", 0)
|
||||||
|
v.SetDefault("sync.max-deactivate-percent", 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
|
||||||
|
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
|
||||||
|
func (c *Config) NormalizeForTest() error { return c.normalize() }
|
||||||
|
|
||||||
|
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。
|
||||||
|
func (c *Config) normalize() error {
|
||||||
|
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
|
||||||
|
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
|
||||||
|
}
|
||||||
|
if c.Ldap.BaseDN == "" {
|
||||||
|
return fmt.Errorf("ldap.base-dn 不能为空")
|
||||||
|
}
|
||||||
|
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" {
|
||||||
|
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空")
|
||||||
|
}
|
||||||
|
if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
|
||||||
|
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
|
||||||
|
}
|
||||||
|
if c.Sync.Enable && c.Sync.Schedule == "" {
|
||||||
|
return fmt.Errorf("sync.schedule 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Ldap.normalizeGroupSchema(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。
|
||||||
|
//
|
||||||
|
// 这两项必须与 Authelia 的 groups_filter 匹配:objectClass 不匹配则 Authelia
|
||||||
|
// 完全看不到我们建的分组;成员属性不匹配则 membership 查询返回空。二者都不会
|
||||||
|
// 报错,只会表现为「分组 ACL 静默失效」,所以这里主动校验而不是默默容忍。
|
||||||
|
func (l *Ldap) normalizeGroupSchema() error {
|
||||||
|
oc := strings.TrimSpace(l.GroupObjectClass)
|
||||||
|
if oc == "" {
|
||||||
|
oc = "groupOfUniqueNames"
|
||||||
|
}
|
||||||
|
l.GroupObjectClass = oc
|
||||||
|
|
||||||
|
attr := strings.TrimSpace(l.GroupMemberAttr)
|
||||||
|
if attr == "" {
|
||||||
|
// 按 objectClass 推导出配套的成员属性
|
||||||
|
switch strings.ToLower(oc) {
|
||||||
|
case "groupofnames":
|
||||||
|
attr = "member"
|
||||||
|
default:
|
||||||
|
attr = "uniqueMember"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.GroupMemberAttr = attr
|
||||||
|
|
||||||
|
// 校验搭配是否正确:groupOfNames 只允许 member,groupOfUniqueNames 只允许 uniqueMember。
|
||||||
|
// 写错会产生 LDAP objectClassViolation,但报错信息晦涩,提前拦下更友好。
|
||||||
|
switch strings.ToLower(oc) {
|
||||||
|
case "groupofnames":
|
||||||
|
if !strings.EqualFold(attr, "member") {
|
||||||
|
return fmt.Errorf("ldap.group-object-class 为 groupOfNames 时,ldap.group-member-attr 必须为 member(当前 %q)", attr)
|
||||||
|
}
|
||||||
|
case "groupofuniquenames":
|
||||||
|
if !strings.EqualFold(attr, "uniqueMember") {
|
||||||
|
return fmt.Errorf("ldap.group-object-class 为 groupOfUniqueNames 时,ldap.group-member-attr 必须为 uniqueMember(当前 %q)", attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserOUDN 返回在职用户的完整 OU DN。
|
||||||
|
func (c *Config) UserOUDN() string {
|
||||||
|
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InactiveOUDN 返回离职用户的完整 OU DN。
|
||||||
|
func (c *Config) InactiveOUDN() string {
|
||||||
|
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupRootDN 返回部门树根 OU 的完整 DN。
|
||||||
|
func (c *Config) GroupRootDN() string {
|
||||||
|
name := strings.TrimSpace(c.Ldap.GroupRootName)
|
||||||
|
if name == "" {
|
||||||
|
name = "feishuroot"
|
||||||
|
}
|
||||||
|
return "ou=" + name + "," + c.Ldap.BaseDN
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinDN 把相对 RDN 与后缀拼成完整 DN,容忍两端多余逗号。
|
||||||
|
func joinDN(rdn, suffix string) string {
|
||||||
|
return strings.TrimSuffix(strings.TrimSpace(rdn), ",") + "," + strings.TrimSpace(suffix)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeConfig 写一份最小可用配置并加载它。
|
||||||
|
func writeConfig(t *testing.T, extra string) *Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := `
|
||||||
|
system:
|
||||||
|
data-dir: /tmp/idsync-test
|
||||||
|
server:
|
||||||
|
admin-user: admin
|
||||||
|
admin-password: pw
|
||||||
|
feishu:
|
||||||
|
app-id: a
|
||||||
|
app-secret: b
|
||||||
|
ldap:
|
||||||
|
url: ldap://localhost:389
|
||||||
|
base-dn: "dc=example,dc=org"
|
||||||
|
admin-dn: "cn=admin,dc=example,dc=org"
|
||||||
|
admin-pass: pw
|
||||||
|
` + extra
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("写配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。
|
||||||
|
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) {
|
||||||
|
cfg := writeConfig(t, "")
|
||||||
|
|
||||||
|
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" {
|
||||||
|
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass)
|
||||||
|
}
|
||||||
|
if cfg.Ldap.GroupMemberAttr != "uniqueMember" {
|
||||||
|
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。
|
||||||
|
//
|
||||||
|
// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、
|
||||||
|
// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以
|
||||||
|
// objectClassViolation 拒绝,而错误信息很不直观。
|
||||||
|
func TestGroupSchemaDerivesMemberAttr(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
objectClass string
|
||||||
|
wantAttr string
|
||||||
|
}{
|
||||||
|
{"groupOfNames", "member"},
|
||||||
|
{"groupOfUniqueNames", "uniqueMember"},
|
||||||
|
// 大小写不敏感
|
||||||
|
{"GroupOfNames", "member"},
|
||||||
|
{"GROUPOFUNIQUENAMES", "uniqueMember"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.objectClass, func(t *testing.T) {
|
||||||
|
cfg := writeConfig(t, " group-object-class: \""+c.objectClass+"\"\n")
|
||||||
|
if cfg.Ldap.GroupMemberAttr != c.wantAttr {
|
||||||
|
t.Errorf("objectClass=%s 推导出的成员属性 = %q, 期望 %q",
|
||||||
|
c.objectClass, cfg.Ldap.GroupMemberAttr, c.wantAttr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroupSchemaRejectsMismatch 验证 objectClass 与成员属性不匹配时直接报错。
|
||||||
|
//
|
||||||
|
// 静默容忍会让问题推迟到运行时,表现为分组建好了但成员查不到,
|
||||||
|
// 排查成本远高于启动即失败。
|
||||||
|
func TestGroupSchemaRejectsMismatch(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "groupOfNames 配 uniqueMember",
|
||||||
|
body: " group-object-class: \"groupOfNames\"\n group-member-attr: \"uniqueMember\"\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "groupOfUniqueNames 配 member",
|
||||||
|
body: " group-object-class: \"groupOfUniqueNames\"\n group-member-attr: \"member\"\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
body := `
|
||||||
|
system:
|
||||||
|
data-dir: /tmp/idsync-test
|
||||||
|
server:
|
||||||
|
admin-user: admin
|
||||||
|
admin-password: pw
|
||||||
|
feishu:
|
||||||
|
app-id: a
|
||||||
|
app-secret: b
|
||||||
|
ldap:
|
||||||
|
url: ldap://localhost:389
|
||||||
|
base-dn: "dc=example,dc=org"
|
||||||
|
admin-dn: "cn=admin,dc=example,dc=org"
|
||||||
|
admin-pass: pw
|
||||||
|
` + c.body
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("写配置失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Errorf("不匹配的组合应被拒绝,却加载成功")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivedDNs 验证各 OU 拼接结果正确。
|
||||||
|
func TestDerivedDNs(t *testing.T) {
|
||||||
|
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n group-root-name: \"feishuroot\"\n")
|
||||||
|
|
||||||
|
if got := cfg.UserOUDN(); got != "ou=users,dc=example,dc=org" {
|
||||||
|
t.Errorf("UserOUDN = %q", got)
|
||||||
|
}
|
||||||
|
if got := cfg.InactiveOUDN(); got != "ou=Inactive,dc=example,dc=org" {
|
||||||
|
t.Errorf("InactiveOUDN = %q", got)
|
||||||
|
}
|
||||||
|
if got := cfg.GroupRootDN(); got != "ou=feishuroot,dc=example,dc=org" {
|
||||||
|
t.Errorf("GroupRootDN = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInactiveOUOutsideUserOU 是一个架构约束测试。
|
||||||
|
//
|
||||||
|
// ou=Inactive 必须位于用户 OU 之外,否则 Authelia(其 base 为 additional_users_dn)
|
||||||
|
// 依然能搜到离职用户,停用机制就失效了。
|
||||||
|
func TestInactiveOUOutsideUserOU(t *testing.T) {
|
||||||
|
cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n")
|
||||||
|
|
||||||
|
userDN := cfg.UserOUDN()
|
||||||
|
inactiveDN := cfg.InactiveOUDN()
|
||||||
|
|
||||||
|
if inactiveDN == userDN {
|
||||||
|
t.Fatalf("离职 OU 与用户 OU 相同: %s", inactiveDN)
|
||||||
|
}
|
||||||
|
// 离职 OU 不应位于用户 OU 之下
|
||||||
|
if len(inactiveDN) > len(userDN) && inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
|
||||||
|
t.Errorf("离职 OU (%s) 位于用户 OU (%s) 之下,Authelia 仍能搜到离职用户", inactiveDN, userDN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsMissingCredentials 验证关键字段缺失时启动即失败。
|
||||||
|
func TestNormalizeRejectsMissingCredentials(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
over string
|
||||||
|
}{
|
||||||
|
{"缺少 app-id", " app-id: \"\"\n"},
|
||||||
|
{"缺少 base-dn", "BASE_DN_MARKER"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
|
||||||
|
feishuID, baseDN := "app", `"dc=example,dc=org"`
|
||||||
|
if c.name == "缺少 app-id" {
|
||||||
|
feishuID = ""
|
||||||
|
}
|
||||||
|
if c.name == "缺少 base-dn" {
|
||||||
|
baseDN = `""`
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `
|
||||||
|
system:
|
||||||
|
data-dir: /tmp/idsync-test
|
||||||
|
server:
|
||||||
|
admin-user: admin
|
||||||
|
admin-password: pw
|
||||||
|
feishu:
|
||||||
|
app-id: "` + feishuID + `"
|
||||||
|
app-secret: b
|
||||||
|
ldap:
|
||||||
|
url: ldap://localhost:389
|
||||||
|
base-dn: ` + baseDN + `
|
||||||
|
admin-dn: "cn=admin,dc=example,dc=org"
|
||||||
|
admin-pass: pw
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("写配置失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Errorf("缺少必要配置项时应加载失败")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Package feishu 封装飞书通讯录的读取,产出与 LDAP 无关的领域模型。
|
||||||
|
//
|
||||||
|
// 这里刻意只做“读”与规范化,不做任何映射或写入决策:把飞书的原始事实
|
||||||
|
// 转成干净的结构,diff 引擎再拿它与 LDAP 现状比较。
|
||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 部门 ID 类型与用户 ID 类型固定使用 open_department_id / union_id:
|
||||||
|
// 前者在租户内全局稳定且不随自定义 ID 变化,后者跨应用一致,适合做去重键。
|
||||||
|
const (
|
||||||
|
deptIDType = "open_department_id"
|
||||||
|
userIDType = "union_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 是对官方 SDK 的薄封装,负责限速、超时与重试。
|
||||||
|
type Client struct {
|
||||||
|
lark *lark.Client
|
||||||
|
log *slog.Logger
|
||||||
|
timeout time.Duration
|
||||||
|
|
||||||
|
// ticker 实现一个简单的令牌节奏限速,避免触发飞书侧频控。
|
||||||
|
mu sync.Mutex
|
||||||
|
lastAt time.Time
|
||||||
|
period time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options 是构造 Client 所需的参数。
|
||||||
|
type Options struct {
|
||||||
|
AppID string
|
||||||
|
AppSecret string
|
||||||
|
BaseURL string
|
||||||
|
QPS int
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造飞书客户端。
|
||||||
|
func New(opt Options, log *slog.Logger) (*Client, error) {
|
||||||
|
if opt.AppID == "" || opt.AppSecret == "" {
|
||||||
|
return nil, fmt.Errorf("飞书 app-id / app-secret 未配置")
|
||||||
|
}
|
||||||
|
if opt.QPS <= 0 {
|
||||||
|
opt.QPS = 5
|
||||||
|
}
|
||||||
|
if opt.Timeout <= 0 {
|
||||||
|
opt.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []lark.ClientOptionFunc{
|
||||||
|
lark.WithReqTimeout(opt.Timeout),
|
||||||
|
lark.WithLogLevel(larkcore.LogLevelError),
|
||||||
|
}
|
||||||
|
if opt.BaseURL != "" {
|
||||||
|
opts = append(opts, lark.WithOpenBaseUrl(opt.BaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
lark: lark.NewClient(opt.AppID, opt.AppSecret, opts...),
|
||||||
|
log: log,
|
||||||
|
timeout: opt.Timeout,
|
||||||
|
period: time.Second / time.Duration(opt.QPS),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait 在两次外部调用之间维持最小间隔,实现全局限速。
|
||||||
|
func (c *Client) wait(ctx context.Context) {
|
||||||
|
c.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
gap := c.period - now.Sub(c.lastAt)
|
||||||
|
if gap > 0 {
|
||||||
|
c.lastAt = now.Add(gap)
|
||||||
|
} else {
|
||||||
|
c.lastAt = now
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if gap > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(gap):
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// call 统一处理限速、超时与有限次数的指数退避重试。
|
||||||
|
//
|
||||||
|
// 只重试看起来是暂时性的错误(超时、连接问题、5xx);权限或参数错误立即返回,
|
||||||
|
// 重试它们只会放大问题并拖慢整轮同步。
|
||||||
|
func (c *Client) call(ctx context.Context, what string, fn func(ctx context.Context) error) error {
|
||||||
|
const maxAttempts = 3
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
c.wait(ctx)
|
||||||
|
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
|
err = fn(callCtx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isRetryable(err) || attempt == maxAttempts {
|
||||||
|
return fmt.Errorf("%s 失败: %w", what, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff := time.Duration(attempt) * 500 * time.Millisecond
|
||||||
|
c.log.Warn("飞书调用失败,准备重试",
|
||||||
|
"op", what, "attempt", attempt, "backoff", backoff, "err", err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(backoff):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s 失败: %w", what, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 做一次轻量的连通性检查:拉取根部门下的一页部门。
|
||||||
|
func (c *Client) Test(ctx context.Context) error {
|
||||||
|
return c.call(ctx, "连通性测试", func(ctx context.Context) error {
|
||||||
|
resp, err := c.lark.Contact.V3.Department.List(ctx,
|
||||||
|
larkcontact.NewListDepartmentReqBuilder().
|
||||||
|
ParentDepartmentId("0").
|
||||||
|
DepartmentIdType(deptIDType).
|
||||||
|
PageSize(1).
|
||||||
|
Build())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isRetryable 判断错误是否值得重试。
|
||||||
|
//
|
||||||
|
// 只重试可能自愈的传输层问题与飞书的频控/服务端错误;权限、参数错误
|
||||||
|
// 立即失败——重试它们既无意义,又会拖慢整轮同步并掩盖真正的问题。
|
||||||
|
func isRetryable(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上下文取消是调用方的决定,不要重试。
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 单次调用的超时值得再试一次;整体超时由外层 ctx 决定。
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
for _, s := range []string{
|
||||||
|
"timeout", "timed out", "connection reset", "connection refused",
|
||||||
|
"broken pipe", "eof", "temporary failure", "too many request",
|
||||||
|
"rate limit", "internal server error", "bad gateway",
|
||||||
|
"service unavailable", "gateway timeout",
|
||||||
|
} {
|
||||||
|
if strings.Contains(msg, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxItems 是单次读取允许的最大条目数,用于防御分页异常。
|
||||||
|
// 一个租户的部门或成员数量远超此值时必然意味着接口行为异常
|
||||||
|
// (例如服务端在 has_more=false 时仍返回非空游标导致原地打转),
|
||||||
|
// 宁可直接失败也不要无限翻页拖垮同步。
|
||||||
|
const maxItems = 200000
|
||||||
|
|
||||||
|
// Department 是从飞书读取的部门。
|
||||||
|
type Department struct {
|
||||||
|
ID string // open_department_id
|
||||||
|
ParentID string
|
||||||
|
Name string
|
||||||
|
// Order 是同级排序,用于稳定输出
|
||||||
|
Order int
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 是从飞书读取的用户,字段已规范化为可空字符串。
|
||||||
|
type User struct {
|
||||||
|
UnionID string
|
||||||
|
UserID string
|
||||||
|
OpenID string
|
||||||
|
Name string
|
||||||
|
EnName string
|
||||||
|
EnterpriseEmail string
|
||||||
|
Email string
|
||||||
|
Mobile string
|
||||||
|
EmployeeNo string
|
||||||
|
JobTitle string
|
||||||
|
WorkStation string
|
||||||
|
DepartmentIDs []string // open_department_id 列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resigned 表示飞书确认已离职的用户。
|
||||||
|
type Resigned struct {
|
||||||
|
UnionID string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot 是一次完整读取的结果。
|
||||||
|
type Snapshot struct {
|
||||||
|
Departments []*Department
|
||||||
|
Users []*User
|
||||||
|
// ResignedUsers 仅在显式检查时填充(见 CheckResigned)。
|
||||||
|
ResignedUsers []*Resigned
|
||||||
|
// SkippedNoEmail 记录因缺少企业邮箱而无法生成登录名的用户。
|
||||||
|
// 这些用户不会被同步,但必须在界面上显式暴露,避免“静默丢失”。
|
||||||
|
SkippedNoEmail []*User
|
||||||
|
// SkippedDuplicate 记录企业邮箱前缀与他人冲突的用户(uid, 姓名)。
|
||||||
|
SkippedDuplicate []DuplicateConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateConflict 描述一次登录名冲突。
|
||||||
|
type DuplicateConflict struct {
|
||||||
|
UID string
|
||||||
|
Names []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch 拉取部门与用户,构建期望状态快照。
|
||||||
|
//
|
||||||
|
// 如果 onlyDepts 非空,则只同步列出的部门;前缀 ^ 表示排除该部门。
|
||||||
|
func (c *Client) Fetch(ctx context.Context, onlyDepts []string) (*Snapshot, error) {
|
||||||
|
depts, err := c.fetchDepartments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
depts = filterDepartments(depts, onlyDepts)
|
||||||
|
|
||||||
|
users, skippedNoEmail, dups, err := c.fetchUsers(ctx, depts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Snapshot{
|
||||||
|
Departments: depts,
|
||||||
|
Users: users,
|
||||||
|
SkippedNoEmail: skippedNoEmail,
|
||||||
|
SkippedDuplicate: dups,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDepartments 拉取全量部门(含子部门)。
|
||||||
|
//
|
||||||
|
// 使用 ParentDepartmentId("0") + FetchChild(true) 一次性取回整棵树,
|
||||||
|
// 避免像旧实现那样逐部门递归调用造成 O(部门数) 次请求。
|
||||||
|
func (c *Client) fetchDepartments(ctx context.Context) ([]*Department, error) {
|
||||||
|
var out []*Department
|
||||||
|
|
||||||
|
err := c.call(ctx, "获取部门列表", func(ctx context.Context) error {
|
||||||
|
iter, err := c.lark.Contact.V3.Department.ListByIterator(ctx,
|
||||||
|
larkcontact.NewListDepartmentReqBuilder().
|
||||||
|
ParentDepartmentId("0").
|
||||||
|
FetchChild(true).
|
||||||
|
DepartmentIdType(deptIDType).
|
||||||
|
PageSize(50).
|
||||||
|
Build())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out = out[:0]
|
||||||
|
for {
|
||||||
|
ok, dept, err := iter.Next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if dept == nil || dept.OpenDepartmentId == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out) >= maxItems {
|
||||||
|
return fmt.Errorf("部门数量超过上限 %d,疑似分页异常", maxItems)
|
||||||
|
}
|
||||||
|
d := &Department{
|
||||||
|
ID: deref(dept.OpenDepartmentId),
|
||||||
|
ParentID: deref(dept.ParentDepartmentId),
|
||||||
|
Name: deref(dept.Name),
|
||||||
|
}
|
||||||
|
if dept.Order != nil {
|
||||||
|
if n, err := parseInt(dept.Order); err == nil {
|
||||||
|
d.Order = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Order != out[j].Order {
|
||||||
|
return out[i].Order < out[j].Order
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUsers 遍历部门拉取用户并按 union_id 去重。
|
||||||
|
//
|
||||||
|
// 为什么按部门遍历而不是用 Email 批量查询:官方 SDK 明确说明
|
||||||
|
// BatchGetId 的 emails 参数不支持企业邮箱,而我们的登录名正是企业邮箱前缀,
|
||||||
|
// 因此只能以部门为入口枚举,再按 union_id 去重(同一用户可属于多个部门)。
|
||||||
|
func (c *Client) fetchUsers(ctx context.Context, depts []*Department) (
|
||||||
|
users []*User, skippedNoEmail []*User, dups []DuplicateConflict, err error,
|
||||||
|
) {
|
||||||
|
byUnionID := make(map[string]*User)
|
||||||
|
seenUID := make(map[string][]string) // uid -> 姓名列表,用于检测冲突
|
||||||
|
|
||||||
|
for _, d := range depts {
|
||||||
|
err := c.call(ctx, "获取部门 "+d.ID+" 的用户", func(ctx context.Context) error {
|
||||||
|
iter, err := c.lark.Contact.V3.User.FindByDepartmentByIterator(ctx,
|
||||||
|
larkcontact.NewFindByDepartmentUserReqBuilder().
|
||||||
|
DepartmentId(d.ID).
|
||||||
|
DepartmentIdType(deptIDType).
|
||||||
|
UserIdType(userIDType).
|
||||||
|
PageSize(50).
|
||||||
|
Build())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
ok, u, err := iter.Next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u == nil || u.UnionId == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(byUnionID) >= maxItems {
|
||||||
|
return fmt.Errorf("用户数量超过上限 %d,疑似分页异常", maxItems)
|
||||||
|
}
|
||||||
|
// FindByDepartment 只返回在职人员,但仍做一次防御性过滤。
|
||||||
|
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := deref(u.UnionId)
|
||||||
|
if existing, ok := byUnionID[uid]; ok {
|
||||||
|
// 同一用户在多部门出现:合并部门列表,而不是覆盖。
|
||||||
|
existing.DepartmentIDs = mergeUnique(existing.DepartmentIDs, derefDepts(u.DepartmentIds))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nu := &User{
|
||||||
|
UnionID: uid,
|
||||||
|
UserID: deref(u.UserId),
|
||||||
|
OpenID: deref(u.OpenId),
|
||||||
|
Name: deref(u.Name),
|
||||||
|
EnName: deref(u.EnName),
|
||||||
|
EnterpriseEmail: deref(u.EnterpriseEmail),
|
||||||
|
Email: deref(u.Email),
|
||||||
|
Mobile: deref(u.Mobile),
|
||||||
|
EmployeeNo: deref(u.EmployeeNo),
|
||||||
|
JobTitle: deref(u.JobTitle),
|
||||||
|
WorkStation: deref(u.WorkStation),
|
||||||
|
DepartmentIDs: derefDepts(u.DepartmentIds),
|
||||||
|
}
|
||||||
|
byUnionID[uid] = nu
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验登录名可用性:必须有企业邮箱,且前缀不与他人冲突。
|
||||||
|
for _, u := range byUnionID {
|
||||||
|
uid, ok := loginName(u)
|
||||||
|
if !ok {
|
||||||
|
skippedNoEmail = append(skippedNoEmail, u)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenUID[uid] = append(seenUID[uid], displayName(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicted := make(map[string]bool)
|
||||||
|
for uid, names := range seenUID {
|
||||||
|
if len(names) > 1 {
|
||||||
|
conflicted[uid] = true
|
||||||
|
dups = append(dups, DuplicateConflict{UID: uid, Names: names})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(dups, func(i, j int) bool { return dups[i].UID < dups[j].UID })
|
||||||
|
|
||||||
|
for _, u := range byUnionID {
|
||||||
|
uid, ok := loginName(u)
|
||||||
|
if !ok || conflicted[uid] {
|
||||||
|
continue // 已在 skippedNoEmail / dups 中报告
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(users, func(i, j int) bool {
|
||||||
|
return loginName2(users[i]) < loginName2(users[j])
|
||||||
|
})
|
||||||
|
return users, skippedNoEmail, dups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResigned 对给定的 union_id 列表确认其是否确实离职。
|
||||||
|
//
|
||||||
|
// 背景:FindByDepartment 不返回离职人员,所以“离职”只能通过缺席推断。
|
||||||
|
// 但缺席也可能是权限范围变更或部门调整导致的假阳性,因此对候选中的人
|
||||||
|
// 逐个调用 Get 读取真实的 status.is_resigned,确认后才停用。
|
||||||
|
func (c *Client) CheckResigned(ctx context.Context, unionIDs []string) ([]*Resigned, error) {
|
||||||
|
var out []*Resigned
|
||||||
|
|
||||||
|
for _, uid := range unionIDs {
|
||||||
|
var got *Resigned
|
||||||
|
err := c.call(ctx, "确认用户状态 "+uid, func(ctx context.Context) error {
|
||||||
|
resp, err := c.lark.Contact.V3.User.Get(ctx,
|
||||||
|
larkcontact.NewGetUserReqBuilder().
|
||||||
|
UserId(uid).
|
||||||
|
UserIdType(userIDType).
|
||||||
|
Build())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
if resp.Data == nil || resp.Data.User == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := resp.Data.User
|
||||||
|
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
|
||||||
|
got = &Resigned{UnionID: uid, UserID: deref(u.UserId), Name: deref(u.Name)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
out = append(out, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginName 导出登录名(企业邮箱 @ 前缀)。
|
||||||
|
func LoginName(u *User) (string, bool) { return loginName(u) }
|
||||||
|
|
||||||
|
// DisplayName 导出用于展示的名字,找不到时回落到登录名。
|
||||||
|
func DisplayName(u *User) string { return displayName(u) }
|
||||||
|
|
||||||
|
// loginName 从企业邮箱推导登录名。
|
||||||
|
//
|
||||||
|
// 刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,
|
||||||
|
// 回落到个人邮箱会产生不可预期、且在离职后仍可用的登录名。
|
||||||
|
func loginName(u *User) (string, bool) {
|
||||||
|
email := strings.TrimSpace(u.EnterpriseEmail)
|
||||||
|
if email == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
at := strings.Index(email, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(email[:at])
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.ToLower(name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayName(u *User) string {
|
||||||
|
if strings.TrimSpace(u.Name) != "" {
|
||||||
|
return u.Name
|
||||||
|
}
|
||||||
|
return u.EnName
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginName2(u *User) string {
|
||||||
|
n, _ := loginName(u)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterDepartments 按配置裁剪部门集合。
|
||||||
|
//
|
||||||
|
// 规则与旧实现保持一致:留空表示全量;列出的部门及其子部门被包含;
|
||||||
|
// 前缀 ^ 表示排除该部门及其子部门。
|
||||||
|
func filterDepartments(all []*Department, want []string) []*Department {
|
||||||
|
if len(want) == 0 {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := make(map[string]*Department, len(all))
|
||||||
|
children := make(map[string][]*Department)
|
||||||
|
for _, d := range all {
|
||||||
|
byID[d.ID] = d
|
||||||
|
children[d.ParentID] = append(children[d.ParentID], d)
|
||||||
|
}
|
||||||
|
|
||||||
|
var include, exclude []string
|
||||||
|
for _, w := range want {
|
||||||
|
w = strings.TrimSpace(w)
|
||||||
|
if w == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(w, "^") {
|
||||||
|
exclude = append(exclude, strings.TrimPrefix(w, "^"))
|
||||||
|
} else {
|
||||||
|
include = append(include, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keep := make(map[string]bool)
|
||||||
|
|
||||||
|
var mark func(id string)
|
||||||
|
mark = func(id string) {
|
||||||
|
if keep[id] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d, ok := byID[id]; ok {
|
||||||
|
keep[d.ID] = true
|
||||||
|
}
|
||||||
|
for _, ch := range children[id] {
|
||||||
|
mark(ch.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmark func(id string)
|
||||||
|
unmark = func(id string) {
|
||||||
|
delete(keep, id)
|
||||||
|
for _, ch := range children[id] {
|
||||||
|
unmark(ch.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(include) > 0 {
|
||||||
|
for _, id := range include {
|
||||||
|
mark(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, d := range all {
|
||||||
|
keep[d.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range exclude {
|
||||||
|
unmark(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]*Department, 0, len(all))
|
||||||
|
for _, d := range all {
|
||||||
|
if keep[d.ID] {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefDepts(in []string) []string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, v := range in {
|
||||||
|
if v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeUnique 合并两个字符串切片并去重,保持原有顺序。
|
||||||
|
func mergeUnique(a, b []string) []string {
|
||||||
|
seen := make(map[string]bool, len(a)+len(b))
|
||||||
|
out := make([]string, 0, len(a)+len(b))
|
||||||
|
for _, list := range [][]string{a, b} {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == "" || seen[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s *string) (int, error) {
|
||||||
|
var n int
|
||||||
|
_, err := fmt.Sscanf(deref(s), "%d", &n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package ldaplib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
|
||||||
|
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
|
||||||
|
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
|
||||||
|
type Client struct {
|
||||||
|
cfg config.Ldap
|
||||||
|
log *slog.Logger
|
||||||
|
tlsConf *tls.Config
|
||||||
|
dialOpts []ldap.DialOpt
|
||||||
|
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
|
||||||
|
group GroupSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
|
||||||
|
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return nil, fmt.Errorf("ldap.url 未配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialOpts := []ldap.DialOpt{
|
||||||
|
ldap.DialWithDialer(&net.Dialer{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var tlsConf *tls.Config
|
||||||
|
if cfg.InsecureSkipVerify || hasLDAPS(cfg.URL) || cfg.StartTLS {
|
||||||
|
tlsConf = &tls.Config{
|
||||||
|
ServerName: serverName(cfg.URL),
|
||||||
|
InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // 由配置显式开启,仅供自签证书环境
|
||||||
|
}
|
||||||
|
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := GroupSchema{
|
||||||
|
ObjectClass: cfg.GroupObjectClass,
|
||||||
|
MemberAttr: cfg.GroupMemberAttr,
|
||||||
|
}
|
||||||
|
if schema.ObjectClass == "" {
|
||||||
|
schema.ObjectClass = "groupOfUniqueNames"
|
||||||
|
}
|
||||||
|
if schema.MemberAttr == "" {
|
||||||
|
schema.MemberAttr = "uniqueMember"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
|
||||||
|
type Conn struct {
|
||||||
|
conn *ldap.Conn
|
||||||
|
cfg config.Ldap
|
||||||
|
group GroupSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放连接。
|
||||||
|
func (c *Conn) Close() {
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial 建立并绑定一个新的 LDAP 连接。
|
||||||
|
func (c *Client) Dial() (*Conn, error) {
|
||||||
|
conn, err := ldap.DialURL(c.cfg.URL, c.dialOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("连接 LDAP 失败 (%s): %w", c.cfg.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.cfg.StartTLS && !hasLDAPS(c.cfg.URL) {
|
||||||
|
if err := conn.StartTLS(c.tlsConf); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("StartTLS 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping 验证连通性与凭据,用于健康检查。
|
||||||
|
func (c *Client) Ping() error {
|
||||||
|
conn, err := c.Dial()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLDAPS(url string) bool {
|
||||||
|
return len(url) >= 8 && url[:8] == "ldaps://"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverName 从 URL 中提取主机名,供 TLS SNI 与证书校验使用。
|
||||||
|
func serverName(rawURL string) string {
|
||||||
|
host := rawURL
|
||||||
|
for _, prefix := range []string{"ldaps://", "ldap://"} {
|
||||||
|
if len(host) >= len(prefix) && host[:len(prefix)] == prefix {
|
||||||
|
host = host[len(prefix):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i := lastIndexByte(host, '/'); i >= 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastIndexByte(s string, b byte) int {
|
||||||
|
for i := len(s) - 1; i >= 0; i-- {
|
||||||
|
if s[i] == b {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。
|
||||||
|
//
|
||||||
|
// 默认不参与 `go test ./...`,需要显式启用:
|
||||||
|
//
|
||||||
|
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||||
|
// go test -tags=integration ./internal/ldaplib/ -v
|
||||||
|
//
|
||||||
|
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
|
||||||
|
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
|
||||||
|
package ldaplib_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/password"
|
||||||
|
)
|
||||||
|
|
||||||
|
// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClient(t *testing.T) *ldaplib.Client {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
url := os.Getenv("LDAP_TEST_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = "ldap://127.0.0.1:3899"
|
||||||
|
}
|
||||||
|
baseDN := os.Getenv("LDAP_TEST_BASE_DN")
|
||||||
|
if baseDN == "" {
|
||||||
|
baseDN = "dc=example,dc=com"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Ldap{
|
||||||
|
URL: url,
|
||||||
|
BaseDN: baseDN,
|
||||||
|
AdminDN: "cn=admin," + baseDN,
|
||||||
|
AdminPass: "adminpass",
|
||||||
|
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
|
||||||
|
TimeoutSec: 10,
|
||||||
|
}
|
||||||
|
if err := cfg.NormalizeForTest(); err != nil {
|
||||||
|
t.Fatalf("配置规范化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := ldaplib.NewClient(cfg, discardLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造客户端失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Ping(); err != nil {
|
||||||
|
t.Skipf("LDAP 不可用,跳过集成测试: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseDN() string {
|
||||||
|
if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "dc=example,dc=com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期,
|
||||||
|
// 并验证密码在属性更新与 modrdn 移动后依然有效。
|
||||||
|
func TestIntegrationUserLifecycle(t *testing.T) {
|
||||||
|
c := testClient(t)
|
||||||
|
base := baseDN()
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
inactiveOU := "ou=Inactive," + base
|
||||||
|
|
||||||
|
conn, err := c.Dial()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||||
|
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil {
|
||||||
|
t.Fatalf("创建 ou=Inactive 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = "idsync-test-user"
|
||||||
|
userDN := "uid=" + uid + "," + peopleOU
|
||||||
|
initialPass := "InitialPass123!"
|
||||||
|
|
||||||
|
// 清理可能残留的条目,让测试可重复执行。
|
||||||
|
_ = conn.DeleteUser(userDN)
|
||||||
|
_ = conn.DeleteUser("uid=" + uid + "," + inactiveOU)
|
||||||
|
|
||||||
|
u := &ldaplib.User{
|
||||||
|
UID: uid, CN: uid, SN: "测试", GivenName: "Test",
|
||||||
|
DisplayName: "测试用户", Mail: "test@example.com",
|
||||||
|
Mobile: "13800000000", EmployeeNumber: "E001",
|
||||||
|
DepartmentNumber: "工程师", PostalAddress: "北京",
|
||||||
|
BusinessCategory: "研发部", EmployeeType: "Active",
|
||||||
|
Description: "测试用户",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断言密码以哈希形式存储,而不是明文。
|
||||||
|
// slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码,
|
||||||
|
// 密码会以明文形式留在目录中。
|
||||||
|
encoded, err := password.EncodeSSHA(initialPass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码密码失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.AddUser(u, peopleOU, encoded); err != nil {
|
||||||
|
t.Fatalf("创建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := conn.GetEntry(userDN, []string{"userPassword"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取 userPassword 失败: %v", err)
|
||||||
|
}
|
||||||
|
stored := raw.Get("userPassword")
|
||||||
|
if stored == initialPass {
|
||||||
|
t.Errorf("userPassword 以明文存储,存在严重安全隐患")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stored, "{SSHA}") {
|
||||||
|
t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建后应当能读到,且属性正确。
|
||||||
|
users, err := conn.ListUsers(peopleOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取用户列表失败: %v", err)
|
||||||
|
}
|
||||||
|
got, ok := users[uid]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid)
|
||||||
|
}
|
||||||
|
if got.Get(ldaplib.AttrMail) != "test@example.com" {
|
||||||
|
t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用用户凭据 bind,确认密码确实生效。
|
||||||
|
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||||
|
t.Fatalf("初始密码无法通过 bind 验证: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新属性:改邮箱与手机号。
|
||||||
|
u.Mail = "test.new@example.com"
|
||||||
|
u.Mobile = "13900000000"
|
||||||
|
existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取现状失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||||
|
t.Fatalf("更新用户失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键回归:把若干可选字段置空后更新,不应失败。
|
||||||
|
// 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让
|
||||||
|
// 整个 Modify 请求失败——这是真实踩到过的 bug。
|
||||||
|
u.PostalAddress = ""
|
||||||
|
u.EmployeeNumber = ""
|
||||||
|
existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||||
|
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||||
|
t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err = conn.ListUsers(peopleOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("更新后读取失败: %v", err)
|
||||||
|
}
|
||||||
|
got = users[uid]
|
||||||
|
if got.Get(ldaplib.AttrMail) != "test.new@example.com" {
|
||||||
|
t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键约束:属性更新绝不能改写密码。
|
||||||
|
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||||
|
t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移到离职 OU:密码必须随条目保留。
|
||||||
|
if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil {
|
||||||
|
t.Fatalf("移动用户失败: %v", err)
|
||||||
|
}
|
||||||
|
movedDN := "uid=" + uid + "," + inactiveOU
|
||||||
|
if err := bindAs(t, c, movedDN, initialPass); err != nil {
|
||||||
|
t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inactive, err := conn.ListUsers(inactiveOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取离职 OU 失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := inactive[uid]; !ok {
|
||||||
|
t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记离职状态
|
||||||
|
if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil {
|
||||||
|
t.Fatalf("设置 employeeType 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移回在职 OU
|
||||||
|
if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil {
|
||||||
|
t.Fatalf("移回失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||||
|
t.Fatalf("移回后密码失效: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
if err := conn.DeleteUser(userDN); err != nil {
|
||||||
|
t.Fatalf("删除用户失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationGroupMembership 验证分组的创建与成员增删。
|
||||||
|
func TestIntegrationGroupMembership(t *testing.T) {
|
||||||
|
c := testClient(t)
|
||||||
|
base := baseDN()
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
groupRoot := "ou=feishuroot," + base
|
||||||
|
|
||||||
|
conn, err := c.Dial()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||||
|
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil {
|
||||||
|
t.Fatalf("创建分组根 OU 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = "idsync-test-member"
|
||||||
|
userDN := "uid=" + uid + "," + peopleOU
|
||||||
|
groupDN := "cn=yanfa," + groupRoot
|
||||||
|
adminDN := "cn=admin," + base
|
||||||
|
|
||||||
|
_ = conn.DeleteUser(userDN)
|
||||||
|
_ = conn.DeleteGroup(groupDN)
|
||||||
|
|
||||||
|
u := &ldaplib.User{
|
||||||
|
UID: uid, CN: uid, SN: "成员", DisplayName: "成员",
|
||||||
|
Mail: "member@example.com", EmployeeType: "Active",
|
||||||
|
}
|
||||||
|
if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil {
|
||||||
|
t.Fatalf("创建用户失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||||
|
t.Fatalf("创建分组失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等性:重复创建同一个组不应报错。
|
||||||
|
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||||
|
t.Errorf("重复创建分组应当幂等,却返回错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err := conn.ListGroups(groupRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取分组失败: %v", err)
|
||||||
|
}
|
||||||
|
g, ok := groups[groupDN]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("未找到刚创建的分组 %s", groupDN)
|
||||||
|
}
|
||||||
|
// 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。
|
||||||
|
if _, has := g.Members()[adminDN]; !has {
|
||||||
|
t.Errorf("分组缺少 admin 成员: %v", g.Members())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入成员,并验证幂等。
|
||||||
|
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||||
|
t.Fatalf("加入成员失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||||
|
t.Errorf("重复加入成员应当幂等,却返回错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err = conn.ListGroups(groupRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("重新读取分组失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, has := groups[groupDN].Members()[userDN]; !has {
|
||||||
|
t.Errorf("成员未被加入: %v", groups[groupDN].Members())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移出成员,并验证幂等。
|
||||||
|
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||||
|
t.Fatalf("移出成员失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||||
|
t.Errorf("重复移出成员应当幂等,却返回错误: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
_ = conn.DeleteUser(userDN)
|
||||||
|
_ = conn.DeleteGroup(groupDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。
|
||||||
|
func TestIntegrationEscaping(t *testing.T) {
|
||||||
|
c := testClient(t)
|
||||||
|
base := baseDN()
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
|
||||||
|
conn, err := c.Dial()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||||
|
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。
|
||||||
|
cases := []string{
|
||||||
|
"zhang.san",
|
||||||
|
"li_si",
|
||||||
|
"wang+wu",
|
||||||
|
"zhao,liu",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uid := range cases {
|
||||||
|
t.Run(uid, func(t *testing.T) {
|
||||||
|
dn := ldaplib.UserDN(uid, peopleOU)
|
||||||
|
_ = conn.DeleteUser(dn)
|
||||||
|
defer func() { _ = conn.DeleteUser(dn) }()
|
||||||
|
|
||||||
|
u := &ldaplib.User{
|
||||||
|
UID: uid, CN: uid, SN: uid, DisplayName: uid,
|
||||||
|
Mail: uid + "@example.com", EmployeeType: "Active",
|
||||||
|
}
|
||||||
|
if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil {
|
||||||
|
t.Fatalf("创建含特殊字符的 uid 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := conn.ListUsers(peopleOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := users[uid]; !ok {
|
||||||
|
t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。
|
||||||
|
func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfgURL := os.Getenv("LDAP_TEST_URL")
|
||||||
|
if cfgURL == "" {
|
||||||
|
cfgURL = "ldap://127.0.0.1:3899"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
return conn.Bind(dn, password)
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package ldaplib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用户与分组的属性清单集中在此,避免散落各处导致不一致。
|
||||||
|
const (
|
||||||
|
AttrObjectClass = "objectClass"
|
||||||
|
AttrUID = "uid"
|
||||||
|
AttrCN = "cn"
|
||||||
|
AttrSN = "sn"
|
||||||
|
AttrGivenName = "givenName"
|
||||||
|
AttrDisplayName = "displayName"
|
||||||
|
AttrMail = "mail"
|
||||||
|
AttrMobile = "mobile"
|
||||||
|
AttrEmployeeNumber = "employeeNumber"
|
||||||
|
AttrDepartmentNumber = "departmentNumber"
|
||||||
|
AttrPostalAddress = "postalAddress"
|
||||||
|
AttrBusinessCategory = "businessCategory"
|
||||||
|
AttrEmployeeType = "employeeType"
|
||||||
|
AttrDescription = "description"
|
||||||
|
AttrUserPassword = "userPassword"
|
||||||
|
AttrUniqueMember = "uniqueMember"
|
||||||
|
AttrMember = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memberAttrs 列出可能承载组成员关系的属性名。
|
||||||
|
//
|
||||||
|
// 读取时两者都查:用哪一种取决于目录的既有约定(groupOfNames 用 member,
|
||||||
|
// groupOfUniqueNames 用 uniqueMember)。只认一种会漏掉另一种的成员关系,
|
||||||
|
// 且不会报错,只会表现为「成员比对结果不对」。
|
||||||
|
var memberAttrs = []string{AttrMember, AttrUniqueMember}
|
||||||
|
|
||||||
|
// ObjectClassUser 等是写入条目时使用的 objectClass。
|
||||||
|
// 分组的 objectClass 由 GroupSchema 在运行期决定,见下。
|
||||||
|
var (
|
||||||
|
ObjectClassUser = []string{"inetOrgPerson"}
|
||||||
|
ObjectClassOU = []string{"organizationalUnit", "top"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// GroupSchema 描述分组条目的形态,必须与 Authelia 的 groups_filter 一致。
|
||||||
|
type GroupSchema struct {
|
||||||
|
// ObjectClass 形如 groupOfUniqueNames 或 groupOfNames
|
||||||
|
ObjectClass string
|
||||||
|
// MemberAttr 形如 uniqueMember 或 member
|
||||||
|
MemberAttr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectClasses 返回写入时使用的 objectClass 列表。
|
||||||
|
func (g GroupSchema) ObjectClasses() []string {
|
||||||
|
return []string{g.ObjectClass, "top"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutableUserAttrs 是更新用户时允许改写的属性。
|
||||||
|
//
|
||||||
|
// 这里刻意不包含 userPassword:Authelia 重置密码时直接写 LDAP 的 userPassword,
|
||||||
|
// 若同步的更新路径也写该属性,就会把用户刚设好的密码打回初始值。
|
||||||
|
// 密码只在创建时写入一次,此后由 Authelia 全权负责。
|
||||||
|
var mutableUserAttrs = []string{
|
||||||
|
AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, AttrMobile,
|
||||||
|
AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
|
||||||
|
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeDN 转义 DN 中的特殊字符,避免 uid 含 . 、+ 、_ 等字符时构造出非法 DN。
|
||||||
|
func escapeDN(s string) string { return ldap.EscapeDN(s) }
|
||||||
|
|
||||||
|
// EscapeDN 导出 DN 转义,供调用方拼装 RDN 时使用。
|
||||||
|
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
|
||||||
|
|
||||||
|
// EscapeFilter 导出过滤器转义。
|
||||||
|
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
|
||||||
|
|
||||||
|
// UserDN 按 uid 与所属 OU 拼接用户 DN。
|
||||||
|
func UserDN(uid, ouDN string) string {
|
||||||
|
return "uid=" + escapeDN(uid) + "," + ouDN
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupDN 拼接部门组 DN。
|
||||||
|
func GroupDN(cn, parentDN string) string {
|
||||||
|
return "cn=" + escapeDN(cn) + "," + parentDN
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 是写入 LDAP 的用户条目。
|
||||||
|
type User struct {
|
||||||
|
UID string
|
||||||
|
CN string
|
||||||
|
SN string
|
||||||
|
GivenName string
|
||||||
|
DisplayName string
|
||||||
|
Mail string
|
||||||
|
Mobile string
|
||||||
|
EmployeeNumber string
|
||||||
|
DepartmentNumber string
|
||||||
|
PostalAddress string
|
||||||
|
BusinessCategory string
|
||||||
|
EmployeeType string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes 返回该用户对应的 LDAP 属性集合(不含 userPassword)。
|
||||||
|
func (u *User) Attributes() map[string][]string {
|
||||||
|
return map[string][]string{
|
||||||
|
AttrCN: {u.CN},
|
||||||
|
AttrSN: {u.SN},
|
||||||
|
AttrGivenName: {u.GivenName},
|
||||||
|
AttrDisplayName: {u.DisplayName},
|
||||||
|
AttrMail: {u.Mail},
|
||||||
|
AttrMobile: {u.Mobile},
|
||||||
|
AttrEmployeeNumber: {u.EmployeeNumber},
|
||||||
|
AttrDepartmentNumber: {u.DepartmentNumber},
|
||||||
|
AttrPostalAddress: {u.PostalAddress},
|
||||||
|
AttrBusinessCategory: {u.BusinessCategory},
|
||||||
|
AttrEmployeeType: {u.EmployeeType},
|
||||||
|
AttrDescription: {u.Description},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUser 创建用户条目。password 仅在此时写入。
|
||||||
|
func (c *Conn) AddUser(u *User, ouDN, password string) error {
|
||||||
|
add := ldap.NewAddRequest(UserDN(u.UID, ouDN), nil)
|
||||||
|
add.Attribute(AttrObjectClass, ObjectClassUser)
|
||||||
|
add.Attribute(AttrUID, []string{u.UID})
|
||||||
|
|
||||||
|
for attr, values := range u.Attributes() {
|
||||||
|
if len(values) == 1 && values[0] == "" {
|
||||||
|
continue // 空值不写,避免产生空属性
|
||||||
|
}
|
||||||
|
add.Attribute(attr, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
add.Attribute(AttrUserPassword, []string{password})
|
||||||
|
|
||||||
|
if err := c.conn.Add(add); err != nil {
|
||||||
|
return fmt.Errorf("创建用户 %s 失败: %w", u.UID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser 更新用户属性。绝不触碰 userPassword。
|
||||||
|
//
|
||||||
|
// existing 是条目当前的内容,用于判断哪些属性需要删除。
|
||||||
|
// 这个参数不是可有可无的:对**不存在**的属性执行 Delete 会让整个 Modify
|
||||||
|
// 请求以 LDAPResultNoSuchAttribute 失败,从而使该用户的全部属性更新回滚。
|
||||||
|
// 早期版本没有传 existing,任何缺少可空字段(如工位为空)的用户都会更新失败。
|
||||||
|
func (c *Conn) UpdateUser(dn string, u *User, existing *Entry) error {
|
||||||
|
modify := ldap.NewModifyRequest(dn, nil)
|
||||||
|
|
||||||
|
attrs := u.Attributes()
|
||||||
|
for _, attr := range mutableUserAttrs {
|
||||||
|
values := attrs[attr]
|
||||||
|
if len(values) == 0 || values[0] == "" {
|
||||||
|
// 上游无此值:仅当条目当前确实存在该属性时才删除,
|
||||||
|
// 否则跳过,避免整个请求失败。
|
||||||
|
if existing != nil && existing.Get(attr) != "" {
|
||||||
|
modify.Delete(attr, nil)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Get(attr) == values[0] {
|
||||||
|
continue // 值未变化,不产生多余的 LDAP 写操作
|
||||||
|
}
|
||||||
|
modify.Replace(attr, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modify.Changes) == 0 {
|
||||||
|
return nil // 没有实际变化,避免发出空 Modify
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结构上的保险:即使将来有人误把 userPassword 加进 mutableUserAttrs,这里也会拦下。
|
||||||
|
for _, ch := range modify.Changes {
|
||||||
|
if strings.EqualFold(ch.Modification.Type, AttrUserPassword) {
|
||||||
|
return fmt.Errorf("内部错误: 更新路径不允许修改 %s", AttrUserPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.conn.Modify(modify); err != nil {
|
||||||
|
return fmt.Errorf("更新用户 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEntry 读取单个条目的属性,供更新前的差异判断使用。
|
||||||
|
func (c *Conn) GetEntry(dn string, attrs []string) (*Entry, error) {
|
||||||
|
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||||
|
"(objectClass=*)", attrs, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取条目 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
if len(sr.Entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("条目不存在: %s", dn)
|
||||||
|
}
|
||||||
|
return entryFrom(sr.Entries[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserReadAttrs 返回读取用户条目所需的属性列表。
|
||||||
|
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
|
||||||
|
|
||||||
|
// DeleteUser 删除用户条目。
|
||||||
|
func (c *Conn) DeleteUser(dn string) error {
|
||||||
|
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
|
||||||
|
return fmt.Errorf("删除用户 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveUser 把用户条目移动到新的父 OU,保留其全部属性(含 userPassword)。
|
||||||
|
// deleteOldRDN 置 true 表示移除旧 RDN 属性值。
|
||||||
|
func (c *Conn) MoveUser(oldDN, newRDN, newParentDN string) error {
|
||||||
|
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, newParentDN)
|
||||||
|
if err := c.conn.ModifyDN(modDN); err != nil {
|
||||||
|
return fmt.Errorf("移动用户 %s → %s,%s 失败: %w", oldDN, newRDN, newParentDN, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddGroup 创建部门分组。
|
||||||
|
//
|
||||||
|
// 无论哪种 objectClass,成员属性都是必填(groupOfNames 的 member 与
|
||||||
|
// groupOfUniqueNames 的 uniqueMember 都是 MUST),因此这里默认把 admin
|
||||||
|
// 加入其中,否则创建会因缺少必填属性而失败。
|
||||||
|
func (c *Conn) AddGroup(dn, name, description, adminDN string) error {
|
||||||
|
if _, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||||
|
"(objectClass=*)", []string{"dn"}, nil,
|
||||||
|
)); err == nil {
|
||||||
|
return nil // 已存在,幂等
|
||||||
|
} else if !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||||
|
return fmt.Errorf("检查分组 %s 是否存在失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
add := ldap.NewAddRequest(dn, nil)
|
||||||
|
add.Attribute(AttrObjectClass, c.group.ObjectClasses())
|
||||||
|
add.Attribute(AttrCN, []string{name})
|
||||||
|
add.Attribute(AttrDescription, []string{description})
|
||||||
|
add.Attribute(c.group.MemberAttr, []string{adminDN})
|
||||||
|
|
||||||
|
if err := c.conn.Add(add); err != nil {
|
||||||
|
return fmt.Errorf("创建分组 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGroup 更新分组的描述。
|
||||||
|
func (c *Conn) UpdateGroup(dn, description string) error {
|
||||||
|
modify := ldap.NewModifyRequest(dn, nil)
|
||||||
|
modify.Replace(AttrDescription, []string{description})
|
||||||
|
if err := c.conn.Modify(modify); err != nil {
|
||||||
|
return fmt.Errorf("更新分组 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameGroup 重命名分组,用于部门改名。
|
||||||
|
func (c *Conn) RenameGroup(oldDN, newRDN string) error {
|
||||||
|
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, "")
|
||||||
|
if err := c.conn.ModifyDN(modDN); err != nil {
|
||||||
|
return fmt.Errorf("重命名分组 %s → %s 失败: %w", oldDN, newRDN, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup 删除分组条目。
|
||||||
|
func (c *Conn) DeleteGroup(dn string) error {
|
||||||
|
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
|
||||||
|
return fmt.Errorf("删除分组 %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEmployeeType 更新条目的在职状态标记(Active / Resigned)。
|
||||||
|
func (c *Conn) SetEmployeeType(dn, employeeType string) error {
|
||||||
|
modify := ldap.NewModifyRequest(dn, nil)
|
||||||
|
modify.Replace(AttrEmployeeType, []string{employeeType})
|
||||||
|
if err := c.conn.Modify(modify); err != nil {
|
||||||
|
return fmt.Errorf("更新 %s 的 employeeType 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberExists 判断用户是否已是分组成员。
|
||||||
|
func (c *Conn) memberExists(groupDN, userDN string) (bool, error) {
|
||||||
|
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
|
||||||
|
c.group.ObjectClass, c.group.MemberAttr, EscapeFilter(userDN))
|
||||||
|
|
||||||
|
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
groupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||||
|
filter, []string{"dn"}, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||||
|
return false, fmt.Errorf("分组不存在: %s", groupDN)
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("查询分组 %s 成员失败: %w", groupDN, err)
|
||||||
|
}
|
||||||
|
return len(sr.Entries) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember 把用户加入分组。已存在时是幂等的。
|
||||||
|
func (c *Conn) AddMember(groupDN, userDN string) error {
|
||||||
|
found, err := c.memberExists(groupDN, userDN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
modify := ldap.NewModifyRequest(groupDN, nil)
|
||||||
|
modify.Add(c.group.MemberAttr, []string{userDN})
|
||||||
|
if err := c.conn.Modify(modify); err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultAttributeOrValueExists) {
|
||||||
|
return nil // 并发下可能已被加入
|
||||||
|
}
|
||||||
|
return fmt.Errorf("将 %s 加入分组 %s 失败: %w", userDN, groupDN, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember 把用户移出分组。不存在时是幂等的。
|
||||||
|
func (c *Conn) RemoveMember(groupDN, userDN string) error {
|
||||||
|
found, err := c.memberExists(groupDN, userDN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
modify := ldap.NewModifyRequest(groupDN, nil)
|
||||||
|
modify.Delete(c.group.MemberAttr, []string{userDN})
|
||||||
|
if err := c.conn.Modify(modify); err != nil {
|
||||||
|
return fmt.Errorf("将 %s 移出分组 %s 失败: %w", userDN, groupDN, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package ldaplib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry 是 LDAP 中一个条目的精简表示,用于与期望状态比对。
|
||||||
|
type Entry struct {
|
||||||
|
DN string
|
||||||
|
Attributes map[string]string
|
||||||
|
// multi 保留多值属性的完整值列表(如 uniqueMember),键为小写属性名。
|
||||||
|
multi map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 返回指定属性的第一个值。
|
||||||
|
func (e *Entry) Get(attr string) string {
|
||||||
|
return e.Attributes[strings.ToLower(attr)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntry 构造一个 Entry。
|
||||||
|
//
|
||||||
|
// Entry 的字段对调用方是只读的(多值属性不导出),但同步逻辑与测试都需要
|
||||||
|
// 在内存中构造它,因此这里提供一个显式构造函数,而不是让调用方猜测内部结构。
|
||||||
|
func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
|
||||||
|
e := &Entry{
|
||||||
|
DN: dn,
|
||||||
|
Attributes: make(map[string]string, len(attrs)),
|
||||||
|
multi: make(map[string][]string),
|
||||||
|
}
|
||||||
|
for k, v := range attrs {
|
||||||
|
e.Attributes[strings.ToLower(k)] = v
|
||||||
|
}
|
||||||
|
if len(members) > 0 {
|
||||||
|
e.multi[strings.ToLower(AttrUniqueMember)] = append([]string{}, members...)
|
||||||
|
e.Attributes[strings.ToLower(AttrUniqueMember)] = members[0]
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// userReadAttrs 是读取用户条目时需要取回的属性。
|
||||||
|
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
|
||||||
|
var userReadAttrs = []string{
|
||||||
|
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
|
||||||
|
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
|
||||||
|
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupReadAttrs 读取分组时额外取回成员列表,以便检测成员漂移。
|
||||||
|
// groupReadAttrs 读取分组时同时请求两种成员属性与 objectClass。
|
||||||
|
// 缺省的属性不会报错,只是不返回,因此同时请求是安全的。
|
||||||
|
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
|
||||||
|
|
||||||
|
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
|
||||||
|
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
|
||||||
|
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
"(objectClass=inetOrgPerson)", userReadAttrs, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||||
|
return map[string]*Entry{}, nil // OU 尚未创建,视为空
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("读取用户列表失败 (%s): %w", ouDN, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]*Entry, len(sr.Entries))
|
||||||
|
for _, e := range sr.Entries {
|
||||||
|
out[strings.ToLower(e.GetAttributeValue(AttrUID))] = entryFrom(e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
|
||||||
|
func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) {
|
||||||
|
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
fmt.Sprintf("(objectClass=%s)", c.group.ObjectClass), groupReadAttrs, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||||
|
return map[string]*Entry{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("读取分组列表失败 (%s): %w", rootDN, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]*Entry, len(sr.Entries))
|
||||||
|
for _, e := range sr.Entries {
|
||||||
|
out[strings.ToLower(e.DN)] = entryFrom(e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members 返回分组条目的成员 DN 集合(全小写,便于比较)。
|
||||||
|
//
|
||||||
|
// 两种成员属性都识别:目录可能用 groupOfNames/member,也可能用
|
||||||
|
// groupOfUniqueNames/uniqueMember,取决于既有约定。写操作会使用配置指定的那一种,
|
||||||
|
// 但读取必须宽松,否则会漏掉另一类成员关系。
|
||||||
|
func (e *Entry) Members() map[string]struct{} {
|
||||||
|
out := make(map[string]struct{})
|
||||||
|
for _, attr := range memberAttrs {
|
||||||
|
for _, m := range e.multi[strings.ToLower(attr)] {
|
||||||
|
out[strings.ToLower(m)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistsDN 判断某个 DN 是否存在,用于幂等前置检查。
|
||||||
|
func (c *Conn) ExistsDN(dn string) (bool, error) {
|
||||||
|
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||||
|
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||||
|
"(objectClass=*)", []string{"dn"}, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("检查 DN 是否存在失败 (%s): %w", dn, err)
|
||||||
|
}
|
||||||
|
return len(sr.Entries) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOU 确保某个 OU 存在,不存在则创建。用于首次同步时准备 ou=Inactive 等。
|
||||||
|
func (c *Conn) EnsureOU(dn, name string) error {
|
||||||
|
exists, err := c.ExistsDN(dn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
add := ldap.NewAddRequest(dn, nil)
|
||||||
|
add.Attribute(AttrObjectClass, ObjectClassOU)
|
||||||
|
add.Attribute("ou", []string{name})
|
||||||
|
if err := c.conn.Add(add); err != nil {
|
||||||
|
if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("创建 OU %s 失败: %w", dn, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryFrom 把 LDAP 条目转成精简表示。
|
||||||
|
// 单值属性存进 Attributes;多值属性原样保留在 multi 中。
|
||||||
|
func entryFrom(e *ldap.Entry) *Entry {
|
||||||
|
ent := &Entry{
|
||||||
|
DN: e.DN,
|
||||||
|
Attributes: make(map[string]string, len(e.Attributes)),
|
||||||
|
multi: make(map[string][]string),
|
||||||
|
}
|
||||||
|
// multiValuedAttrs 记录必须按多值处理的属性。
|
||||||
|
// 不能只凭「值个数 > 1」判断:一个只含 admin 的分组其 uniqueMember
|
||||||
|
// 只有一个值,若按单值属性存放,Members() 就会返回空集合,导致
|
||||||
|
// 成员比对完全失效(集成测试捕获过这个问题)。
|
||||||
|
multiValued := map[string]bool{
|
||||||
|
strings.ToLower(AttrMember): true,
|
||||||
|
strings.ToLower(AttrUniqueMember): true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range e.Attributes {
|
||||||
|
key := strings.ToLower(a.Name)
|
||||||
|
if multiValued[key] {
|
||||||
|
ent.multi[key] = append([]string{}, a.Values...)
|
||||||
|
if len(a.Values) > 0 {
|
||||||
|
ent.Attributes[key] = a.Values[0]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(a.Values) == 1 {
|
||||||
|
ent.Attributes[key] = a.Values[0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ent.multi[key] = a.Values
|
||||||
|
if len(a.Values) > 0 {
|
||||||
|
ent.Attributes[key] = a.Values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// Package mapping 把飞书快照转换为期望的 LDAP 状态。
|
||||||
|
//
|
||||||
|
// 这一层是纯函数:不触碰 IO,不依赖配置以外的全局状态。DN 的构造规则
|
||||||
|
// 集中在此,便于测试与审查——DN 出错会造成最难排查的故障。
|
||||||
|
package mapping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mozillazg/go-pinyin"
|
||||||
|
|
||||||
|
"idsync/internal/feishu"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 员工在职状态标记。
|
||||||
|
//
|
||||||
|
// 定义在 mapping 而非 syncer:它是「期望状态」的一部分,需要参与差异比对,
|
||||||
|
// 因此必须与期望状态放在一起,否则会出现「写入了但预览里看不到」的不一致。
|
||||||
|
const (
|
||||||
|
EmployeeTypeActive = "Active"
|
||||||
|
EmployeeTypeResigned = "Resigned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State 是期望写入 LDAP 的完整状态。
|
||||||
|
type State struct {
|
||||||
|
Groups []*Group
|
||||||
|
Users []*User
|
||||||
|
// GroupByDeptID 便于按部门查组
|
||||||
|
GroupByDeptID map[string]*Group
|
||||||
|
// Warnings 是映射过程中发现的问题,需要人工留意。
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 是期望的分组。
|
||||||
|
type Group struct {
|
||||||
|
DeptID string
|
||||||
|
DN string
|
||||||
|
Name string
|
||||||
|
ParentID string
|
||||||
|
// Depth 是部门在树中的层级,根为 0。用于保证父分组先于子分组创建。
|
||||||
|
Depth int
|
||||||
|
// Members 是期望成员的用户 uid 集合
|
||||||
|
Members map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User 是期望的用户。
|
||||||
|
type User struct {
|
||||||
|
UID string
|
||||||
|
DN string
|
||||||
|
InactiveDN string
|
||||||
|
CN string
|
||||||
|
SN string
|
||||||
|
GivenName string
|
||||||
|
DisplayName string
|
||||||
|
Mail string
|
||||||
|
Mobile string
|
||||||
|
EmployeeNumber string
|
||||||
|
DepartmentNumber string
|
||||||
|
PostalAddress string
|
||||||
|
BusinessCategory string
|
||||||
|
Description string
|
||||||
|
// EmployeeType 为 Active;离职条目由执行器单独标记为 Resigned。
|
||||||
|
EmployeeType string
|
||||||
|
DeptIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildOptions 是构造期望状态所需的配置。
|
||||||
|
type BuildOptions struct {
|
||||||
|
UserOUDN string
|
||||||
|
InactiveOUDN string
|
||||||
|
// GroupRootName 是部门树根 OU 的名称(不含 ou=)
|
||||||
|
GroupRootName string
|
||||||
|
GroupRootDN string
|
||||||
|
// CollisionSuffix 在登录名冲突时用于消歧,默认取员工工号或 union_id 片段
|
||||||
|
Disambiguate func(u *feishu.User) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 由飞书快照构造期望状态。
|
||||||
|
func Build(snap *feishu.Snapshot, opt BuildOptions) *State {
|
||||||
|
st := &State{
|
||||||
|
GroupByDeptID: make(map[string]*Group),
|
||||||
|
Warnings: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门 → 分组的 DN 需要自顶向下构造,先建索引再按层级解析。
|
||||||
|
deptByID := make(map[string]*feishu.Department, len(snap.Departments))
|
||||||
|
for _, d := range snap.Departments {
|
||||||
|
deptByID[d.ID] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按层级排序,确保父部门先于子部门处理,从而能复用父的 DN。
|
||||||
|
ordered := orderByDepth(snap.Departments, deptByID)
|
||||||
|
|
||||||
|
for _, d := range ordered {
|
||||||
|
parentDN := opt.GroupRootDN
|
||||||
|
if d.ParentID != "" && d.ParentID != "0" {
|
||||||
|
switch pg, ok := st.GroupByDeptID[d.ParentID]; {
|
||||||
|
case ok:
|
||||||
|
parentDN = pg.DN
|
||||||
|
case deptByID[d.ParentID] != nil:
|
||||||
|
// 父部门在范围内但尚未处理:正常情况下不会发生,
|
||||||
|
// 因为 ordered 已保证父先于子。出现即说明建树有缺陷。
|
||||||
|
st.Warnings = append(st.Warnings,
|
||||||
|
fmt.Sprintf("部门 %s(%s) 的父部门 %s 尚未处理,已跳过该部门",
|
||||||
|
d.Name, d.ID, d.ParentID))
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
// 父部门完全不在本次同步范围内(例如 dept-list 配置不当,
|
||||||
|
// 或通讯录权限被收窄)。此时挂到分组根下,让它至少能被同步,
|
||||||
|
// 但必须显式告警——静默地把部门挂到错误层级比失败更难察觉。
|
||||||
|
st.Warnings = append(st.Warnings,
|
||||||
|
fmt.Sprintf("部门 %s(%s) 的父部门 %s 不在同步范围内,已挂到 %s 下,请确认层级是否正确",
|
||||||
|
d.Name, d.ID, d.ParentID, opt.GroupRootDN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name := cnName(d.Name, d.ID)
|
||||||
|
g := &Group{
|
||||||
|
DeptID: d.ID,
|
||||||
|
DN: ldaplib.GroupDN(name, parentDN),
|
||||||
|
Name: d.Name,
|
||||||
|
ParentID: d.ParentID,
|
||||||
|
Depth: depthOf(d.ID, deptByID),
|
||||||
|
Members: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
st.Groups = append(st.Groups, g)
|
||||||
|
st.GroupByDeptID[d.ID] = g
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户
|
||||||
|
for _, u := range snap.Users {
|
||||||
|
uid, ok := feishu.LoginName(u)
|
||||||
|
if !ok {
|
||||||
|
continue // 已在快照的 SkippedNoEmail 中报告
|
||||||
|
}
|
||||||
|
|
||||||
|
du := &User{
|
||||||
|
UID: uid,
|
||||||
|
DN: ldaplib.UserDN(uid, opt.UserOUDN),
|
||||||
|
InactiveDN: ldaplib.UserDN(uid, opt.InactiveOUDN),
|
||||||
|
CN: uid,
|
||||||
|
SN: firstName(u),
|
||||||
|
GivenName: firstNonEmpty(u.EnName, u.Name, uid),
|
||||||
|
DisplayName: firstNonEmpty(u.Name, u.EnName, uid),
|
||||||
|
Mail: u.EnterpriseEmail,
|
||||||
|
Mobile: u.Mobile,
|
||||||
|
EmployeeNumber: u.EmployeeNo,
|
||||||
|
DepartmentNumber: u.JobTitle,
|
||||||
|
PostalAddress: u.WorkStation,
|
||||||
|
BusinessCategory: strings.Join(deptNames(u.DepartmentIDs, deptByID), ","),
|
||||||
|
Description: firstNonEmpty(u.Name, u.EnName, uid),
|
||||||
|
EmployeeType: EmployeeTypeActive,
|
||||||
|
DeptIDs: u.DepartmentIDs,
|
||||||
|
}
|
||||||
|
st.Users = append(st.Users, du)
|
||||||
|
|
||||||
|
// 维护成员关系:只把用户加入其实际所属且存在于本次同步范围的部门组。
|
||||||
|
for _, deptID := range u.DepartmentIDs {
|
||||||
|
if g, ok := st.GroupByDeptID[deptID]; ok {
|
||||||
|
g.Members[du.DN] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键:按层级排序,父分组必须排在子分组之前。
|
||||||
|
//
|
||||||
|
// 不能用 DeptID 字母序:部门 ID 与层级无关,字母序会把子部门排到父部门
|
||||||
|
// 前面,导致创建子分组时父分组尚不存在(LDAP 报 No Such Object)。
|
||||||
|
// 这个问题在部门名恰好有序时才不会暴露,因此必须显式按深度排序。
|
||||||
|
sort.SliceStable(st.Groups, func(i, j int) bool {
|
||||||
|
if st.Groups[i].Depth != st.Groups[j].Depth {
|
||||||
|
return st.Groups[i].Depth < st.Groups[j].Depth
|
||||||
|
}
|
||||||
|
return st.Groups[i].DeptID < st.Groups[j].DeptID
|
||||||
|
})
|
||||||
|
sort.SliceStable(st.Users, func(i, j int) bool {
|
||||||
|
return st.Users[i].UID < st.Users[j].UID
|
||||||
|
})
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// depthOf 计算部门在树中的深度,根部门为 0。
|
||||||
|
//
|
||||||
|
// 带有步数上限,防止数据异常(如父子互指形成环)时无限循环。
|
||||||
|
func depthOf(id string, byID map[string]*feishu.Department) int {
|
||||||
|
const maxDepth = 64
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
cur := id
|
||||||
|
for i := 0; i < maxDepth; i++ {
|
||||||
|
d, ok := byID[cur]
|
||||||
|
if !ok || d.ParentID == "" || d.ParentID == "0" {
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
if _, ok := byID[d.ParentID]; !ok {
|
||||||
|
return depth // 父不在同步范围内,视作根
|
||||||
|
}
|
||||||
|
depth++
|
||||||
|
cur = d.ParentID
|
||||||
|
}
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
|
||||||
|
// cnName 为部门生成稳定的 CN。
|
||||||
|
//
|
||||||
|
// 用拼音而非中文:中文作 RDN 在 DN 中需要转义为 #hex 形式,可读性差且
|
||||||
|
// 在部分工具链下有兼容问题;拼音与现有目录的部门命名也保持一致。
|
||||||
|
// 拼音为空(部门名为纯符号)时回落到部门 ID,保证 CN 非空且稳定。
|
||||||
|
func cnName(deptName, deptID string) string {
|
||||||
|
name := toPinyin(deptName)
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "dept-" + deptID
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPinyin 把中文转为无音调拼音,保留 ASCII 与数字。
|
||||||
|
func toPinyin(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已全是 ASCII 时直接使用,避免拼音库对英文做多余处理。
|
||||||
|
if isASCII(s) {
|
||||||
|
return sanitizeCN(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := pinyin.NewArgs()
|
||||||
|
args.Style = pinyin.Normal
|
||||||
|
parts := pinyin.Pinyin(s, args)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range parts {
|
||||||
|
if len(p) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(p[0])
|
||||||
|
}
|
||||||
|
return sanitizeCN(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeCN 清理 CN 中会导致 DN 书写困难的字符。
|
||||||
|
func sanitizeCN(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-' || r == '_':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '.':
|
||||||
|
// 空格与点号在 CN 中会造成歧义,转为下划线
|
||||||
|
b.WriteRune('_')
|
||||||
|
case r > 127:
|
||||||
|
// 剩余的非 ASCII(拼音库未能转换的字符)丢弃
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCII(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderByDepth 返回按层级(父在子前)排序的部门列表。
|
||||||
|
//
|
||||||
|
// 使用迭代式的层级展开,并对异常数据(环、悬挂父引用)做保护:
|
||||||
|
// 无法归入任何层级的部门会被追加到末尾,绝不死循环。
|
||||||
|
func orderByDepth(all []*feishu.Department, byID map[string]*feishu.Department) []*feishu.Department {
|
||||||
|
children := make(map[string][]*feishu.Department)
|
||||||
|
roots := make([]*feishu.Department, 0)
|
||||||
|
for _, d := range all {
|
||||||
|
if d.ParentID == "" || d.ParentID == "0" {
|
||||||
|
roots = append(roots, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := byID[d.ParentID]; !ok {
|
||||||
|
roots = append(roots, d) // 父不在范围内,当根处理
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
children[d.ParentID] = append(children[d.ParentID], d)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]*feishu.Department, 0, len(all))
|
||||||
|
visited := make(map[string]bool, len(all))
|
||||||
|
|
||||||
|
var walk func(list []*feishu.Department)
|
||||||
|
walk = func(list []*feishu.Department) {
|
||||||
|
for _, d := range list {
|
||||||
|
if visited[d.ID] {
|
||||||
|
continue // 环保护
|
||||||
|
}
|
||||||
|
visited[d.ID] = true
|
||||||
|
out = append(out, d)
|
||||||
|
walk(children[d.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(roots)
|
||||||
|
|
||||||
|
// 未被访问到的(处在环中)按 ID 顺序补齐,避免静默丢部门。
|
||||||
|
var rest []*feishu.Department
|
||||||
|
for _, d := range all {
|
||||||
|
if !visited[d.ID] {
|
||||||
|
rest = append(rest, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID })
|
||||||
|
out = append(out, rest...)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deptNames(ids []string, byID map[string]*feishu.Department) []string {
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if d, ok := byID[id]; ok && d.Name != "" {
|
||||||
|
out = append(out, d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstName 为 inetOrgPerson 的必填属性 sn 提供值。
|
||||||
|
// 拆不出姓氏时用全名,保证非空。
|
||||||
|
func firstName(u *feishu.User) string {
|
||||||
|
if n := strings.TrimSpace(u.Name); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if n := strings.TrimSpace(u.EnName); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return u.UnionID
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package mapping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"idsync/internal/feishu"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dept(id, parent, name string) *feishu.Department {
|
||||||
|
return &feishu.Department{ID: id, ParentID: parent, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func user(uid, name, email string, depts ...string) *feishu.User {
|
||||||
|
return &feishu.User{
|
||||||
|
UnionID: "u-" + uid,
|
||||||
|
Name: name,
|
||||||
|
EnterpriseEmail: email,
|
||||||
|
DepartmentIDs: depts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func options() BuildOptions {
|
||||||
|
return BuildOptions{
|
||||||
|
UserOUDN: "ou=people,dc=example,dc=com",
|
||||||
|
InactiveOUDN: "ou=Inactive,dc=example,dc=com",
|
||||||
|
GroupRootDN: "ou=feishuroot,dc=example,dc=com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildGroupOrderingIsParentFirst 是一个关键回归测试。
|
||||||
|
//
|
||||||
|
// 分组必须按层级排序,父分组排在子分组之前。早期实现按部门 ID 字母序排序,
|
||||||
|
// 当子部门的 ID 字母序恰好排在父部门之前时,创建子分组会因父分组尚不存在
|
||||||
|
// 而失败(LDAP No Such Object)。
|
||||||
|
func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{
|
||||||
|
// 子部门的 ID 字母序刻意排在父部门之前
|
||||||
|
dept("od-aaa-child", "od-root", "子部门"),
|
||||||
|
dept("od-root", "0", "根部门"),
|
||||||
|
dept("od-zzz-grandchild", "od-aaa-child", "孙部门"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
|
||||||
|
seen := make(map[string]int)
|
||||||
|
for i, g := range st.Groups {
|
||||||
|
seen[g.DeptID] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range st.Groups {
|
||||||
|
if g.ParentID == "" || g.ParentID == "0" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pi, ok := seen[g.ParentID]
|
||||||
|
if !ok {
|
||||||
|
continue // 父不在范围内
|
||||||
|
}
|
||||||
|
ci := seen[g.DeptID]
|
||||||
|
if pi > ci {
|
||||||
|
t.Errorf("子分组 %s 排在父分组 %s 之前(%d > %d)",
|
||||||
|
g.DeptID, g.ParentID, ci, pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深度应正确反映层级
|
||||||
|
for _, g := range st.Groups {
|
||||||
|
switch g.DeptID {
|
||||||
|
case "od-root":
|
||||||
|
if g.Depth != 0 {
|
||||||
|
t.Errorf("根部门深度 = %d, 期望 0", g.Depth)
|
||||||
|
}
|
||||||
|
case "od-aaa-child":
|
||||||
|
if g.Depth != 1 {
|
||||||
|
t.Errorf("子部门深度 = %d, 期望 1", g.Depth)
|
||||||
|
}
|
||||||
|
case "od-zzz-grandchild":
|
||||||
|
if g.Depth != 2 {
|
||||||
|
t.Errorf("孙部门深度 = %d, 期望 2", g.Depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。
|
||||||
|
func TestBuildGroupDNReflectsNesting(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{
|
||||||
|
dept("od-root", "0", "Acme"),
|
||||||
|
dept("od-rd", "od-root", "研发部"),
|
||||||
|
dept("od-backend", "od-rd", "后端组"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
|
||||||
|
rootDN := st.GroupByDeptID["od-root"].DN
|
||||||
|
if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||||
|
t.Errorf("根部门 DN = %q", rootDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
rdDN := st.GroupByDeptID["od-rd"].DN
|
||||||
|
if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||||
|
t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
bdDN := st.GroupByDeptID["od-backend"].DN
|
||||||
|
if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
|
||||||
|
t.Errorf("后端组 DN = %q", bdDN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
|
||||||
|
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
|
||||||
|
Users: []*feishu.User{
|
||||||
|
user("1", "张三", "ZhangSan@Example.com", "od-root"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
if len(st.Users) != 1 {
|
||||||
|
t.Fatalf("用户数 = %d, 期望 1", len(st.Users))
|
||||||
|
}
|
||||||
|
|
||||||
|
u := st.Users[0]
|
||||||
|
// 登录名应小写且不含域名
|
||||||
|
if u.UID != "zhangsan" {
|
||||||
|
t.Errorf("uid = %q, 期望 zhangsan(应取企业邮箱前缀并转小写)", u.UID)
|
||||||
|
}
|
||||||
|
if u.DN != "uid=zhangsan,ou=people,dc=example,dc=com" {
|
||||||
|
t.Errorf("DN = %q", u.DN)
|
||||||
|
}
|
||||||
|
if u.InactiveDN != "uid=zhangsan,ou=Inactive,dc=example,dc=com" {
|
||||||
|
t.Errorf("离职 DN = %q", u.InactiveDN)
|
||||||
|
}
|
||||||
|
// mail 必须是企业邮箱本身(保留原始大小写)
|
||||||
|
if u.Mail != "ZhangSan@Example.com" {
|
||||||
|
t.Errorf("mail = %q, 期望企业邮箱原值", u.Mail)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "张三" {
|
||||||
|
t.Errorf("displayName = %q, 期望 张三", u.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildSkipsUserWithoutEnterpriseEmail 验证缺少企业邮箱的用户不会被臆造登录名。
|
||||||
|
//
|
||||||
|
// 这里刻意不断言「回落到个人邮箱」——那样的行为会产生不可预期、
|
||||||
|
// 且离职后仍然有效的登录名,因此必须跳过。
|
||||||
|
func TestBuildSkipsUserWithoutEnterpriseEmail(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
|
||||||
|
Users: []*feishu.User{
|
||||||
|
// 有个人邮箱但没有企业邮箱
|
||||||
|
{
|
||||||
|
UnionID: "u-nomail",
|
||||||
|
Name: "孙七",
|
||||||
|
Email: "sq@personal.com",
|
||||||
|
DepartmentIDs: []string{"od-root"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
if len(st.Users) != 0 {
|
||||||
|
t.Errorf("缺少企业邮箱的用户不应被映射为登录名,实际生成 %d 个", len(st.Users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildMembership 验证成员关系按部门正确归集。
|
||||||
|
func TestBuildMembership(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{
|
||||||
|
dept("od-root", "0", "Acme"),
|
||||||
|
dept("od-rd", "od-root", "研发部"),
|
||||||
|
},
|
||||||
|
Users: []*feishu.User{
|
||||||
|
user("1", "张三", "zhangsan@example.com", "od-rd"),
|
||||||
|
// 一人属多部门
|
||||||
|
user("2", "李四", "lisi@example.com", "od-root", "od-rd"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
|
||||||
|
rd := st.GroupByDeptID["od-rd"]
|
||||||
|
if _, ok := rd.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; !ok {
|
||||||
|
t.Errorf("张三 未加入研发部")
|
||||||
|
}
|
||||||
|
if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
|
||||||
|
t.Errorf("李四 未加入研发部")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := st.GroupByDeptID["od-root"]
|
||||||
|
if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
|
||||||
|
t.Errorf("李四 未加入根部门")
|
||||||
|
}
|
||||||
|
if _, ok := root.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; ok {
|
||||||
|
t.Errorf("张三 只属研发部,不应出现在根部门")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildOrphanDepartmentWarnsAndAttachesToRoot 验证父部门不在范围内时的处理。
|
||||||
|
//
|
||||||
|
// 行为是「挂到分组根下并告警」,而不是静默跳过或静默挂错层级:
|
||||||
|
// 静默跳过会让部门凭空消失,静默挂错层级会让人以为结构正确。
|
||||||
|
func TestBuildOrphanDepartmentWarnsAndAttachesToRoot(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{
|
||||||
|
// 父部门 od-missing 不在快照中
|
||||||
|
dept("od-orphan", "od-missing", "孤儿部门"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
|
||||||
|
g, ok := st.GroupByDeptID["od-orphan"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("父部门不在范围内的部门仍应被同步(挂到分组根下)")
|
||||||
|
}
|
||||||
|
if want := "cn=guerbumen,ou=feishuroot,dc=example,dc=com"; g.DN != want {
|
||||||
|
t.Errorf("孤儿部门 DN = %q, 期望 %q", g.DN, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, w := range st.Warnings {
|
||||||
|
if strings.Contains(w, "od-missing") && strings.Contains(w, "不在同步范围内") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("父部门不在范围内时应产生告警,实际告警: %v", st.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildCycleProtection 验证部门树出现环时不会死循环。
|
||||||
|
func TestBuildCycleProtection(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{
|
||||||
|
// a 的父是 b,b 的父是 a:构造一个环
|
||||||
|
dept("od-a", "od-b", "A"),
|
||||||
|
dept("od-b", "od-a", "B"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan *State, 1)
|
||||||
|
go func() { done <- Build(snap, options()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case st := <-done:
|
||||||
|
// 关键是能返回而不是卡死
|
||||||
|
if st == nil {
|
||||||
|
t.Fatalf("Build 返回 nil")
|
||||||
|
}
|
||||||
|
case <-timeAfterSeconds(5):
|
||||||
|
t.Fatalf("部门数据成环时 Build 未在 5 秒内返回,可能存在死循环")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToPinyin 验证部门名到 CN 的转换与清理。
|
||||||
|
func TestToPinyin(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"研发部", "yanfabu"},
|
||||||
|
{"后端组", "houduanzu"},
|
||||||
|
{"Acme", "Acme"},
|
||||||
|
{"IT Dept", "IT_Dept"},
|
||||||
|
{"", ""},
|
||||||
|
{" ", ""},
|
||||||
|
{"Finance-2", "Finance-2"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := toPinyin(c.in); got != c.want {
|
||||||
|
t.Errorf("toPinyin(%q) = %q, 期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCnNameFallsBackToDeptID 验证纯符号部门名不会产生空 CN。
|
||||||
|
func TestCnNameFallsBackToDeptID(t *testing.T) {
|
||||||
|
got := cnName("★☆", "od-xyz")
|
||||||
|
if got == "" {
|
||||||
|
t.Errorf("cnName 不应返回空字符串")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "od-xyz") {
|
||||||
|
t.Errorf("无法转为拼音时应回落到部门 ID,实际: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAttrsPreserveEmptyOptionalFields 验证可选字段为空时不会填充臆造值。
|
||||||
|
//
|
||||||
|
// 这些属性在 LDAP 侧会走删除分支,而不是写入 "Default: xxx" 之类的占位值。
|
||||||
|
func TestBuildKeepsOptionalFieldsEmpty(t *testing.T) {
|
||||||
|
snap := &feishu.Snapshot{
|
||||||
|
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
|
||||||
|
Users: []*feishu.User{
|
||||||
|
{
|
||||||
|
UnionID: "u-1", Name: "张三", EnterpriseEmail: "zhangsan@example.com",
|
||||||
|
// Mobile/EmployeeNo/JobTitle/WorkStation 全部留空
|
||||||
|
DepartmentIDs: []string{"od-root"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
st := Build(snap, options())
|
||||||
|
u := st.Users[0]
|
||||||
|
|
||||||
|
if u.Mobile != "" {
|
||||||
|
t.Errorf("mobile 应为空,实际 %q", u.Mobile)
|
||||||
|
}
|
||||||
|
if u.EmployeeNumber != "" {
|
||||||
|
t.Errorf("employeeNumber 应为空,实际 %q", u.EmployeeNumber)
|
||||||
|
}
|
||||||
|
if u.DepartmentNumber != "" {
|
||||||
|
t.Errorf("departmentNumber 应为空,实际 %q", u.DepartmentNumber)
|
||||||
|
}
|
||||||
|
if u.PostalAddress != "" {
|
||||||
|
t.Errorf("postalAddress 应为空,实际 %q", u.PostalAddress)
|
||||||
|
}
|
||||||
|
// sn 是 inetOrgPerson 的必填项,必须有值
|
||||||
|
if u.SN == "" {
|
||||||
|
t.Errorf("sn 不可为空(inetOrgPerson 必填)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeAfterSeconds 返回一个等待指定秒数后触发的通道,用于检验死循环。
|
||||||
|
func timeAfterSeconds(n int) <-chan struct{} {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Duration(n) * time.Second)
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Package password 提供 OpenLDAP userPassword 的哈希编码。
|
||||||
|
//
|
||||||
|
// 为什么需要它:slapd 对客户端通过普通 Modify 写入的 userPassword **不做**
|
||||||
|
// 自动哈希(除非启用 ppolicy 的 hash_cleartext),因此如果直接写明文,
|
||||||
|
// 目录里就会以明文形式保存密码。这里在写入前自行编码成 {SSHA},
|
||||||
|
// 不依赖服务端配置。
|
||||||
|
package password
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncodeSSHA 把明文密码编码成 OpenLDAP 的 {SSHA} 形式。
|
||||||
|
//
|
||||||
|
// SSHA = base64( SHA1(密码 + 盐) + 盐 )。附带随机盐可以避免彩虹表,
|
||||||
|
// 并让相同密码产生不同哈希。
|
||||||
|
func EncodeSSHA(plain string) (string, error) {
|
||||||
|
salt := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("生成盐失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(plain))
|
||||||
|
h.Write(salt)
|
||||||
|
sum := h.Sum(nil)
|
||||||
|
|
||||||
|
// 哈希后拼接盐,一并 base64
|
||||||
|
out := append(sum, salt...)
|
||||||
|
return "{SSHA}" + base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifySSHA 校验明文密码与 {SSHA} 哈希是否匹配。
|
||||||
|
//
|
||||||
|
// 主要用于测试,也可供将来的密码校验功能复用。
|
||||||
|
func VerifySSHA(encoded, plain string) bool {
|
||||||
|
const prefix = "{SSHA}"
|
||||||
|
if len(encoded) <= len(prefix) || encoded[:len(prefix)] != prefix {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded[len(prefix):])
|
||||||
|
if err != nil || len(raw) < sha1.Size {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := raw[:sha1.Size]
|
||||||
|
salt := raw[sha1.Size:]
|
||||||
|
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(plain))
|
||||||
|
h.Write(salt)
|
||||||
|
want := h.Sum(nil)
|
||||||
|
|
||||||
|
if len(want) != len(sum) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 恒定时间比较,避免时序侧信道(虽然此处主要用于测试,
|
||||||
|
// 保持与生产代码一致的比较方式更不易被误用)。
|
||||||
|
var diff byte
|
||||||
|
for i := range want {
|
||||||
|
diff |= want[i] ^ sum[i]
|
||||||
|
}
|
||||||
|
return diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random 生成一个用户不可知的随机初始密码。
|
||||||
|
//
|
||||||
|
// 用户不需要知道它:Authelia 的密码重置流程不需要旧密码,
|
||||||
|
// 因此新账号首次登录应走“忘记密码”。这比为所有人设同一个初始密码安全得多。
|
||||||
|
func Random() (string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("生成随机密码失败: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package password
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncodeSSHA(t *testing.T) {
|
||||||
|
const plain = "correct horse battery staple"
|
||||||
|
|
||||||
|
encoded, err := EncodeSSHA(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(encoded, "{SSHA}") {
|
||||||
|
t.Errorf("结果应以 {SSHA} 开头,实际: %q", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键性质:明文绝不能出现在结果里。
|
||||||
|
// slapd 不会自动哈希 Modify 写入的 userPassword,因此这一步是
|
||||||
|
// 密码不落明文存储的唯一保障。
|
||||||
|
if strings.Contains(encoded, plain) {
|
||||||
|
t.Errorf("编码结果中出现了明文密码: %q", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !VerifySSHA(encoded, plain) {
|
||||||
|
t.Errorf("正确密码校验失败: %q", encoded)
|
||||||
|
}
|
||||||
|
if VerifySSHA(encoded, plain+"x") {
|
||||||
|
t.Errorf("错误密码不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeSSHASaltIsRandom(t *testing.T) {
|
||||||
|
const plain = "same-password"
|
||||||
|
|
||||||
|
a, err := EncodeSSHA(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码失败: %v", err)
|
||||||
|
}
|
||||||
|
b, err := EncodeSSHA(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机盐应让同一密码产生不同哈希,避免相同密码在目录中可被关联识别。
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("同一密码两次编码结果相同,说明盐未随机化: %q", a)
|
||||||
|
}
|
||||||
|
if !VerifySSHA(a, plain) || !VerifySSHA(b, plain) {
|
||||||
|
t.Errorf("带随机盐的两个哈希都应能校验通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySSHARejectsMalformed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
encoded string
|
||||||
|
}{
|
||||||
|
{"空字符串", ""},
|
||||||
|
{"缺少前缀", "abcdef"},
|
||||||
|
{"前缀但非法 base64", "{SSHA}!!!not-base64!!!"},
|
||||||
|
{"长度不足以容纳哈希", "{SSHA}YWJj"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if VerifySSHA(c.encoded, "whatever") {
|
||||||
|
t.Errorf("畸形输入 %q 不应校验通过", c.encoded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandom(t *testing.T) {
|
||||||
|
a, err := Random()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成失败: %v", err)
|
||||||
|
}
|
||||||
|
b, err := Random()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成失败: %v", err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("两次生成的随机密码相同: %q", a)
|
||||||
|
}
|
||||||
|
if len(a) < 20 {
|
||||||
|
t.Errorf("随机密码过短(%d 字符),强度不足", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Package server 提供 WebUI 所需的 HTTP 接口与静态资源。
|
||||||
|
//
|
||||||
|
// 认证只做一件事:保护这个管理界面本身。用户认证完全由 Authelia 承担,
|
||||||
|
// 本工具不参与任何登录链路,因此这里就是一个单管理员的会话,不做角色体系。
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionTTL 是会话有效期。
|
||||||
|
const SessionTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// cookieName 是会话 cookie 的名称。
|
||||||
|
const cookieName = "idsync_session"
|
||||||
|
|
||||||
|
// Auth 负责单管理员认证与会话签名。
|
||||||
|
type Auth struct {
|
||||||
|
username string
|
||||||
|
passwordHash []byte
|
||||||
|
secret []byte
|
||||||
|
secureCookie bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuth 构造认证器。passwordHash 为空时用明文密码生成 bcrypt 哈希。
|
||||||
|
func NewAuth(username string, passwordHash []byte, plainPassword string, secret []byte, secureCookie bool) (*Auth, error) {
|
||||||
|
if len(passwordHash) == 0 {
|
||||||
|
if plainPassword == "" {
|
||||||
|
return nil, fmt.Errorf("未配置管理员密码")
|
||||||
|
}
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("生成密码哈希失败: %w", err)
|
||||||
|
}
|
||||||
|
passwordHash = h
|
||||||
|
}
|
||||||
|
if len(secret) < 32 {
|
||||||
|
return nil, fmt.Errorf("会话密钥长度不足(至少 32 字节)")
|
||||||
|
}
|
||||||
|
return &Auth{
|
||||||
|
username: username,
|
||||||
|
passwordHash: passwordHash,
|
||||||
|
secret: secret,
|
||||||
|
secureCookie: secureCookie,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordHashB64 返回可写回配置文件的哈希(base64)。
|
||||||
|
func (a *Auth) PasswordHashB64() string {
|
||||||
|
return base64.StdEncoding.EncodeToString(a.passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 校验凭据;成功则下发签名 cookie。
|
||||||
|
func (a *Auth) Login(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
return fmt.Errorf("请求格式错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户名与密码都要做恒定时间比较,避免通过响应时间探测用户名是否存在。
|
||||||
|
userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(a.username)) == 1
|
||||||
|
passErr := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(req.Password))
|
||||||
|
if !userOK || passErr != nil {
|
||||||
|
return fmt.Errorf("用户名或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := a.sign(time.Now().Add(SessionTTL))
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: a.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(SessionTTL),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 清除会话 cookie。
|
||||||
|
func (a *Auth) Logout(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: a.secureCookie,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated 判断请求是否带有有效会话。
|
||||||
|
func (a *Auth) Authenticated(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload, sig, ok := strings.Cut(c.Value, ".")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(sig), []byte(a.signPayload(payload))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 载荷即过期时间的 Unix 秒
|
||||||
|
exp, err := time.Parse(time.RFC3339, decodeB64(payload))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Before(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware 保护需要登录的接口。
|
||||||
|
func (a *Auth) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.Authenticated(r) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign 生成 "payload.signature" 形式的令牌。
|
||||||
|
func (a *Auth) sign(exp time.Time) string {
|
||||||
|
payload := encodeB64(exp.UTC().Format(time.RFC3339))
|
||||||
|
return payload + "." + a.signPayload(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signPayload 用 HMAC-SHA256 对载荷签名。
|
||||||
|
func (a *Auth) signPayload(payload string) string {
|
||||||
|
mac := hmac.New(sha256.New, a.secret)
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return encodeB64Bytes(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSecret 生成会话密钥。
|
||||||
|
func GenerateSecret() (string, error) {
|
||||||
|
b := make([]byte, 48)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return encodeB64Bytes(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeB64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||||
|
func encodeB64Bytes(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
func decodeB64(s string) string {
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimeout 给 handler 提供一个带超时的 context。
|
||||||
|
func WithTimeout(d time.Duration, fn func(ctx context.Context, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), d)
|
||||||
|
defer cancel()
|
||||||
|
fn(ctx, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword 为给定明文密码生成 bcrypt 哈希(base64 编码),供 -hash-password 使用。
|
||||||
|
func HashPassword(plain string) (string, error) {
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(h), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestAuth(t *testing.T) *Auth {
|
||||||
|
t.Helper()
|
||||||
|
a, err := NewAuth("admin", nil, "correct-password",
|
||||||
|
[]byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造认证器失败: %v", err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, a *Auth, user, pass string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
body := strings.NewReader(`{"username":"` + user + `","password":"` + pass + `"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/login", body)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
err := a.Login(rec, req)
|
||||||
|
if err != nil {
|
||||||
|
return &http.Response{StatusCode: http.StatusUnauthorized}
|
||||||
|
}
|
||||||
|
return rec.Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginAcceptsCorrectCredentials(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
res := login(t, a, "admin", "correct-password")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("登录失败,状态码 %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies := res.Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatalf("登录成功但未下发 cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
var session *http.Cookie
|
||||||
|
for _, c := range cookies {
|
||||||
|
if c.Name == cookieName {
|
||||||
|
session = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session == nil {
|
||||||
|
t.Fatalf("未找到会话 cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键安全属性
|
||||||
|
if !session.HttpOnly {
|
||||||
|
t.Errorf("会话 cookie 必须设置 HttpOnly,否则可被 JS 读取")
|
||||||
|
}
|
||||||
|
if session.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Errorf("会话 cookie 应设置 SameSite=Lax,实际 %v", session.SameSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsWrongCredentials(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
}{
|
||||||
|
{"密码错误", "admin", "wrong-password"},
|
||||||
|
{"用户名错误", "root", "correct-password"},
|
||||||
|
{"均为空", "", ""},
|
||||||
|
{"密码为空", "admin", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
res := login(t, a, c.user, c.pass)
|
||||||
|
if res.StatusCode == http.StatusOK {
|
||||||
|
t.Errorf("错误凭据不应登录成功")
|
||||||
|
}
|
||||||
|
for _, ck := range res.Cookies() {
|
||||||
|
if ck.Name == cookieName && ck.Value != "" {
|
||||||
|
t.Errorf("登录失败时不应下发有效会话 cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionValidation(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
res := login(t, a, "admin", "correct-password")
|
||||||
|
|
||||||
|
var token string
|
||||||
|
for _, c := range res.Cookies() {
|
||||||
|
if c.Name == cookieName {
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"有效会话", token, true},
|
||||||
|
{"空值", "", false},
|
||||||
|
{"无签名部分", "abc", false},
|
||||||
|
{"签名被篡改", token[:len(token)-4] + "AAAA", false},
|
||||||
|
{"伪造载荷", "ZmFrZQ.AAAA", false},
|
||||||
|
{"随机字符串", "not-a-token-at-all", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
if c.value != "" {
|
||||||
|
req.AddCookie(&http.Cookie{Name: cookieName, Value: c.value})
|
||||||
|
}
|
||||||
|
if got := a.Authenticated(req); got != c.want {
|
||||||
|
t.Errorf("Authenticated() = %v, 期望 %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionRejectsForgedSignature 验证签名密钥不同则无法通过校验。
|
||||||
|
//
|
||||||
|
// 这是会话机制的核心安全性:没有正确的密钥,就无法构造出有效的会话令牌。
|
||||||
|
func TestSessionRejectsForgedSignature(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
res := login(t, a, "admin", "correct-password")
|
||||||
|
var token string
|
||||||
|
for _, c := range res.Cookies() {
|
||||||
|
if c.Name == cookieName {
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用不同密钥的认证器校验同一个令牌
|
||||||
|
other, err := NewAuth("admin", nil, "correct-password",
|
||||||
|
[]byte("ffffffffffffffffffffffffffffffffffffffffffffffff"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: cookieName, Value: token})
|
||||||
|
if other.Authenticated(req) {
|
||||||
|
t.Errorf("换了会话密钥后旧令牌不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionExpiry 验证过期会话被拒。
|
||||||
|
//
|
||||||
|
// 这里直接构造一个已过期的载荷并签名,模拟「昨天登录、今天再用」的情形。
|
||||||
|
func TestSessionExpiry(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
expired := a.sign(time.Now().Add(-time.Minute))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: cookieName, Value: expired})
|
||||||
|
if a.Authenticated(req) {
|
||||||
|
t.Errorf("已过期的会话不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareBlocksAnonymous(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
h := a.Middleware(func(w http.ResponseWriter, r *http.Request) { called = true })
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest(http.MethodGet, "/api/status", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("未登录访问受保护接口应返回 401,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Errorf("未登录时不应执行受保护的处理逻辑")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutClearsCookie(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.Logout(rec)
|
||||||
|
|
||||||
|
var cleared bool
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == cookieName && c.MaxAge < 0 {
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Errorf("登出应清除会话 cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAuthRequiresSecret(t *testing.T) {
|
||||||
|
if _, err := NewAuth("admin", nil, "pw", []byte("short"), false); err == nil {
|
||||||
|
t.Errorf("过短的会话密钥应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := NewAuth("admin", nil, "", []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false); err == nil {
|
||||||
|
t.Errorf("缺少密码时应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashPasswordRoundTrip(t *testing.T) {
|
||||||
|
h, err := HashPassword("my-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成哈希失败: %v", err)
|
||||||
|
}
|
||||||
|
// 生成的哈希应能通过 NewAuth 校验
|
||||||
|
a, err := NewAuth("admin", mustDecodeB64(t, h), "",
|
||||||
|
[]byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("用哈希构造认证器失败: %v", err)
|
||||||
|
}
|
||||||
|
if res := login(t, a, "admin", "my-secret"); res.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("用生成的哈希登录失败")
|
||||||
|
}
|
||||||
|
if res := login(t, a, "admin", "wrong"); res.StatusCode == http.StatusOK {
|
||||||
|
t.Errorf("错误密码不应通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSecret(t *testing.T) {
|
||||||
|
a, err := GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成失败: %v", err)
|
||||||
|
}
|
||||||
|
b, err := GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成失败: %v", err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("两次生成的密钥相同")
|
||||||
|
}
|
||||||
|
if len(a) < 32 {
|
||||||
|
t.Errorf("密钥过短: %d", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDecodeB64(t *testing.T, s string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
dec, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64 解码失败: %v", err)
|
||||||
|
}
|
||||||
|
return dec
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/store"
|
||||||
|
"idsync/internal/syncer"
|
||||||
|
"idsync/internal/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps 是服务器依赖。
|
||||||
|
type Deps struct {
|
||||||
|
Cfg *config.Config
|
||||||
|
Auth *Auth
|
||||||
|
Store *store.Store
|
||||||
|
Runner *syncer.Runner
|
||||||
|
LDAP *ldaplib.Client
|
||||||
|
// FeishuTest 检查飞书连通性
|
||||||
|
FeishuTest func(ctx context.Context) error
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server 装配路由。
|
||||||
|
type Server struct {
|
||||||
|
deps *Deps
|
||||||
|
mux *http.ServeMux
|
||||||
|
// syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。
|
||||||
|
syncMu chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造服务器。
|
||||||
|
func New(d *Deps) *Server {
|
||||||
|
s := &Server{
|
||||||
|
deps: d,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
syncMu: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
s.routes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 返回可挂载到 http.Server 的处理器。
|
||||||
|
func (s *Server) Handler() http.Handler { return s.mux }
|
||||||
|
|
||||||
|
func (s *Server) routes() {
|
||||||
|
// 静态资源与登录页不需要认证。
|
||||||
|
s.mux.HandleFunc("/", s.handleIndex)
|
||||||
|
s.mux.HandleFunc("/static/", webui.AssetHandler())
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/api/login", s.handleLogin)
|
||||||
|
s.mux.HandleFunc("/api/logout", s.handleLogout)
|
||||||
|
|
||||||
|
// 以下接口需要登录。
|
||||||
|
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
|
||||||
|
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
|
||||||
|
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
|
||||||
|
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
|
||||||
|
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
|
||||||
|
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。
|
||||||
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webui.IndexHandler()(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin 处理登录。
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.Auth.Login(w, r); err != nil {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout 处理登出。
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.deps.Auth.Logout(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus 返回概览信息:连通性、计数、上次同步。
|
||||||
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cfg := s.deps.Cfg
|
||||||
|
|
||||||
|
ldapOK := true
|
||||||
|
ldapErr := ""
|
||||||
|
if err := s.deps.LDAP.Ping(); err != nil {
|
||||||
|
ldapOK = false
|
||||||
|
ldapErr = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
feishuOK := true
|
||||||
|
feishuErr := ""
|
||||||
|
if s.deps.FeishuTest != nil {
|
||||||
|
if err := s.deps.FeishuTest(ctx); err != nil {
|
||||||
|
feishuOK = false
|
||||||
|
feishuErr = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := s.deps.Store.AllAccounts()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groups, err := s.deps.Store.AllGroups()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active, inactive := 0, 0
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.State == "inactive" {
|
||||||
|
inactive++
|
||||||
|
} else {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRun, err := s.deps.Store.LastRun()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ldap": map[string]any{
|
||||||
|
"ok": ldapOK, "error": ldapErr,
|
||||||
|
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN,
|
||||||
|
"userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(),
|
||||||
|
},
|
||||||
|
"feishu": map[string]any{
|
||||||
|
"ok": feishuOK, "error": feishuErr,
|
||||||
|
"appId": maskSecret(cfg.Feishu.AppID),
|
||||||
|
"deptList": cfg.Feishu.DeptList,
|
||||||
|
},
|
||||||
|
"counts": map[string]any{
|
||||||
|
"activeUsers": active, "inactiveUsers": inactive, "groups": len(groups),
|
||||||
|
},
|
||||||
|
"lastRun": lastRun,
|
||||||
|
"sync": map[string]any{
|
||||||
|
"enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule,
|
||||||
|
"defaultDryRun": cfg.Sync.DefaultDryRun,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。
|
||||||
|
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accounts, err := s.deps.Store.AllAccounts()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项,
|
||||||
|
// 这类问题在界面上一眼可见比事后翻日志有用得多。
|
||||||
|
drift := map[string]string{}
|
||||||
|
if conn, err := s.deps.LDAP.Dial(); err == nil {
|
||||||
|
defer conn.Close()
|
||||||
|
active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN())
|
||||||
|
inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN())
|
||||||
|
if errA == nil && errI == nil {
|
||||||
|
for _, a := range accounts {
|
||||||
|
switch a.State {
|
||||||
|
case "inactive":
|
||||||
|
if _, ok := inactive[a.UID]; !ok {
|
||||||
|
drift[a.UID] = "台账标记离职,但 LDAP 中不存在"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if _, ok := active[a.UID]; !ok {
|
||||||
|
if _, inInactive := inactive[a.UID]; inInactive {
|
||||||
|
drift[a.UID] = "台账标记在职,实际位于离职 OU"
|
||||||
|
} else {
|
||||||
|
drift[a.UID] = "台账存在,但 LDAP 中不存在"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
State string `json:"state"`
|
||||||
|
DN string `json:"dn"`
|
||||||
|
DeptIDs string `json:"deptIds"`
|
||||||
|
LastSync string `json:"lastSyncedAt"`
|
||||||
|
Drift string `json:"drift,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]row, 0, len(accounts))
|
||||||
|
for _, a := range accounts {
|
||||||
|
rows = append(rows, row{
|
||||||
|
UID: a.UID,
|
||||||
|
Name: a.Name,
|
||||||
|
Email: a.Email,
|
||||||
|
State: a.State,
|
||||||
|
DN: a.DN,
|
||||||
|
DeptIDs: a.DeptIDs,
|
||||||
|
LastSync: a.LastSyncedAt.Format(time.RFC3339),
|
||||||
|
Drift: drift[a.UID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGroups 返回受管分组清单。
|
||||||
|
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groups, err := s.deps.Store.AllGroups()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlan 计算并返回 dry-run 计划,不产生任何写入。
|
||||||
|
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
plan, err := s.deps.Runner.Preview(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。
|
||||||
|
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DryRun *bool `json:"dryRun"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 dry-run:忘记传参数时宁可什么都不做。
|
||||||
|
dryRun := true
|
||||||
|
if req.DryRun != nil {
|
||||||
|
dryRun = *req.DryRun
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一时刻只允许一次同步。
|
||||||
|
select {
|
||||||
|
case s.syncMu <- struct{}{}:
|
||||||
|
defer func() { <-s.syncMu }()
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||||
|
"error": err.Error(), "plan": plan,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"dryRun": dryRun, "plan": plan, "result": res,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRuns 返回同步历史。
|
||||||
|
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
runs, err := s.deps.Store.RecentRuns(100)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON 输出 JSON 响应。
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。
|
||||||
|
func maskSecret(s string) string {
|
||||||
|
if len(s) <= 8 {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return "未配置"
|
||||||
|
}
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return s[:4] + "****" + s[len(s)-4:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 账号台账 ---
|
||||||
|
|
||||||
|
// AllAccounts 返回全部受管账号。
|
||||||
|
func (s *Store) AllAccounts() ([]*Account, error) {
|
||||||
|
var out []*Account
|
||||||
|
if err := s.db.Order("uid").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountsByUID 返回 uid → 账号 的映射,供 diff 引擎使用。
|
||||||
|
func (s *Store) AccountsByUID() (map[string]*Account, error) {
|
||||||
|
list, err := s.AllAccounts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]*Account, len(list))
|
||||||
|
for _, a := range list {
|
||||||
|
out[a.UID] = a
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertAccount 写入或更新一条账号台账。
|
||||||
|
func (s *Store) UpsertAccount(a *Account) error {
|
||||||
|
a.LastSyncedAt = time.Now()
|
||||||
|
return s.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "uid"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"dn", "union_id", "user_id", "email", "name",
|
||||||
|
"state", "attrs_hash", "dept_ids", "last_synced_at",
|
||||||
|
}),
|
||||||
|
}).Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccountState 更新账号状态与 DN(用于在职 ↔ 离职迁移)。
|
||||||
|
func (s *Store) SetAccountState(uid, state, dn string) error {
|
||||||
|
return s.db.Model(&Account{}).Where("uid = ?", uid).
|
||||||
|
Updates(map[string]any{"state": state, "dn": dn}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount 从台账删除一条账号。
|
||||||
|
func (s *Store) DeleteAccount(uid string) error {
|
||||||
|
return s.db.Where("uid = ?", uid).Delete(&Account{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 分组台账 ---
|
||||||
|
|
||||||
|
// AllGroups 返回全部受管分组。
|
||||||
|
func (s *Store) AllGroups() ([]*Group, error) {
|
||||||
|
var out []*Group
|
||||||
|
if err := s.db.Order("dept_id").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupsByDeptID 返回 dept_id → 分组 的映射。
|
||||||
|
func (s *Store) GroupsByDeptID() (map[string]*Group, error) {
|
||||||
|
list, err := s.AllGroups()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]*Group, len(list))
|
||||||
|
for _, g := range list {
|
||||||
|
out[g.DeptID] = g
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertGroup 写入或更新一条分组台账。
|
||||||
|
func (s *Store) UpsertGroup(g *Group) error {
|
||||||
|
g.LastSyncedAt = time.Now()
|
||||||
|
return s.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "dept_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"dn", "name", "parent_id", "state", "last_synced_at",
|
||||||
|
}),
|
||||||
|
}).Create(g).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupState 更新分组状态。
|
||||||
|
func (s *Store) SetGroupState(deptID, state string) error {
|
||||||
|
return s.db.Model(&Group{}).Where("dept_id = ?", deptID).
|
||||||
|
Update("state", state).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup 从台账删除一条分组。
|
||||||
|
func (s *Store) DeleteGroup(deptID string) error {
|
||||||
|
return s.db.Where("dept_id = ?", deptID).Delete(&Group{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 同步记录 ---
|
||||||
|
|
||||||
|
// StartRun 创建一条同步记录并返回该记录。
|
||||||
|
//
|
||||||
|
// 返回完整记录而非仅 ID:调用方若自行构造一个只含 ID 的结构体再保存,
|
||||||
|
// GORM 的 Save 会把未赋值的 StartedAt/Mode/Trigger 一并写成零值,
|
||||||
|
// 导致历史页面显示 0001-01-01 与空白模式。
|
||||||
|
func (s *Store) StartRun(mode, trigger string) (*SyncRun, error) {
|
||||||
|
run := &SyncRun{
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Mode: mode,
|
||||||
|
Trigger: trigger,
|
||||||
|
Status: "running",
|
||||||
|
}
|
||||||
|
if err := s.db.Create(run).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRun 完成一条同步记录。
|
||||||
|
func (s *Store) FinishRun(run *SyncRun) error {
|
||||||
|
run.FinishedAt = time.Now()
|
||||||
|
return s.db.Save(run).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRuns 返回最近的同步记录,最新在前。
|
||||||
|
func (s *Store) RecentRuns(limit int) ([]*SyncRun, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var out []*SyncRun
|
||||||
|
if err := s.db.Order("id desc").Limit(limit).Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRun 返回最近一次成功执行的记录,没有则返回 nil。
|
||||||
|
func (s *Store) LastRun() (*SyncRun, error) {
|
||||||
|
var out SyncRun
|
||||||
|
err := s.db.Where("status = ?", "success").Order("id desc").First(&out).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Package store 维护本地 SQLite 台账。
|
||||||
|
//
|
||||||
|
// 台账的职责只有一个:记录“哪些 LDAP 条目是我们创建的”。
|
||||||
|
// 这是安全删除的唯一依据——没有台账就没有删除权,因此
|
||||||
|
// 服务账号、手工创建的账号、admin 永远不会被同步任务误伤。
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account 是一个由同步器管理的用户账号。
|
||||||
|
type Account struct {
|
||||||
|
// UID 即登录名(企业邮箱前缀),同时是主键。
|
||||||
|
UID string `gorm:"primaryKey;size:190"`
|
||||||
|
DN string `gorm:"size:512;not null"`
|
||||||
|
UnionID string `gorm:"size:190;index"`
|
||||||
|
UserID string `gorm:"size:190"`
|
||||||
|
Email string `gorm:"size:255"`
|
||||||
|
Name string `gorm:"size:255"`
|
||||||
|
// State: active(在 ou=people)或 inactive(在 ou=Inactive)
|
||||||
|
State string `gorm:"size:16;not null;default:active"`
|
||||||
|
// AttrsHash 是上一次写入的属性指纹,用于快速判断是否漂移
|
||||||
|
AttrsHash string `gorm:"size:64"`
|
||||||
|
// DeptIDs 是所属部门,逗号分隔
|
||||||
|
DeptIDs string `gorm:"size:1024"`
|
||||||
|
LastSyncedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group 是一个由同步器管理的部门分组。
|
||||||
|
type Group struct {
|
||||||
|
// DeptID 是飞书的 open_department_id,作为主键。
|
||||||
|
DeptID string `gorm:"primaryKey;size:190" json:"deptId"`
|
||||||
|
DN string `gorm:"size:512;not null;uniqueIndex" json:"dn"`
|
||||||
|
Name string `gorm:"size:255" json:"name"`
|
||||||
|
ParentID string `gorm:"size:190;index" json:"parentId"`
|
||||||
|
State string `gorm:"size:16;not null;default=active" json:"state"`
|
||||||
|
LastSyncedAt time.Time `json:"lastSyncedAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncRun 记录一次同步执行的结果,供历史页面展示。
|
||||||
|
type SyncRun struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
|
// Mode: dry-run 或 apply
|
||||||
|
Mode string `gorm:"size:16" json:"mode"`
|
||||||
|
Trigger string `gorm:"size:32" json:"trigger"` // manual 或 schedule
|
||||||
|
Status string `gorm:"size:16" json:"status"` // success 或 failed
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Moved int `json:"moved"`
|
||||||
|
Added int `json:"added"` // 加入分组次数
|
||||||
|
Removed int `json:"removed"` // 移出分组次数
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
ErrorMsg string `gorm:"size:2048" json:"errorMsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 是台账的访问入口。
|
||||||
|
type Store struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 打开(必要时创建)SQLite 台账并完成建表。
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开台账数据库失败 (%s): %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite 在并发写时容易报 database is locked;限制为单连接可彻底避免。
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取数据库句柄失败: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&Account{}, &Group{}, &SyncRun{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("建表失败: %w", err)
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭底层连接。
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 暴露底层句柄,供需要自定义查询的调用方使用。
|
||||||
|
func (s *Store) DB() *gorm.DB { return s.db }
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。
|
||||||
|
//
|
||||||
|
// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、
|
||||||
|
// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。
|
||||||
|
// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通,
|
||||||
|
// 并断言最终落在 OpenLDAP 里的内容。
|
||||||
|
//
|
||||||
|
// 启用方式(需要真实 LDAP 实例):
|
||||||
|
//
|
||||||
|
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||||
|
// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd
|
||||||
|
package syncer_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
"idsync/internal/feishu"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/store"
|
||||||
|
"idsync/internal/syncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeDept 是假服务返回的部门。
|
||||||
|
type fakeDept struct {
|
||||||
|
openID string
|
||||||
|
parentID string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeUser 是假服务返回的用户。
|
||||||
|
type fakeUser struct {
|
||||||
|
unionID string
|
||||||
|
userID string
|
||||||
|
name string
|
||||||
|
enName string
|
||||||
|
enterpriseEm string
|
||||||
|
email string
|
||||||
|
mobile string
|
||||||
|
employeeNo string
|
||||||
|
jobTitle string
|
||||||
|
deptIDs []string
|
||||||
|
resigned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。
|
||||||
|
type fakeFeishu struct {
|
||||||
|
depts []fakeDept
|
||||||
|
users []fakeUser
|
||||||
|
// served 记录各接口被调用的次数,用于验证分页与去重行为。
|
||||||
|
calls map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeFeishu) handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.calls["token"]++
|
||||||
|
writeFakeJSON(w, map[string]any{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"tenant_access_token": "fake-token",
|
||||||
|
"expire": 7200,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.calls["departments"]++
|
||||||
|
|
||||||
|
parent := r.URL.Query().Get("parent_department_id")
|
||||||
|
if parent == "" {
|
||||||
|
parent = "0"
|
||||||
|
}
|
||||||
|
// 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。
|
||||||
|
// 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。
|
||||||
|
fetchChild := r.URL.Query().Get("fetch_child") == "true"
|
||||||
|
|
||||||
|
want := map[string]bool{parent: true}
|
||||||
|
if fetchChild {
|
||||||
|
for changed := true; changed; {
|
||||||
|
changed = false
|
||||||
|
for _, d := range f.depts {
|
||||||
|
if !want[d.openID] && want[d.parentID] {
|
||||||
|
want[d.openID] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
for _, d := range f.depts {
|
||||||
|
if !want[d.openID] || d.openID == parent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": d.name,
|
||||||
|
"parent_department_id": d.parentID,
|
||||||
|
"open_department_id": d.openID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeFakeJSON(w, map[string]any{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"has_more": false, "items": items,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.calls["find_by_department"]++
|
||||||
|
|
||||||
|
deptID := r.URL.Query().Get("department_id")
|
||||||
|
items := make([]map[string]any, 0)
|
||||||
|
for _, u := range f.users {
|
||||||
|
if u.resigned {
|
||||||
|
continue // 与真实接口一致:不返回离职人员
|
||||||
|
}
|
||||||
|
if !containsStr(u.deptIDs, deptID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"union_id": u.unionID,
|
||||||
|
"user_id": u.userID,
|
||||||
|
"name": u.name,
|
||||||
|
"en_name": u.enName,
|
||||||
|
"enterprise_email": u.enterpriseEm,
|
||||||
|
"email": u.email,
|
||||||
|
"mobile": u.mobile,
|
||||||
|
"employee_no": u.employeeNo,
|
||||||
|
"job_title": u.jobTitle,
|
||||||
|
"department_ids": u.deptIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeFakeJSON(w, map[string]any{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"has_more": false, "items": items,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.calls["user_get"]++
|
||||||
|
|
||||||
|
// 从路径末尾取出 union_id
|
||||||
|
parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/")
|
||||||
|
unionID := parts[len(parts)-1]
|
||||||
|
|
||||||
|
for _, u := range f.users {
|
||||||
|
if u.unionID != unionID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeFakeJSON(w, map[string]any{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"union_id": unionID,
|
||||||
|
"user_id": u.userID,
|
||||||
|
"name": u.name,
|
||||||
|
"status": map[string]any{"is_resigned": u.resigned},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"})
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFakeJSON(w http.ResponseWriter, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(list []string, want string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。
|
||||||
|
func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ldapURL := os.Getenv("LDAP_TEST_URL")
|
||||||
|
if ldapURL == "" {
|
||||||
|
ldapURL = "ldap://127.0.0.1:3899"
|
||||||
|
}
|
||||||
|
base := os.Getenv("LDAP_TEST_BASE_DN")
|
||||||
|
if base == "" {
|
||||||
|
base = "dc=example,dc=com"
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
|
// 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍:
|
||||||
|
// groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与
|
||||||
|
// groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。
|
||||||
|
groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS")
|
||||||
|
if groupOC == "" {
|
||||||
|
groupOC = "groupOfUniqueNames"
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapCfg := config.Ldap{
|
||||||
|
URL: ldapURL, BaseDN: base,
|
||||||
|
AdminDN: "cn=admin," + base, AdminPass: "adminpass",
|
||||||
|
GroupObjectClass: groupOC,
|
||||||
|
TimeoutSec: 10,
|
||||||
|
}
|
||||||
|
if err := ldapCfg.NormalizeForTest(); err != nil {
|
||||||
|
t.Fatalf("LDAP 配置规范化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapClient, err := ldaplib.NewClient(ldapCfg, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造 LDAP 客户端失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := ldapClient.Ping(); err != nil {
|
||||||
|
t.Skipf("LDAP 不可用,跳过端到端测试: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake := &fakeFeishu{calls: map[string]int{}}
|
||||||
|
ts := httptest.NewServer(fake.handler())
|
||||||
|
|
||||||
|
feishuClient, err := feishu.New(feishu.Options{
|
||||||
|
AppID: "fake", AppSecret: "fake",
|
||||||
|
BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second,
|
||||||
|
}, log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("构造飞书客户端失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := t.TempDir() + "/idsync.db"
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开台账失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapCfg.UserOU = "ou=people"
|
||||||
|
ldapCfg.InactiveOU = "ou=Inactive"
|
||||||
|
ldapCfg.GroupRootName = "feishuroot"
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
System: config.System{Mode: "release", DataDir: t.TempDir()},
|
||||||
|
Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10},
|
||||||
|
Ldap: ldapCfg,
|
||||||
|
Database: config.Database{Source: dbPath},
|
||||||
|
Sync: config.Sync{MaxDeactivatePercent: 0.5},
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
ts.Close()
|
||||||
|
st.Close()
|
||||||
|
}
|
||||||
|
return runner, ldapClient, st, fake, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、
|
||||||
|
// 属性变更、部门调整、离职迁移与重新入职。
|
||||||
|
func TestEndToEndSync(t *testing.T) {
|
||||||
|
runner, ldapClient, st, fake, cleanup := setup(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const base = "dc=example,dc=com"
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
inactiveOU := "ou=Inactive," + base
|
||||||
|
groupRoot := "ou=feishuroot," + base
|
||||||
|
|
||||||
|
// 准备目录结构并清理历史残留。
|
||||||
|
conn, err := ldapClient.Dial()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, ou := range []struct{ dn, name string }{
|
||||||
|
{peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"},
|
||||||
|
} {
|
||||||
|
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||||
|
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
wipeTestOUs(t, conn, base)
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 两个部门,其中“研发部”是“后端组”的父部门。
|
||||||
|
// 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root),
|
||||||
|
// 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。
|
||||||
|
fake.depts = []fakeDept{
|
||||||
|
{openID: "od-root", parentID: "0", name: "Acme"},
|
||||||
|
{openID: "od-aaa-child", parentID: "od-root", name: "yanfa"},
|
||||||
|
}
|
||||||
|
fake.users = []fakeUser{
|
||||||
|
{unionID: "u1", userID: "1", name: "张三", enName: "Zhang San",
|
||||||
|
enterpriseEm: "zhangsan@example.com", email: "personal1@x.com",
|
||||||
|
mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师",
|
||||||
|
deptIDs: []string{"od-aaa-child"}},
|
||||||
|
{unionID: "u2", userID: "2", name: "李四", enName: "Li Si",
|
||||||
|
enterpriseEm: "lisi@example.com", email: "personal2@x.com",
|
||||||
|
mobile: "13800000002", employeeNo: "E002", jobTitle: "经理",
|
||||||
|
deptIDs: []string{"od-aaa-child"}},
|
||||||
|
// 没有企业邮箱:应当被跳过而不是回落到个人邮箱。
|
||||||
|
{unionID: "u3", userID: "3", name: "王五",
|
||||||
|
enterpriseEm: "", email: "wangwu@personal.com",
|
||||||
|
deptIDs: []string{"od-aaa-child"}},
|
||||||
|
}
|
||||||
|
wipeTestOUs(t, conn, base)
|
||||||
|
|
||||||
|
// ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ----
|
||||||
|
plan, res, err := runner.Run(t.Context(), true, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run 失败: %v", err)
|
||||||
|
}
|
||||||
|
if res != nil {
|
||||||
|
t.Fatalf("dry-run 不应返回执行结果")
|
||||||
|
}
|
||||||
|
if plan.Stats.CreateUser != 2 {
|
||||||
|
t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)",
|
||||||
|
plan.Stats.CreateUser)
|
||||||
|
}
|
||||||
|
foundEmailWarning := false
|
||||||
|
for _, wr := range plan.Warnings {
|
||||||
|
if strings.Contains(wr, "企业邮箱") {
|
||||||
|
foundEmailWarning = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundEmailWarning {
|
||||||
|
t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dry-run 后 LDAP 中不应有任何用户。
|
||||||
|
users, err := conn.ListUsers(peopleOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取用户失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 0 {
|
||||||
|
t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第二步:真正执行 ----
|
||||||
|
_, res, err = runner.Run(t.Context(), false, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("执行同步失败: %v", err)
|
||||||
|
}
|
||||||
|
if res.Failed != 0 {
|
||||||
|
t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err = conn.ListUsers(peopleOU)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取用户失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := users["zhangsan"]; !ok {
|
||||||
|
t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users))
|
||||||
|
}
|
||||||
|
if _, ok := users["lisi"]; !ok {
|
||||||
|
t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users))
|
||||||
|
}
|
||||||
|
if _, ok := users["wangwu"]; ok {
|
||||||
|
t.Errorf("wangwu 缺少企业邮箱,不应被创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。
|
||||||
|
zs := users["zhangsan"]
|
||||||
|
if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" {
|
||||||
|
t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got)
|
||||||
|
}
|
||||||
|
if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" {
|
||||||
|
t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got)
|
||||||
|
}
|
||||||
|
if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" {
|
||||||
|
t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断言分组与成员关系。
|
||||||
|
groups, err := conn.ListGroups(groupRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取分组失败: %v", err)
|
||||||
|
}
|
||||||
|
// 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。
|
||||||
|
acmeDN := "cn=acme," + groupRoot
|
||||||
|
yanfaDN := "cn=yanfa," + acmeDN
|
||||||
|
g, ok := groups[yanfaDN]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups))
|
||||||
|
}
|
||||||
|
members := g.Members()
|
||||||
|
if _, has := members["uid=zhangsan,"+peopleOU]; !has {
|
||||||
|
t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父部门分组也应存在,验证树形嵌套正确。
|
||||||
|
if _, ok := groups[acmeDN]; !ok {
|
||||||
|
t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 台账应记录两个账号。
|
||||||
|
accounts, err := st.AccountsByUID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取台账失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(accounts) != 2 {
|
||||||
|
t.Errorf("台账记录数 = %d, 期望 2", len(accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ----
|
||||||
|
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第二次 dry-run 失败: %v", err)
|
||||||
|
}
|
||||||
|
total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup +
|
||||||
|
plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember +
|
||||||
|
plan.Stats.Deactivate + plan.Stats.Reactivate
|
||||||
|
if total != 0 {
|
||||||
|
t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第四步:属性变更应被检测并同步 ----
|
||||||
|
fake.users[0].jobTitle = "高级工程师"
|
||||||
|
fake.users[0].mobile = "13900000001"
|
||||||
|
|
||||||
|
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("变更检测失败: %v", err)
|
||||||
|
}
|
||||||
|
if plan.Stats.UpdateUser != 1 {
|
||||||
|
t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||||
|
t.Fatalf("应用变更失败: %v", err)
|
||||||
|
}
|
||||||
|
users, _ = conn.ListUsers(peopleOU)
|
||||||
|
if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" {
|
||||||
|
t.Errorf("职位未更新: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第五步:离职应迁入 ou=Inactive ----
|
||||||
|
fake.users[1].resigned = true
|
||||||
|
|
||||||
|
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("离职检测失败: %v", err)
|
||||||
|
}
|
||||||
|
if plan.Stats.Deactivate != 1 {
|
||||||
|
t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||||
|
t.Fatalf("执行离职迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, _ = conn.ListUsers(peopleOU)
|
||||||
|
if _, still := users["lisi"]; still {
|
||||||
|
t.Errorf("lisi 已离职,不应仍在 %s", peopleOU)
|
||||||
|
}
|
||||||
|
inactive, _ := conn.ListUsers(inactiveOU)
|
||||||
|
if _, moved := inactive["lisi"]; !moved {
|
||||||
|
t.Errorf("lisi 未迁入 %s", inactiveOU)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 离职用户也应从部门分组中移除。
|
||||||
|
groups, _ = conn.ListGroups(groupRoot)
|
||||||
|
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has {
|
||||||
|
t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 第六步:重新入职应迁回 ----
|
||||||
|
fake.users[1].resigned = false
|
||||||
|
|
||||||
|
plan, _, err = runner.Run(t.Context(), true, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("复职检测失败: %v", err)
|
||||||
|
}
|
||||||
|
if plan.Stats.Reactivate != 1 {
|
||||||
|
t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil {
|
||||||
|
t.Fatalf("执行复职失败: %v", err)
|
||||||
|
}
|
||||||
|
users, _ = conn.ListUsers(peopleOU)
|
||||||
|
if _, back := users["lisi"]; !back {
|
||||||
|
t.Errorf("lisi 复职后未回到 %s", peopleOU)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复职后应重新加入部门分组。
|
||||||
|
groups, _ = conn.ListGroups(groupRoot)
|
||||||
|
if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has {
|
||||||
|
t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。
|
||||||
|
func TestEndToEndGuard(t *testing.T) {
|
||||||
|
runner, ldapClient, _, fake, cleanup := setup(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const base = "dc=example,dc=com"
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
groupRoot := "ou=feishuroot," + base
|
||||||
|
|
||||||
|
conn, err := ldapClient.Dial()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
for _, ou := range []struct{ dn, name string }{
|
||||||
|
{peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"},
|
||||||
|
} {
|
||||||
|
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||||
|
t.Fatalf("创建 %s 失败: %v", ou.dn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fake.depts = []fakeDept{{openID: "od-root", parentID: "0", name: "Acme"}}
|
||||||
|
fake.users = nil
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
fake.users = append(fake.users, fakeUser{
|
||||||
|
unionID: fmt.Sprintf("g%d", i), userID: fmt.Sprintf("%d", i),
|
||||||
|
name: fmt.Sprintf("用户%d", i), enterpriseEm: fmt.Sprintf("user%d@example.com", i),
|
||||||
|
deptIDs: []string{"od-root"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wipeTestOUs(t, conn, base)
|
||||||
|
defer wipeTestOUs(t, conn, base)
|
||||||
|
|
||||||
|
if _, res, err := runner.Run(t.Context(), false, false, "test"); err != nil || res.Failed != 0 {
|
||||||
|
t.Fatalf("初次同步失败: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 让飞书侧几乎所有用户消失:这模拟了权限范围被误改的灾难场景。
|
||||||
|
fake.users = fake.users[:1]
|
||||||
|
|
||||||
|
if _, _, err := runner.Run(t.Context(), false, false, "test"); err == nil {
|
||||||
|
t.Fatalf("停用 3/4 账号应当被护栏拦截,却成功执行了")
|
||||||
|
} else if !strings.Contains(err.Error(), "停用") {
|
||||||
|
t.Errorf("护栏错误信息应当说明停用数量问题,实际: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制模式应当放行。
|
||||||
|
if _, res, err := runner.Run(t.Context(), false, true, "test"); err != nil {
|
||||||
|
t.Fatalf("强制模式执行失败: %v", err)
|
||||||
|
} else if res.Failed != 0 {
|
||||||
|
t.Errorf("强制模式有 %d 个操作失败: %v", res.Failed, res.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wipeTestOUs 删除测试用 OU 下的全部用户与分组。
|
||||||
|
//
|
||||||
|
// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职),
|
||||||
|
// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。
|
||||||
|
func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
peopleOU := "ou=people," + base
|
||||||
|
inactiveOU := "ou=Inactive," + base
|
||||||
|
groupRoot := "ou=feishuroot," + base
|
||||||
|
|
||||||
|
for _, ou := range []string{peopleOU, inactiveOU} {
|
||||||
|
users, err := conn.ListUsers(ou)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for uid := range users {
|
||||||
|
_ = conn.DeleteUser("uid=" + uid + "," + ou)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err := conn.ListGroups(groupRoot)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 先删深层分组,再删浅层,避免「非叶子节点无法删除」。
|
||||||
|
dns := keysOf(groups)
|
||||||
|
sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) })
|
||||||
|
for _, dn := range dns {
|
||||||
|
_ = conn.DeleteGroup(groups[dn].DN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。
|
||||||
|
func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot }
|
||||||
|
|
||||||
|
func keysOf[V any](m map[string]V) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpOps(p *syncer.Plan) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, op := range p.Ops {
|
||||||
|
fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail)
|
||||||
|
}
|
||||||
|
for _, wr := range p.Warnings {
|
||||||
|
fmt.Fprintf(&b, "\n 警告: %s", wr)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/mapping"
|
||||||
|
"idsync/internal/password"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guard 是执行前的安全阈值。
|
||||||
|
//
|
||||||
|
// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、
|
||||||
|
// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。
|
||||||
|
// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。
|
||||||
|
type Guard struct {
|
||||||
|
// MaxDeactivate 单次允许停用的最大账号数,0 表示不限制。
|
||||||
|
MaxDeactivate int
|
||||||
|
// MaxDeactivatePercent 单次允许停用的账号比例(0-1),0 表示不限制。
|
||||||
|
MaxDeactivatePercent float64
|
||||||
|
// AllowBulk 显式跳过全部阈值检查。
|
||||||
|
AllowBulk bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuardError 表示计划未通过安全阈值检查。
|
||||||
|
type GuardError struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GuardError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
// Check 校验计划是否满足安全阈值。
|
||||||
|
func (g Guard) Check(p *Plan) error {
|
||||||
|
if g.AllowBulk {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate := p.Stats.Deactivate
|
||||||
|
if deactivate == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate {
|
||||||
|
return &GuardError{Message: fmt.Sprintf(
|
||||||
|
"本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑",
|
||||||
|
deactivate, g.MaxDeactivate)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 {
|
||||||
|
ratio := float64(deactivate) / float64(p.Stats.TotalAccounts)
|
||||||
|
if ratio > g.MaxDeactivatePercent {
|
||||||
|
return &GuardError{Message: fmt.Sprintf(
|
||||||
|
"本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
|
||||||
|
deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result 是执行结果统计。
|
||||||
|
type Result struct {
|
||||||
|
Created int
|
||||||
|
Updated int
|
||||||
|
Moved int
|
||||||
|
Added int
|
||||||
|
Removed int
|
||||||
|
Failed int
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor 把计划应用到 LDAP。
|
||||||
|
type Executor struct {
|
||||||
|
client *ldaplib.Client
|
||||||
|
cfg ExecConfig
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecConfig 是执行器所需的环境参数。
|
||||||
|
type ExecConfig struct {
|
||||||
|
UserOUDN string
|
||||||
|
InactiveOUDN string
|
||||||
|
GroupRootDN string
|
||||||
|
GroupRootName string
|
||||||
|
AdminDN string
|
||||||
|
// InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。
|
||||||
|
InitialPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor 构造执行器。
|
||||||
|
func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor {
|
||||||
|
return &Executor{client: client, cfg: cfg, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作——
|
||||||
|
// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。
|
||||||
|
func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result {
|
||||||
|
res := &Result{}
|
||||||
|
|
||||||
|
conn, err := e.client.Dial()
|
||||||
|
if err != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Errors = append(res.Errors, err.Error())
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 确保基础 OU 存在,否则后续写入会失败。
|
||||||
|
//
|
||||||
|
// ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、
|
||||||
|
// 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串
|
||||||
|
// 难以理解的 No Such Object。
|
||||||
|
baseOUs := []struct{ dn, name string }{
|
||||||
|
{e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)},
|
||||||
|
{e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)},
|
||||||
|
{e.cfg.GroupRootDN, e.cfg.GroupRootName},
|
||||||
|
}
|
||||||
|
for _, ou := range baseOUs {
|
||||||
|
if ou.dn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := conn.EnsureOU(ou.dn, ou.name); err != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Errors = append(res.Errors, err.Error())
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
res.Errors = append(res.Errors, "同步被取消: "+err.Error())
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.applyOne(conn, op); err != nil {
|
||||||
|
res.Failed++
|
||||||
|
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
|
||||||
|
res.Errors = append(res.Errors, msg)
|
||||||
|
e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op.Kind {
|
||||||
|
case OpCreateUser:
|
||||||
|
res.Created++
|
||||||
|
case OpUpdateUser, OpReactivate:
|
||||||
|
res.Updated++
|
||||||
|
if op.Kind == OpReactivate {
|
||||||
|
res.Moved++
|
||||||
|
}
|
||||||
|
case OpDeactivate:
|
||||||
|
res.Updated++
|
||||||
|
res.Moved++
|
||||||
|
case OpAddMember:
|
||||||
|
res.Added++
|
||||||
|
case OpRemoveMember:
|
||||||
|
res.Removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOne 执行单个操作。
|
||||||
|
func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error {
|
||||||
|
switch op.Kind {
|
||||||
|
case OpCreateGroup:
|
||||||
|
// 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。
|
||||||
|
return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN)
|
||||||
|
|
||||||
|
case OpUpdateGroup:
|
||||||
|
return conn.UpdateGroup(op.DN, op.Group.Name)
|
||||||
|
|
||||||
|
case OpCreateUser:
|
||||||
|
plain := e.cfg.InitialPassword
|
||||||
|
if plain == "" {
|
||||||
|
generated, err := password.Random()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plain = generated
|
||||||
|
}
|
||||||
|
// 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希,
|
||||||
|
// 直接写明文会让密码以明文形式留在目录里。
|
||||||
|
encoded, err := password.EncodeSSHA(plain)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded)
|
||||||
|
|
||||||
|
case OpUpdateUser:
|
||||||
|
// 若 DN 因 OU 配置变化而不同,先移动再更新属性。
|
||||||
|
if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) {
|
||||||
|
uid := op.User.UID
|
||||||
|
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 读取现状:UpdateUser 需要据此决定哪些属性应当删除,
|
||||||
|
// 对不存在的属性执行 Delete 会导致整个 Modify 失败。
|
||||||
|
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
|
||||||
|
|
||||||
|
case OpDeactivate:
|
||||||
|
// 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录;
|
||||||
|
// 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留,
|
||||||
|
// 便于日后重新入职时原样恢复。
|
||||||
|
uid := op.Target
|
||||||
|
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned)
|
||||||
|
|
||||||
|
case OpReactivate:
|
||||||
|
uid := op.User.UID
|
||||||
|
if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing)
|
||||||
|
|
||||||
|
case OpAddMember:
|
||||||
|
return conn.AddMember(op.GroupDN, op.MemberDN)
|
||||||
|
|
||||||
|
case OpRemoveMember:
|
||||||
|
return conn.RemoveMember(op.GroupDN, op.MemberDN)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("未知操作类型: %s", op.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLDAPUser 把期望状态转成 LDAP 写入结构。
|
||||||
|
func toLDAPUser(u *mapping.User) *ldaplib.User {
|
||||||
|
return &ldaplib.User{
|
||||||
|
UID: u.UID,
|
||||||
|
CN: u.CN,
|
||||||
|
SN: u.SN,
|
||||||
|
GivenName: u.GivenName,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Mail: u.Mail,
|
||||||
|
Mobile: u.Mobile,
|
||||||
|
EmployeeNumber: u.EmployeeNumber,
|
||||||
|
DepartmentNumber: u.DepartmentNumber,
|
||||||
|
PostalAddress: u.PostalAddress,
|
||||||
|
BusinessCategory: u.BusinessCategory,
|
||||||
|
EmployeeType: u.EmployeeType,
|
||||||
|
Description: u.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ouName 从 OU 的 DN 中取出 ou 的值。
|
||||||
|
func ouName(dn string) string {
|
||||||
|
first := dn
|
||||||
|
if i := strings.Index(dn, ","); i >= 0 {
|
||||||
|
first = dn[:i]
|
||||||
|
}
|
||||||
|
if i := strings.Index(first, "="); i >= 0 {
|
||||||
|
return first[i+1:]
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
|
||||||
|
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
|
||||||
|
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
|
||||||
|
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
|
||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/mapping"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpKind 是操作类型。
|
||||||
|
type OpKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpCreateUser OpKind = "create_user"
|
||||||
|
OpUpdateUser OpKind = "update_user"
|
||||||
|
OpDeactivate OpKind = "deactivate_user"
|
||||||
|
OpReactivate OpKind = "reactivate_user"
|
||||||
|
OpCreateGroup OpKind = "create_group"
|
||||||
|
OpUpdateGroup OpKind = "update_group"
|
||||||
|
OpAddMember OpKind = "add_member"
|
||||||
|
OpRemoveMember OpKind = "remove_member"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op 是一次待执行(或仅预览)的操作。
|
||||||
|
type Op struct {
|
||||||
|
Kind OpKind `json:"kind"`
|
||||||
|
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
|
||||||
|
Target string `json:"target"`
|
||||||
|
// Name 是展示名,方便界面阅读。
|
||||||
|
Name string `json:"name"`
|
||||||
|
// DN 是操作涉及的条目 DN(新建时为目标 DN)。
|
||||||
|
DN string `json:"dn,omitempty"`
|
||||||
|
// Detail 是人类可读的说明,直接展示在预览页。
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
|
||||||
|
// 以下字段供执行器使用,不参与 JSON 展示。
|
||||||
|
User *mapping.User `json:"-"`
|
||||||
|
Group *mapping.Group `json:"-"`
|
||||||
|
MemberDN string `json:"-"`
|
||||||
|
GroupDN string `json:"-"`
|
||||||
|
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。
|
||||||
|
TargetDN string `json:"-"`
|
||||||
|
AttrsHash string `json:"-"`
|
||||||
|
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
|
||||||
|
// (例如父分组必须先于子分组创建)。
|
||||||
|
Seq int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan 是一次同步的完整计划。
|
||||||
|
type Plan struct {
|
||||||
|
Ops []Op `json:"ops"`
|
||||||
|
// Warnings 是不阻塞执行、但需要人工留意的信息。
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Stats Stats `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 是计划的分类统计。
|
||||||
|
type Stats struct {
|
||||||
|
CreateUser int `json:"createUser"`
|
||||||
|
UpdateUser int `json:"updateUser"`
|
||||||
|
Deactivate int `json:"deactivateUser"`
|
||||||
|
Reactivate int `json:"reactivateUser"`
|
||||||
|
CreateGroup int `json:"createGroup"`
|
||||||
|
UpdateGroup int `json:"updateGroup"`
|
||||||
|
AddMember int `json:"addMember"`
|
||||||
|
RemoveMember int `json:"removeMember"`
|
||||||
|
TotalAccounts int `json:"totalAccounts"`
|
||||||
|
Unchanged int `json:"unchanged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input 是计算计划所需的全部输入。
|
||||||
|
type Input struct {
|
||||||
|
// Desired 是期望状态(来自飞书)
|
||||||
|
Desired *mapping.State
|
||||||
|
// Active 是 ou=people 下现有用户:uid(小写)→ 条目
|
||||||
|
Active map[string]*ldaplib.Entry
|
||||||
|
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目
|
||||||
|
Inactive map[string]*ldaplib.Entry
|
||||||
|
// Groups 是现有分组:DN(小写)→ 条目
|
||||||
|
Groups map[string]*ldaplib.Entry
|
||||||
|
// LedgerAccounts 是台账中的账号:uid → state(active/inactive)
|
||||||
|
LedgerAccounts map[string]string
|
||||||
|
// LedgerGroups 是台账中的分组:dept_id → dn
|
||||||
|
LedgerGroups map[string]string
|
||||||
|
// AdminDN 与相关基础 DN
|
||||||
|
AdminDN string
|
||||||
|
GroupRootDN string
|
||||||
|
UserOUDN string
|
||||||
|
InactiveOUDN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// opPhase 决定操作的执行顺序。
|
||||||
|
//
|
||||||
|
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
|
||||||
|
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
|
||||||
|
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
|
||||||
|
// 集成测试立刻暴露了这个问题。
|
||||||
|
func opPhase(k OpKind) int {
|
||||||
|
switch k {
|
||||||
|
case OpCreateGroup, OpUpdateGroup:
|
||||||
|
return 0
|
||||||
|
case OpCreateUser, OpUpdateUser:
|
||||||
|
return 1
|
||||||
|
case OpReactivate:
|
||||||
|
return 2
|
||||||
|
case OpAddMember:
|
||||||
|
return 3
|
||||||
|
case OpRemoveMember:
|
||||||
|
return 4
|
||||||
|
case OpDeactivate:
|
||||||
|
return 5
|
||||||
|
default:
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortOps 按依赖顺序排列操作。
|
||||||
|
//
|
||||||
|
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
|
||||||
|
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
|
||||||
|
// “od-root” 之前,导致子分组先于父分组创建而失败)。
|
||||||
|
func sortOps(ops []Op) {
|
||||||
|
sort.SliceStable(ops, func(i, j int) bool {
|
||||||
|
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
|
||||||
|
if pi != pj {
|
||||||
|
return pi < pj
|
||||||
|
}
|
||||||
|
return ops[i].Seq < ops[j].Seq
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。
|
||||||
|
func ComputePlan(in *Input) *Plan {
|
||||||
|
p := &Plan{
|
||||||
|
Ops: make([]Op, 0, 64),
|
||||||
|
Warnings: append([]string{}, in.Desired.Warnings...),
|
||||||
|
Stats: Stats{
|
||||||
|
TotalAccounts: len(in.LedgerAccounts),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
planGroups(p, in)
|
||||||
|
planUsers(p, in)
|
||||||
|
planMembership(p, in)
|
||||||
|
|
||||||
|
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
|
||||||
|
// (mapping 已按父先于子的顺序产出分组)。
|
||||||
|
for i := range p.Ops {
|
||||||
|
p.Ops[i].Seq = i
|
||||||
|
}
|
||||||
|
sortOps(p.Ops)
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// planGroups 处理分组的创建与更新。
|
||||||
|
func planGroups(p *Plan, in *Input) {
|
||||||
|
for _, g := range in.Desired.Groups {
|
||||||
|
existing, exists := in.Groups[strings.ToLower(g.DN)]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpCreateGroup,
|
||||||
|
Target: g.DeptID,
|
||||||
|
Name: g.Name,
|
||||||
|
DN: g.DN,
|
||||||
|
Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN),
|
||||||
|
Group: g,
|
||||||
|
})
|
||||||
|
p.Stats.CreateGroup++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分组已存在:仅当描述不一致时更新。
|
||||||
|
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮
|
||||||
|
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理,
|
||||||
|
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。
|
||||||
|
if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name {
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpUpdateGroup,
|
||||||
|
Target: g.DeptID,
|
||||||
|
Name: g.Name,
|
||||||
|
DN: g.DN,
|
||||||
|
Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name),
|
||||||
|
Group: g,
|
||||||
|
})
|
||||||
|
p.Stats.UpdateGroup++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 台账中存在、但期望状态里已消失的分组:不自动删除。
|
||||||
|
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
|
||||||
|
wantedDept := make(map[string]string, len(in.Desired.Groups))
|
||||||
|
for _, g := range in.Desired.Groups {
|
||||||
|
wantedDept[g.DeptID] = g.DN
|
||||||
|
}
|
||||||
|
for deptID, dn := range in.LedgerGroups {
|
||||||
|
wantDN, stillWanted := wantedDept[deptID]
|
||||||
|
switch {
|
||||||
|
case !stillWanted:
|
||||||
|
p.Warnings = append(p.Warnings,
|
||||||
|
fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn))
|
||||||
|
case !strings.EqualFold(wantDN, dn):
|
||||||
|
// 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的
|
||||||
|
// uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。
|
||||||
|
p.Warnings = append(p.Warnings,
|
||||||
|
fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)",
|
||||||
|
deptID, dn, wantDN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// planUsers 处理用户的创建、更新、停用与恢复。
|
||||||
|
func planUsers(p *Plan, in *Input) {
|
||||||
|
desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users))
|
||||||
|
for _, u := range in.Desired.Users {
|
||||||
|
desiredByUID[u.UID] = u
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range in.Desired.Users {
|
||||||
|
_, inActiveOU := in.Active[u.UID]
|
||||||
|
_, inInactiveOU := in.Inactive[u.UID]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case inActiveOU:
|
||||||
|
// 已存在且在职:比对属性是否漂移。
|
||||||
|
existing := in.Active[u.UID]
|
||||||
|
canonical := strings.ToLower(existing.DN)
|
||||||
|
|
||||||
|
// DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。
|
||||||
|
// 用大小写无关比较:LDAP 服务端可能对 DN 做规范化,
|
||||||
|
// 直接字符串比较会造成每轮都产生无意义的“更新”。
|
||||||
|
if !strings.EqualFold(existing.DN, u.DN) {
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpUpdateUser,
|
||||||
|
Target: u.UID,
|
||||||
|
Name: u.DisplayName,
|
||||||
|
DN: u.DN,
|
||||||
|
Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN),
|
||||||
|
User: u,
|
||||||
|
TargetDN: canonical,
|
||||||
|
})
|
||||||
|
p.Stats.UpdateUser++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if attrsDiffer(existing, u) {
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpUpdateUser,
|
||||||
|
Target: u.UID,
|
||||||
|
Name: u.DisplayName,
|
||||||
|
DN: u.DN,
|
||||||
|
Detail: "用户属性发生变化:" + describeAttrDiff(existing, u),
|
||||||
|
User: u,
|
||||||
|
TargetDN: canonical,
|
||||||
|
})
|
||||||
|
p.Stats.UpdateUser++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Stats.Unchanged++
|
||||||
|
|
||||||
|
case inInactiveOU:
|
||||||
|
// 存在于 ou=Inactive 但期望是在职:恢复。
|
||||||
|
// 仅当台账确认该账号由我们管理时才动它。
|
||||||
|
if _, managed := in.LedgerAccounts[u.UID]; !managed {
|
||||||
|
p.Warnings = append(p.Warnings,
|
||||||
|
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpReactivate,
|
||||||
|
Target: u.UID,
|
||||||
|
Name: u.DisplayName,
|
||||||
|
DN: u.DN,
|
||||||
|
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
|
||||||
|
User: u,
|
||||||
|
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
|
||||||
|
})
|
||||||
|
p.Stats.Reactivate++
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 两处都没有:新建。
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpCreateUser,
|
||||||
|
Target: u.UID,
|
||||||
|
Name: u.DisplayName,
|
||||||
|
DN: u.DN,
|
||||||
|
Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN),
|
||||||
|
User: u,
|
||||||
|
})
|
||||||
|
p.Stats.CreateUser++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 台账中在职、但期望状态里已消失:候选停用。
|
||||||
|
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
|
||||||
|
for uid, state := range in.LedgerAccounts {
|
||||||
|
if state != "active" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := desiredByUID[uid]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, ok := in.Active[uid]
|
||||||
|
if !ok {
|
||||||
|
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
|
||||||
|
p.Warnings = append(p.Warnings,
|
||||||
|
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpDeactivate,
|
||||||
|
Target: uid,
|
||||||
|
Name: entry.Get(ldaplib.AttrDisplayName),
|
||||||
|
DN: ldaplib.UserDN(uid, in.InactiveOUDN),
|
||||||
|
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
|
||||||
|
TargetDN: strings.ToLower(entry.DN),
|
||||||
|
})
|
||||||
|
p.Stats.Deactivate++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
|
||||||
|
func planMembership(p *Plan, in *Input) {
|
||||||
|
for _, g := range in.Desired.Groups {
|
||||||
|
existing, ok := in.Groups[strings.ToLower(g.DN)]
|
||||||
|
if !ok {
|
||||||
|
// 分组是新建的,成员关系会在创建后由执行器处理;
|
||||||
|
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。
|
||||||
|
for memberDN := range g.Members {
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpAddMember,
|
||||||
|
Target: g.DeptID,
|
||||||
|
Name: g.Name,
|
||||||
|
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||||
|
Group: g,
|
||||||
|
GroupDN: g.DN,
|
||||||
|
MemberDN: memberDN,
|
||||||
|
})
|
||||||
|
p.Stats.AddMember++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := existing.Members()
|
||||||
|
|
||||||
|
// 期望有、实际没有 → 加入
|
||||||
|
for memberDN := range g.Members {
|
||||||
|
if _, has := actual[strings.ToLower(memberDN)]; has {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpAddMember,
|
||||||
|
Target: g.DeptID,
|
||||||
|
Name: g.Name,
|
||||||
|
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
|
||||||
|
Group: g,
|
||||||
|
GroupDN: existing.DN,
|
||||||
|
MemberDN: memberDN,
|
||||||
|
})
|
||||||
|
p.Stats.AddMember++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
|
||||||
|
for memberDN := range actual {
|
||||||
|
if _, want := g.Members[memberDN]; want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
|
||||||
|
if !isManagedMember(memberDN, in) {
|
||||||
|
p.Warnings = append(p.Warnings,
|
||||||
|
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Ops = append(p.Ops, Op{
|
||||||
|
Kind: OpRemoveMember,
|
||||||
|
Target: g.DeptID,
|
||||||
|
Name: g.Name,
|
||||||
|
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
|
||||||
|
Group: g,
|
||||||
|
GroupDN: existing.DN,
|
||||||
|
MemberDN: memberDN,
|
||||||
|
})
|
||||||
|
p.Stats.RemoveMember++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
|
||||||
|
//
|
||||||
|
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
|
||||||
|
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
|
||||||
|
func isManagedMember(memberDN string, in *Input) bool {
|
||||||
|
uid, ok := uidFromDN(memberDN)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, managed := in.LedgerAccounts[uid]; managed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
|
||||||
|
for _, u := range in.Desired.Users {
|
||||||
|
if u.UID == uid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。
|
||||||
|
func uidFromDN(dn string) (string, bool) {
|
||||||
|
first := dn
|
||||||
|
if i := strings.Index(dn, ","); i >= 0 {
|
||||||
|
first = dn[:i]
|
||||||
|
}
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
if len(first) < 4 || !strings.EqualFold(first[:4], "uid=") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(first[4:])), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberRDN 从成员 DN 中提取用于展示的标识。
|
||||||
|
func memberRDN(dn string) string {
|
||||||
|
if uid, ok := uidFromDN(dn); ok {
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
return dn
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
|
||||||
|
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
|
||||||
|
checks := []struct {
|
||||||
|
attr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ldaplib.AttrCN, want.CN},
|
||||||
|
{ldaplib.AttrSN, want.SN},
|
||||||
|
{ldaplib.AttrGivenName, want.GivenName},
|
||||||
|
{ldaplib.AttrDisplayName, want.DisplayName},
|
||||||
|
{ldaplib.AttrMail, want.Mail},
|
||||||
|
{ldaplib.AttrMobile, want.Mobile},
|
||||||
|
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||||
|
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||||
|
{ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||||
|
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||||
|
{ldaplib.AttrDescription, want.Description},
|
||||||
|
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
|
||||||
|
{ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
|
||||||
|
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
|
||||||
|
fields := []struct {
|
||||||
|
label string
|
||||||
|
attr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"mail", ldaplib.AttrMail, want.Mail},
|
||||||
|
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
|
||||||
|
{"mobile", ldaplib.AttrMobile, want.Mobile},
|
||||||
|
{"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
|
||||||
|
{"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
|
||||||
|
{"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress},
|
||||||
|
{"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory},
|
||||||
|
{"givenName", ldaplib.AttrGivenName, want.GivenName},
|
||||||
|
{"sn", ldaplib.AttrSN, want.SN},
|
||||||
|
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
|
||||||
|
}
|
||||||
|
|
||||||
|
var diffs []string
|
||||||
|
for _, f := range fields {
|
||||||
|
old := existing.Get(f.attr)
|
||||||
|
if normalizeAttr(old) == normalizeAttr(f.want) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
|
||||||
|
}
|
||||||
|
if len(diffs) == 0 {
|
||||||
|
return "属性无变化"
|
||||||
|
}
|
||||||
|
return strings.Join(diffs, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
|
||||||
|
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
|
||||||
|
func normalizeAttr(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
|
||||||
|
func AttrsHash(u *mapping.User) string {
|
||||||
|
h := sha256.New()
|
||||||
|
for _, v := range []string{
|
||||||
|
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
|
||||||
|
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
|
||||||
|
u.BusinessCategory, u.Description,
|
||||||
|
} {
|
||||||
|
h.Write([]byte(v))
|
||||||
|
h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/mapping"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUserOU = "ou=people,dc=example,dc=com"
|
||||||
|
testInactiveOU = "ou=Inactive,dc=example,dc=com"
|
||||||
|
testGroupRoot = "ou=feishuroot,dc=example,dc=com"
|
||||||
|
testAdminDN = "cn=admin,dc=example,dc=com"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry 构造一个 LDAP 条目,便于测试。
|
||||||
|
func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry {
|
||||||
|
return ldaplib.NewEntry(dn, attrs, members)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUser(uid string) *mapping.User {
|
||||||
|
return &mapping.User{
|
||||||
|
UID: uid,
|
||||||
|
DN: "uid=" + uid + "," + testUserOU,
|
||||||
|
InactiveDN: "uid=" + uid + "," + testInactiveOU,
|
||||||
|
CN: uid,
|
||||||
|
SN: uid,
|
||||||
|
GivenName: uid,
|
||||||
|
DisplayName: uid,
|
||||||
|
Mail: uid + "@example.com",
|
||||||
|
Description: uid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseInput(desired *mapping.State) *Input {
|
||||||
|
return &Input{
|
||||||
|
Desired: desired,
|
||||||
|
Active: map[string]*ldaplib.Entry{},
|
||||||
|
Inactive: map[string]*ldaplib.Entry{},
|
||||||
|
Groups: map[string]*ldaplib.Entry{},
|
||||||
|
LedgerAccounts: map[string]string{},
|
||||||
|
LedgerGroups: map[string]string{},
|
||||||
|
AdminDN: testAdminDN,
|
||||||
|
GroupRootDN: testGroupRoot,
|
||||||
|
UserOUDN: testUserOU,
|
||||||
|
InactiveOUDN: testInactiveOU,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindsOf(plan *Plan) map[OpKind]int {
|
||||||
|
out := make(map[OpKind]int)
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
out[op.Kind]++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputePlan(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
desired *mapping.State
|
||||||
|
mutate func(in *Input)
|
||||||
|
wantKinds map[OpKind]int
|
||||||
|
// wantDetailContains 断言某个操作详情包含特定文本
|
||||||
|
wantDetailContains string
|
||||||
|
// forbidKinds 断言这些操作绝不出现(安全性回归)
|
||||||
|
forbidKinds []OpKind
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "全新用户:创建",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{newUser("alice")},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpCreateUser: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "属性一致:不产生操作",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{newUser("alice")},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
u := newUser("alice")
|
||||||
|
in.Active["alice"] = entry(u.DN, map[string]string{
|
||||||
|
ldaplib.AttrCN: u.CN,
|
||||||
|
ldaplib.AttrSN: u.SN,
|
||||||
|
ldaplib.AttrGivenName: u.GivenName,
|
||||||
|
ldaplib.AttrDisplayName: u.DisplayName,
|
||||||
|
ldaplib.AttrMail: u.Mail,
|
||||||
|
ldaplib.AttrMobile: u.Mobile,
|
||||||
|
ldaplib.AttrEmployeeNumber: u.EmployeeNumber,
|
||||||
|
ldaplib.AttrDepartmentNumber: u.DepartmentNumber,
|
||||||
|
ldaplib.AttrPostalAddress: u.PostalAddress,
|
||||||
|
ldaplib.AttrBusinessCategory: u.BusinessCategory,
|
||||||
|
ldaplib.AttrDescription: u.Description,
|
||||||
|
}, nil)
|
||||||
|
in.LedgerAccounts["alice"] = "active"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "邮箱变化:更新属性",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{
|
||||||
|
func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(),
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
u := newUser("alice")
|
||||||
|
in.Active["alice"] = entry(u.DN, map[string]string{
|
||||||
|
ldaplib.AttrCN: u.CN,
|
||||||
|
ldaplib.AttrDisplayName: u.DisplayName,
|
||||||
|
ldaplib.AttrMail: "old@example.com",
|
||||||
|
}, nil)
|
||||||
|
in.LedgerAccounts["alice"] = "active"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpUpdateUser: 1},
|
||||||
|
wantDetailContains: "alice.new@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "台账在职但飞书消失:停用",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{
|
||||||
|
ldaplib.AttrDisplayName: "Bob",
|
||||||
|
}, nil)
|
||||||
|
in.LedgerAccounts["bob"] = "active"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpDeactivate: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非台账账号缺席:不得停用(关键安全回归)",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
// LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。
|
||||||
|
in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{
|
||||||
|
ldaplib.AttrDisplayName: "Service",
|
||||||
|
}, nil)
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
forbidKinds: []OpKind{OpDeactivate},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "在 Inactive 且台账受管:恢复",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{newUser("carol")},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{
|
||||||
|
ldaplib.AttrDisplayName: "Carol",
|
||||||
|
}, nil)
|
||||||
|
in.LedgerAccounts["carol"] = "inactive"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpReactivate: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "在 Inactive 但非台账:只告警不改动",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{newUser("dave")},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil)
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
forbidKinds: []OpKind{OpReactivate},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "分组不存在:创建并加成员",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "成员缺席:加入分组",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{"uid=alice," + testUserOU: {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||||
|
"cn=yanfa,"+testGroupRoot,
|
||||||
|
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||||
|
[]string{testAdminDN})
|
||||||
|
in.LedgerAccounts["alice"] = "active"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpAddMember: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "成员多余且受管:移出分组",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||||
|
"cn=yanfa,"+testGroupRoot,
|
||||||
|
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||||
|
[]string{testAdminDN, "uid=alice," + testUserOU})
|
||||||
|
in.LedgerAccounts["alice"] = "active"
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{OpRemoveMember: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin 永不被移出分组(关键安全回归)",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||||
|
"cn=yanfa,"+testGroupRoot,
|
||||||
|
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||||
|
[]string{testAdminDN})
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
forbidKinds: []OpKind{OpRemoveMember},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让
|
||||||
|
// isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑
|
||||||
|
// 才能阻止它被移出——否则该用例会失败。没有这层构造,
|
||||||
|
// 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。
|
||||||
|
name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
adminUIDDN := "uid=admin," + testUserOU
|
||||||
|
in.AdminDN = adminUIDDN
|
||||||
|
in.LedgerAccounts["admin"] = "active"
|
||||||
|
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||||
|
"cn=yanfa,"+testGroupRoot,
|
||||||
|
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||||
|
[]string{adminUIDDN})
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
forbidKinds: []OpKind{OpRemoveMember},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "外部手工成员:不移出,仅告警(关键安全回归)",
|
||||||
|
desired: &mapping.State{
|
||||||
|
Users: []*mapping.User{},
|
||||||
|
Groups: []*mapping.Group{
|
||||||
|
{
|
||||||
|
DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部",
|
||||||
|
Members: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
},
|
||||||
|
mutate: func(in *Input) {
|
||||||
|
in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry(
|
||||||
|
"cn=yanfa,"+testGroupRoot,
|
||||||
|
map[string]string{ldaplib.AttrDescription: "研发部"},
|
||||||
|
[]string{testAdminDN, "uid=external," + testUserOU})
|
||||||
|
},
|
||||||
|
wantKinds: map[OpKind]int{},
|
||||||
|
forbidKinds: []OpKind{OpRemoveMember},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
in := baseInput(tt.desired)
|
||||||
|
if tt.mutate != nil {
|
||||||
|
tt.mutate(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := ComputePlan(in)
|
||||||
|
got := kindsOf(plan)
|
||||||
|
|
||||||
|
for kind, want := range tt.wantKinds {
|
||||||
|
if got[kind] != want {
|
||||||
|
t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s",
|
||||||
|
kind, got[kind], want, dumpPlan(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind, count := range got {
|
||||||
|
if _, expected := tt.wantKinds[kind]; !expected {
|
||||||
|
t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s",
|
||||||
|
kind, count, dumpPlan(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, kind := range tt.forbidKinds {
|
||||||
|
if got[kind] > 0 {
|
||||||
|
t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.wantDetailContains != "" {
|
||||||
|
found := false
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
if strings.Contains(op.Detail, tt.wantDetailContains) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s",
|
||||||
|
tt.wantDetailContains, dumpPlan(plan))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputePlanNeverTouchesPassword 是一个结构性回归测试:
|
||||||
|
// 无论输入如何,计划中都不能出现改写 userPassword 的操作。
|
||||||
|
// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。
|
||||||
|
func TestComputePlanNeverTouchesPassword(t *testing.T) {
|
||||||
|
u := newUser("alice")
|
||||||
|
in := baseInput(&mapping.State{
|
||||||
|
Users: []*mapping.User{u},
|
||||||
|
GroupByDeptID: map[string]*mapping.Group{},
|
||||||
|
})
|
||||||
|
in.LedgerAccounts["bob"] = "active"
|
||||||
|
in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil)
|
||||||
|
|
||||||
|
plan := ComputePlan(in)
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
if strings.Contains(strings.ToLower(op.Detail), "userpassword") {
|
||||||
|
t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuard(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
guard Guard
|
||||||
|
plan *Plan
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "无停用时通过",
|
||||||
|
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||||
|
plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "超过绝对上限被拒",
|
||||||
|
guard: Guard{MaxDeactivate: 5},
|
||||||
|
plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "超过比例上限被拒",
|
||||||
|
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||||
|
plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "比例在阈值内通过",
|
||||||
|
guard: Guard{MaxDeactivatePercent: 0.2},
|
||||||
|
plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "强制模式跳过检查",
|
||||||
|
guard: Guard{MaxDeactivate: 1, AllowBulk: true},
|
||||||
|
plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.guard.Check(tt.plan)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Errorf("期望返回错误,实际通过")
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("期望通过,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpPlan(p *Plan) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, op := range p.Ops {
|
||||||
|
b.WriteString("\n ")
|
||||||
|
b.WriteString(string(op.Kind))
|
||||||
|
b.WriteString(" target=")
|
||||||
|
b.WriteString(op.Target)
|
||||||
|
b.WriteString(" detail=")
|
||||||
|
b.WriteString(op.Detail)
|
||||||
|
}
|
||||||
|
for _, w := range p.Warnings {
|
||||||
|
b.WriteString("\n WARN ")
|
||||||
|
b.WriteString(w)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
"idsync/internal/feishu"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/mapping"
|
||||||
|
"idsync/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。
|
||||||
|
type Runner struct {
|
||||||
|
cfg *config.Config
|
||||||
|
feishu *feishu.Client
|
||||||
|
ldap *ldaplib.Client
|
||||||
|
store *store.Store
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
// guard 是执行前的安全阈值。
|
||||||
|
guard Guard
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner 构造编排器。
|
||||||
|
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
feishu: f,
|
||||||
|
ldap: l,
|
||||||
|
store: s,
|
||||||
|
log: log,
|
||||||
|
guard: Guard{
|
||||||
|
MaxDeactivate: cfg.Sync.MaxDeactivate,
|
||||||
|
MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码,
|
||||||
|
// 因此预览内容与真正会发生的事情一致。
|
||||||
|
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
|
||||||
|
plan, _, err := r.plan(ctx)
|
||||||
|
return plan, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。
|
||||||
|
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
|
||||||
|
run, err := r.store.StartRun(modeOf(dryRun), trigger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("创建同步记录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, desired, err := r.prepare(ctx, run, force)
|
||||||
|
if err != nil {
|
||||||
|
return plan, nil, err
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
fillRunFromPlan(run, plan)
|
||||||
|
run.Status = "success"
|
||||||
|
if err := r.store.FinishRun(run); err != nil {
|
||||||
|
r.log.Warn("写入同步记录失败", "err", err)
|
||||||
|
}
|
||||||
|
return plan, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res := r.apply(ctx, plan)
|
||||||
|
fillRunFromResult(run, plan, res)
|
||||||
|
|
||||||
|
// 只有全部成功才回写台账:部分失败时保持台账不变,
|
||||||
|
// 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。
|
||||||
|
if res.Failed == 0 {
|
||||||
|
if err := r.persist(plan, desired); err != nil {
|
||||||
|
run.Status = "failed"
|
||||||
|
run.ErrorMsg = "回写台账失败: " + err.Error()
|
||||||
|
_ = r.store.FinishRun(run)
|
||||||
|
return plan, res, fmt.Errorf("回写台账失败: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
run.Status = "success"
|
||||||
|
if res.Failed > 0 {
|
||||||
|
run.Status = "failed"
|
||||||
|
run.ErrorMsg = strings.Join(res.Errors, "; ")
|
||||||
|
}
|
||||||
|
if err := r.store.FinishRun(run); err != nil {
|
||||||
|
r.log.Warn("写入同步记录失败", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare 计算计划并做安全检查。
|
||||||
|
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) {
|
||||||
|
plan, desired, err := r.plan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
run.Status = "failed"
|
||||||
|
run.ErrorMsg = err.Error()
|
||||||
|
_ = r.store.FinishRun(run)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。
|
||||||
|
// 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。
|
||||||
|
guard := r.guard
|
||||||
|
if force {
|
||||||
|
guard.AllowBulk = true
|
||||||
|
}
|
||||||
|
if err := guard.Check(plan); err != nil {
|
||||||
|
run.Status = "failed"
|
||||||
|
run.ErrorMsg = err.Error()
|
||||||
|
_ = r.store.FinishRun(run)
|
||||||
|
return plan, desired, err
|
||||||
|
}
|
||||||
|
return plan, desired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。
|
||||||
|
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) {
|
||||||
|
snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err)
|
||||||
|
}
|
||||||
|
r.log.Info("已读取飞书数据",
|
||||||
|
"departments", len(snap.Departments), "users", len(snap.Users),
|
||||||
|
"skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate))
|
||||||
|
|
||||||
|
// 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里,
|
||||||
|
// 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。
|
||||||
|
var skipWarnings []string
|
||||||
|
for _, u := range snap.SkippedNoEmail {
|
||||||
|
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||||||
|
"用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过",
|
||||||
|
feishu.DisplayName(u), u.UnionID))
|
||||||
|
}
|
||||||
|
for _, d := range snap.SkippedDuplicate {
|
||||||
|
skipWarnings = append(skipWarnings, fmt.Sprintf(
|
||||||
|
"企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱",
|
||||||
|
d.UID, strings.Join(d.Names, "、")))
|
||||||
|
}
|
||||||
|
|
||||||
|
desired := mapping.Build(snap, mapping.BuildOptions{
|
||||||
|
UserOUDN: r.cfg.UserOUDN(),
|
||||||
|
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||||
|
GroupRootDN: r.cfg.GroupRootDN(),
|
||||||
|
})
|
||||||
|
|
||||||
|
conn, err := r.ldap.Dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
active, err := conn.ListUsers(r.cfg.UserOUDN())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
inactive, err := conn.ListUsers(r.cfg.InactiveOUDN())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
groups, err := conn.ListGroups(r.cfg.GroupRootDN())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := r.store.AccountsByUID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
groupLedger, err := r.store.GroupsByDeptID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ledgerAccounts := make(map[string]string, len(accounts))
|
||||||
|
for uid, a := range accounts {
|
||||||
|
ledgerAccounts[uid] = a.State
|
||||||
|
}
|
||||||
|
ledgerGroups := make(map[string]string, len(groupLedger))
|
||||||
|
for deptID, g := range groupLedger {
|
||||||
|
ledgerGroups[deptID] = g.DN
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := ComputePlan(&Input{
|
||||||
|
Desired: desired,
|
||||||
|
Active: active,
|
||||||
|
Inactive: inactive,
|
||||||
|
Groups: groups,
|
||||||
|
LedgerAccounts: ledgerAccounts,
|
||||||
|
LedgerGroups: ledgerGroups,
|
||||||
|
AdminDN: r.cfg.Ldap.AdminDN,
|
||||||
|
GroupRootDN: r.cfg.GroupRootDN(),
|
||||||
|
UserOUDN: r.cfg.UserOUDN(),
|
||||||
|
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||||
|
})
|
||||||
|
|
||||||
|
plan.Warnings = append(skipWarnings, plan.Warnings...)
|
||||||
|
|
||||||
|
// 对“候选停用”的用户,向飞书确认是否真的离职。
|
||||||
|
// 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。
|
||||||
|
if err := r.confirmResigned(ctx, plan, snap); err != nil {
|
||||||
|
r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, desired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。
|
||||||
|
func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error {
|
||||||
|
var uids []string
|
||||||
|
|
||||||
|
// 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。
|
||||||
|
accounts, err := r.store.AccountsByUID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range plan.Ops {
|
||||||
|
if plan.Ops[i].Kind != OpDeactivate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc, ok := accounts[plan.Ops[i].Target]
|
||||||
|
if !ok || acc.UnionID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uids = append(uids, acc.UnionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resigned, err := r.feishu.CheckResigned(ctx, uids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed := make(map[string]bool, len(resigned))
|
||||||
|
for _, rr := range resigned {
|
||||||
|
confirmed[rr.UnionID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
keep := make([]Op, 0, len(plan.Ops))
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
if op.Kind != OpDeactivate {
|
||||||
|
keep = append(keep, op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc := accounts[op.Target]
|
||||||
|
if acc != nil && confirmed[acc.UnionID] {
|
||||||
|
keep = append(keep, op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 未能确认离职:不动作,并明确告知。
|
||||||
|
plan.Stats.Deactivate--
|
||||||
|
plan.Warnings = append(plan.Warnings, fmt.Sprintf(
|
||||||
|
"账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s",
|
||||||
|
op.Target, r.cfg.InactiveOUDN()))
|
||||||
|
}
|
||||||
|
plan.Ops = keep
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply 执行计划。
|
||||||
|
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
|
||||||
|
exec := NewExecutor(r.ldap, ExecConfig{
|
||||||
|
UserOUDN: r.cfg.UserOUDN(),
|
||||||
|
InactiveOUDN: r.cfg.InactiveOUDN(),
|
||||||
|
GroupRootDN: r.cfg.GroupRootDN(),
|
||||||
|
GroupRootName: r.cfg.Ldap.GroupRootName,
|
||||||
|
AdminDN: r.cfg.Ldap.AdminDN,
|
||||||
|
InitialPassword: r.cfg.Ldap.InitialPassword,
|
||||||
|
}, r.log)
|
||||||
|
return exec.Apply(ctx, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist 把本次同步的期望状态回写台账。
|
||||||
|
//
|
||||||
|
// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。
|
||||||
|
// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记
|
||||||
|
// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。
|
||||||
|
// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。
|
||||||
|
func (r *Runner) persist(plan *Plan, desired *mapping.State) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 分组:凡在本次期望范围内的都登记。
|
||||||
|
for _, g := range desired.Groups {
|
||||||
|
if err := r.store.UpsertGroup(&store.Group{
|
||||||
|
DeptID: g.DeptID,
|
||||||
|
DN: g.DN,
|
||||||
|
Name: g.Name,
|
||||||
|
ParentID: g.ParentID,
|
||||||
|
State: "active",
|
||||||
|
LastSyncedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户:在职的登记为 active。
|
||||||
|
for _, u := range desired.Users {
|
||||||
|
if err := r.store.UpsertAccount(&store.Account{
|
||||||
|
UID: u.UID,
|
||||||
|
DN: u.DN,
|
||||||
|
Email: u.Mail,
|
||||||
|
Name: u.DisplayName,
|
||||||
|
State: "active",
|
||||||
|
AttrsHash: AttrsHash(u),
|
||||||
|
DeptIDs: strings.Join(u.DeptIDs, ","),
|
||||||
|
LastSyncedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停用的账号:更新状态与,DN 指向离职 OU。
|
||||||
|
for _, op := range plan.Ops {
|
||||||
|
if op.Kind == OpDeactivate {
|
||||||
|
if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeOf 把 dry-run 标志转成记录用的模式名。
|
||||||
|
func modeOf(dryRun bool) string {
|
||||||
|
if dryRun {
|
||||||
|
return "dry-run"
|
||||||
|
}
|
||||||
|
return "apply"
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。
|
||||||
|
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
|
||||||
|
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
|
||||||
|
run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup +
|
||||||
|
plan.Stats.Reactivate + plan.Stats.Deactivate
|
||||||
|
run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate
|
||||||
|
run.Added = plan.Stats.AddMember
|
||||||
|
run.Removed = plan.Stats.RemoveMember
|
||||||
|
run.Skipped = plan.Stats.Unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillRunFromResult 用实际执行结果填充同步记录。
|
||||||
|
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
|
||||||
|
run.Created = res.Created
|
||||||
|
run.Updated = res.Updated
|
||||||
|
run.Moved = res.Moved
|
||||||
|
run.Added = res.Added
|
||||||
|
run.Removed = res.Removed
|
||||||
|
run.Failed = res.Failed
|
||||||
|
run.Skipped = plan.Stats.Unchanged
|
||||||
|
}
|
||||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+423
@@ -0,0 +1,423 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
|
||||||
|
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||||
|
|
||||||
|
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||||
|
const OP_LABELS = {
|
||||||
|
create_user: '新建用户',
|
||||||
|
update_user: '更新用户',
|
||||||
|
deactivate_user: '迁入离职 OU',
|
||||||
|
reactivate_user: '恢复用户',
|
||||||
|
create_group: '新建分组',
|
||||||
|
update_group: '更新分组',
|
||||||
|
add_member: '加入分组',
|
||||||
|
remove_member: '移出分组',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OP_BADGES = {
|
||||||
|
create_user: 'badge-success',
|
||||||
|
create_group: 'badge-success',
|
||||||
|
update_user: 'badge-info',
|
||||||
|
update_group: 'badge-info',
|
||||||
|
reactivate_user: 'badge-warning',
|
||||||
|
deactivate_user: 'badge-error',
|
||||||
|
add_member: 'badge-success',
|
||||||
|
remove_member: 'badge-error',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 主题
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem('idsync-theme');
|
||||||
|
if (saved === 'light' || saved === 'dark') {
|
||||||
|
document.documentElement.dataset.theme = saved;
|
||||||
|
}
|
||||||
|
$('#themeToggle').addEventListener('click', () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
root.dataset.theme = next;
|
||||||
|
localStorage.setItem('idsync-theme', next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
showLogin();
|
||||||
|
throw new Error('登录状态已失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
// 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = $('#globalError');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
$('#globalError').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 视图切换
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
$('#loginView').classList.remove('hidden');
|
||||||
|
$('#loginView').classList.add('flex');
|
||||||
|
$('#appView').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
$('#loginView').classList.add('hidden');
|
||||||
|
$('#loginView').classList.remove('flex');
|
||||||
|
$('#appView').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(name) {
|
||||||
|
$$('.view').forEach((el) => el.classList.add('hidden'));
|
||||||
|
const view = $(`#view-${name}`);
|
||||||
|
if (view) view.classList.remove('hidden');
|
||||||
|
|
||||||
|
$$('.tabs .tab').forEach((t) => {
|
||||||
|
t.classList.toggle('tab-active', t.dataset.view === name);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (name === 'overview') loadStatus();
|
||||||
|
if (name === 'users') loadUsers();
|
||||||
|
if (name === 'groups') loadGroups();
|
||||||
|
if (name === 'plan') loadPlan();
|
||||||
|
if (name === 'runs') loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 登录
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const errEl = $('#loginError');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
|
||||||
|
});
|
||||||
|
showApp();
|
||||||
|
switchView('overview');
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await api('/api/logout', { method: 'POST' });
|
||||||
|
} catch (_) {
|
||||||
|
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||||
|
}
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 概览
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/status');
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
$('#statActive').textContent = d.counts.activeUsers;
|
||||||
|
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||||
|
$('#statGroups').textContent = d.counts.groups;
|
||||||
|
|
||||||
|
$('#connStatus').innerHTML = [
|
||||||
|
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||||
|
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
$('#lastRunInfo').innerHTML = d.lastRun
|
||||||
|
? `<div class="space-y-1">
|
||||||
|
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||||
|
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||||
|
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||||
|
</div>`
|
||||||
|
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusRow(name, ok, error, detail) {
|
||||||
|
const badge = ok
|
||||||
|
? '<span class="badge badge-success badge-sm">正常</span>'
|
||||||
|
: '<span class="badge badge-error badge-sm">异常</span>';
|
||||||
|
const extra = ok
|
||||||
|
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
|
||||||
|
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
|
||||||
|
return `<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
|
||||||
|
${extra}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 用户
|
||||||
|
|
||||||
|
let usersCache = [];
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/users');
|
||||||
|
usersCache = d.users || [];
|
||||||
|
renderUsers();
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
const kw = $('#userFilter').value.trim().toLowerCase();
|
||||||
|
const rows = usersCache.filter((u) => {
|
||||||
|
if (!kw) return true;
|
||||||
|
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#userRows').innerHTML = rows.map((u) => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||||
|
<td>${escapeHtml(u.name || '')}</td>
|
||||||
|
<td>${escapeHtml(u.email || '')}</td>
|
||||||
|
<td>${u.state === 'inactive'
|
||||||
|
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||||
|
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||||
|
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||||
|
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||||
|
<td>${u.drift
|
||||||
|
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||||
|
: '<span class="text-base-content/40">—</span>'}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 分组
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/groups');
|
||||||
|
const groups = d.groups || [];
|
||||||
|
$('#groupRows').innerHTML = groups.map((g) => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 变更预览
|
||||||
|
|
||||||
|
let currentPlan = null;
|
||||||
|
|
||||||
|
async function loadPlan() {
|
||||||
|
$('#planRows').innerHTML = '';
|
||||||
|
$('#planSummary').innerHTML = '';
|
||||||
|
$('#planWarnings').classList.add('hidden');
|
||||||
|
$('#planEmpty').classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plan = await api('/api/plan');
|
||||||
|
currentPlan = plan;
|
||||||
|
renderPlan(plan);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan(plan) {
|
||||||
|
const s = plan.stats || {};
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
['新建用户', s.createUser, 'text-success'],
|
||||||
|
['更新用户', s.updateUser, 'text-info'],
|
||||||
|
['迁入离职', s.deactivateUser, 'text-error'],
|
||||||
|
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||||
|
['新建分组', s.createGroup, 'text-success'],
|
||||||
|
['加入分组', s.addMember, 'text-success'],
|
||||||
|
['移出分组', s.removeMember, 'text-error'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<span class="text-xs text-base-content/60">${label}</span>
|
||||||
|
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const warnings = plan.warnings || [];
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
const el = $('#planWarnings');
|
||||||
|
el.innerHTML = `<div class="alert alert-warning text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = plan.ops || [];
|
||||||
|
$('#planRows').innerHTML = ops.map((op) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||||
|
<td class="font-medium">${escapeHtml(op.target)}</td>
|
||||||
|
<td>${escapeHtml(op.detail)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlan(force = false) {
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.close === 'function') modal.close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await api('/api/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ dryRun: false, force }),
|
||||||
|
});
|
||||||
|
const res = d.result || {};
|
||||||
|
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||||
|
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
|
||||||
|
switchView('runs');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 同步历史
|
||||||
|
|
||||||
|
async function loadRuns() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/runs');
|
||||||
|
const runs = d.runs || [];
|
||||||
|
$('#runRows').innerHTML = runs.map((r) => `
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
|
||||||
|
<td>${r.mode === 'dry-run'
|
||||||
|
? '<span class="badge badge-ghost badge-sm">预览</span>'
|
||||||
|
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
|
||||||
|
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
|
||||||
|
<td>${r.status === 'success'
|
||||||
|
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||||
|
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||||
|
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
|
||||||
|
<td>${r.added}</td><td>${r.removed}</td>
|
||||||
|
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 工具
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '—';
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 启动
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
$('#loginBtn').addEventListener('click', doLogin);
|
||||||
|
$('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
$('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
$('#logoutBtn').addEventListener('click', doLogout);
|
||||||
|
|
||||||
|
$$('.tabs .tab').forEach((t) => {
|
||||||
|
t.addEventListener('click', () => switchView(t.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#userFilter').addEventListener('input', renderUsers);
|
||||||
|
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||||
|
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||||
|
$('#previewBtn').addEventListener('click', loadPlan);
|
||||||
|
|
||||||
|
$('#applyBtn').addEventListener('click', () => {
|
||||||
|
if (!currentPlan) return;
|
||||||
|
const s = currentPlan.stats || {};
|
||||||
|
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||||
|
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||||
|
if (total === 0) {
|
||||||
|
showError('没有需要应用的变更。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#applyModalBody').innerHTML = `
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
|
||||||
|
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
|
||||||
|
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
|
||||||
|
</div>`;
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.showModal === 'function') modal.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#applyCancel').addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.close === 'function') modal.close();
|
||||||
|
});
|
||||||
|
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
|
||||||
|
|
||||||
|
// 已有会话则直接进入主界面
|
||||||
|
api('/api/status')
|
||||||
|
.then(() => { showApp(); switchView('overview'); })
|
||||||
|
.catch(() => showLogin());
|
||||||
|
});
|
||||||
Vendored
+206
File diff suppressed because one or more lines are too long
Vendored
+229
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>idsync · 飞书通讯录同步</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
|
||||||
|
<!-- 未登录:登录卡片 -->
|
||||||
|
<div id="loginView" class="hidden items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="card w-full max-w-sm bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||||
|
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
|
||||||
|
|
||||||
|
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
|
||||||
|
|
||||||
|
<label class="label" for="username"><span class="label-text">用户名</span></label>
|
||||||
|
<input id="username" type="text" class="input w-full mb-3" autocomplete="username" />
|
||||||
|
|
||||||
|
<label class="label" for="password"><span class="label-text">密码</span></label>
|
||||||
|
<input id="password" type="password" class="input w-full mb-5" autocomplete="current-password" />
|
||||||
|
|
||||||
|
<button id="loginBtn" class="btn btn-primary w-full">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已登录:主界面 -->
|
||||||
|
<div id="appView" class="hidden">
|
||||||
|
<header class="navbar bg-base-100 shadow-sm px-4 sticky top-0 z-20">
|
||||||
|
<div class="flex-1 flex items-center gap-2">
|
||||||
|
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||||
|
<span class="text-lg font-semibold">idsync</span>
|
||||||
|
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none flex items-center gap-2">
|
||||||
|
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||||
|
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto p-4">
|
||||||
|
<!-- 页签 -->
|
||||||
|
<div class="tabs tabs-border mb-4" role="tablist">
|
||||||
|
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
|
||||||
|
<button class="tab" data-view="users" role="tab">用户</button>
|
||||||
|
<button class="tab" data-view="groups" role="tab">部门分组</button>
|
||||||
|
<button class="tab" data-view="plan" role="tab">变更预览</button>
|
||||||
|
<button class="tab" data-view="runs" role="tab">同步历史</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="globalError" class="alert alert-error text-sm mb-4 hidden"></div>
|
||||||
|
|
||||||
|
<!-- 概览 -->
|
||||||
|
<section id="view-overview" class="view">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">在职账号</span>
|
||||||
|
<span id="statActive" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">离职账号</span>
|
||||||
|
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">部门分组</span>
|
||||||
|
<span id="statGroups" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">服务状态</h2>
|
||||||
|
<div id="connStatus" class="space-y-2 text-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">上次同步</h2>
|
||||||
|
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 用户 -->
|
||||||
|
<section id="view-users" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||||
|
<h2 class="card-title text-base">受管用户</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="userFilter" type="search" class="input input-sm w-56" placeholder="按 uid / 姓名 / 邮箱过滤" />
|
||||||
|
<button id="reloadUsers" class="btn btn-sm btn-outline">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>uid</th><th>姓名</th><th>企业邮箱</th><th>状态</th>
|
||||||
|
<th>归属部门数</th><th>最后同步</th><th>一致性</th><th>DN</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="userRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="userEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管用户,请先执行一次同步。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 部门分组 -->
|
||||||
|
<section id="view-groups" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base mb-3">受管部门分组</h2>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th>部门</th><th>飞书部门 ID</th><th>父部门</th><th>DN</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groupRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="groupEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管分组。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 变更预览 -->
|
||||||
|
<section id="view-plan" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-base">变更预览(dry-run)</h2>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
|
预览与实际执行使用同一份计划代码,因此这里显示的就是将要发生的变更。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
|
||||||
|
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="planWarnings" class="hidden mb-4"></div>
|
||||||
|
|
||||||
|
<div id="planSummary" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 mb-4"></div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-base mb-2">操作明细</h3>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th class="w-40">类型</th><th class="w-56">对象</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="planRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="planEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">
|
||||||
|
没有需要变更的内容,两侧状态已一致。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 同步历史 -->
|
||||||
|
<section id="view-runs" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="card-title text-base">同步历史</h2>
|
||||||
|
<button id="reloadRuns" class="btn btn-sm btn-outline">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>开始时间</th><th>模式</th><th>触发</th><th>结果</th>
|
||||||
|
<th>新建</th><th>更新</th><th>迁移</th><th>加组</th><th>移组</th><th>失败</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="runRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="runEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无同步记录。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用变更的确认对话框 -->
|
||||||
|
<dialog id="applyModal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||||
|
</p>
|
||||||
|
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||||
|
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/flyonui.js"></script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Package webui 通过 go:embed 把前端资源打进二进制。
|
||||||
|
//
|
||||||
|
// 前端用 Tailwind v4 + FlyonUI 构建:FlyonUI 官方不支持 CDN 方式
|
||||||
|
// (样式在构建期生成),所以这里使用构建产物,用户无需 Node 环境。
|
||||||
|
// 构建命令见 scripts/build-webui.sh,产物在 internal/webui/dist 下。
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dist 是前端构建产物。
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
// assets 保存资源名 → 内容;assetETags 保存内容指纹用于 ETag。
|
||||||
|
var (
|
||||||
|
assets map[string][]byte
|
||||||
|
assetETags map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
fsys, err := fs.Sub(dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assets = make(map[string][]byte, 8)
|
||||||
|
assetETags = make(map[string]string, 8)
|
||||||
|
|
||||||
|
_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
assets[path] = data
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
assetETags[path] = `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentTypes 是各静态资源的 MIME 类型。
|
||||||
|
var contentTypes = map[string]string{
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexHandler 输出内嵌的 index.html。
|
||||||
|
//
|
||||||
|
// 刻意不缓存 HTML:它引用 app.js,一旦被缓存,升级二进制后浏览器仍会
|
||||||
|
// 加载旧脚本,表现为界面数据莫名其妙地缺失或显示 undefined。
|
||||||
|
func IndexHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, ok := assets["index.html"]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "前端资源缺失,请先执行 make css", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetHandler 提供 /static/ 下的静态资源。
|
||||||
|
//
|
||||||
|
// 用内容指纹做 ETag 并允许缓存,但每次回源校验(no-cache)。
|
||||||
|
// 这样既有 304 的高效,又不会在升级二进制后继续使用旧资源——
|
||||||
|
// 仅设 max-age 而不带指纹的策略在内嵌场景下会长期给出过期界面。
|
||||||
|
func AssetHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/static/")
|
||||||
|
if name == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := assets[name]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := assetETags[name]
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct, ok := contentTypes[extOf(name)]; ok {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprint(len(data)))
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extOf(name string) string {
|
||||||
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||||
|
return name[i:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetNames 返回已嵌入的资源名,供测试与健康检查使用。
|
||||||
|
func AssetNames() []string {
|
||||||
|
out := make([]string, 0, len(assets))
|
||||||
|
for name := range assets {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
|
"idsync/internal/config"
|
||||||
|
"idsync/internal/feishu"
|
||||||
|
"idsync/internal/ldaplib"
|
||||||
|
"idsync/internal/server"
|
||||||
|
"idsync/internal/store"
|
||||||
|
"idsync/internal/syncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version 由构建参数注入。
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
cfgPath = flag.String("config", "config.yml", "配置文件路径")
|
||||||
|
dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP")
|
||||||
|
applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)")
|
||||||
|
force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏")
|
||||||
|
genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出")
|
||||||
|
hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出")
|
||||||
|
checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *genSecret {
|
||||||
|
s, err := server.GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
fatal("生成密钥失败", err)
|
||||||
|
}
|
||||||
|
fmt.Println(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *hashPass != "" {
|
||||||
|
h, err := server.HashPassword(*hashPass)
|
||||||
|
if err != nil {
|
||||||
|
fatal("生成哈希失败", err)
|
||||||
|
}
|
||||||
|
fmt.Println(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置自检:把解析后的有效值打出来(凭据脱敏)。
|
||||||
|
// 用于在真正连接目录前确认关键项,尤其是分组形态——
|
||||||
|
// 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。
|
||||||
|
if *checkCfg {
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
fatal("配置加载失败", err)
|
||||||
|
}
|
||||||
|
printEffectiveConfig(cfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
fatal("配置加载失败", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if cfg.System.Mode == "debug" {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.System.DataDir, 0o755); err != nil {
|
||||||
|
fatal("创建数据目录失败", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapClient, err := ldaplib.NewClient(cfg.Ldap, log)
|
||||||
|
if err != nil {
|
||||||
|
fatal("初始化 LDAP 客户端失败", err)
|
||||||
|
}
|
||||||
|
if err := ldapClient.Ping(); err != nil {
|
||||||
|
log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err)
|
||||||
|
} else {
|
||||||
|
log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
feishuClient, err := feishu.New(feishu.Options{
|
||||||
|
AppID: cfg.Feishu.AppID,
|
||||||
|
AppSecret: cfg.Feishu.AppSecret,
|
||||||
|
BaseURL: cfg.Feishu.BaseURL,
|
||||||
|
QPS: cfg.Feishu.QPS,
|
||||||
|
Timeout: time.Duration(cfg.Feishu.TimeoutSec) * time.Second,
|
||||||
|
}, log)
|
||||||
|
if err != nil {
|
||||||
|
fatal("初始化飞书客户端失败", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.Database.Source)
|
||||||
|
if err != nil {
|
||||||
|
fatal("打开台账数据库失败", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log)
|
||||||
|
|
||||||
|
// 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。
|
||||||
|
if *dryRun || *applyAll {
|
||||||
|
runOnce(cfg, runner, *applyAll, *force, log)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务模式
|
||||||
|
srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log)
|
||||||
|
if err != nil {
|
||||||
|
fatal("初始化服务失败", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Addr: cfg.Server.Listen,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Info("管理界面已启动", "addr", cfg.Server.Listen, "version", version)
|
||||||
|
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
fatal("HTTP 服务异常退出", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if cfg.Sync.Enable {
|
||||||
|
scheduleSync(cfg, runner, log)
|
||||||
|
} else {
|
||||||
|
log.Info("定时同步未启用(sync.enable=false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Info("正在关闭服务…")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||||
|
log.Error("关闭 HTTP 服务时出错", "err", err)
|
||||||
|
}
|
||||||
|
log.Info("已退出")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printEffectiveConfig 输出解析后的关键配置,凭据一律脱敏。
|
||||||
|
func printEffectiveConfig(cfg *config.Config) {
|
||||||
|
mask := func(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "(空)"
|
||||||
|
}
|
||||||
|
if len(s) <= 8 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return s[:4] + "****" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("===== 有效配置 =====")
|
||||||
|
fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID))
|
||||||
|
fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)"))
|
||||||
|
fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL)
|
||||||
|
fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN)
|
||||||
|
fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN)
|
||||||
|
fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass))
|
||||||
|
fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN())
|
||||||
|
fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN())
|
||||||
|
fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN())
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass)
|
||||||
|
fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr)
|
||||||
|
fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n",
|
||||||
|
cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)"))
|
||||||
|
fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n",
|
||||||
|
cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun)
|
||||||
|
fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n",
|
||||||
|
cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100)
|
||||||
|
|
||||||
|
// 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。
|
||||||
|
userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN()
|
||||||
|
if inactiveDN == userDN {
|
||||||
|
fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效")
|
||||||
|
} else if len(inactiveDN) > len(userDN) &&
|
||||||
|
inactiveDN[len(inactiveDN)-len(userDN):] == userDN {
|
||||||
|
fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(v, def string) string {
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOnce 执行一次同步并打印计划,然后退出。
|
||||||
|
func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
plan, res, err := runner.Run(ctx, !apply, force, "manual")
|
||||||
|
if plan != nil {
|
||||||
|
printPlanTo(plan)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fatal("同步失败", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("同步完成",
|
||||||
|
"created", res.Created, "updated", res.Updated, "moved", res.Moved,
|
||||||
|
"added", res.Added, "removed", res.Removed, "failed", res.Failed)
|
||||||
|
for _, e := range res.Errors {
|
||||||
|
log.Error("操作失败详情", "detail", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServer 组装 HTTP 服务,并在缺少密钥/哈希时生成安全的替代值。
|
||||||
|
func buildServer(
|
||||||
|
cfg *config.Config,
|
||||||
|
runner *syncer.Runner,
|
||||||
|
db *store.Store,
|
||||||
|
ldapClient *ldaplib.Client,
|
||||||
|
feishuClient *feishu.Client,
|
||||||
|
log *slog.Logger,
|
||||||
|
) (*server.Server, error) {
|
||||||
|
secret := cfg.Server.SessionSecret
|
||||||
|
if secret == "" {
|
||||||
|
// 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。
|
||||||
|
generated, err := server.GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("生成会话密钥失败: %w", err)
|
||||||
|
}
|
||||||
|
secret = generated
|
||||||
|
log.Warn("未配置 server.session-secret,已生成临时密钥,重启后需重新登录",
|
||||||
|
"hint", "把它写入配置可保持会话", "session_secret", secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashBytes []byte
|
||||||
|
if cfg.Server.AdminPasswordHash != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(cfg.Server.AdminPasswordHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("server.admin-password-hash 不是合法的 base64: %w", err)
|
||||||
|
}
|
||||||
|
hashBytes = b
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := server.NewAuth(
|
||||||
|
cfg.Server.AdminUser, hashBytes, cfg.Server.AdminPassword,
|
||||||
|
[]byte(secret), cfg.Server.SecureCookie,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.New(&server.Deps{
|
||||||
|
Cfg: cfg,
|
||||||
|
Auth: auth,
|
||||||
|
Store: db,
|
||||||
|
Runner: runner,
|
||||||
|
LDAP: ldapClient,
|
||||||
|
FeishuTest: func(ctx context.Context) error {
|
||||||
|
return feishuClient.Test(ctx)
|
||||||
|
},
|
||||||
|
Log: log,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleSync 注册定时同步。
|
||||||
|
//
|
||||||
|
// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量
|
||||||
|
// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。
|
||||||
|
func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) {
|
||||||
|
c := cron.New(
|
||||||
|
cron.WithSeconds(),
|
||||||
|
cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := c.AddFunc(cfg.Sync.Schedule, func() {
|
||||||
|
dry := cfg.Sync.DefaultDryRun
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Info("定时同步开始", "dry_run", dry)
|
||||||
|
plan, res, err := runner.Run(ctx, dry, false, "schedule")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("定时同步失败", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dry {
|
||||||
|
planned := 0
|
||||||
|
if plan != nil {
|
||||||
|
planned = len(plan.Ops)
|
||||||
|
}
|
||||||
|
log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("定时同步完成",
|
||||||
|
"created", res.Created, "updated", res.Updated,
|
||||||
|
"moved", res.Moved, "failed", res.Failed)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Start()
|
||||||
|
log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlanTo(p *syncer.Plan) {
|
||||||
|
fmt.Println("===== 同步计划 =====")
|
||||||
|
s := p.Stats
|
||||||
|
fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n",
|
||||||
|
s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate)
|
||||||
|
fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n",
|
||||||
|
s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged)
|
||||||
|
|
||||||
|
if len(p.Ops) > 0 {
|
||||||
|
fmt.Println("\n----- 操作明细 -----")
|
||||||
|
for _, op := range p.Ops {
|
||||||
|
fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Warnings) > 0 {
|
||||||
|
fmt.Println("\n----- 需要注意 -----")
|
||||||
|
for _, w := range p.Warnings {
|
||||||
|
fmt.Printf(" ! %s\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string, err error) {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 构建 WebUI 资源,供 go:embed 使用。
|
||||||
|
#
|
||||||
|
# FlyonUI 的样式必须在构建期由 Tailwind 生成(官方不支持 CDN 方式),
|
||||||
|
# 因此这一步需要 Node。产物会提交进仓库,使用者无需 Node 环境。
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
OUT="$HERE/internal/webui/dist"
|
||||||
|
|
||||||
|
PROXY="${PROXY:-http://127.0.0.1:10808}"
|
||||||
|
NODE_IMAGE="${NODE_IMAGE:-docker.io/library/node:20-alpine}"
|
||||||
|
|
||||||
|
# Tailwind 不会创建输出目录,必须先建好。
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
if command -v npx >/dev/null 2>&1; then
|
||||||
|
echo "使用本机 Node 构建"
|
||||||
|
cd "$HERE/web"
|
||||||
|
[ -d node_modules ] || npm install --no-fund --no-audit
|
||||||
|
npx @tailwindcss/cli -i src/input.css -o "$OUT/app.css" --minify
|
||||||
|
else
|
||||||
|
echo "本机无 Node,改用容器构建($NODE_IMAGE)"
|
||||||
|
# 挂载整个项目根目录,使容器内的输出路径与宿主一致。
|
||||||
|
podman run --rm --pull=never --network=host \
|
||||||
|
-e http_proxy="$PROXY" -e https_proxy="$PROXY" \
|
||||||
|
-v "$HERE":/app -w /app/web "$NODE_IMAGE" \
|
||||||
|
sh -c '
|
||||||
|
set -e
|
||||||
|
[ -d node_modules ] || npm install --no-fund --no-audit >/dev/null 2>&1
|
||||||
|
npx @tailwindcss/cli -i src/input.css -o /app/internal/webui/dist/app.css --minify
|
||||||
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拷贝静态资源;FlyonUI 的 JS 提供 tabs / modal 等交互组件。
|
||||||
|
cp -f "$HERE/web/src/index.html" "$OUT/index.html"
|
||||||
|
cp -f "$HERE/web/src/app.js" "$OUT/app.js"
|
||||||
|
cp -f "$HERE/web/node_modules/flyonui/flyonui.js" "$OUT/flyonui.js"
|
||||||
|
|
||||||
|
echo "WebUI 资源已生成于 internal/webui/dist"
|
||||||
|
ls -la "$OUT"
|
||||||
Generated
+1169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "idsync-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "idsync 管理界面样式构建",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i src/input.css -o dist/app.css --minify",
|
||||||
|
"dev": "tailwindcss -i src/input.css -o dist/app.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.11",
|
||||||
|
"flyonui": "^2.4.1",
|
||||||
|
"tailwindcss": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
+423
@@ -0,0 +1,423 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
|
||||||
|
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||||
|
|
||||||
|
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
|
||||||
|
const OP_LABELS = {
|
||||||
|
create_user: '新建用户',
|
||||||
|
update_user: '更新用户',
|
||||||
|
deactivate_user: '迁入离职 OU',
|
||||||
|
reactivate_user: '恢复用户',
|
||||||
|
create_group: '新建分组',
|
||||||
|
update_group: '更新分组',
|
||||||
|
add_member: '加入分组',
|
||||||
|
remove_member: '移出分组',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OP_BADGES = {
|
||||||
|
create_user: 'badge-success',
|
||||||
|
create_group: 'badge-success',
|
||||||
|
update_user: 'badge-info',
|
||||||
|
update_group: 'badge-info',
|
||||||
|
reactivate_user: 'badge-warning',
|
||||||
|
deactivate_user: 'badge-error',
|
||||||
|
add_member: 'badge-success',
|
||||||
|
remove_member: 'badge-error',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 主题
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem('idsync-theme');
|
||||||
|
if (saved === 'light' || saved === 'dark') {
|
||||||
|
document.documentElement.dataset.theme = saved;
|
||||||
|
}
|
||||||
|
$('#themeToggle').addEventListener('click', () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
root.dataset.theme = next;
|
||||||
|
localStorage.setItem('idsync-theme', next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
showLogin();
|
||||||
|
throw new Error('登录状态已失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
// 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = $('#globalError');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
$('#globalError').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 视图切换
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
$('#loginView').classList.remove('hidden');
|
||||||
|
$('#loginView').classList.add('flex');
|
||||||
|
$('#appView').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
$('#loginView').classList.add('hidden');
|
||||||
|
$('#loginView').classList.remove('flex');
|
||||||
|
$('#appView').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(name) {
|
||||||
|
$$('.view').forEach((el) => el.classList.add('hidden'));
|
||||||
|
const view = $(`#view-${name}`);
|
||||||
|
if (view) view.classList.remove('hidden');
|
||||||
|
|
||||||
|
$$('.tabs .tab').forEach((t) => {
|
||||||
|
t.classList.toggle('tab-active', t.dataset.view === name);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (name === 'overview') loadStatus();
|
||||||
|
if (name === 'users') loadUsers();
|
||||||
|
if (name === 'groups') loadGroups();
|
||||||
|
if (name === 'plan') loadPlan();
|
||||||
|
if (name === 'runs') loadRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 登录
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const errEl = $('#loginError');
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
|
||||||
|
});
|
||||||
|
showApp();
|
||||||
|
switchView('overview');
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
try {
|
||||||
|
await api('/api/logout', { method: 'POST' });
|
||||||
|
} catch (_) {
|
||||||
|
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
|
||||||
|
}
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 概览
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/status');
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
$('#statActive').textContent = d.counts.activeUsers;
|
||||||
|
$('#statInactive').textContent = d.counts.inactiveUsers;
|
||||||
|
$('#statGroups').textContent = d.counts.groups;
|
||||||
|
|
||||||
|
$('#connStatus').innerHTML = [
|
||||||
|
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
|
||||||
|
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
$('#lastRunInfo').innerHTML = d.lastRun
|
||||||
|
? `<div class="space-y-1">
|
||||||
|
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
|
||||||
|
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
|
||||||
|
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
|
||||||
|
</div>`
|
||||||
|
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusRow(name, ok, error, detail) {
|
||||||
|
const badge = ok
|
||||||
|
? '<span class="badge badge-success badge-sm">正常</span>'
|
||||||
|
: '<span class="badge badge-error badge-sm">异常</span>';
|
||||||
|
const extra = ok
|
||||||
|
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
|
||||||
|
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
|
||||||
|
return `<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
|
||||||
|
${extra}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 用户
|
||||||
|
|
||||||
|
let usersCache = [];
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/users');
|
||||||
|
usersCache = d.users || [];
|
||||||
|
renderUsers();
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
const kw = $('#userFilter').value.trim().toLowerCase();
|
||||||
|
const rows = usersCache.filter((u) => {
|
||||||
|
if (!kw) return true;
|
||||||
|
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#userRows').innerHTML = rows.map((u) => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">${escapeHtml(u.uid)}</td>
|
||||||
|
<td>${escapeHtml(u.name || '')}</td>
|
||||||
|
<td>${escapeHtml(u.email || '')}</td>
|
||||||
|
<td>${u.state === 'inactive'
|
||||||
|
? '<span class="badge badge-error badge-sm">离职</span>'
|
||||||
|
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
|
||||||
|
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
|
||||||
|
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
|
||||||
|
<td>${u.drift
|
||||||
|
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
|
||||||
|
: '<span class="text-base-content/40">—</span>'}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 分组
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/groups');
|
||||||
|
const groups = d.groups || [];
|
||||||
|
$('#groupRows').innerHTML = groups.map((g) => `
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">${escapeHtml(g.name || '')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
|
||||||
|
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 变更预览
|
||||||
|
|
||||||
|
let currentPlan = null;
|
||||||
|
|
||||||
|
async function loadPlan() {
|
||||||
|
$('#planRows').innerHTML = '';
|
||||||
|
$('#planSummary').innerHTML = '';
|
||||||
|
$('#planWarnings').classList.add('hidden');
|
||||||
|
$('#planEmpty').classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plan = await api('/api/plan');
|
||||||
|
currentPlan = plan;
|
||||||
|
renderPlan(plan);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan(plan) {
|
||||||
|
const s = plan.stats || {};
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
['新建用户', s.createUser, 'text-success'],
|
||||||
|
['更新用户', s.updateUser, 'text-info'],
|
||||||
|
['迁入离职', s.deactivateUser, 'text-error'],
|
||||||
|
['恢复用户', s.reactivateUser, 'text-warning'],
|
||||||
|
['新建分组', s.createGroup, 'text-success'],
|
||||||
|
['加入分组', s.addMember, 'text-success'],
|
||||||
|
['移出分组', s.removeMember, 'text-error'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<span class="text-xs text-base-content/60">${label}</span>
|
||||||
|
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const warnings = plan.warnings || [];
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
const el = $('#planWarnings');
|
||||||
|
el.innerHTML = `<div class="alert alert-warning text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = plan.ops || [];
|
||||||
|
$('#planRows').innerHTML = ops.map((op) => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
|
||||||
|
<td class="font-medium">${escapeHtml(op.target)}</td>
|
||||||
|
<td>${escapeHtml(op.detail)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlan(force = false) {
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.close === 'function') modal.close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await api('/api/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ dryRun: false, force }),
|
||||||
|
});
|
||||||
|
const res = d.result || {};
|
||||||
|
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
|
||||||
|
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
|
||||||
|
switchView('runs');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 同步历史
|
||||||
|
|
||||||
|
async function loadRuns() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/runs');
|
||||||
|
const runs = d.runs || [];
|
||||||
|
$('#runRows').innerHTML = runs.map((r) => `
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
|
||||||
|
<td>${r.mode === 'dry-run'
|
||||||
|
? '<span class="badge badge-ghost badge-sm">预览</span>'
|
||||||
|
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
|
||||||
|
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
|
||||||
|
<td>${r.status === 'success'
|
||||||
|
? '<span class="badge badge-success badge-sm">成功</span>'
|
||||||
|
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
|
||||||
|
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
|
||||||
|
<td>${r.added}</td><td>${r.removed}</td>
|
||||||
|
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
|
||||||
|
hideError();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 工具
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '—';
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 启动
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
$('#loginBtn').addEventListener('click', doLogin);
|
||||||
|
$('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
$('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
$('#logoutBtn').addEventListener('click', doLogout);
|
||||||
|
|
||||||
|
$$('.tabs .tab').forEach((t) => {
|
||||||
|
t.addEventListener('click', () => switchView(t.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#userFilter').addEventListener('input', renderUsers);
|
||||||
|
$('#reloadUsers').addEventListener('click', loadUsers);
|
||||||
|
$('#reloadRuns').addEventListener('click', loadRuns);
|
||||||
|
$('#previewBtn').addEventListener('click', loadPlan);
|
||||||
|
|
||||||
|
$('#applyBtn').addEventListener('click', () => {
|
||||||
|
if (!currentPlan) return;
|
||||||
|
const s = currentPlan.stats || {};
|
||||||
|
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
|
||||||
|
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
|
||||||
|
if (total === 0) {
|
||||||
|
showError('没有需要应用的变更。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#applyModalBody').innerHTML = `
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
|
||||||
|
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
|
||||||
|
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
|
||||||
|
</div>`;
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.showModal === 'function') modal.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#applyCancel').addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('applyModal');
|
||||||
|
if (modal && typeof modal.close === 'function') modal.close();
|
||||||
|
});
|
||||||
|
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
|
||||||
|
|
||||||
|
// 已有会话则直接进入主界面
|
||||||
|
api('/api/status')
|
||||||
|
.then(() => { showApp(); switchView('overview'); })
|
||||||
|
.catch(() => showLogin());
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>idsync · 飞书通讯录同步</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
|
||||||
|
<!-- 未登录:登录卡片 -->
|
||||||
|
<div id="loginView" class="hidden items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="card w-full max-w-sm bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||||
|
<h1 class="card-title text-lg m-0">idsync</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
|
||||||
|
|
||||||
|
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
|
||||||
|
|
||||||
|
<label class="label" for="username"><span class="label-text">用户名</span></label>
|
||||||
|
<input id="username" type="text" class="input w-full mb-3" autocomplete="username" />
|
||||||
|
|
||||||
|
<label class="label" for="password"><span class="label-text">密码</span></label>
|
||||||
|
<input id="password" type="password" class="input w-full mb-5" autocomplete="current-password" />
|
||||||
|
|
||||||
|
<button id="loginBtn" class="btn btn-primary w-full">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已登录:主界面 -->
|
||||||
|
<div id="appView" class="hidden">
|
||||||
|
<header class="navbar bg-base-100 shadow-sm px-4 sticky top-0 z-20">
|
||||||
|
<div class="flex-1 flex items-center gap-2">
|
||||||
|
<span class="inline-block size-3 rounded-full bg-primary"></span>
|
||||||
|
<span class="text-lg font-semibold">idsync</span>
|
||||||
|
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none flex items-center gap-2">
|
||||||
|
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
|
||||||
|
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto p-4">
|
||||||
|
<!-- 页签 -->
|
||||||
|
<div class="tabs tabs-border mb-4" role="tablist">
|
||||||
|
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
|
||||||
|
<button class="tab" data-view="users" role="tab">用户</button>
|
||||||
|
<button class="tab" data-view="groups" role="tab">部门分组</button>
|
||||||
|
<button class="tab" data-view="plan" role="tab">变更预览</button>
|
||||||
|
<button class="tab" data-view="runs" role="tab">同步历史</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="globalError" class="alert alert-error text-sm mb-4 hidden"></div>
|
||||||
|
|
||||||
|
<!-- 概览 -->
|
||||||
|
<section id="view-overview" class="view">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">在职账号</span>
|
||||||
|
<span id="statActive" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">离职账号</span>
|
||||||
|
<span id="statInactive" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-sm text-base-content/60">部门分组</span>
|
||||||
|
<span id="statGroups" class="text-3xl font-semibold">–</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">服务状态</h2>
|
||||||
|
<div id="connStatus" class="space-y-2 text-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base">上次同步</h2>
|
||||||
|
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 用户 -->
|
||||||
|
<section id="view-users" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||||
|
<h2 class="card-title text-base">受管用户</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="userFilter" type="search" class="input input-sm w-56" placeholder="按 uid / 姓名 / 邮箱过滤" />
|
||||||
|
<button id="reloadUsers" class="btn btn-sm btn-outline">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>uid</th><th>姓名</th><th>企业邮箱</th><th>状态</th>
|
||||||
|
<th>归属部门数</th><th>最后同步</th><th>一致性</th><th>DN</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="userRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="userEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管用户,请先执行一次同步。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 部门分组 -->
|
||||||
|
<section id="view-groups" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base mb-3">受管部门分组</h2>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th>部门</th><th>飞书部门 ID</th><th>父部门</th><th>DN</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groupRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="groupEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管分组。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 变更预览 -->
|
||||||
|
<section id="view-plan" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-base">变更预览(dry-run)</h2>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
|
预览与实际执行使用同一份计划代码,因此这里显示的就是将要发生的变更。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
|
||||||
|
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="planWarnings" class="hidden mb-4"></div>
|
||||||
|
|
||||||
|
<div id="planSummary" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 mb-4"></div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-base mb-2">操作明细</h3>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th class="w-40">类型</th><th class="w-56">对象</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="planRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="planEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">
|
||||||
|
没有需要变更的内容,两侧状态已一致。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 同步历史 -->
|
||||||
|
<section id="view-runs" class="view hidden">
|
||||||
|
<div class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="card-title text-base">同步历史</h2>
|
||||||
|
<button id="reloadRuns" class="btn btn-sm btn-outline">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="idsync-scroll">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>开始时间</th><th>模式</th><th>触发</th><th>结果</th>
|
||||||
|
<th>新建</th><th>更新</th><th>迁移</th><th>加组</th><th>移组</th><th>失败</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="runRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="runEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无同步记录。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用变更的确认对话框 -->
|
||||||
|
<dialog id="applyModal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
|
||||||
|
</p>
|
||||||
|
<div id="applyModalBody" class="text-sm mb-4"></div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button id="applyCancel" class="btn btn-ghost">取消</button>
|
||||||
|
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/flyonui.js"></script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* FlyonUI 组件与主题。
|
||||||
|
*
|
||||||
|
* 必须走构建期插件(官方不支持 CDN 方式):FlyonUI 的样式在编译时生成,
|
||||||
|
* npm 包里那份预编译 CSS 不含 flex/grid/spacing 等布局工具类,
|
||||||
|
* 而 Tailwind 的浏览器版明确拒绝加载插件。因此这里用 Tailwind CLI 产出完整样式表,
|
||||||
|
* 构建产物再被 go:embed 打进二进制,部署时无需 Node。 */
|
||||||
|
@plugin "flyonui";
|
||||||
|
|
||||||
|
/* 让 Tailwind 扫描页面与脚本,只保留实际用到的类。 */
|
||||||
|
@source "../src/*.html";
|
||||||
|
@source "../src/*.js";
|
||||||
|
@source "../node_modules/flyonui/dist/index.js";
|
||||||
|
|
||||||
|
/* 自定义主题:白/暗底色 + Gitea 绿主色。
|
||||||
|
*
|
||||||
|
* 命名为 light / dark 是刻意的——主题插件会把自定义 token 与同名的内置主题
|
||||||
|
* 合并,因此这里只需覆盖主色,其余语义色沿用 FlyonUI 的默认值。
|
||||||
|
* 主色取 Gitea logo 绿 #609926(注意 Gitea 自身的 --color-primary 是蓝色,
|
||||||
|
* 它的品牌绿其实是 logo 色),暗色下用 #7ab63a 以保证对比度。 */
|
||||||
|
@plugin "flyonui/theme" {
|
||||||
|
name: "light";
|
||||||
|
default: true;
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--color-primary: #609926;
|
||||||
|
--color-primary-content: #ffffff;
|
||||||
|
|
||||||
|
--color-success: #21ba45;
|
||||||
|
--color-success-content: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "flyonui/theme" {
|
||||||
|
name: "dark";
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--color-primary: #7ab63a;
|
||||||
|
--color-primary-content: #101d05;
|
||||||
|
|
||||||
|
--color-success: #46de6a;
|
||||||
|
--color-success-content: #071505;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 少量自定义样式:表格可横向滚动,长 DN 不撑破布局。 */
|
||||||
|
@layer components {
|
||||||
|
.idsync-dn {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.idsync-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user