From 2a2831e576457e3ad8dcfdea022ceb7bc5299dd2 Mon Sep 17 00:00:00 2001 From: sauls8t Date: Wed, 29 Aug 2018 16:20:37 +0100 Subject: [PATCH] LDAP group fetching and AD connectivity --- domain/auth/ldap/ad_test.go | 221 +++++++++++++++ domain/auth/ldap/endpoint.go | 1 + domain/auth/ldap/ldap_test.go | 400 +++++++++++++++++++++------ gui/app/pods/customize/auth/route.js | 12 - model/auth/ldap.go | 25 +- 5 files changed, 564 insertions(+), 95 deletions(-) create mode 100644 domain/auth/ldap/ad_test.go diff --git a/domain/auth/ldap/ad_test.go b/domain/auth/ldap/ad_test.go new file mode 100644 index 00000000..54254a86 --- /dev/null +++ b/domain/auth/ldap/ad_test.go @@ -0,0 +1,221 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package ldap + +import ( + "crypto/tls" + "fmt" + "strings" + "testing" + + lm "github.com/documize/community/model/auth" + ld "gopkg.in/ldap.v2" +) + +func TestADServer_UserList(t *testing.T) { + c := lm.LDAPConfig{} + c.ServerHost = "40.117.188.17" + c.ServerPort = 389 + c.EncryptionType = "none" + c.BaseDN = "DC=mycompany,DC=local" + c.BindDN = "CN=ad-admin,CN=Users,DC=mycompany,DC=local" + c.BindPassword = "8B5tNRLvbk8K" + c.UserFilter = "" + c.GroupFilter = "" + + address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) + + t.Log("Connecting to AD server", address) + + l, err := ld.Dial("tcp", address) + if err != nil { + t.Error("Error: unable to dial AD server: ", err.Error()) + return + } + defer l.Close() + + if c.EncryptionType == "starttls" { + t.Log("Using StartTLS with AD server") + err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Error("Error: unable to startTLS with AD server: ", err.Error()) + return + } + } + + // Authenticate with AD server using admin credentials. + t.Log("Binding AD admin user") + err = l.Bind(c.BindDN, c.BindPassword) + if err != nil { + t.Error("Error: unable to bind specified admin user to AD: ", err.Error()) + return + } + + // Get users from AD server by using filter + filter := "" + attrs := []string{} + if len(c.GroupFilter) > 0 { + filter = fmt.Sprintf("(&(objectClass=group)(cn=%s))", c.GroupFilter) + attrs = []string{"cn"} + } else { + filter = "(|(objectCategory=person)(objectClass=user)(objectClass=inetOrgPerson))" + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "sAMAccountName"} + } + + searchRequest := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil { + t.Error("Error: unable to execute directory search: ", err.Error()) + return + } + + t.Logf("AD search entries found: %d", len(sr.Entries)) + if len(sr.Entries) == 0 { + t.Error("Received ZERO AD search entries") + return + } + + for _, entry := range sr.Entries { + t.Logf("[%s] %s (%s %s) @ %s\n", + entry.GetAttributeValue("sAMAccountName"), + entry.GetAttributeValue("cn"), + entry.GetAttributeValue("givenName"), + entry.GetAttributeValue("sn"), + entry.GetAttributeValue("mail")) + } +} + +func TestADServer_Groups(t *testing.T) { + c := lm.LDAPConfig{} + c.ServerHost = "40.117.188.17" + c.ServerPort = 389 + c.EncryptionType = "none" + c.BaseDN = "DC=mycompany,DC=local" + c.BindDN = "CN=ad-admin,CN=Users,DC=mycompany,DC=local" + c.BindPassword = "8B5tNRLvbk8K" + c.UserFilter = "" + c.GroupFilter = "(cn=Accounting)" + + address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) + t.Log("Connecting to AD server", address) + l, err := ld.Dial("tcp", address) + if err != nil { + t.Error("Error: unable to dial AD server: ", err.Error()) + return + } + defer l.Close() + + if c.EncryptionType == "starttls" { + t.Log("Using StartTLS with AD server") + err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Error("Error: unable to startTLS with AD server: ", err.Error()) + return + } + } + + // Authenticate with AD server using admin credentials. + t.Log("Binding AD admin user") + err = l.Bind(c.BindDN, c.BindPassword) + if err != nil { + t.Error("Error: unable to bind specified admin user to AD: ", err.Error()) + return + } + + // Get users from AD server by using filter + filter := "" + attrs := []string{} + if len(c.GroupFilter) > 0 { + filter = c.GroupFilter + attrs = []string{"dn", "cn", "member"} + } else if len(c.UserFilter) > 0 { + filter = c.UserFilter + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "sAMAccountName"} + } else { + filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "sAMAccountName"} + } + + searchRequest := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + + t.Log("AD search filter:", filter) + sr, err := l.Search(searchRequest) + if err != nil { + t.Error("Error: unable to execute directory search: ", err.Error()) + return + } + + t.Logf("AD search entries found: %d", len(sr.Entries)) + if len(sr.Entries) == 0 { + t.Error("Received ZERO AD search entries") + return + } + + // Get list of group members + rawMembers := sr.Entries[0].GetAttributeValues("member") + fmt.Printf("%s", sr.Entries[0].DN) + + if len(rawMembers) == 0 { + t.Error("Error: group member attribute returned no users") + return + } + + t.Logf("AD group contains %d members", len(rawMembers)) + + for _, entry := range rawMembers { + // get CN element from DN + parts := strings.Split(entry, ",") + if len(parts) == 0 { + continue + } + filter := fmt.Sprintf("(%s)", parts[0]) + + usr := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn", "cn", "givenName", "sn", "mail", "sAMAccountName"}, + nil, + ) + ue, err := l.Search(usr) + if err != nil { + t.Error("Error: unable to execute directory search for group member: ", err.Error()) + return + } + + if len(ue.Entries) > 0 { + for _, ur := range ue.Entries { + t.Logf("[%s] %s (%s %s) @ %s\n", + ur.GetAttributeValue("sAMAccountName"), + ur.GetAttributeValue("cn"), + ur.GetAttributeValue("givenName"), + ur.GetAttributeValue("sn"), + ur.GetAttributeValue("mail")) + } + } else { + t.Log("group member search failed:", filter) + } + } +} diff --git a/domain/auth/ldap/endpoint.go b/domain/auth/ldap/endpoint.go index c4997522..86a0343b 100644 --- a/domain/auth/ldap/endpoint.go +++ b/domain/auth/ldap/endpoint.go @@ -92,6 +92,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) { c.BaseDN = "dc=example,dc=com" c.BindDN = "cn=read-only-admin,dc=example,dc=com" c.BindPassword = "password" + c.UserFilter = "" c.GroupFilter = "" c.DisableLogout = false c.DefaultPermissionAddSpace = false diff --git a/domain/auth/ldap/ldap_test.go b/domain/auth/ldap/ldap_test.go index 28090c91..ee022d1e 100644 --- a/domain/auth/ldap/ldap_test.go +++ b/domain/auth/ldap/ldap_test.go @@ -14,13 +14,14 @@ package ldap import ( "crypto/tls" "fmt" + "strings" "testing" lm "github.com/documize/community/model/auth" ld "gopkg.in/ldap.v2" ) -func TestPublicLDAPServer(t *testing.T) { +func TestPublicLDAPServer_UserList(t *testing.T) { c := lm.LDAPConfig{} c.ServerHost = "ldap.forumsys.com" c.ServerPort = 389 @@ -28,85 +29,7 @@ func TestPublicLDAPServer(t *testing.T) { c.BaseDN = "dc=example,dc=com" c.BindDN = "cn=read-only-admin,dc=example,dc=com" c.BindPassword = "password" - c.GroupFilter = "" - - address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) - - t.Log("Connecting to LDAP server ", address) - - l, err := ld.Dial("tcp", address) - if err != nil { - t.Error("Error: unable to dial LDAP server: ", err.Error()) - return - } - defer l.Close() - - if c.EncryptionType == "starttls" { - t.Log("Using StartTLS with LDAP server") - err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) - if err != nil { - t.Error("Error: unable to startTLS with LDAP server: ", err.Error()) - return - } - } - - // Authenticate with LDAP server using admin credentials. - t.Log("Binding LDAP admin user") - err = l.Bind(c.BindDN, c.BindPassword) - if err != nil { - t.Error("Error: unable to bind specified admin user to LDAP: ", err.Error()) - return - } - - // Get users from LDAP server by using filter - filter := "" - attrs := []string{} - if len(c.GroupFilter) > 0 { - filter = fmt.Sprintf("(&(objectClass=group)(cn=%s))", c.GroupFilter) - attrs = []string{"cn"} - } else { - filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" - attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} - } - - searchRequest := ld.NewSearchRequest( - c.BaseDN, - ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, - filter, - attrs, - nil, - ) - - sr, err := l.Search(searchRequest) - if err != nil { - t.Error("Error: unable to execute directory search: ", err.Error()) - return - } - - t.Logf("entries found: %d", len(sr.Entries)) - if len(sr.Entries) == 0 { - t.Error("Received ZERO entries") - return - } - - for _, entry := range sr.Entries { - fmt.Printf("[%s] %s (%s %s) @ %s\n", - entry.GetAttributeValue("uid"), - entry.GetAttributeValue("cn"), - entry.GetAttributeValue("givenName"), - entry.GetAttributeValue("sn"), - entry.GetAttributeValue("mail")) - } -} - -func TestLocalLDAPServer(t *testing.T) { - c := lm.LDAPConfig{} - c.ServerHost = "127.0.0.1" - c.ServerPort = 389 - c.EncryptionType = "starttls" - c.BaseDN = "ou=people,dc=planetexpress,dc=com" - c.BindDN = "cn=admin,dc=planetexpress,dc=com" - c.BindPassword = "GoodNewsEveryone" + c.UserFilter = "" c.GroupFilter = "" address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) @@ -162,9 +85,9 @@ func TestLocalLDAPServer(t *testing.T) { return } - t.Logf("entries found: %d", len(sr.Entries)) + t.Logf("LDAP search entries found: %d", len(sr.Entries)) if len(sr.Entries) == 0 { - t.Error("Received ZERO entries") + t.Error("Received ZERO LDAP search entries") return } @@ -177,3 +100,316 @@ func TestLocalLDAPServer(t *testing.T) { entry.GetAttributeValue("mail")) } } + +func TestPublicLDAPServer_Groups(t *testing.T) { + c := lm.LDAPConfig{} + c.ServerHost = "ldap.forumsys.com" + c.ServerPort = 389 + c.EncryptionType = "none" + c.BaseDN = "dc=example,dc=com" + c.BindDN = "cn=read-only-admin,dc=example,dc=com" + c.BindPassword = "password" + c.UserFilter = "" + c.GroupFilter = "(ou=Chemists)" + + address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) + t.Log("Connecting to LDAP server", address) + l, err := ld.Dial("tcp", address) + if err != nil { + t.Error("Error: unable to dial LDAP server: ", err.Error()) + return + } + defer l.Close() + + if c.EncryptionType == "starttls" { + t.Log("Using StartTLS with LDAP server") + err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Error("Error: unable to startTLS with LDAP server: ", err.Error()) + return + } + } + + // Authenticate with LDAP server using admin credentials. + t.Log("Binding LDAP admin user") + err = l.Bind(c.BindDN, c.BindPassword) + if err != nil { + t.Error("Error: unable to bind specified admin user to LDAP: ", err.Error()) + return + } + + // Get users from LDAP server by using filter + filter := "" + attrs := []string{} + if len(c.GroupFilter) > 0 { + filter = c.GroupFilter + attrs = []string{"dn", "cn"} + } else if len(c.UserFilter) > 0 { + filter = c.UserFilter + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } else { + filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } + + searchRequest := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + + t.Log("LDAP search filter:", filter) + sr, err := l.Search(searchRequest) + if err != nil { + t.Error("Error: unable to execute directory search: ", err.Error()) + return + } + + t.Logf("LDAP search entries found: %d", len(sr.Entries)) + if len(sr.Entries) == 0 { + t.Error("Received ZERO LDAP search entries") + return + } + + // Get list of group members + rawMembers := sr.Entries[0].GetAttributeValues("uniqueMember") + fmt.Printf("%s", sr.Entries[0].DN) + + if len(rawMembers) == 0 { + t.Error("Error: group member attribute returned no users") + return + } + + t.Logf("LDAP group contains %d members", len(rawMembers)) + + for _, entry := range rawMembers { + // get CN element from DN + parts := strings.Split(entry, ",") + if len(parts) == 0 { + continue + } + filter := fmt.Sprintf("(%s)", parts[0]) + + usr := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn", "cn", "givenName", "sn", "mail", "uid"}, + nil, + ) + ue, err := l.Search(usr) + if err != nil { + t.Error("Error: unable to execute directory search for group member: ", err.Error()) + return + } + + if len(ue.Entries) > 0 { + for _, ur := range ue.Entries { + t.Logf("%s", ur.GetAttributeValue("mail")) + } + } else { + t.Log("group member search failed:", filter) + } + } +} + +func TestLocalLDAPServer_AllUsers(t *testing.T) { + c := lm.LDAPConfig{} + c.ServerHost = "127.0.0.1" + c.ServerPort = 389 + c.EncryptionType = "starttls" + c.BaseDN = "ou=people,dc=planetexpress,dc=com" + c.BindDN = "cn=admin,dc=planetexpress,dc=com" + c.BindPassword = "GoodNewsEveryone" + c.UserFilter = "" + c.GroupFilter = "" + + address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) + + t.Log("Connecting to LDAP server", address) + + l, err := ld.Dial("tcp", address) + if err != nil { + t.Error("Error: unable to dial LDAP server: ", err.Error()) + return + } + defer l.Close() + + if c.EncryptionType == "starttls" { + t.Log("Using StartTLS with LDAP server") + err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Error("Error: unable to startTLS with LDAP server: ", err.Error()) + return + } + } + + // Authenticate with LDAP server using admin credentials. + t.Log("Binding LDAP admin user") + err = l.Bind(c.BindDN, c.BindPassword) + if err != nil { + t.Error("Error: unable to bind specified admin user to LDAP: ", err.Error()) + return + } + + // Get users from LDAP server by using filter + filter := "" + attrs := []string{} + if len(c.GroupFilter) > 0 { + filter = c.GroupFilter + attrs = []string{"dn", "cn"} + } else if len(c.UserFilter) > 0 { + filter = c.UserFilter + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } else { + filter = "(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } + + searchRequest := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + + t.Log("LDAP search filter:", filter) + sr, err := l.Search(searchRequest) + if err != nil { + t.Error("Error: unable to execute directory search: ", err.Error()) + return + } + + t.Logf("LDAP search entries found: %d", len(sr.Entries)) + if len(sr.Entries) == 0 { + t.Error("Received ZERO LDAP search entries") + return + } + + for _, entry := range sr.Entries { + t.Logf("[%s] %s (%s %s) @ %s\n", + entry.GetAttributeValue("uid"), + entry.GetAttributeValue("cn"), + entry.GetAttributeValue("givenName"), + entry.GetAttributeValue("sn"), + entry.GetAttributeValue("mail")) + } +} + +func TestLocalLDAPServer_UsersInGroup(t *testing.T) { + c := lm.LDAPConfig{} + c.ServerHost = "127.0.0.1" + c.ServerPort = 389 + c.EncryptionType = "starttls" + c.BaseDN = "dc=planetexpress,dc=com" + c.BindDN = "cn=admin,dc=planetexpress,dc=com" + c.BindPassword = "GoodNewsEveryone" + c.UserFilter = "" + c.GroupFilter = "(&(objectClass=group)(cn=ship_crew))" + + address := fmt.Sprintf("%s:%d", c.ServerHost, c.ServerPort) + + t.Log("Connecting to LDAP server", address) + + l, err := ld.Dial("tcp", address) + if err != nil { + t.Error("Error: unable to dial LDAP server: ", err.Error()) + return + } + defer l.Close() + + if c.EncryptionType == "starttls" { + t.Log("Using StartTLS with LDAP server") + err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Error("Error: unable to startTLS with LDAP server: ", err.Error()) + return + } + } + + // Authenticate with LDAP server using admin credentials. + t.Log("Binding LDAP admin user") + err = l.Bind(c.BindDN, c.BindPassword) + if err != nil { + t.Error("Error: unable to bind specified admin user to LDAP: ", err.Error()) + return + } + + // Get users from LDAP server by using filter + filter := "" + attrs := []string{} + if len(c.GroupFilter) > 0 { + filter = c.GroupFilter + attrs = []string{"cn", "member"} + } else if len(c.UserFilter) > 0 { + filter = c.UserFilter + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } else { + filter = "(|(objectCategory=person)(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + attrs = []string{"dn", "cn", "givenName", "sn", "mail", "uid"} + } + + searchRequest := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + attrs, + nil, + ) + + t.Log("LDAP search filter:", filter) + sr, err := l.Search(searchRequest) + if err != nil { + t.Error("Error: unable to execute directory search: ", err.Error()) + return + } + + t.Logf("LDAP search entries found: %d", len(sr.Entries)) + if len(sr.Entries) == 0 { + t.Error("Received ZERO LDAP search entries") + return + } + + // Get list of group members + rawMembers := sr.Entries[0].GetAttributeValues("member") + + if len(rawMembers) == 0 { + t.Error("Error: group member attribute returned no users") + return + } + + t.Logf("LDAP group contains %d members", len(rawMembers)) + + for _, entry := range rawMembers { + // get CN element from DN + parts := strings.Split(entry, ",") + if len(parts) == 0 { + continue + } + filter := fmt.Sprintf("(%s)", parts[0]) + + usr := ld.NewSearchRequest( + c.BaseDN, + ld.ScopeWholeSubtree, ld.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn", "cn", "givenName", "sn", "mail", "uid"}, + nil, + ) + ue, err := l.Search(usr) + if err != nil { + t.Error("Error: unable to execute directory search for group member: ", err.Error()) + return + } + + if len(ue.Entries) > 0 { + for _, ur := range ue.Entries { + t.Logf("%s", ur.GetAttributeValue("mail")) + } + } else { + t.Log("group member search failed:", filter) + } + } +} diff --git a/gui/app/pods/customize/auth/route.js b/gui/app/pods/customize/auth/route.js index b1c38e5e..1ac01de0 100644 --- a/gui/app/pods/customize/auth/route.js +++ b/gui/app/pods/customize/auth/route.js @@ -31,18 +31,6 @@ export default Route.extend(AuthenticatedRouteMixin, { authConfig: null, }; - // TEST - // TEST - // TEST - // TEST - // TEST - this.get('global').syncLDAP().then(() => {}); - // TEST - // TEST - // TEST - // TEST - // TEST - return new EmberPromise((resolve) => { let constants = this.get('constants'); diff --git a/model/auth/ldap.go b/model/auth/ldap.go index 939430a3..9b5f66ee 100644 --- a/model/auth/ldap.go +++ b/model/auth/ldap.go @@ -11,7 +11,21 @@ package auth -// LDAPConfig connection information +// LDAPConfig that specifies LDAP server connection details and query filters. +// +// +// Example for Active Directory -- filter users that belong to SomeGroupName: +// (&(objectCategory=Person)(sAMAccountName=*)(memberOf=cn=SomeGroupName,ou=users,dc=example,dc=com)) +// +// Example for Active Directory -- filter all users that belong to SomeGroupName: +// (&(objectCategory=Person)(sAMAccountName=*)(memberOf:1.2.840.113556.1.4.1941:=cn=SomeGroupName,ou=users,dc=example,dc=com)) +// +// Example for Active Directory -- filter all users that belong to MyGroup1, MyGroup2 or MyGroup3: +// (&(objectCategory=Person)(sAMAccountName=*)(|(memberOf=cn=MyGroup1,ou=users,dc=example,dc=com)(memberOf=cn=MyGroup2,ou=users,dc=example,dc=com)(memberOf=cn=MyGroup3,ou=users,dc=example,dc=com))) +// +// Example of group filter that returns users belonging to either Developers or Administrators group: +// (&(objectCategory=Group)(|(cn=developers)(cn=administrators))) +// type LDAPConfig struct { ServerHost string `json:"serverHost"` ServerPort int `json:"serverPort"` @@ -19,9 +33,18 @@ type LDAPConfig struct { BaseDN string `json:"baseDN"` BindDN string `json:"bindDN"` BindPassword string `json:"bindPassword"` + UserFilter string `json:"userFilter"` GroupFilter string `json:"groupFilter"` DisableLogout bool `json:"disableLogout"` DefaultPermissionAddSpace bool `json:"defaultPermissionAddSpace"` + AttributeUserRDN string `json:"attributeUserRDN"` + AttributeUserID string `json:"attributeUserID"` // uid or sAMAccountName + AttributeUserFirstname string `json:"attributeUserFirstname"` + AttributeUserLastname string `json:"attributeUserLastname"` + AttributeUserEmail string `json:"attributeUserEmail"` + AttributeUserDisplayName string `json:"attributeUserDisplayName"` + AttributeUserGroupName string `json:"attributeUserGroupName"` + AttributeGroupMember string `json:"attributeGroupMember"` } // LDAPUser details user record returned by LDAP