diff --git a/config.example.yml b/config.example.yml index 92899f6..11b6de1 100644 --- a/config.example.yml +++ b/config.example.yml @@ -1,74 +1,81 @@ # idsync 配置示例。复制为 config.yml 后按实际环境修改。 # -# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。 -# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。 +# 两阶段架构:飞书 → SQLite 镜像 → LLDAP。 +# - 快照:低频(默认每天一次),飞书 → 镜像 + 离职流程推进。 +# - 物化:略高频(默认每 2–4 小时一次),镜像在职视图 → LLDAP(写走 GraphQL)。 +# 本工具不参与认证链路:用户登录/密码重置/发信由 Authelia 负责,因此这里没有 +# 认证相关配置,只有数据管道的调度与通道配置。 system: # debug 会输出更详细的日志;生产环境用 release mode: release - # 台账数据库与运行数据目录 + # 镜像数据库与运行数据目录 data-dir: data server: listen: 0.0.0.0:8899 - # 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关) + # 管理界面自身的登录凭据(仅保护界面,与 LDAP 用户无关) admin-user: admin # 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希 admin-password: change-me # 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码 admin-password-hash: "" - # 会话签名密钥,用 `./idsync -gen-secret` 生成。 - # 留空会自动生成临时密钥,但重启后需要重新登录。 + # 会话签名密钥,用 `./idsync -gen-secret` 生成。留空会自动生成临时密钥 session-secret: "" # 经 HTTPS 反代访问时设为 true secure-cookie: false + # 离职确认端点的对外 base-url;必须是公网/反代可达地址。飞书离职卡片的 + # 「确认离职」按钮 url 用它拼接,因此该项缺失时卡片将无法提供确认入口。 + confirm-base-url: "https://idsync.example.com" feishu: app-id: "cli_xxxxxxxxxxxx" app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx" # 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com base-url: "" - # 留空表示同步全部部门;填部门 ID 则只同步这些部门, - # 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"]) + # 留空表示同步全部部门;填部门 ID 则只同步这些部门,前缀 ^ 表示排除 dept-list: [] # 调用飞书的速率上限(次/秒),避免触发频控 qps: 5 timeout-sec: 30 + # 离职确认卡片「自定义机器人」的 webhook 地址,发给用户管理群 + webhook-url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" ldap: - # 生产环境务必使用 ldaps:// 或开启 start-tls: + # LLDAP 的 LDAP 读通道(连接/搜索)。生产建议 ldaps:// 或开启 start-tls: # Authelia 重置密码时会把新密码经此连接发送。 - url: ldaps://ldap.example.com:636 + url: "ldaps://lldap.example.com:6360" 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" - # 在职用户所在 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 start-tls: false # 仅自签证书的测试环境使用 insecure-skip-verify: false - # 新建用户的初始密码。留空则每个用户随机生成, - # 用户首次登录应走 Authelia 的“忘记密码”自助重置。 - initial-password: "" + +lldap: + # LLDAP 的 GraphQL 写通道地址(WebUI/GraphQL 端口) + base-url: "http://lldap:17170" + admin-user: admin + admin-pass: "change-me" database: source: data/idsync.db -sync: - # 是否启用定时同步 +schedule: + # 是否启用定时任务 enable: true - # 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。 - schedule: "0 40 0 * * *" - # true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。 - default-dry-run: true - # 单次同步允许停用的账号数上限,0 表示不限制 + # 快照 cron(秒级):低频,默认每天 00:20 + snapshot: "0 20 0 * * *" + # 物化 cron(秒级):略高频,默认每 2 小时 30 分 + sync: "0 30 */2 * * *" + # 物化是否默认 dry-run。快照永远直接落库;建议先观察几轮物化计划再放开。 + sync-dry-run: true + +guard: + # 单次物化允许删除(离职确认后)的账号数上限,0 表示不限制 max-deactivate: 0 - # 单次同步允许停用的账号比例上限(0-1)。 - # 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。 - max-deactivate-percent: 0.2 + # 单次物化允许删除的账号比例上限(0-1)。 + # 防止飞书应用权限被误改导致大批账号被判定为离职。 + max-deactivate-percent: 0.2 \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 8e766c5..4845782 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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,。 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) +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bec939a..1180fdd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 + } +} \ No newline at end of file diff --git a/internal/ldaplib/attrs.go b/internal/ldaplib/attrs.go new file mode 100644 index 0000000..01e8e0e --- /dev/null +++ b/internal/ldaplib/attrs.go @@ -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 +} \ No newline at end of file diff --git a/internal/ldaplib/client.go b/internal/ldaplib/client.go index df9116c..eb95ee5 100644 --- a/internal/ldaplib/client.go +++ b/internal/ldaplib/client.go @@ -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 -} +} \ No newline at end of file diff --git a/internal/ldaplib/ops.go b/internal/ldaplib/ops.go deleted file mode 100644 index dcad366..0000000 --- a/internal/ldaplib/ops.go +++ /dev/null @@ -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 -} diff --git a/internal/ldaplib/read.go b/internal/ldaplib/read.go index eb61ba7..295d479 100644 --- a/internal/ldaplib/read.go +++ b/internal/ldaplib/read.go @@ -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 { diff --git a/internal/mapping/flat.go b/internal/mapping/flat.go index 3d64c79..2ac4a99 100644 --- a/internal/mapping/flat.go +++ b/internal/mapping/flat.go @@ -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,"。 - 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=,ou=groups,。open_department_id 在飞书租户内全局唯一、 +// 且部门改名/挪动不变,因此组身份稳定;部门树的父子/层级关系只维护在 SQLite 的 +// mirror_departments.parent_id,不从组名解码(见 AGENTS.md 约束 12)。 +func DeptGroupDN(deptID, baseDN string) string { + return ldaplib.GroupDN(deptID, GroupRootDN(baseDN)) } \ No newline at end of file diff --git a/internal/mapping/flat_test.go b/internal/mapping/flat_test.go index 6b2ef69..bb22ff8 100644 --- a/internal/mapping/flat_test.go +++ b/internal/mapping/flat_test.go @@ -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= 开头") } } \ No newline at end of file diff --git a/internal/mapping/mirror.go b/internal/mapping/mirror.go index febeb6f..63d70a1 100644 --- a/internal/mapping/mirror.go +++ b/internal/mapping/mirror.go @@ -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=);用户 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,)。 + // DN 是完整组 DN(来自 mirror_departments,形式 cn=,ou=groups,)。 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{}), diff --git a/internal/mapping/mirror_test.go b/internal/mapping/mirror_test.go index f88aa9d..21bd615 100644 --- a/internal/mapping/mirror_test.go +++ b/internal/mapping/mirror_test.go @@ -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") diff --git a/internal/password/password.go b/internal/password/password.go deleted file mode 100644 index 1cd2dd2..0000000 --- a/internal/password/password.go +++ /dev/null @@ -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 -} diff --git a/internal/password/password_test.go b/internal/password/password_test.go deleted file mode 100644 index 389f5b7..0000000 --- a/internal/password/password_test.go +++ /dev/null @@ -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)) - } -} diff --git a/internal/store/mirror.go b/internal/store/mirror.go index 5c77239..36cde12 100644 --- a/internal/store/mirror.go +++ b/internal/store/mirror.go @@ -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 diff --git a/internal/syncer/snapshot.go b/internal/syncer/snapshot.go index f6d5c8f..19cfa84 100644 --- a/internal/syncer/snapshot.go +++ b/internal/syncer/snapshot.go @@ -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 { diff --git a/internal/syncer/snapshot_test.go b/internal/syncer/snapshot_test.go index b5ebe11..e474c4d 100644 --- a/internal/syncer/snapshot_test.go +++ b/internal/syncer/snapshot_test.go @@ -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=", d) } // 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),