3

Our Active Directory groups are containing 500k users, one even more than a million users.

We are adding and removing users from groups using the System.DirectoryServices.AccountManagement namespace, as described here: https://stackoverflow.com/a/2143742/1099519

The code itself works perfectly fine, besides the fact that is super slow, adding a user takes up to a minute, sometimes even more!

I could figure out the following line of code, seems to trigger a lazy load mechanism in .net:

adGroupPrincipal.Members.Add(userPrincipal);

I used Wireshark to see what's happening, when calling GroupPrincipal.Members.Add(UserPrincipal) and I saw a lot of network traffic. My assumption: Accessing the Members property triggers a lazy load method to get all members of a group.

In the official documentation of the Members-property (https://msdn.microsoft.com/en-us/library/system.directoryservices.accountmanagement.groupprincipal.members(v=vs.110).aspx) is no information of its behavior.

Comparing adding a user the "old school" way with DirectoryEntry of the System.DirectoryServices namespace as such:

DirectoryEntry groupEntry = new DirectoryEntry("LDAP://server/CN=GROUPNAME,OU=Groups,OU=_CUSTOMERS,DC=srv,DC=tld", "USERNAME", "PASSWORD");
string userDn = String.Concat("LDAP://server/CN=", samAccountName, ",OU=Groups,OU=_CUSTOMERS,DC=srv,DC=tld"));    
groupEntry.Invoke("Add", new object[] { userDn });
groupEntry.CommitChanges();

That takes roughly 50ms.

Note that the Invoke("Add", new object[] { userDn }) method I used, was recommend in this Stackoverflow article Server is unwilling to process the request - Active Directory - Add User via C# in order to avoid the "Server is unwilling to process the request" exception

So basically my workaround does the job, but somehow I am not 100% happy, as I actually prefer to use the System.DirectoryServices.AccountManagement namespace, any ideas how to avoid the performance issue using that namespace?

Community
  • 1
  • 1
DominikAmon
  • 892
  • 1
  • 14
  • 26
  • From the source code it seems that GroupPrincipal loads membership when you access its Members field. This class is not designed for high performance tasks. Using DirectoryEntry or even classes from System.DirectoryServices.Protocols namespace is a solution here – oldovets Apr 25 '17 at 20:03

1 Answers1

4

I opened an "Advisory Call" at Microsoft for this issue, here is their answer (in German, English below):

S.DS.AM (System.DirectoryServices.Accountmanagement) ist nun nicht der Renner unter den Programmierschnittstellen, Bequemlichkeit ist Trumpf, perf-issues mit großen Gruppen sind also by Design. Wenn er auf Performance aus ist, sollte er S.DS.P (System.DirectoryServices.Protocols) oder plain LDAP verwenden.“

The meaningful translation in English would be:

Comparing the APIs, S.DS.AM (System.DirectoryServices.Accountmanagement) is not a "racer", but comfort is trump. Performance issues for larger groups is by design. When performance matters, use S.DS.P (System.DirectoryServices.Protocols) or plain LDAP.

I created a Console application in order to measure the differences of adding and removing a user from a group in milliseconds.

AccountManagement

public static void InsertGroupAccountManagement(UserPrincipal userPrincipal)
{
    using (GroupPrincipal adGroup = GroupPrincipal.FindByIdentity(_principalGroupContext, IdentityType.Guid, PRODUCT_USER_GROUP_ID))
    {
        adGroup.Members.Add(userPrincipal);
        adGroup.Save();
        adGroup.Members.Remove(userPrincipal);
        adGroup.Save();
    }
}

DirectoryServices

public static void InsertGroupDirectoryServices(string samAccountName)
{
    DirectoryEntry groupEntry = new DirectoryEntry("LDAP://server.address/CN=PSO_PRODUCT_USER,OU=PSO_,OU=Groups,OU=_PRODUCT,DC=address,DC=server", "USERNAME", "PASSWORD");
    string userDn = String.Concat("LDAP://server.address/CN=", samAccountName, ",OU=Users,OU=_PRODUCT,DC=address,DC=server");
    DirectoryEntry userEntry = new DirectoryEntry(userDn, "USERNAME", "PASSWORD");
    groupEntry.Invoke("Add", new object[] { userDn });
    groupEntry.CommitChanges();            
    groupEntry.Invoke("Remove", new object[] { userDn });
    groupEntry.CommitChanges();            
    groupEntry.Close();
}

Protocols

public static void InsertGroupProtocols(string samAccountName)
{
    LdapDirectoryIdentifier ldapDirectoryIdentifier = new LdapDirectoryIdentifier("server.address");
    NetworkCredential credentials = new NetworkCredential("USERNAME", "PASSWORD");
    LdapConnection ldapConnection = new LdapConnection(ldapDirectoryIdentifier, credentials);
    ldapConnection.SessionOptions.ProtocolVersion = 3;
    ldapConnection.SessionOptions.Signing = true;
    ldapConnection.SessionOptions.Sealing = true;
    ldapConnection.AuthType = AuthType.Negotiate;
    ldapConnection.Bind();

    // Add
    DirectoryAttributeModification addDirectoryModification = new DirectoryAttributeModification();
    addDirectoryModification.Name = "member";
    addDirectoryModification.Add(String.Concat("CN=", samAccountName, ",OU=Users,OU=_PRODUCT,DC=address,DC=server"));
    addDirectoryModification.Operation = DirectoryAttributeOperation.Add;

    ModifyRequest addRequest = new ModifyRequest("CN=PSO_PRODUCT_USER,OU=PSO_,OU=Groups,OU=_PRODUCT,DC=address,DC=server", addDirectoryModification);
    ModifyResponse addResponse = ldapConnection.SendRequest(addRequest) as ModifyResponse;

    // Remoove
    DirectoryAttributeModification deleteDirectoryModification = new DirectoryAttributeModification();
    deleteDirectoryModification.Name = "member";
    deleteDirectoryModification.Add(String.Concat("CN=", samAccountName, ",OU=Users,OU=_PRODUCT,DC=address,DC=server"));
    deleteDirectoryModification.Operation = DirectoryAttributeOperation.Delete;

    ModifyRequest deleteRequest = new ModifyRequest("CN=PSO_PRODUCT_USER,OU=PSO_,OU=Groups,OU=_PRODUCT,DC=address,DC=server", deleteDirectoryModification);
    ModifyResponse deleteResponse = ldapConnection.SendRequest(deleteRequest) as ModifyResponse;
}

Result table in milliseconds

Running 10 tests in a row

Result table of time taken

So in my particular case the solution via DirectoryServices / DirectoryEntry is the fastest.

DominikAmon
  • 892
  • 1
  • 14
  • 26
  • The results you provided seem not accurate. DirectoryEntry uses ADSI under the hood. ADSI itself calls LDAP API methods. Classes from S.DS.P namespace call LDAP methods directly. S.DS.P should be the fastest one. I assume that this caused by the fact that you enabled encryption and signing for LDAP but did not enable for DirectoryEntry. I. e. call DirectoryEntry ctor with 4 parameters. The forth one is AuthenticationTypes, where you need to specify encryption and signing. The other way is to disable these options in LDAP. And run the performance tests again, of course – oldovets May 07 '17 at 02:46
  • 1
    I tried again as you recommended to run additional tests by setting SessionOptions.Signing = false; and SessionOptions.Sealing = false; Now the S.DS.P. are between 5ms to 8ms now, but still a bit slower than DirectoryServices. Another test with additionally setting ldapConnection.AuthType = AuthType.Ntlm (Instead of Negotiate), still DirectoryServices was faster. In general, the reply of Microsoft says: When performance matters, use S.DS.P . Maybe there is some sort of caching mechanism under the hood? – DominikAmon May 08 '17 at 05:59