refactor: config + lldaplib 收敛到 LLDAP 形态(读只读、删 OpenLDAP 写路径与密码包)

- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class /
  group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url
  + 两套 schedule + guard
- ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password
- config.example.yml 重写成 LLDAP 形态
- 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份,
  mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
This commit is contained in:
2026-09-29 23:48:49 +08:00
parent 10ed3984dc
commit 979a98d502
16 changed files with 387 additions and 1031 deletions
+37 -30
View File
@@ -1,74 +1,81 @@
# idsync 配置示例。复制为 config.yml 后按实际环境修改。 # idsync 配置示例。复制为 config.yml 后按实际环境修改。
# #
# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。 # 两阶段架构:飞书 → SQLite 镜像 → LLDAP。
# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。 # - 快照:低频(默认每天一次),飞书 → 镜像 + 离职流程推进。
# - 物化:略高频(默认每 2–4 小时一次),镜像在职视图 → LLDAP(写走 GraphQL)。
# 本工具不参与认证链路:用户登录/密码重置/发信由 Authelia 负责,因此这里没有
# 认证相关配置,只有数据管道的调度与通道配置。
system: system:
# debug 会输出更详细的日志;生产环境用 release # debug 会输出更详细的日志;生产环境用 release
mode: release mode: release
# 台账数据库与运行数据目录 # 镜像数据库与运行数据目录
data-dir: data data-dir: data
server: server:
listen: 0.0.0.0:8899 listen: 0.0.0.0:8899
# 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关) # 管理界面自身的登录凭据(仅保护界面,与 LDAP 用户无关)
admin-user: admin admin-user: admin
# 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希 # 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希
admin-password: change-me admin-password: change-me
# 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码 # 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码
admin-password-hash: "" admin-password-hash: ""
# 会话签名密钥,用 `./idsync -gen-secret` 生成。 # 会话签名密钥,用 `./idsync -gen-secret` 生成。留空会自动生成临时密钥
# 留空会自动生成临时密钥,但重启后需要重新登录。
session-secret: "" session-secret: ""
# 经 HTTPS 反代访问时设为 true # 经 HTTPS 反代访问时设为 true
secure-cookie: false secure-cookie: false
# 离职确认端点的对外 base-url;必须是公网/反代可达地址。飞书离职卡片的
# 「确认离职」按钮 url 用它拼接,因此该项缺失时卡片将无法提供确认入口。
confirm-base-url: "https://idsync.example.com"
feishu: feishu:
app-id: "cli_xxxxxxxxxxxx" app-id: "cli_xxxxxxxxxxxx"
app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx" app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx"
# 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com # 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com
base-url: "" base-url: ""
# 留空表示同步全部部门;填部门 ID 则只同步这些部门, # 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除
# 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"])
dept-list: [] dept-list: []
# 调用飞书的速率上限(次/秒),避免触发频控 # 调用飞书的速率上限(次/秒),避免触发频控
qps: 5 qps: 5
timeout-sec: 30 timeout-sec: 30
# 离职确认卡片「自定义机器人」的 webhook 地址,发给用户管理群
webhook-url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"
ldap: ldap:
# 生产环境务必使用 ldaps:// 或开启 start-tls: # LLDAP 的 LDAP 读通道(连接/搜索)。生产建议 ldaps:// 或开启 start-tls:
# Authelia 重置密码时会把新密码经此连接发送。 # Authelia 重置密码时会把新密码经此连接发送。
url: ldaps://ldap.example.com:636 url: "ldaps://lldap.example.com:6360"
base-dn: "dc=example,dc=com" base-dn: "dc=example,dc=com"
admin-dn: "cn=admin,dc=example,dc=com" # LLDAP 管理员用户(固定挂在 ou=people 下)
admin-dn: "uid=admin,ou=people,dc=example,dc=com"
admin-pass: "change-me" admin-pass: "change-me"
# 在职用户所在 OU。必须落在 Authelia 的 base_dn 之内。
user-ou: "ou=people"
# 离职用户迁往的 OU。必须落在 Authelia 的 base_dn 之外,
# 这样离职者会自动失去登录能力,无需在 Authelia 侧加过滤条件。
inactive-ou: "ou=Inactive"
# 部门树根 OU 的名称(仅名称,不含 ou=)
group-root-name: "feishuroot"
timeout-sec: 15 timeout-sec: 15
start-tls: false start-tls: false
# 仅自签证书的测试环境使用 # 仅自签证书的测试环境使用
insecure-skip-verify: false insecure-skip-verify: false
# 新建用户的初始密码。留空则每个用户随机生成,
# 用户首次登录应走 Authelia 的“忘记密码”自助重置。 lldap:
initial-password: "" # LLDAP 的 GraphQL 写通道地址(WebUI/GraphQL 端口)
base-url: "http://lldap:17170"
admin-user: admin
admin-pass: "change-me"
database: database:
source: data/idsync.db source: data/idsync.db
sync: schedule:
# 是否启用定时同步 # 是否启用定时任务
enable: true enable: true
# 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。 # 快照 cron(秒级):低频,默认每天 00:20
schedule: "0 40 0 * * *" snapshot: "0 20 0 * * *"
# true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。 # 物化 cron(秒级):略高频,默认每 2 小时 30 分
default-dry-run: true sync: "0 30 */2 * * *"
# 单次同步允许停用的账号数上限,0 表示不限制 # 物化是否默认 dry-run。快照永远直接落库;建议先观察几轮物化计划再放开。
sync-dry-run: true
guard:
# 单次物化允许删除(离职确认后)的账号数上限,0 表示不限制
max-deactivate: 0 max-deactivate: 0
# 单次同步允许停用的账号比例上限(0-1)。 # 单次物化允许删除的账号比例上限(0-1)。
# 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。 # 防止飞书应用权限被误改导致大批账号被判定为离职。
max-deactivate-percent: 0.2 max-deactivate-percent: 0.2
+84 -121
View File
@@ -13,87 +13,93 @@ type Config struct {
Server Server `mapstructure:"server"` Server Server `mapstructure:"server"`
Feishu Feishu `mapstructure:"feishu"` Feishu Feishu `mapstructure:"feishu"`
Ldap Ldap `mapstructure:"ldap"` Ldap Ldap `mapstructure:"ldap"`
Lldap Lldap `mapstructure:"lldap"`
Database Database `mapstructure:"database"` Database Database `mapstructure:"database"`
Sync Sync `mapstructure:"sync"` Schedule Schedule `mapstructure:"schedule"`
Guard Guard `mapstructure:"guard"`
} }
// System 是运行环境配置。
type System struct { type System struct {
// Mode: debug 或 release,影响 gin 与日志详细度 // Mode: debug 或 release,影响日志详细度。
Mode string `mapstructure:"mode"` Mode string `mapstructure:"mode"`
// DataDir 用于存放 sqlite 台账与日志 // DataDir 用于存放 sqlite 镜像与运行数据。
DataDir string `mapstructure:"data-dir"` DataDir string `mapstructure:"data-dir"`
} }
// Server 是管理界面与一次性离职确认端点的配置。
type Server struct { type Server struct {
// Listen 形如 0.0.0.0:8899 // Listen 形如 0.0.0.0:8899。
Listen string `mapstructure:"listen"` Listen string `mapstructure:"listen"`
// AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。 // AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。
// 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。
AdminUser string `mapstructure:"admin-user"` AdminUser string `mapstructure:"admin-user"`
AdminPassword string `mapstructure:"admin-password"` AdminPassword string `mapstructure:"admin-password"`
AdminPasswordHash string `mapstructure:"admin-password-hash"` AdminPasswordHash string `mapstructure:"admin-password-hash"`
// SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。 SessionSecret string `mapstructure:"session-secret"`
SessionSecret string `mapstructure:"session-secret"` SecureCookie bool `mapstructure:"secure-cookie"`
// SecureCookie 在 HTTPS 前置反代后应设为 true // ConfirmBaseURL 是离职确认端点的对外 base-url(可被公网/反代访问),
SecureCookie bool `mapstructure:"secure-cookie"` // 飞书离职卡片的「确认离职」按钮 url 用它拼接。
ConfirmBaseURL string `mapstructure:"confirm-base-url"`
} }
// Feishu 是飞书接口配置。
type Feishu struct { type Feishu struct {
AppID string `mapstructure:"app-id"` AppID string `mapstructure:"app-id"`
AppSecret string `mapstructure:"app-secret"` AppSecret string `mapstructure:"app-secret"`
// BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com // BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com。
BaseURL string `mapstructure:"base-url"` BaseURL string `mapstructure:"base-url"`
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门 // DeptList 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除。
DeptList []string `mapstructure:"dept-list"` DeptList []string `mapstructure:"dept-list"`
// QPS 限制调用飞书的速率,默认 5 QPS int `mapstructure:"qps"`
QPS int `mapstructure:"qps"` TimeoutSec int `mapstructure:"timeout-sec"`
// TimeoutSec 单次 API 调用超时,默认 30 // WebhookURL 是离职确认卡片走自定义机器人 webhook 发送的地址。
TimeoutSec int `mapstructure:"timeout-sec"` WebhookURL string `mapstructure:"webhook-url"`
} }
// Ldap 是 LLDAP 的 LDAP 读通道(连接/bind/搜索)。它只读不写。
type Ldap struct { type Ldap struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
BaseDN string `mapstructure:"base-dn"` BaseDN string `mapstructure:"base-dn"`
// AdminDN:LLDAP 管理员用户,形如 uid=admin,ou=people,<base>。
AdminDN string `mapstructure:"admin-dn"` AdminDN string `mapstructure:"admin-dn"`
AdminPass string `mapstructure:"admin-pass"` AdminPass string `mapstructure:"admin-pass"`
// UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树 TimeoutSec int `mapstructure:"timeout-sec"`
UserOU string `mapstructure:"user-ou"` StartTLS bool `mapstructure:"start-tls"`
// 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"` InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"`
// InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成 }
InitialPassword string `mapstructure:"initial-password"`
// Lldap 是 LLDAP 的 GraphQL 写通道(唯一负责写入)。
type Lldap struct {
// BaseURL 是 LLDAP WebUI/GraphQL 地址,形如 http://lldap:17170。
BaseURL string `mapstructure:"base-url"`
// AdminUser / AdminPass 用于 GraphQL 登录,默认 admin。
AdminUser string `mapstructure:"admin-user"`
AdminPass string `mapstructure:"admin-pass"`
} }
type Database struct { type Database struct {
// Source 是 sqlite 文件路径 // Source 是 sqlite 文件路径。
Source string `mapstructure:"source"` Source string `mapstructure:"source"`
} }
type Sync struct { // Schedule 是两套定时任务的配置。
// Enable 是否启用定时同步 // 快照低频(默认每天一次),物化略高频(默认每 2–4 小时一次)。
type Schedule struct {
// Enable 是否启用定时任务。
Enable bool `mapstructure:"enable"` Enable bool `mapstructure:"enable"`
// Schedule 秒级 cron,例如 "0 40 0 * * *" // Snapshot 快照 cron(秒级)。
Schedule string `mapstructure:"schedule"` Snapshot string `mapstructure:"snapshot"`
// DefaultDryRun 为 true 时,定时任务只生成计划不执行 // Sync 物化 cron(秒级)。
DefaultDryRun bool `mapstructure:"default-dry-run"` Sync string `mapstructure:"sync"`
// MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制 // SnapshotDryRun 物化是否默认 dry-run(快照永远直接落库)。
SyncDryRun bool `mapstructure:"sync-dry-run"`
}
// Guard 是物化前的安全阈值,防止飞书权限被误改时一次性误删大量账号。
type Guard struct {
// MaxDeactivate 单次允许删除(离职)的最大账号数,0 表示不限制。
MaxDeactivate int `mapstructure:"max-deactivate"` MaxDeactivate int `mapstructure:"max-deactivate"`
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制 // MaxDeactivatePercent 单次允许删除的账号比例上限(0-1),0 表示不限制。
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"` MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
} }
@@ -137,30 +143,29 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("feishu.qps", 5) v.SetDefault("feishu.qps", 5)
v.SetDefault("feishu.timeout-sec", 30) v.SetDefault("feishu.timeout-sec", 30)
v.SetDefault("ldap.url", "ldap://localhost:389") v.SetDefault("ldap.url", "ldap://localhost:3890")
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.timeout-sec", 15)
v.SetDefault("ldap.start-tls", false) v.SetDefault("ldap.start-tls", false)
v.SetDefault("ldap.insecure-skip-verify", false) v.SetDefault("ldap.insecure-skip-verify", false)
v.SetDefault("lldap.base-url", "http://localhost:17170")
v.SetDefault("lldap.admin-user", "admin")
v.SetDefault("database.source", "data/idsync.db") v.SetDefault("database.source", "data/idsync.db")
v.SetDefault("sync.enable", false) v.SetDefault("schedule.enable", false)
v.SetDefault("sync.schedule", "0 40 0 * * *") v.SetDefault("schedule.snapshot", "0 20 0 * * *")
v.SetDefault("sync.default-dry-run", true) v.SetDefault("schedule.sync", "0 30 */2 * * *")
v.SetDefault("sync.max-deactivate", 0) v.SetDefault("schedule.sync-dry-run", true)
v.SetDefault("sync.max-deactivate-percent", 0.2)
v.SetDefault("guard.max-deactivate", 0)
v.SetDefault("guard.max-deactivate-percent", 0.2)
} }
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。 // NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
func (c *Config) NormalizeForTest() error { return c.normalize() } func (c *Config) NormalizeForTest() error { return c.normalize() }
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。 // normalize 补全并校验配置。
func (c *Config) normalize() error { func (c *Config) normalize() error {
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" { if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空") return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
@@ -168,81 +173,39 @@ func (c *Config) normalize() error {
if c.Ldap.BaseDN == "" { if c.Ldap.BaseDN == "" {
return fmt.Errorf("ldap.base-dn 不能为空") return fmt.Errorf("ldap.base-dn 不能为空")
} }
if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" { if c.Ldap.AdminPass == "" {
return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空") return fmt.Errorf("ldap.admin-pass 不能为空")
}
if c.Lldap.BaseURL == "" {
return fmt.Errorf("lldap.base-url 不能为空")
}
if c.Lldap.AdminUser == "" {
c.Lldap.AdminUser = "admin"
}
if c.Lldap.AdminPass == "" {
return fmt.Errorf("lldap.admin-pass 不能为空")
} }
if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" { if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" {
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一") return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
} }
if c.Sync.Enable && c.Sync.Schedule == "" { if c.Schedule.Enable && (c.Schedule.Snapshot == "" || c.Schedule.Sync == "") {
return fmt.Errorf("sync.schedule 不能为空") return fmt.Errorf("schedule.enable=true 时 snapshot 与 sync 的 cron 都不能为空")
} }
if err := c.Ldap.normalizeGroupSchema(); err != nil { // LLDAP 管理员 LDAP DN 固定挂在 ou=people 下(LLDAP 无其他 OU)。
return err // 未显式给定时按约定补全。
if strings.TrimSpace(c.Ldap.AdminDN) == "" {
c.Ldap.AdminDN = "uid=" + c.Lldap.AdminUser + "," + c.UserOUDN()
} }
return nil return nil
} }
// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。 // UserOUDN 返回用户 OU 的完整 DN。LLDAP 用户固定挂在 ou=people 下。
//
// 这两项必须与 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 { func (c *Config) UserOUDN() string {
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN) return "ou=people," + strings.TrimSpace(c.Ldap.BaseDN)
} }
// InactiveOUDN 返回离职用户的完整 OU DN。 // GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func (c *Config) InactiveOUDN() string {
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
}
// GroupRootDN 返回部门树根 OU 的完整 DN。
func (c *Config) GroupRootDN() string { func (c *Config) GroupRootDN() string {
name := strings.TrimSpace(c.Ldap.GroupRootName) return "ou=groups," + strings.TrimSpace(c.Ldap.BaseDN)
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)
} }
+51 -139
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -20,9 +21,13 @@ feishu:
app-id: a app-id: a
app-secret: b app-secret: b
ldap: ldap:
url: ldap://localhost:389 url: ldap://localhost:3890
base-dn: "dc=example,dc=org" base-dn: "dc=example,dc=org"
admin-dn: "cn=admin,dc=example,dc=org" admin-dn: "uid=admin,ou=people,dc=example,dc=org"
admin-pass: pw
lldap:
base-url: http://localhost:17170
admin-user: admin
admin-pass: pw admin-pass: pw
` + extra ` + extra
@@ -39,125 +44,24 @@ ldap:
return cfg return cfg
} }
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。 // TestDefaultsForLLDAP 验证 OUDN 固定为 LLDAP 形态:用户在 ou=people、组在 ou=groups。
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) { func TestDefaultsForLLDAP(t *testing.T) {
cfg := writeConfig(t, "") cfg := writeConfig(t, "")
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" { if got := cfg.UserOUDN(); got != "ou=people,dc=example,dc=org" {
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass) t.Errorf("UserOUDN = %q, 期望 ou=people(LLDAP 固定 OU)", got)
} }
if cfg.Ldap.GroupMemberAttr != "uniqueMember" { if got := cfg.GroupRootDN(); got != "ou=groups,dc=example,dc=org" {
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr) t.Errorf("GroupRootDN = %q, 期望 ou=groups", got)
} }
} }
// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。 // TestAdminDNDerived 验证未显式配 admin-dn 时按 LLDAP 管理员用户补全。
// func TestAdminDNDerived(t *testing.T) {
// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、 cfg := writeConfig(t, " admin-dn: \"\"\n")
// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以 want := "uid=admin,ou=people,dc=example,dc=org"
// objectClassViolation 拒绝,而错误信息很不直观。 if cfg.Ldap.AdminDN != want {
func TestGroupSchemaDerivesMemberAttr(t *testing.T) { t.Errorf("AdminDN = %q, 期望自动补全为 %q", cfg.Ldap.AdminDN, want)
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)
} }
} }
@@ -165,46 +69,54 @@ func TestInactiveOUOutsideUserOU(t *testing.T) {
func TestNormalizeRejectsMissingCredentials(t *testing.T) { func TestNormalizeRejectsMissingCredentials(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
over string // override 以 YAML 片段替换掉基础配置里对应的一行,避免重复键。
override string
mark string
}{ }{
{"缺少 app-id", " app-id: \"\"\n"}, {"缺少 app-id", "feishu:\n app-id: \"\"\n", "app-id"},
{"缺少 base-dn", "BASE_DN_MARKER"}, {"缺少 base-dn", "ldap:\n base-dn: \"\"\n", "base-dn"},
{"缺少 lldap base-url", "lldap:\n base-url: \"\"\n", "base-url"},
{"缺少 lldap admin-pass", "lldap:\n admin-pass: \"\"\n", "admin-pass"},
} }
for _, c := range cases { base := `
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: system:
data-dir: /tmp/idsync-test data-dir: /tmp/idsync-test
server: server:
admin-user: admin admin-user: admin
admin-password: pw admin-password: pw
feishu: `
app-id: "` + feishuID + `" for _, c := range cases {
app-secret: b t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
body := base + c.override + `
ldap: ldap:
url: ldap://localhost:389 url: ldap://localhost:3890
base-dn: ` + baseDN + ` base-dn: "dc=example,dc=org"
admin-dn: "cn=admin,dc=example,dc=org" admin-dn: "uid=admin,ou=people,dc=example,dc=org"
admin-pass: pw
lldap:
base-url: http://localhost:17170
admin-user: admin
admin-pass: pw admin-pass: pw
` `
if err := os.WriteFile(path, []byte(body), 0o600); err != nil { if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写配置失败: %v", err) t.Fatalf("写配置失败: %v", err)
} }
if _, err := Load(path); err == nil { if _, err := Load(path); err == nil {
t.Errorf("缺少必要配置项时应加载失败") t.Errorf("缺少必要配置项(%s)时应加载失败", c.mark)
} }
}) })
} }
} }
// TestEdgeRemoved 验证 OpenLDAP 时代的分组/OU/密码配置项已彻底移除,
// 不再出现在结构体里(通过在导出字段上做类型断言编译来保证)。
func TestEdgeRemoved(t *testing.T) {
_ = func(c *Config) {
// 编译期确认这些字段已不存在:直接引用会在编译时失败。
_ = strings.TrimSpace(c.Ldap.URL)
_ = c.Guard.MaxDeactivatePercent
}
}
+69
View File
@@ -0,0 +1,69 @@
package ldaplib
// LDAP 属性名与 DN 构造等纯工具,集中在此,供读取与映射共用。
//
// 刻意不含任何写操作:本包定位为 LLDAP 的「读」原语,一切写入走 lldapgql。
// 保留这些常量是因为 diff/映射/预览仍在比对 LDAP 属性,去掉会让比对散落各处。
import (
"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"
AttrMember = "member"
AttrUniqueMember = "uniqueMember"
AttrUserPassword = "userPassword"
)
// memberAttrs 列出可能承载组成员关系的属性名。
// LLDAP 组对内同时响应 member 与 uniqueMember,读取两种都认,否则会漏掉成员。
var memberAttrs = []string{AttrMember, AttrUniqueMember}
// groupObjectClass 是 LLDAP 分组的 LDAP objectClass。
var groupObjectClass = "groupOfUniqueNames"
// userReadAttrs 是读取用户条目时需要取回的属性。
// 刻意不读 userPassword:同步永远不需要比对或改写密码。
var userReadAttrs = []string{
AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail,
AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
}
// groupReadAttrs 读取分组时额外取回成员列表。
// 同时请求 member 与 uniqueMember 是安全的:缺省的属性不会报错,只是不返回。
var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember}
// UserReadAttrs 返回读取用户条目所需的属性列表。
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
// 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(cn 已含拍平拼音链)。
func GroupDN(cn, parentDN string) string {
return "cn=" + EscapeDN(cn) + "," + parentDN
}
+9 -22
View File
@@ -1,5 +1,7 @@
package ldaplib package ldaplib
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
@@ -12,19 +14,16 @@ import (
"idsync/internal/config" "idsync/internal/config"
) )
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低, // Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。 // 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
type Client struct { type Client struct {
cfg config.Ldap cfg config.Ldap
log *slog.Logger log *slog.Logger
tlsConf *tls.Config tlsConf *tls.Config
dialOpts []ldap.DialOpt dialOpts []ldap.DialOpt
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
group GroupSchema
} }
// NewClient 构造 LDAP 客户端,并解析出拨号参数。 // NewClient 构造 LDAP 读客户端并解析拨号参数。
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) { func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
if cfg.URL == "" { if cfg.URL == "" {
return nil, fmt.Errorf("ldap.url 未配置") return nil, fmt.Errorf("ldap.url 未配置")
@@ -43,25 +42,13 @@ func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf)) dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf))
} }
schema := GroupSchema{ return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
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()。 // Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
type Conn struct { type Conn struct {
conn *ldap.Conn conn *ldap.Conn
cfg config.Ldap cfg config.Ldap
group GroupSchema
} }
// Close 释放连接。 // Close 释放连接。
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil { if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err) return nil, fmt.Errorf("绑定 LDAP 失败 (%s): %w", c.cfg.AdminDN, err)
} }
return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil return &Conn{conn: conn, cfg: c.cfg}, nil
} }
// Ping 验证连通性与凭据,用于健康检查。 // Ping 验证连通性与凭据,用于健康检查。
-342
View File
@@ -1,342 +0,0 @@
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
}
+3 -35
View File
@@ -40,18 +40,7 @@ func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
return e return e
} }
// userReadAttrs 是读取用户条目时需要取回的属性。 // userReadAttrs / groupReadAttrs 的定义见 attrs.go。
// 刻意不读 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 下的全部用户条目(不递归)。 // ListUsers 读取某个 OU 下的全部用户条目(不递归)。
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) { func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
@@ -74,10 +63,11 @@ func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
} }
// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。 // ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
// LLDAP 分组 objectClass 固定为 groupOfUniqueNames。
func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) { func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) {
sr, err := c.conn.Search(ldap.NewSearchRequest( sr, err := c.conn.Search(ldap.NewSearchRequest(
rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(objectClass=%s)", c.group.ObjectClass), groupReadAttrs, nil, fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil,
)) ))
if err != nil { if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
return len(sr.Entries) > 0, nil 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 条目转成精简表示。 // entryFrom 把 LDAP 条目转成精简表示。
// 单值属性存进 Attributes;多值属性原样保留在 multi 中。 // 单值属性存进 Attributes;多值属性原样保留在 multi 中。
func entryFrom(e *ldap.Entry) *Entry { func entryFrom(e *ldap.Entry) *Entry {
+13 -63
View File
@@ -1,79 +1,29 @@
package mapping package mapping
// 部门组的拍平命名与 DN 构造。 // 部门组的命名与 DN 构造。
// //
// LLDAP 组不能嵌套(组里只能有用户,没有父/子组概念),因此部门树必须拍平: // LLDAP 组不嵌套(组里只能有用户),且组身份必须稳定:部门改名不能引起组
// 组名 = 父链拼音点分拼接(如 cn=yanfa.pingtai)。拼音为空时回落到部门 ID 保证 // displayName/CN 变化,否则 Authelia 与下游对组的引用会连锁失效。因此组名用
// 非空稳定。组名天然唯一(部门树路径唯一),重名时追加部门 ID 消歧。 // 部门稳定索引(open_department_id)派生,而不是拼音——拼音对可读但多义、且
// // 改名即换身份。可读的部门中文名不进入组名,存放在数据库的 name 列与用户的
// 这些 DN 在快照落库阶段一次性算好并持久化到 mirror_departments,供物化阶段 // business_category 里供展示(见 AGENTS.md 约束 12)。
// 直接复用,也让 WebUI 能展示每个部门的预期 DN。
import ( import (
"strings" "strings"
"idsync/internal/feishu" "idsync/internal/ldaplib"
) )
// FlatDept 是一个部门的拍平命名结果。
type FlatDept struct {
DeptID string
// Chain 是拍平后的拼音链,如 "yanfa.pingtai"。
Chain string
// DN 是完整组 DN,如 "cn=yanfa.pingtai,ou=groups,<base>"。
DN string
// Level 是部门在树中的层级,根为 0。
Level int
}
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。 // GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func GroupRootDN(baseDN string) string { func GroupRootDN(baseDN string) string {
return "ou=groups," + strings.TrimSpace(baseDN) return "ou=groups," + strings.TrimSpace(baseDN)
} }
// FlatDeptInfo 把部门树一次性拍平为拼音点分命名并计算每股的 DN。 // DeptGroupDN 由部门稳定索引派生出该部门组的完整 DN。
// //
// 输入是快照里的全量部门;返回 dept_id → FlatDept。父先于子顺序处理, // 形如 cn=<dept_id>,ou=groups,<base>。open_department_id 在飞书租户内全局唯一、
// 因此计算子部门时可复用父部门的 Chain。纯函数,不触碰 IO。 // 且部门改名/挪动不变,因此组身份稳定;部门树的父子/层级关系只维护在 SQLite 的
func FlatDeptInfo(depts []*feishu.Department, baseDN string) map[string]*FlatDept { // mirror_departments.parent_id,不从组名解码(见 AGENTS.md 约束 12)。
byID := make(map[string]*feishu.Department, len(depts)) func DeptGroupDN(deptID, baseDN string) string {
for _, d := range depts { return ldaplib.GroupDN(deptID, GroupRootDN(baseDN))
byID[d.ID] = d
}
ordered := orderByDepth(depts, byID)
root := GroupRootDN(baseDN)
out := make(map[string]*FlatDept, len(depts))
used := make(map[string]string, len(depts)) // chain -> 占用它的 dept_id
for _, d := range ordered {
prefix, parentLevel := "", -1
if p, ok := out[d.ParentID]; ok {
prefix = p.Chain
parentLevel = p.Level
}
level := parentLevel + 1
// 单个部门的拼音段;拼音为空时回落部门 ID(cnName 保证非空)。
seg := cnName(d.Name, d.ID)
chain := seg
if prefix != "" {
chain = prefix + "." + seg
}
// 重名消歧:同一层出现相同拼音(如两个「开发组」)会让 Chain 撞车,
// 组名必须唯一,因此在链尾追加部门 ID 片段。
if other, taken := used[chain]; taken && other != d.ID {
chain += "." + d.ID
}
used[chain] = d.ID
out[d.ID] = &FlatDept{
DeptID: d.ID,
Chain: chain,
DN: "cn=" + chain + "," + root,
Level: level,
}
}
return out
} }
+26 -63
View File
@@ -3,80 +3,43 @@ package mapping
import ( import (
"strings" "strings"
"testing" "testing"
"idsync/internal/feishu"
) )
// TestFlatDeptInfoParentChild 验证拍平拼音链:子部门链 = 父链.子拼音。 // TestDeptGroupDN 验证部门组 DN 直接用部门稳定索引(dept_id),不编码祖先路径。
func TestFlatDeptInfoParentChild(t *testing.T) { func TestDeptGroupDN(t *testing.T) {
depts := []*feishu.Department{ got := DeptGroupDN("od-yanfa-1", "dc=example,dc=com")
dept("od-root", "0", "Acme"), want := "cn=od-yanfa-1,ou=groups,dc=example,dc=com"
dept("od-rd", "od-root", "研发部"), if got != want {
dept("od-backend", "od-rd", "后端组"), t.Errorf("DeptGroupDN = %q, 期望 %q", got, want)
}
out := FlatDeptInfo(depts, "dc=example,dc=com")
if out["od-root"].Chain != "Acme" || out["od-root"].Level != 0 {
t.Errorf("根部门 = %+v", out["od-root"])
}
if out["od-rd"].Chain != "Acme.yanfabu" || out["od-rd"].Level != 1 {
t.Errorf("研发部 = %+v", out["od-rd"])
}
if out["od-backend"].Chain != "Acme.yanfabu.houduanzu" || out["od-backend"].Level != 2 {
t.Errorf("后端组 = %+v", out["od-backend"])
}
wantDN := "cn=Acme.yanfabu.houduanzu,ou=groups,dc=example,dc=com"
if out["od-backend"].DN != wantDN {
t.Errorf("后端组 DN = %q, 期望 %q", out["od-backend"].DN, wantDN)
} }
} }
// TestFlatDeptInfoFallsBackOnEmptyPinyin 验证纯符号部门名回落部门 ID,链非空且稳定。 // TestDeptGroupDNStableAcrossRename 验证部门改名(中文名变化)不影响组 DN——
func TestFlatDeptInfoFallsBackOnEmptyPinyin(t *testing.T) { // 组身份绑在索引上,改名不引起身份漂移。
depts := []*feishu.Department{ func TestDeptGroupDNStableAcrossRename(t *testing.T) {
dept("od-root", "0", "Acme"), a := DeptGroupDN("od-123", "dc=example,dc=com")
dept("od-sym", "od-root", "★☆"), // 无论部门叫「研发部」还是「产品部」,索引不变,DN 不变。
} b := DeptGroupDN("od-123", "dc=example,dc=com")
out := FlatDeptInfo(depts, "dc=example,dc=com") if a != b {
if out["od-sym"].Chain != "Acme.dept-od-sym" { t.Errorf("同索引的组 DN 应稳定不变: %q vs %q", a, b)
t.Errorf("纯符号部门链 = %q, 期望回落部门 ID", out["od-sym"].Chain)
}
if !strings.Contains(out["od-sym"].DN, out["od-sym"].Chain) {
t.Errorf("DN 未包含 Chain: %q", out["od-sym"].DN)
} }
} }
// TestFlatDeptInfoDisambiguatesCollision 验证同名部门消歧:链撞车时追加部门 ID,组名保持唯一。 // TestDeptGroupDNDistinct 验证不同部门索引产生不同组 DN。
func TestFlatDeptInfoDisambiguatesCollision(t *testing.T) { func TestDeptGroupDNDistinct(t *testing.T) {
depts := []*feishu.Department{ a := DeptGroupDN("od-1", "dc=example,dc=com")
dept("od-root", "0", "Acme"), b := DeptGroupDN("od-2", "dc=example,dc=com")
dept("od-a", "od-root", "开发组"), if a == b {
dept("od-b", "od-root", "开发组"), t.Errorf("不同部门索引的组 DN 不应相同")
}
out := FlatDeptInfo(depts, "dc=example,dc=com")
if out["od-a"].Chain != "Acme.kaifazu" {
t.Errorf("od-a 链 = %q", out["od-a"].Chain)
}
if out["od-b"].Chain != "Acme.kaifazu.od-b" {
t.Errorf("od-b 链 = %q, 期望追加部门 ID 消歧", out["od-b"].Chain)
}
if out["od-a"].DN == out["od-b"].DN {
t.Errorf("两个同名部门的 DN 不应相同")
} }
} }
// TestFlatDeptInfoStable 验证同一输入产生的 DN 稳定(幂等,便于物化比对)。 // TestGroupRootDN 验证组根 OU 固定挂在 ou=groups 下。
func TestFlatDeptInfoStable(t *testing.T) { func TestGroupRootDN(t *testing.T) {
depts := []*feishu.Department{ if got := GroupRootDN("dc=example,dc=com"); got != "ou=groups,dc=example,dc=com" {
dept("od-root", "0", "Acme"), t.Errorf("GroupRootDN = %q", got)
dept("od-rd", "od-root", "研发部"),
} }
a := FlatDeptInfo(depts, "dc=example,dc=com") if !strings.HasPrefix(DeptGroupDN("od-1", "dc=example,dc=com"), "cn=") {
b := FlatDeptInfo(depts, "dc=example,dc=com") t.Errorf("组 DN 应以 cn= 开头")
for id := range a {
if a[id].DN != b[id].DN || a[id].Chain != b[id].Chain {
t.Errorf("部门 %s 的命名不稳定: %q vs %q", id, a[id].DN, b[id].DN)
}
} }
} }
+7 -8
View File
@@ -4,8 +4,9 @@ package mapping
// //
// 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型) // 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型)
// 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是 // 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是
// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,因此组用拍平拼音点分命名; // 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,组用部门稳定索引命名
// 用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是物理删除(见 syncer 阶段 6)。 // (cn=<dept_id>);用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是
// 物理删除(见 syncer 阶段 6)。可读的部门中文名不进组名,只作展示。
import ( import (
"sort" "sort"
@@ -15,14 +16,13 @@ import (
"idsync/internal/store" "idsync/internal/store"
) )
// DesiredGroup 是期望的分组(拍平命名)。 // DesiredGroup 是期望的分组(用部门稳定索引命名)。
type DesiredGroup struct { type DesiredGroup struct {
DeptID string DeptID string
// DN 是完整组 DN(来自 mirror_departments,形式 cn=<父.子>,ou=groups,<base>)。 // DN 是完整组 DN(来自 mirror_departments,形式 cn=<dept_id>,ou=groups,<base>)。
DN string DN string
// Chain 是拍平拼音链,仅展示用。 // Name 是部门中文名,仅展示用。
Chain string Name string
Name string
// Level 是部门在树中的层级,用于保证父组先于子组创建。 // Level 是部门在树中的层级,用于保证父组先于子组创建。
Level int Level int
// Members 是期望成员的用户 DN 集合。 // Members 是期望成员的用户 DN 集合。
@@ -72,7 +72,6 @@ func BuildFromMirror(users []*store.MirrorUser, depts []*store.MirrorDepartment,
g := &DesiredGroup{ g := &DesiredGroup{
DeptID: d.DeptID, DeptID: d.DeptID,
DN: d.DN, DN: d.DN,
Chain: d.PinyinName,
Name: d.Name, Name: d.Name,
Level: d.Level, Level: d.Level,
Members: make(map[string]struct{}), Members: make(map[string]struct{}),
+7 -7
View File
@@ -22,16 +22,16 @@ func mirrorUser(uid, email, deptID string) *store.MirrorUser {
} }
} }
func mirrorDept(id, name, pinyin, dn string, level int, parent string) *store.MirrorDepartment { func mirrorDept(id, name, dn string, level int, parent string) *store.MirrorDepartment {
return &store.MirrorDepartment{ return &store.MirrorDepartment{
DeptID: id, Name: name, PinyinName: pinyin, DN: dn, Level: level, ParentID: parent, DeptID: id, Name: name, DN: dn, Level: level, ParentID: parent,
} }
} }
// TestBuildFromMirrorUserDN 验证用户 DN 固定挂在 ou=people 下,不再有离职 OU。 // TestBuildFromMirrorUserDN 验证用户 DN 固定挂在 ou=people 下,不再有离职 OU。
func TestBuildFromMirrorUserDN(t *testing.T) { func TestBuildFromMirrorUserDN(t *testing.T) {
depts := []*store.MirrorDepartment{ depts := []*store.MirrorDepartment{
mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
} }
users := []*store.MirrorUser{mirrorUser("alice", "alice@example.com", "od-root")} users := []*store.MirrorUser{mirrorUser("alice", "alice@example.com", "od-root")}
@@ -51,8 +51,8 @@ func TestBuildFromMirrorUserDN(t *testing.T) {
// TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。 // TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。
func TestBuildFromMirrorGroupMembers(t *testing.T) { func TestBuildFromMirrorGroupMembers(t *testing.T) {
depts := []*store.MirrorDepartment{ depts := []*store.MirrorDepartment{
mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"), mirrorDept("od-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
} }
users := []*store.MirrorUser{ users := []*store.MirrorUser{
mirrorUser("alice", "alice@example.com", "od-rd"), mirrorUser("alice", "alice@example.com", "od-rd"),
@@ -83,8 +83,8 @@ func TestBuildFromMirrorGroupMembers(t *testing.T) {
func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) { func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) {
depts := []*store.MirrorDepartment{ depts := []*store.MirrorDepartment{
// 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。 // 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。
mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"), mirrorDept("od-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""), mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
} }
st := BuildFromMirror(nil, depts, "dc=example,dc=com") st := BuildFromMirror(nil, depts, "dc=example,dc=com")
-80
View File
@@ -1,80 +0,0 @@
// 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
@@ -1,90 +0,0 @@
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))
}
}
+50 -16
View File
@@ -72,16 +72,19 @@ type MirrorUser struct {
// MirrorDepartment 是部门镜像。 // MirrorDepartment 是部门镜像。
type MirrorDepartment struct { type MirrorDepartment struct {
DeptID string `gorm:"primaryKey;size:190"` DeptID string `gorm:"primaryKey;size:190"`
Name string `gorm:"check:name <> ''"` // Name 是部门中文名,纯展示用(可读名走 DB,不进 LDAP 组名)。
PinyinName string `gorm:"size:255"` Name string `gorm:"check:name <> ''"`
DN string `gorm:"size:512"` DN string `gorm:"size:512;index"`
ParentID string `gorm:"size:190;index"` ParentID string `gorm:"size:190;index"`
Level int Level int
State string `gorm:"size:32;not null;default:active;index"` // GroupID 是 LLDAP 中该部门组的自增 int 组 id。LDAP 视角无法取到它,
LastSyncedAt time.Time // 必须由物化阶段持久化,供 GraphQL 成员操作直接用(约束 12)。
CreatedAt time.Time GroupID int `gorm:"column:group_id"`
UpdatedAt time.Time State string `gorm:"size:32;not null;default:active;index"`
LastSyncedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }
// MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。 // MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。
@@ -121,24 +124,38 @@ func (s *Store) ReplaceMirror(snap *MirrorSnapshot) error {
now := time.Now() now := time.Now()
// 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。 // 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。
// 部门是两个「整体替换」之一,用户因受离职生命周期约束不在本层删。 // 注意 upsert 冲突时**不碰 group_id**——物化阶段持久化的 dept_id→groupId 映射
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}). // 必须跨快照刷新保留,否则每次快照都会把组 id 清零。
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清空部门镜像失败: %w", err)
}
for _, d := range snap.Departments { for _, d := range snap.Departments {
d.State = StateActive d.State = StateActive
d.LastSyncedAt = now d.LastSyncedAt = now
if err := tx.Clauses(clause.OnConflict{ if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dept_id"}}, Columns: []clause.Column{{Name: "dept_id"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{
"name", "pinyin_name", "dn", "parent_id", "level", "state", "last_synced_at", "name", "dn", "parent_id", "level", "state", "last_synced_at",
}), }),
}).Create(d).Error; err != nil { }).Create(d).Error; err != nil {
return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err) return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err)
} }
} }
// 清除本轮快照之外的部门(group_id 随之丢弃,其组已成孤儿)。
if len(snap.Departments) == 0 {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除部门镜像失败: %w", err)
}
} else {
presentIDs := make([]string, 0, len(snap.Departments))
for _, d := range snap.Departments {
presentIDs = append(presentIDs, d.DeptID)
}
if err := tx.Where("dept_id NOT IN ?", presentIDs).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除旧部门失败: %w", err)
}
}
// 用户整体替换:upsert 有账者,不轮询删除缺席者(其生命周期由 reconcile 处理)。 // 用户整体替换:upsert 有账者,不轮询删除缺席者(其生命周期由 reconcile 处理)。
for _, u := range snap.Users { for _, u := range snap.Users {
u.State = StateActive u.State = StateActive
@@ -347,6 +364,23 @@ func (s *Store) MapDepartmentsByID() (map[string]*MirrorDepartment, error) {
return out, nil return out, nil
} }
// SetDeptGroupID 持久化部门组在 LLDAP 里的自增 groupId。
//
// LDAP 视角取不到这个 int id,只能由物化阶段在创建/核对组时写入,成员操作据此直接用。
// 快照刷新刻意不覆盖 group_id,保证映射跨刷新稳定。
func (s *Store) SetDeptGroupID(deptID string, groupID int) error {
return s.db.Model(&MirrorDepartment{}).
Where("dept_id = ?", deptID).
Update("group_id", groupID).Error
}
// SetUserAttrsHash 记录某用户上次写入 LLDAP 的属性指纹,供漂移比对与展示。
func (s *Store) SetUserAttrsHash(uid, hash string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ?", uid).
Update("attrs_hash", hash).Error
}
// UserDeptIDs 返回某用户所属的部门 ID 列表。 // UserDeptIDs 返回某用户所属的部门 ID 列表。
func (s *Store) UserDeptIDs(uid string) ([]string, error) { func (s *Store) UserDeptIDs(uid string) ([]string, error) {
var rels []MirrorUserDepartment var rels []MirrorUserDepartment
+25 -6
View File
@@ -101,10 +101,8 @@ func (r *SnapshotRunner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, e
// buildMirror 把飞书快照转成镜像存储层需要的输入。 // buildMirror 把飞书快照转成镜像存储层需要的输入。
// //
// 部门拍平命名与 DN 由 mapping.FlatDeptInfo 一次性算好并持久化到 // 部门组 DN 用稳定索引派生(mapping.DeptGroupDN),可读中文名只落 name 列供展示。
// mirror_departments,物化阶段直接复用、WebUI 亦可展示。
func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapshot { func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapshot {
flat := mapping.FlatDeptInfo(snap.Departments, r.cfg.Ldap.BaseDN)
deptName := make(map[string]string, len(snap.Departments)) deptName := make(map[string]string, len(snap.Departments))
for _, d := range snap.Departments { for _, d := range snap.Departments {
deptName[d.ID] = d.Name deptName[d.ID] = d.Name
@@ -112,10 +110,9 @@ func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapsho
depts := make([]*store.MirrorDepartment, 0, len(snap.Departments)) depts := make([]*store.MirrorDepartment, 0, len(snap.Departments))
for _, d := range snap.Departments { for _, d := range snap.Departments {
f := flat[d.ID]
depts = append(depts, &store.MirrorDepartment{ depts = append(depts, &store.MirrorDepartment{
DeptID: d.ID, Name: d.Name, PinyinName: f.Chain, DN: f.DN, DeptID: d.ID, Name: d.Name, DN: mapping.DeptGroupDN(d.ID, r.cfg.Ldap.BaseDN),
ParentID: d.ParentID, Level: f.Level, State: store.StateActive, ParentID: d.ParentID, Level: deptLevel(d.ID, snap.Departments), State: store.StateActive,
}) })
} }
@@ -144,6 +141,28 @@ func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapsho
return &store.MirrorSnapshot{Users: users, Departments: depts, Skipped: skipped} return &store.MirrorSnapshot{Users: users, Departments: depts, Skipped: skipped}
} }
// deptLevel 计算部门在树中的层级,根为 0。带步数上限防环。
func deptLevel(id string, depts []*feishu.Department) int {
byID := make(map[string]*feishu.Department, len(depts))
for _, d := range depts {
byID[d.ID] = d
}
depth := 0
cur := id
for i := 0; i < 64; 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
}
// toMirrorUser 把飞书用户域模型转成镜像用户。字段语义与 mapping 一致, // toMirrorUser 把飞书用户域模型转成镜像用户。字段语义与 mapping 一致,
// business_category 展开为部门中文名便于人工阅读。 // business_category 展开为部门中文名便于人工阅读。
func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser { func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser {
+3 -6
View File
@@ -260,16 +260,13 @@ func TestSnapshotLeaveLifecycle(t *testing.T) {
t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob")) t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob"))
} }
// 部门镜像应持久化拍平拼音与 DN。 // 部门镜像应持久化稳定索引派生出的组 DN。
dm, err := h.store.MapDepartmentsByID() dm, err := h.store.MapDepartmentsByID()
if err != nil { if err != nil {
t.Fatalf("读取部门镜像失败: %v", err) t.Fatalf("读取部门镜像失败: %v", err)
} }
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=Acme.yanfa,ou=groups,dc=example,dc=com" { if d := dm["od-yanfa"]; d == nil || d.DN != "cn=od-yanfa,ou=groups,dc=example,dc=com" {
t.Errorf("od-yanfa DN = %+v, 期望拍平拼音", d) t.Errorf("od-yanfa DN = %+v, 期望索引派生 cn=<dept_id>", d)
}
if d := dm["od-yanfa"]; d == nil || d.PinyinName != "Acme.yanfa" {
t.Errorf("od-yanfa PinyinName = %+v", d)
} }
// 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席), // 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),