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:
+85
-122
@@ -13,87 +13,93 @@ type Config struct {
|
||||
Server Server `mapstructure:"server"`
|
||||
Feishu Feishu `mapstructure:"feishu"`
|
||||
Ldap Ldap `mapstructure:"ldap"`
|
||||
Lldap Lldap `mapstructure:"lldap"`
|
||||
Database Database `mapstructure:"database"`
|
||||
Sync Sync `mapstructure:"sync"`
|
||||
Schedule Schedule `mapstructure:"schedule"`
|
||||
Guard Guard `mapstructure:"guard"`
|
||||
}
|
||||
|
||||
// System 是运行环境配置。
|
||||
type System struct {
|
||||
// Mode: debug 或 release,影响 gin 与日志详细度
|
||||
// Mode: debug 或 release,影响日志详细度。
|
||||
Mode string `mapstructure:"mode"`
|
||||
// DataDir 用于存放 sqlite 台账与日志
|
||||
// DataDir 用于存放 sqlite 镜像与运行数据。
|
||||
DataDir string `mapstructure:"data-dir"`
|
||||
}
|
||||
|
||||
// Server 是管理界面与一次性离职确认端点的配置。
|
||||
type Server struct {
|
||||
// Listen 形如 0.0.0.0:8899
|
||||
// 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"`
|
||||
SessionSecret string `mapstructure:"session-secret"`
|
||||
SecureCookie bool `mapstructure:"secure-cookie"`
|
||||
// ConfirmBaseURL 是离职确认端点的对外 base-url(可被公网/反代访问),
|
||||
// 飞书离职卡片的「确认离职」按钮 url 用它拼接。
|
||||
ConfirmBaseURL string `mapstructure:"confirm-base-url"`
|
||||
}
|
||||
|
||||
// Feishu 是飞书接口配置。
|
||||
type Feishu struct {
|
||||
AppID string `mapstructure:"app-id"`
|
||||
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"`
|
||||
// DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门
|
||||
DeptList []string `mapstructure:"dept-list"`
|
||||
// QPS 限制调用飞书的速率,默认 5
|
||||
QPS int `mapstructure:"qps"`
|
||||
// TimeoutSec 单次 API 调用超时,默认 30
|
||||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||
// DeptList 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除。
|
||||
DeptList []string `mapstructure:"dept-list"`
|
||||
QPS int `mapstructure:"qps"`
|
||||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||
// WebhookURL 是离职确认卡片走自定义机器人 webhook 发送的地址。
|
||||
WebhookURL string `mapstructure:"webhook-url"`
|
||||
}
|
||||
|
||||
// Ldap 是 LLDAP 的 LDAP 读通道(连接/bind/搜索)。它只读不写。
|
||||
type Ldap struct {
|
||||
URL string `mapstructure:"url"`
|
||||
BaseDN string `mapstructure:"base-dn"`
|
||||
// AdminDN:LLDAP 管理员用户,形如 uid=admin,ou=people,<base>。
|
||||
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 仅用于自签证书的测试环境
|
||||
TimeoutSec int `mapstructure:"timeout-sec"`
|
||||
StartTLS bool `mapstructure:"start-tls"`
|
||||
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 {
|
||||
// Source 是 sqlite 文件路径
|
||||
// Source 是 sqlite 文件路径。
|
||||
Source string `mapstructure:"source"`
|
||||
}
|
||||
|
||||
type Sync struct {
|
||||
// Enable 是否启用定时同步
|
||||
// Schedule 是两套定时任务的配置。
|
||||
// 快照低频(默认每天一次),物化略高频(默认每 2–4 小时一次)。
|
||||
type Schedule 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 表示不限制
|
||||
// Snapshot 快照 cron(秒级)。
|
||||
Snapshot string `mapstructure:"snapshot"`
|
||||
// Sync 物化 cron(秒级)。
|
||||
Sync string `mapstructure:"sync"`
|
||||
// SnapshotDryRun 物化是否默认 dry-run(快照永远直接落库)。
|
||||
SyncDryRun bool `mapstructure:"sync-dry-run"`
|
||||
}
|
||||
|
||||
// Guard 是物化前的安全阈值,防止飞书权限被误改时一次性误删大量账号。
|
||||
type Guard struct {
|
||||
// MaxDeactivate 单次允许删除(离职)的最大账号数,0 表示不限制。
|
||||
MaxDeactivate int `mapstructure:"max-deactivate"`
|
||||
// MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制
|
||||
// MaxDeactivatePercent 单次允许删除的账号比例上限(0-1),0 表示不限制。
|
||||
MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"`
|
||||
}
|
||||
|
||||
@@ -137,30 +143,29 @@ func setDefaults(v *viper.Viper) {
|
||||
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.url", "ldap://localhost:3890")
|
||||
v.SetDefault("ldap.timeout-sec", 15)
|
||||
v.SetDefault("ldap.start-tls", 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("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)
|
||||
v.SetDefault("schedule.enable", false)
|
||||
v.SetDefault("schedule.snapshot", "0 20 0 * * *")
|
||||
v.SetDefault("schedule.sync", "0 30 */2 * * *")
|
||||
v.SetDefault("schedule.sync-dry-run", true)
|
||||
|
||||
v.SetDefault("guard.max-deactivate", 0)
|
||||
v.SetDefault("guard.max-deactivate-percent", 0.2)
|
||||
}
|
||||
|
||||
// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。
|
||||
// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。
|
||||
func (c *Config) NormalizeForTest() error { return c.normalize() }
|
||||
|
||||
// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。
|
||||
// normalize 补全并校验配置。
|
||||
func (c *Config) normalize() error {
|
||||
if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" {
|
||||
return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空")
|
||||
@@ -168,81 +173,39 @@ func (c *Config) normalize() error {
|
||||
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.Ldap.AdminPass == "" {
|
||||
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 == "" {
|
||||
return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一")
|
||||
}
|
||||
if c.Sync.Enable && c.Sync.Schedule == "" {
|
||||
return fmt.Errorf("sync.schedule 不能为空")
|
||||
if c.Schedule.Enable && (c.Schedule.Snapshot == "" || c.Schedule.Sync == "") {
|
||||
return fmt.Errorf("schedule.enable=true 时 snapshot 与 sync 的 cron 都不能为空")
|
||||
}
|
||||
|
||||
if err := c.Ldap.normalizeGroupSchema(); err != nil {
|
||||
return err
|
||||
// LLDAP 管理员 LDAP DN 固定挂在 ou=people 下(LLDAP 无其他 OU)。
|
||||
// 未显式给定时按约定补全。
|
||||
if strings.TrimSpace(c.Ldap.AdminDN) == "" {
|
||||
c.Ldap.AdminDN = "uid=" + c.Lldap.AdminUser + "," + c.UserOUDN()
|
||||
}
|
||||
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。
|
||||
// UserOUDN 返回用户 OU 的完整 DN。LLDAP 用户固定挂在 ou=people 下。
|
||||
func (c *Config) UserOUDN() string {
|
||||
return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN)
|
||||
return "ou=people," + strings.TrimSpace(c.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
// InactiveOUDN 返回离职用户的完整 OU DN。
|
||||
func (c *Config) InactiveOUDN() string {
|
||||
return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN)
|
||||
}
|
||||
|
||||
// GroupRootDN 返回部门树根 OU 的完整 DN。
|
||||
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
|
||||
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)
|
||||
}
|
||||
return "ou=groups," + strings.TrimSpace(c.Ldap.BaseDN)
|
||||
}
|
||||
+51
-139
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,9 +21,13 @@ feishu:
|
||||
app-id: a
|
||||
app-secret: b
|
||||
ldap:
|
||||
url: ldap://localhost:389
|
||||
url: ldap://localhost:3890
|
||||
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
|
||||
` + extra
|
||||
|
||||
@@ -39,125 +44,24 @@ ldap:
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。
|
||||
func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) {
|
||||
// TestDefaultsForLLDAP 验证 OUDN 固定为 LLDAP 形态:用户在 ou=people、组在 ou=groups。
|
||||
func TestDefaultsForLLDAP(t *testing.T) {
|
||||
cfg := writeConfig(t, "")
|
||||
|
||||
if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" {
|
||||
t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass)
|
||||
if got := cfg.UserOUDN(); got != "ou=people,dc=example,dc=org" {
|
||||
t.Errorf("UserOUDN = %q, 期望 ou=people(LLDAP 固定 OU)", got)
|
||||
}
|
||||
if cfg.Ldap.GroupMemberAttr != "uniqueMember" {
|
||||
t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr)
|
||||
if got := cfg.GroupRootDN(); got != "ou=groups,dc=example,dc=org" {
|
||||
t.Errorf("GroupRootDN = %q, 期望 ou=groups", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// TestAdminDNDerived 验证未显式配 admin-dn 时按 LLDAP 管理员用户补全。
|
||||
func TestAdminDNDerived(t *testing.T) {
|
||||
cfg := writeConfig(t, " admin-dn: \"\"\n")
|
||||
want := "uid=admin,ou=people,dc=example,dc=org"
|
||||
if cfg.Ldap.AdminDN != want {
|
||||
t.Errorf("AdminDN = %q, 期望自动补全为 %q", cfg.Ldap.AdminDN, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,46 +69,54 @@ func TestInactiveOUOutsideUserOU(t *testing.T) {
|
||||
func TestNormalizeRejectsMissingCredentials(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
over string
|
||||
// override 以 YAML 片段替换掉基础配置里对应的一行,避免重复键。
|
||||
override string
|
||||
mark string
|
||||
}{
|
||||
{"缺少 app-id", " app-id: \"\"\n"},
|
||||
{"缺少 base-dn", "BASE_DN_MARKER"},
|
||||
{"缺少 app-id", "feishu:\n app-id: \"\"\n", "app-id"},
|
||||
{"缺少 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 {
|
||||
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 := `
|
||||
base := `
|
||||
system:
|
||||
data-dir: /tmp/idsync-test
|
||||
server:
|
||||
admin-user: admin
|
||||
admin-password: pw
|
||||
feishu:
|
||||
app-id: "` + feishuID + `"
|
||||
app-secret: b
|
||||
`
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
body := base + c.override + `
|
||||
ldap:
|
||||
url: ldap://localhost:389
|
||||
base-dn: ` + baseDN + `
|
||||
admin-dn: "cn=admin,dc=example,dc=org"
|
||||
url: ldap://localhost:3890
|
||||
base-dn: "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
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写配置失败: %v", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+10
-23
@@ -1,5 +1,7 @@
|
||||
package ldaplib
|
||||
|
||||
// LLDAP 的 LDAP 读通道:连接、绑定、搜索。刻意只读,写全走 lldapgql。
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
@@ -12,19 +14,16 @@ import (
|
||||
"idsync/internal/config"
|
||||
)
|
||||
|
||||
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
|
||||
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
|
||||
// Client 封装与 LLDAP 的 LDAP 连接。不使用连接池:同步任务的并发很低,
|
||||
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
|
||||
type Client struct {
|
||||
cfg config.Ldap
|
||||
log *slog.Logger
|
||||
tlsConf *tls.Config
|
||||
dialOpts []ldap.DialOpt
|
||||
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
|
||||
group GroupSchema
|
||||
}
|
||||
|
||||
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
|
||||
// NewClient 构造 LDAP 读客户端并解析拨号参数。
|
||||
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
||||
if cfg.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))
|
||||
}
|
||||
|
||||
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
|
||||
return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts}, nil
|
||||
}
|
||||
|
||||
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
|
||||
type Conn struct {
|
||||
conn *ldap.Conn
|
||||
cfg config.Ldap
|
||||
group GroupSchema
|
||||
conn *ldap.Conn
|
||||
cfg config.Ldap
|
||||
}
|
||||
|
||||
// Close 释放连接。
|
||||
@@ -87,10 +74,10 @@ func (c *Client) Dial() (*Conn, error) {
|
||||
|
||||
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 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 验证连通性与凭据,用于健康检查。
|
||||
@@ -132,4 +119,4 @@ func lastIndexByte(s string, b byte) int {
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,18 +40,7 @@ func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
|
||||
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}
|
||||
// userReadAttrs / groupReadAttrs 的定义见 attrs.go。
|
||||
|
||||
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
|
||||
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 小写为键。
|
||||
// LLDAP 分组 objectClass 固定为 groupOfUniqueNames。
|
||||
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,
|
||||
fmt.Sprintf("(objectClass=%s)", groupObjectClass), groupReadAttrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
@@ -123,28 +113,6 @@ func (c *Conn) ExistsDN(dn string) (bool, error) {
|
||||
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 {
|
||||
|
||||
+13
-63
@@ -1,79 +1,29 @@
|
||||
package mapping
|
||||
|
||||
// 部门组的拍平命名与 DN 构造。
|
||||
// 部门组的命名与 DN 构造。
|
||||
//
|
||||
// LLDAP 组不能嵌套(组里只能有用户,没有父/子组概念),因此部门树必须拍平:
|
||||
// 组名 = 父链拼音点分拼接(如 cn=yanfa.pingtai)。拼音为空时回落到部门 ID 保证
|
||||
// 非空稳定。组名天然唯一(部门树路径唯一),重名时追加部门 ID 消歧。
|
||||
//
|
||||
// 这些 DN 在快照落库阶段一次性算好并持久化到 mirror_departments,供物化阶段
|
||||
// 直接复用,也让 WebUI 能展示每个部门的预期 DN。
|
||||
// LLDAP 组不嵌套(组里只能有用户),且组身份必须稳定:部门改名不能引起组
|
||||
// displayName/CN 变化,否则 Authelia 与下游对组的引用会连锁失效。因此组名用
|
||||
// 部门稳定索引(open_department_id)派生,而不是拼音——拼音对可读但多义、且
|
||||
// 改名即换身份。可读的部门中文名不进入组名,存放在数据库的 name 列与用户的
|
||||
// business_category 里供展示(见 AGENTS.md 约束 12)。
|
||||
|
||||
import (
|
||||
"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 下。
|
||||
func GroupRootDN(baseDN string) string {
|
||||
return "ou=groups," + strings.TrimSpace(baseDN)
|
||||
}
|
||||
|
||||
// FlatDeptInfo 把部门树一次性拍平为拼音点分命名并计算每股的 DN。
|
||||
// DeptGroupDN 由部门稳定索引派生出该部门组的完整 DN。
|
||||
//
|
||||
// 输入是快照里的全量部门;返回 dept_id → FlatDept。父先于子顺序处理,
|
||||
// 因此计算子部门时可复用父部门的 Chain。纯函数,不触碰 IO。
|
||||
func FlatDeptInfo(depts []*feishu.Department, baseDN string) map[string]*FlatDept {
|
||||
byID := make(map[string]*feishu.Department, len(depts))
|
||||
for _, d := range depts {
|
||||
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
|
||||
// 形如 cn=<dept_id>,ou=groups,<base>。open_department_id 在飞书租户内全局唯一、
|
||||
// 且部门改名/挪动不变,因此组身份稳定;部门树的父子/层级关系只维护在 SQLite 的
|
||||
// mirror_departments.parent_id,不从组名解码(见 AGENTS.md 约束 12)。
|
||||
func DeptGroupDN(deptID, baseDN string) string {
|
||||
return ldaplib.GroupDN(deptID, GroupRootDN(baseDN))
|
||||
}
|
||||
@@ -3,80 +3,43 @@ package mapping
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"idsync/internal/feishu"
|
||||
)
|
||||
|
||||
// TestFlatDeptInfoParentChild 验证拍平拼音链:子部门链 = 父链.子拼音。
|
||||
func TestFlatDeptInfoParentChild(t *testing.T) {
|
||||
depts := []*feishu.Department{
|
||||
dept("od-root", "0", "Acme"),
|
||||
dept("od-rd", "od-root", "研发部"),
|
||||
dept("od-backend", "od-rd", "后端组"),
|
||||
}
|
||||
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)
|
||||
// TestDeptGroupDN 验证部门组 DN 直接用部门稳定索引(dept_id),不编码祖先路径。
|
||||
func TestDeptGroupDN(t *testing.T) {
|
||||
got := DeptGroupDN("od-yanfa-1", "dc=example,dc=com")
|
||||
want := "cn=od-yanfa-1,ou=groups,dc=example,dc=com"
|
||||
if got != want {
|
||||
t.Errorf("DeptGroupDN = %q, 期望 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlatDeptInfoFallsBackOnEmptyPinyin 验证纯符号部门名回落部门 ID,链非空且稳定。
|
||||
func TestFlatDeptInfoFallsBackOnEmptyPinyin(t *testing.T) {
|
||||
depts := []*feishu.Department{
|
||||
dept("od-root", "0", "Acme"),
|
||||
dept("od-sym", "od-root", "★☆"),
|
||||
}
|
||||
out := FlatDeptInfo(depts, "dc=example,dc=com")
|
||||
if out["od-sym"].Chain != "Acme.dept-od-sym" {
|
||||
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)
|
||||
// TestDeptGroupDNStableAcrossRename 验证部门改名(中文名变化)不影响组 DN——
|
||||
// 组身份绑在索引上,改名不引起身份漂移。
|
||||
func TestDeptGroupDNStableAcrossRename(t *testing.T) {
|
||||
a := DeptGroupDN("od-123", "dc=example,dc=com")
|
||||
// 无论部门叫「研发部」还是「产品部」,索引不变,DN 不变。
|
||||
b := DeptGroupDN("od-123", "dc=example,dc=com")
|
||||
if a != b {
|
||||
t.Errorf("同索引的组 DN 应稳定不变: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlatDeptInfoDisambiguatesCollision 验证同名部门消歧:链撞车时追加部门 ID,组名保持唯一。
|
||||
func TestFlatDeptInfoDisambiguatesCollision(t *testing.T) {
|
||||
depts := []*feishu.Department{
|
||||
dept("od-root", "0", "Acme"),
|
||||
dept("od-a", "od-root", "开发组"),
|
||||
dept("od-b", "od-root", "开发组"),
|
||||
}
|
||||
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 不应相同")
|
||||
// TestDeptGroupDNDistinct 验证不同部门索引产生不同组 DN。
|
||||
func TestDeptGroupDNDistinct(t *testing.T) {
|
||||
a := DeptGroupDN("od-1", "dc=example,dc=com")
|
||||
b := DeptGroupDN("od-2", "dc=example,dc=com")
|
||||
if a == b {
|
||||
t.Errorf("不同部门索引的组 DN 不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlatDeptInfoStable 验证同一输入产生的 DN 稳定(幂等,便于物化比对)。
|
||||
func TestFlatDeptInfoStable(t *testing.T) {
|
||||
depts := []*feishu.Department{
|
||||
dept("od-root", "0", "Acme"),
|
||||
dept("od-rd", "od-root", "研发部"),
|
||||
// TestGroupRootDN 验证组根 OU 固定挂在 ou=groups 下。
|
||||
func TestGroupRootDN(t *testing.T) {
|
||||
if got := GroupRootDN("dc=example,dc=com"); got != "ou=groups,dc=example,dc=com" {
|
||||
t.Errorf("GroupRootDN = %q", got)
|
||||
}
|
||||
a := FlatDeptInfo(depts, "dc=example,dc=com")
|
||||
b := FlatDeptInfo(depts, "dc=example,dc=com")
|
||||
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)
|
||||
}
|
||||
if !strings.HasPrefix(DeptGroupDN("od-1", "dc=example,dc=com"), "cn=") {
|
||||
t.Errorf("组 DN 应以 cn= 开头")
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ package mapping
|
||||
//
|
||||
// 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型)
|
||||
// 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是
|
||||
// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,因此组用拍平拼音点分命名;
|
||||
// 用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是物理删除(见 syncer 阶段 6)。
|
||||
// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,组用部门稳定索引命名
|
||||
// (cn=<dept_id>);用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是
|
||||
// 物理删除(见 syncer 阶段 6)。可读的部门中文名不进组名,只作展示。
|
||||
|
||||
import (
|
||||
"sort"
|
||||
@@ -15,14 +16,13 @@ import (
|
||||
"idsync/internal/store"
|
||||
)
|
||||
|
||||
// DesiredGroup 是期望的分组(拍平命名)。
|
||||
// DesiredGroup 是期望的分组(用部门稳定索引命名)。
|
||||
type DesiredGroup struct {
|
||||
DeptID string
|
||||
// DN 是完整组 DN(来自 mirror_departments,形式 cn=<父.子>,ou=groups,<base>)。
|
||||
// DN 是完整组 DN(来自 mirror_departments,形式 cn=<dept_id>,ou=groups,<base>)。
|
||||
DN string
|
||||
// Chain 是拍平拼音链,仅展示用。
|
||||
Chain string
|
||||
Name string
|
||||
// Name 是部门中文名,仅展示用。
|
||||
Name string
|
||||
// Level 是部门在树中的层级,用于保证父组先于子组创建。
|
||||
Level int
|
||||
// Members 是期望成员的用户 DN 集合。
|
||||
@@ -72,7 +72,6 @@ func BuildFromMirror(users []*store.MirrorUser, depts []*store.MirrorDepartment,
|
||||
g := &DesiredGroup{
|
||||
DeptID: d.DeptID,
|
||||
DN: d.DN,
|
||||
Chain: d.PinyinName,
|
||||
Name: d.Name,
|
||||
Level: d.Level,
|
||||
Members: make(map[string]struct{}),
|
||||
|
||||
@@ -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{
|
||||
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。
|
||||
func TestBuildFromMirrorUserDN(t *testing.T) {
|
||||
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")}
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestBuildFromMirrorUserDN(t *testing.T) {
|
||||
// TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。
|
||||
func TestBuildFromMirrorGroupMembers(t *testing.T) {
|
||||
depts := []*store.MirrorDepartment{
|
||||
mirrorDept("od-root", "Acme", "Acme", "cn=Acme,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-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
|
||||
mirrorDept("od-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
|
||||
}
|
||||
users := []*store.MirrorUser{
|
||||
mirrorUser("alice", "alice@example.com", "od-rd"),
|
||||
@@ -83,8 +83,8 @@ func TestBuildFromMirrorGroupMembers(t *testing.T) {
|
||||
func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) {
|
||||
depts := []*store.MirrorDepartment{
|
||||
// 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。
|
||||
mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,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-rd", "研发部", "cn=od-rd,ou=groups,dc=example,dc=com", 1, "od-root"),
|
||||
mirrorDept("od-root", "Acme", "cn=od-root,ou=groups,dc=example,dc=com", 0, ""),
|
||||
}
|
||||
|
||||
st := BuildFromMirror(nil, depts, "dc=example,dc=com")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -72,16 +72,19 @@ type MirrorUser struct {
|
||||
|
||||
// MirrorDepartment 是部门镜像。
|
||||
type MirrorDepartment struct {
|
||||
DeptID string `gorm:"primaryKey;size:190"`
|
||||
Name string `gorm:"check:name <> ''"`
|
||||
PinyinName string `gorm:"size:255"`
|
||||
DN string `gorm:"size:512"`
|
||||
ParentID string `gorm:"size:190;index"`
|
||||
Level int
|
||||
State string `gorm:"size:32;not null;default:active;index"`
|
||||
LastSyncedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeptID string `gorm:"primaryKey;size:190"`
|
||||
// Name 是部门中文名,纯展示用(可读名走 DB,不进 LDAP 组名)。
|
||||
Name string `gorm:"check:name <> ''"`
|
||||
DN string `gorm:"size:512;index"`
|
||||
ParentID string `gorm:"size:190;index"`
|
||||
Level int
|
||||
// GroupID 是 LLDAP 中该部门组的自增 int 组 id。LDAP 视角无法取到它,
|
||||
// 必须由物化阶段持久化,供 GraphQL 成员操作直接用(约束 12)。
|
||||
GroupID int `gorm:"column:group_id"`
|
||||
State string `gorm:"size:32;not null;default:active;index"`
|
||||
LastSyncedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。
|
||||
@@ -121,24 +124,38 @@ func (s *Store) ReplaceMirror(snap *MirrorSnapshot) error {
|
||||
now := time.Now()
|
||||
|
||||
// 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。
|
||||
// 部门是两个「整体替换」之一,用户因受离职生命周期约束不在本层删。
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
|
||||
Delete(&MirrorDepartment{}).Error; err != nil {
|
||||
return fmt.Errorf("清空部门镜像失败: %w", err)
|
||||
}
|
||||
// 注意 upsert 冲突时**不碰 group_id**——物化阶段持久化的 dept_id→groupId 映射
|
||||
// 必须跨快照刷新保留,否则每次快照都会把组 id 清零。
|
||||
for _, d := range snap.Departments {
|
||||
d.State = StateActive
|
||||
d.LastSyncedAt = now
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "dept_id"}},
|
||||
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 {
|
||||
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 处理)。
|
||||
for _, u := range snap.Users {
|
||||
u.State = StateActive
|
||||
@@ -347,6 +364,23 @@ func (s *Store) MapDepartmentsByID() (map[string]*MirrorDepartment, error) {
|
||||
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 列表。
|
||||
func (s *Store) UserDeptIDs(uid string) ([]string, error) {
|
||||
var rels []MirrorUserDepartment
|
||||
|
||||
@@ -101,10 +101,8 @@ func (r *SnapshotRunner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, e
|
||||
|
||||
// buildMirror 把飞书快照转成镜像存储层需要的输入。
|
||||
//
|
||||
// 部门拍平命名与 DN 由 mapping.FlatDeptInfo 一次性算好并持久化到
|
||||
// mirror_departments,物化阶段直接复用、WebUI 亦可展示。
|
||||
// 部门组 DN 用稳定索引派生(mapping.DeptGroupDN),可读中文名只落 name 列供展示。
|
||||
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))
|
||||
for _, d := range snap.Departments {
|
||||
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))
|
||||
for _, d := range snap.Departments {
|
||||
f := flat[d.ID]
|
||||
depts = append(depts, &store.MirrorDepartment{
|
||||
DeptID: d.ID, Name: d.Name, PinyinName: f.Chain, DN: f.DN,
|
||||
ParentID: d.ParentID, Level: f.Level, State: store.StateActive,
|
||||
DeptID: d.ID, Name: d.Name, DN: mapping.DeptGroupDN(d.ID, r.cfg.Ldap.BaseDN),
|
||||
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}
|
||||
}
|
||||
|
||||
// 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 一致,
|
||||
// business_category 展开为部门中文名便于人工阅读。
|
||||
func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser {
|
||||
|
||||
@@ -260,16 +260,13 @@ func TestSnapshotLeaveLifecycle(t *testing.T) {
|
||||
t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob"))
|
||||
}
|
||||
|
||||
// 部门镜像应持久化拍平拼音与 DN。
|
||||
// 部门镜像应持久化稳定索引派生出的组 DN。
|
||||
dm, err := h.store.MapDepartmentsByID()
|
||||
if err != nil {
|
||||
t.Fatalf("读取部门镜像失败: %v", err)
|
||||
}
|
||||
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=Acme.yanfa,ou=groups,dc=example,dc=com" {
|
||||
t.Errorf("od-yanfa DN = %+v, 期望拍平拼音", d)
|
||||
}
|
||||
if d := dm["od-yanfa"]; d == nil || d.PinyinName != "Acme.yanfa" {
|
||||
t.Errorf("od-yanfa PinyinName = %+v", d)
|
||||
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=od-yanfa,ou=groups,dc=example,dc=com" {
|
||||
t.Errorf("od-yanfa DN = %+v, 期望索引派生 cn=<dept_id>", d)
|
||||
}
|
||||
|
||||
// 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),
|
||||
|
||||
Reference in New Issue
Block a user