chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package ldaplib
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"idsync/internal/config"
|
||||
)
|
||||
|
||||
// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低,
|
||||
// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。
|
||||
// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。
|
||||
type Client struct {
|
||||
cfg config.Ldap
|
||||
log *slog.Logger
|
||||
tlsConf *tls.Config
|
||||
dialOpts []ldap.DialOpt
|
||||
// group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。
|
||||
group GroupSchema
|
||||
}
|
||||
|
||||
// NewClient 构造 LDAP 客户端,并解析出拨号参数。
|
||||
func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, fmt.Errorf("ldap.url 未配置")
|
||||
}
|
||||
|
||||
dialOpts := []ldap.DialOpt{
|
||||
ldap.DialWithDialer(&net.Dialer{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}),
|
||||
}
|
||||
|
||||
var tlsConf *tls.Config
|
||||
if cfg.InsecureSkipVerify || hasLDAPS(cfg.URL) || cfg.StartTLS {
|
||||
tlsConf = &tls.Config{
|
||||
ServerName: serverName(cfg.URL),
|
||||
InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // 由配置显式开启,仅供自签证书环境
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。
|
||||
type Conn struct {
|
||||
conn *ldap.Conn
|
||||
cfg config.Ldap
|
||||
group GroupSchema
|
||||
}
|
||||
|
||||
// Close 释放连接。
|
||||
func (c *Conn) Close() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Dial 建立并绑定一个新的 LDAP 连接。
|
||||
func (c *Client) Dial() (*Conn, error) {
|
||||
conn, err := ldap.DialURL(c.cfg.URL, c.dialOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 LDAP 失败 (%s): %w", c.cfg.URL, err)
|
||||
}
|
||||
|
||||
if c.cfg.StartTLS && !hasLDAPS(c.cfg.URL) {
|
||||
if err := conn.StartTLS(c.tlsConf); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("StartTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil
|
||||
}
|
||||
|
||||
// Ping 验证连通性与凭据,用于健康检查。
|
||||
func (c *Client) Ping() error {
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasLDAPS(url string) bool {
|
||||
return len(url) >= 8 && url[:8] == "ldaps://"
|
||||
}
|
||||
|
||||
// serverName 从 URL 中提取主机名,供 TLS SNI 与证书校验使用。
|
||||
func serverName(rawURL string) string {
|
||||
host := rawURL
|
||||
for _, prefix := range []string{"ldaps://", "ldap://"} {
|
||||
if len(host) >= len(prefix) && host[:len(prefix)] == prefix {
|
||||
host = host[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if i := lastIndexByte(host, '/'); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func lastIndexByte(s string, b byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:对真实的 OpenLDAP 实例验证写入路径。
|
||||
//
|
||||
// 默认不参与 `go test ./...`,需要显式启用:
|
||||
//
|
||||
// LDAP_TEST_URL=ldap://127.0.0.1:3899 \
|
||||
// go test -tags=integration ./internal/ldaplib/ -v
|
||||
//
|
||||
// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、
|
||||
// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。
|
||||
package ldaplib_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"idsync/internal/config"
|
||||
"idsync/internal/ldaplib"
|
||||
"idsync/internal/password"
|
||||
)
|
||||
|
||||
// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func testClient(t *testing.T) *ldaplib.Client {
|
||||
t.Helper()
|
||||
|
||||
url := os.Getenv("LDAP_TEST_URL")
|
||||
if url == "" {
|
||||
url = "ldap://127.0.0.1:3899"
|
||||
}
|
||||
baseDN := os.Getenv("LDAP_TEST_BASE_DN")
|
||||
if baseDN == "" {
|
||||
baseDN = "dc=example,dc=com"
|
||||
}
|
||||
|
||||
cfg := config.Ldap{
|
||||
URL: url,
|
||||
BaseDN: baseDN,
|
||||
AdminDN: "cn=admin," + baseDN,
|
||||
AdminPass: "adminpass",
|
||||
GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"),
|
||||
TimeoutSec: 10,
|
||||
}
|
||||
if err := cfg.NormalizeForTest(); err != nil {
|
||||
t.Fatalf("配置规范化失败: %v", err)
|
||||
}
|
||||
|
||||
c, err := ldaplib.NewClient(cfg, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("构造客户端失败: %v", err)
|
||||
}
|
||||
if err := c.Ping(); err != nil {
|
||||
t.Skipf("LDAP 不可用,跳过集成测试: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func baseDN() string {
|
||||
if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "dc=example,dc=com"
|
||||
}
|
||||
|
||||
// TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期,
|
||||
// 并验证密码在属性更新与 modrdn 移动后依然有效。
|
||||
func TestIntegrationUserLifecycle(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
inactiveOU := "ou=Inactive," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil {
|
||||
t.Fatalf("创建 ou=Inactive 失败: %v", err)
|
||||
}
|
||||
|
||||
const uid = "idsync-test-user"
|
||||
userDN := "uid=" + uid + "," + peopleOU
|
||||
initialPass := "InitialPass123!"
|
||||
|
||||
// 清理可能残留的条目,让测试可重复执行。
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteUser("uid=" + uid + "," + inactiveOU)
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: "测试", GivenName: "Test",
|
||||
DisplayName: "测试用户", Mail: "test@example.com",
|
||||
Mobile: "13800000000", EmployeeNumber: "E001",
|
||||
DepartmentNumber: "工程师", PostalAddress: "北京",
|
||||
BusinessCategory: "研发部", EmployeeType: "Active",
|
||||
Description: "测试用户",
|
||||
}
|
||||
|
||||
// 断言密码以哈希形式存储,而不是明文。
|
||||
// slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码,
|
||||
// 密码会以明文形式留在目录中。
|
||||
encoded, err := password.EncodeSSHA(initialPass)
|
||||
if err != nil {
|
||||
t.Fatalf("编码密码失败: %v", err)
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, encoded); err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
raw, err := conn.GetEntry(userDN, []string{"userPassword"})
|
||||
if err != nil {
|
||||
t.Fatalf("读取 userPassword 失败: %v", err)
|
||||
}
|
||||
stored := raw.Get("userPassword")
|
||||
if stored == initialPass {
|
||||
t.Errorf("userPassword 以明文存储,存在严重安全隐患")
|
||||
}
|
||||
if !strings.HasPrefix(stored, "{SSHA}") {
|
||||
t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored)
|
||||
}
|
||||
|
||||
// 创建后应当能读到,且属性正确。
|
||||
users, err := conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取用户列表失败: %v", err)
|
||||
}
|
||||
got, ok := users[uid]
|
||||
if !ok {
|
||||
t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid)
|
||||
}
|
||||
if got.Get(ldaplib.AttrMail) != "test@example.com" {
|
||||
t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail))
|
||||
}
|
||||
|
||||
// 用用户凭据 bind,确认密码确实生效。
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("初始密码无法通过 bind 验证: %v", err)
|
||||
}
|
||||
|
||||
// 更新属性:改邮箱与手机号。
|
||||
u.Mail = "test.new@example.com"
|
||||
u.Mobile = "13900000000"
|
||||
existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||
if err != nil {
|
||||
t.Fatalf("读取现状失败: %v", err)
|
||||
}
|
||||
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||
t.Fatalf("更新用户失败: %v", err)
|
||||
}
|
||||
|
||||
// 关键回归:把若干可选字段置空后更新,不应失败。
|
||||
// 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让
|
||||
// 整个 Modify 请求失败——这是真实踩到过的 bug。
|
||||
u.PostalAddress = ""
|
||||
u.EmployeeNumber = ""
|
||||
existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs())
|
||||
if err := conn.UpdateUser(userDN, u, existing); err != nil {
|
||||
t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err)
|
||||
}
|
||||
|
||||
users, err = conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("更新后读取失败: %v", err)
|
||||
}
|
||||
got = users[uid]
|
||||
if got.Get(ldaplib.AttrMail) != "test.new@example.com" {
|
||||
t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail))
|
||||
}
|
||||
|
||||
// 关键约束:属性更新绝不能改写密码。
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err)
|
||||
}
|
||||
|
||||
// 移到离职 OU:密码必须随条目保留。
|
||||
if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil {
|
||||
t.Fatalf("移动用户失败: %v", err)
|
||||
}
|
||||
movedDN := "uid=" + uid + "," + inactiveOU
|
||||
if err := bindAs(t, c, movedDN, initialPass); err != nil {
|
||||
t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err)
|
||||
}
|
||||
|
||||
inactive, err := conn.ListUsers(inactiveOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取离职 OU 失败: %v", err)
|
||||
}
|
||||
if _, ok := inactive[uid]; !ok {
|
||||
t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid)
|
||||
}
|
||||
|
||||
// 标记离职状态
|
||||
if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil {
|
||||
t.Fatalf("设置 employeeType 失败: %v", err)
|
||||
}
|
||||
|
||||
// 移回在职 OU
|
||||
if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil {
|
||||
t.Fatalf("移回失败: %v", err)
|
||||
}
|
||||
if err := bindAs(t, c, userDN, initialPass); err != nil {
|
||||
t.Fatalf("移回后密码失效: %v", err)
|
||||
}
|
||||
|
||||
// 清理
|
||||
if err := conn.DeleteUser(userDN); err != nil {
|
||||
t.Fatalf("删除用户失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationGroupMembership 验证分组的创建与成员增删。
|
||||
func TestIntegrationGroupMembership(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
groupRoot := "ou=feishuroot," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil {
|
||||
t.Fatalf("创建分组根 OU 失败: %v", err)
|
||||
}
|
||||
|
||||
const uid = "idsync-test-member"
|
||||
userDN := "uid=" + uid + "," + peopleOU
|
||||
groupDN := "cn=yanfa," + groupRoot
|
||||
adminDN := "cn=admin," + base
|
||||
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteGroup(groupDN)
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: "成员", DisplayName: "成员",
|
||||
Mail: "member@example.com", EmployeeType: "Active",
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||
t.Fatalf("创建分组失败: %v", err)
|
||||
}
|
||||
|
||||
// 幂等性:重复创建同一个组不应报错。
|
||||
if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil {
|
||||
t.Errorf("重复创建分组应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
groups, err := conn.ListGroups(groupRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("读取分组失败: %v", err)
|
||||
}
|
||||
g, ok := groups[groupDN]
|
||||
if !ok {
|
||||
t.Fatalf("未找到刚创建的分组 %s", groupDN)
|
||||
}
|
||||
// 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。
|
||||
if _, has := g.Members()[adminDN]; !has {
|
||||
t.Errorf("分组缺少 admin 成员: %v", g.Members())
|
||||
}
|
||||
|
||||
// 加入成员,并验证幂等。
|
||||
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||
t.Fatalf("加入成员失败: %v", err)
|
||||
}
|
||||
if err := conn.AddMember(groupDN, userDN); err != nil {
|
||||
t.Errorf("重复加入成员应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
groups, err = conn.ListGroups(groupRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("重新读取分组失败: %v", err)
|
||||
}
|
||||
if _, has := groups[groupDN].Members()[userDN]; !has {
|
||||
t.Errorf("成员未被加入: %v", groups[groupDN].Members())
|
||||
}
|
||||
|
||||
// 移出成员,并验证幂等。
|
||||
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||
t.Fatalf("移出成员失败: %v", err)
|
||||
}
|
||||
if err := conn.RemoveMember(groupDN, userDN); err != nil {
|
||||
t.Errorf("重复移出成员应当幂等,却返回错误: %v", err)
|
||||
}
|
||||
|
||||
// 清理
|
||||
_ = conn.DeleteUser(userDN)
|
||||
_ = conn.DeleteGroup(groupDN)
|
||||
}
|
||||
|
||||
// TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。
|
||||
func TestIntegrationEscaping(t *testing.T) {
|
||||
c := testClient(t)
|
||||
base := baseDN()
|
||||
peopleOU := "ou=people," + base
|
||||
|
||||
conn, err := c.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("连接失败: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.EnsureOU(peopleOU, "people"); err != nil {
|
||||
t.Fatalf("创建 ou=people 失败: %v", err)
|
||||
}
|
||||
|
||||
// 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。
|
||||
cases := []string{
|
||||
"zhang.san",
|
||||
"li_si",
|
||||
"wang+wu",
|
||||
"zhao,liu",
|
||||
}
|
||||
|
||||
for _, uid := range cases {
|
||||
t.Run(uid, func(t *testing.T) {
|
||||
dn := ldaplib.UserDN(uid, peopleOU)
|
||||
_ = conn.DeleteUser(dn)
|
||||
defer func() { _ = conn.DeleteUser(dn) }()
|
||||
|
||||
u := &ldaplib.User{
|
||||
UID: uid, CN: uid, SN: uid, DisplayName: uid,
|
||||
Mail: uid + "@example.com", EmployeeType: "Active",
|
||||
}
|
||||
if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil {
|
||||
t.Fatalf("创建含特殊字符的 uid 失败: %v", err)
|
||||
}
|
||||
|
||||
users, err := conn.ListUsers(peopleOU)
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if _, ok := users[uid]; !ok {
|
||||
t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。
|
||||
func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error {
|
||||
t.Helper()
|
||||
|
||||
cfgURL := os.Getenv("LDAP_TEST_URL")
|
||||
if cfgURL == "" {
|
||||
cfgURL = "ldap://127.0.0.1:3899"
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.Bind(dn, password)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package ldaplib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// Entry 是 LDAP 中一个条目的精简表示,用于与期望状态比对。
|
||||
type Entry struct {
|
||||
DN string
|
||||
Attributes map[string]string
|
||||
// multi 保留多值属性的完整值列表(如 uniqueMember),键为小写属性名。
|
||||
multi map[string][]string
|
||||
}
|
||||
|
||||
// Get 返回指定属性的第一个值。
|
||||
func (e *Entry) Get(attr string) string {
|
||||
return e.Attributes[strings.ToLower(attr)]
|
||||
}
|
||||
|
||||
// NewEntry 构造一个 Entry。
|
||||
//
|
||||
// Entry 的字段对调用方是只读的(多值属性不导出),但同步逻辑与测试都需要
|
||||
// 在内存中构造它,因此这里提供一个显式构造函数,而不是让调用方猜测内部结构。
|
||||
func NewEntry(dn string, attrs map[string]string, members []string) *Entry {
|
||||
e := &Entry{
|
||||
DN: dn,
|
||||
Attributes: make(map[string]string, len(attrs)),
|
||||
multi: make(map[string][]string),
|
||||
}
|
||||
for k, v := range attrs {
|
||||
e.Attributes[strings.ToLower(k)] = v
|
||||
}
|
||||
if len(members) > 0 {
|
||||
e.multi[strings.ToLower(AttrUniqueMember)] = append([]string{}, members...)
|
||||
e.Attributes[strings.ToLower(AttrUniqueMember)] = members[0]
|
||||
}
|
||||
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}
|
||||
|
||||
// ListUsers 读取某个 OU 下的全部用户条目(不递归)。
|
||||
func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) {
|
||||
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(objectClass=inetOrgPerson)", userReadAttrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return map[string]*Entry{}, nil // OU 尚未创建,视为空
|
||||
}
|
||||
return nil, fmt.Errorf("读取用户列表失败 (%s): %w", ouDN, err)
|
||||
}
|
||||
|
||||
out := make(map[string]*Entry, len(sr.Entries))
|
||||
for _, e := range sr.Entries {
|
||||
out[strings.ToLower(e.GetAttributeValue(AttrUID))] = entryFrom(e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。
|
||||
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,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return map[string]*Entry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取分组列表失败 (%s): %w", rootDN, err)
|
||||
}
|
||||
|
||||
out := make(map[string]*Entry, len(sr.Entries))
|
||||
for _, e := range sr.Entries {
|
||||
out[strings.ToLower(e.DN)] = entryFrom(e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Members 返回分组条目的成员 DN 集合(全小写,便于比较)。
|
||||
//
|
||||
// 两种成员属性都识别:目录可能用 groupOfNames/member,也可能用
|
||||
// groupOfUniqueNames/uniqueMember,取决于既有约定。写操作会使用配置指定的那一种,
|
||||
// 但读取必须宽松,否则会漏掉另一类成员关系。
|
||||
func (e *Entry) Members() map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, attr := range memberAttrs {
|
||||
for _, m := range e.multi[strings.ToLower(attr)] {
|
||||
out[strings.ToLower(m)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExistsDN 判断某个 DN 是否存在,用于幂等前置检查。
|
||||
func (c *Conn) ExistsDN(dn string) (bool, error) {
|
||||
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||
"(objectClass=*)", []string{"dn"}, nil,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("检查 DN 是否存在失败 (%s): %w", dn, err)
|
||||
}
|
||||
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 {
|
||||
ent := &Entry{
|
||||
DN: e.DN,
|
||||
Attributes: make(map[string]string, len(e.Attributes)),
|
||||
multi: make(map[string][]string),
|
||||
}
|
||||
// multiValuedAttrs 记录必须按多值处理的属性。
|
||||
// 不能只凭「值个数 > 1」判断:一个只含 admin 的分组其 uniqueMember
|
||||
// 只有一个值,若按单值属性存放,Members() 就会返回空集合,导致
|
||||
// 成员比对完全失效(集成测试捕获过这个问题)。
|
||||
multiValued := map[string]bool{
|
||||
strings.ToLower(AttrMember): true,
|
||||
strings.ToLower(AttrUniqueMember): true,
|
||||
}
|
||||
|
||||
for _, a := range e.Attributes {
|
||||
key := strings.ToLower(a.Name)
|
||||
if multiValued[key] {
|
||||
ent.multi[key] = append([]string{}, a.Values...)
|
||||
if len(a.Values) > 0 {
|
||||
ent.Attributes[key] = a.Values[0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(a.Values) == 1 {
|
||||
ent.Attributes[key] = a.Values[0]
|
||||
continue
|
||||
}
|
||||
ent.multi[key] = a.Values
|
||||
if len(a.Values) > 0 {
|
||||
ent.Attributes[key] = a.Values[0]
|
||||
}
|
||||
}
|
||||
return ent
|
||||
}
|
||||
Reference in New Issue
Block a user