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:
+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")
|
||||
|
||||
Reference in New Issue
Block a user