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:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+248
View File
@@ -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)
}
+210
View File
@@ -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("缺少必要配置项时应加载失败")
}
})
}
}
+147
View File
@@ -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
})
}
+45
View File
@@ -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
}
+451
View File
@@ -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
}
+135
View File
@@ -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
}
+377
View File
@@ -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)
}
+342
View File
@@ -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
}
+184
View File
@@ -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
}
+354
View File
@@ -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 ""
}
+341
View File
@@ -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
}
+80
View File
@@ -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
}
+90
View File
@@ -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))
}
}
+188
View File
@@ -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
}
+255
View File
@@ -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
}
+326
View File
@@ -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:]
}
+153
View File
@@ -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
}
+106
View File
@@ -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 }
+624
View File
@@ -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()
}
+274
View File
@@ -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
}
+524
View File
@@ -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))
}
+435
View File
@@ -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()
}
+364
View File
@@ -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
}
+2
View File
File diff suppressed because one or more lines are too long
+423
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------- 启动
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());
});
+206
View File
File diff suppressed because one or more lines are too long
+229
View File
@@ -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>
+130
View File
@@ -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
}