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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user