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
+51 -139
View File
@@ -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
}
}