Tuesday, February 26, 2013

SPUser and Query-Based Distribution Lists

My current contract is with a company that decided after they installed Exchange 2003 that "if it ain't broke, don't fix it!!!!" So, they haven't updated Exchange in 10 years. It makes life harder in certain ECM and in Records Management situations, but for the most part it really doesn't affect me with my SharePoint work. Until the client wanted to get a task list created from every user in a very particular Distribution List...

First, what is a Query-Based Distribution List? In the Exchange 2003 world it is a security group that contains LDAP query objects, instead of the typical principal entities that normal groups contain. Now, it is a security group in name only. Because it does not contain actual entities, you can't use it to secure anything. You can only use it to email the users that are returned from the query or queries contained within. What makes it tricky is that the membership of the group is dynamic. The user list is created on the fly by the queries every time the group is called. Also, in Exchange 2003, there is no Exchange API that can be used to connect to these groups to resolve the members. Got all that? Good. Here we go!

First things first... In SharePoint if you are given a Security Group or a traditional Distribution List, how do you resolve the membership? Simple!! You need only call the SPUtility.GetPrincipalsInGroup method. That guy will return to you an array of SPPrincipalInfo objects which you can use to create SPUser objects in the manor of your choosing. My preference is to use the SPWeb.EnsureUser method. If the SPUser is not a member of the web or the group EnsureUser will add it and return the resolved SPUser object. If it IS a member it simply returns the SPUser object. It makes no difference if the group is a Distribution List or if the group is a Security Group. This method works for both.
public List<SPUser> GetUsersFromADGroup(string groupName, string groupDisplayName,  System.Collections.Generic.List<SPUser> masterList, SPWeb web) {
bool reachedMaxCount;
SPPrincipalInfo[] principalInfoArray = SPUtility.GetPrincipalsInGroup(web, groupName, int.MaxValue - 1, out reachedMaxCount);
if (principalInfoArray.Count() != 0) {
  foreach (SPPrincipalInfo info in principalInfoArray) {
    if (info.PrincipalType == SPPrincipalType.SecurityGroup || info.PrincipalType == SPPrincipalType.DistributionList) {
     GetUsersFromADGroup(info.LoginName, info.DisplayName,  masterList, web);
   } else {
      try {
       SPUser user = web.EnsureUser(info.LoginName);
        if (!masterList.Any(u => u.Name == user.Name)) {
         masterList.Add(user);
        }
     } catch {
        continue;
     }
   }
  }
  } else {
    GetUsersFromDynamicGroup(groupDisplayName, masterList, web);
  }
    return masterList;
}

A quick bit of code to show how you can get the SPRincipalInfo array using the SPUtility.GetPrincipalsInGroup method, then create SPUsers from that. The try/catch block is there in case there are any orphaned accounts sitting in the groups. These accounts can not be resolved, and will throw exceptions. You can choose to output these to an error list, or simply ignore them as I do here.


BUT, what about the Query-Based Distribution List(QBDL)? Since the QBDL has no actual members, remember the membership of the group is determined by LDAP query, the entities don't actually "belong" to the group, when you call the SPUtility.GetPrincipalsInGroup, you get a SPPrincipalInfo array with no objects. Bummer!

So, where do we go from here? We cannot directly get the group membership. So, have to come at this problem from a different angle. What if we had the LDAP query contained within the AD Query Object? If we had that, we could use .NET's System.DirectoryServices classes to execute it. Great!! So... where is that query held??
The one thing we know for sure about Microsoft Exchange is that they LOVE updating Active Directory Schema. They do it every time there is an update. Fortunately for us, anything that is added to the AD schema, we can very easily pull out and use.
First, you need to add a couple of references. You are going to need to reference System.DirectoryServices and System.DirectoryServices.AccountManagement. Add those guys to your using statements:

using System.DirectoryServices.AccountManagement;
using System.DirectoryServices;

After that we will be constructing the GetUsersFromDynamicGroup method that will be called should the SPPrincipalInfo array return with a count that is equal to 0. You can see the if up in the code, if (principalInfoArray.Count() != 0), and our method GetUsersFromDynamicGroup, being called in the "else" statement.

Now, what do we need to make everything happen. I first need the display name of the group. This is important, because the display name is how the methods in the System.DirectoryServices find the actual group. Why? Because that is how the object is named in LDAP. Note that the SPUtility.GetPrincipalsInGroup uses the login name of the group, NOT the display name. SPUtility.GetPrincipalsInGroup is looking for AD principals, NOT LDAP objects. These two concepts must be kept separate, or this process will not work (LDAP is looking for CN=GroupName,OU=OrgUnitName,DC=DomainName,DC=com, where a principal is looking for a name of DomainName\GroupLogInName).
After I have the display name, I am going to need all of the stuff to make a SPUser so I need the SPWeb object. Since I am returning everything as a List, I want to make sure that I am appending my SPUsers found in the QBDL to whatever else is in the group object, I include the existing List.

The first thing we are going to do in the method is set up a System.DirectoryServices.AccountManagement.PrincipalContext. Why? Well, I need to get a hold of the System.DirectoryServices.DirectoryEntry of the group. From that object, I can read what the members of the group are, and begin to resolve the users. Fortunately, getting the DirectoryEntry is very easy. I create a PrincipalContext using my domain name, then I create a GroupPrincipal using the PrincipalContext and the group display name. Now, it is just a matter of casting the return of the GroupPrincipal.GetUnderlyingObject method as a DirectoryEntry.
PrincipalContext principalContext = new PrincipalContext(ContextType.Domain, "DomainName");
GroupPrincipal groupPrincipal = GroupPrincipal.FindByIdentity(principalContext, IdentityType.Name, groupDisplayName);
DirectoryEntry group = (DirectoryEntry)groupPrincipal.GetUnderlyingObject();

After we have these objects ready to go, we are ready to start the heavy lifting. Now that we have the group as a DirectoryEntry, we can strip out the members of the group, in this case it will be the query objects. Since there really isn't a DirectoryEntry.Members property that will give us a nice DirectoryEntry.MembersCollection, we have to do that on our own. Luckily, we only need to cast the members property as IEnumerable. From there we can create our "foreach" loop and start our work.

object members = group.Invoke("Members", null);
foreach (object member in (IEnumerable)members) {
   //Do work in here
}

Here is where things get a little messy. We now have the Members as generic "objects." We really can't do anything with "objects." C# is a strongly typed language, so we need to transform this "object" in to something that has meaning. So, we need to create a new DirectoryEntry object. We then pass the member object to the DirectoryEntry constructor and, we automagically have a DirectoryEntry object from just a plain old "object." I have to admit here that I don't like using "objects." I like to strongly type everything so there is no confusion at design time or run time as to what objects are and how they can be used... But, because there is no MembersCollection object provided by the DirectoryEntry class, I couldn't figure out away to get the members and put them in a foreach loop. Sure, I could use some other loop, but... I am lazy and I didn't want to. You can make my code suck less and make your own cool loops. I didn't want to worry about it, so I punted and used "object."
Anyway, we have the member as a DirectoryEntry now. This member represents the query object that contains the LDAP query we need to actually resolve the users in the group. So, we can now finally call up the property that stores the LDAP query and execute it!! Yay!!

The property that concerns us is "msExchDynamicDLFilter." Essentially, all we need is that guy's value, and we are off to the next section of our code. Remember that all property objects are Dictionary objects. So you would retrieve the property value the exact same way you would any other dictionary value:
DirectoryEntry.Properties["msExchDynamicDLFilter"].Value.ToString().
Fun, right?

There is one other property that we need to get. Because we want to execute our LDAP query across the entirety of our domain forest, we want to grab the value of the msExchDynamicDLBaseDN property as well. With this guy we will construct the LDAP URI that we will use as our search base.

Now that we have our search base and our LDAP query we are ready to search AD for the members of the Dynamic group. .Net makes this very easy for us, because Microsoft has included a DirectorySearcher class that we can use to search AD. Intuitive, right? Actually it is:
  DirectoryEntry memberEntry = new DirectoryEntry(member);
  string ldapBase = memberEntry.Properties["msExchDynamicDLBaseDN"].Value.ToString();
  ldapBase = string.Format("LDAP://{0}", ldapBase);
  DirectoryEntry adRoot = new DirectoryEntry(ldapBase);
  DirectorySearcher search = new DirectorySearcher(adRoot, memberEntry.Properties["msExchDynamicDLFilter"].Value.ToString());
  SearchResultCollection results = search.FindAll();
 foreach (SearchResult result in results) {
//Create your SPUsers here
}

As you can see, we create the DirectorySearcher object using the the search base (adRoot), and the LDAP query (memberEntry.Properties["msExchDynamicDLFilter"].Value.ToString()). Microsoft provides us with a SearchResultCollection, nice of them, and all we need to do is call the FindAll method to populate it. There is also a FindOne method, if you are only looking for a single item. I'm looking for lots and lots, so I call FindAlll.
As with all of Microsoft's "collection" objects, the SearchResultsCollection inherits IEnumerable, so we can create a foreach loop using it.

Now it is just a matter of getting the property in the SearchResult object that contains the user's login name. After we have that, we need only call SPWeb.EnsureUser(loginName) and we are ready to add our SPUser object to the master List list. We do that the same way that we did it above:

 string loginName = string.Format("TRONOX\\{0}", result.Properties["samaccountname"].Value.ToString());
 try {
    SPUser user = web.EnsureUser(loginName);
    if (!masterList.Any(u => u.Name == user.Name)) {
      masterList.Add(user);
    }
} catch {
    continue;
}


You will notice that I do a little LINQ after calling EnsureUser and before I actually add the SPUser object to the List. I don't want any duplicates, so I check to see if there is a user already in the list with the same Name. If there isn't, I add it to the list. If there is, I ignore the object.

That is all there is to it!! It takes some getting around, but it is possible to get the membership of the QBDL!

Here are both of the methods that I was using in their entirety:

public List<SPUser> GetUsersFromADGroup(string groupName, string groupDisplayName,  System.Collections.Generic.List<SPUser> masterList, SPWeb web) {
 bool reachedMaxCount;
 SPPrincipalInfo[] principalInfoArray = SPUtility.GetPrincipalsInGroup(web, groupName, int.MaxValue - 1, out reachedMaxCount);
 if (principalInfoArray.Count() != 0) {
  foreach (SPPrincipalInfo info in principalInfoArray) {
    if (info.PrincipalType == SPPrincipalType.SecurityGroup || info.PrincipalType == SPPrincipalType.DistributionList) {
     GetUsersFromADGroup(info.LoginName, info.DisplayName,  masterList, web);
   } else {
      try {
       SPUser user = web.EnsureUser(info.LoginName);
        if (!masterList.Any(u => u.Name == user.Name)) {
         masterList.Add(user);
        }
     } catch {
        continue;
     }
   }
  }
  } else {
    GetUsersFromDynamicGroup(groupDisplayName, masterList, web);
  }
    return masterList;
}


public List<SPUser> GetUsersFromDynamicGroup(string groupDisplayName, List<SPUser> masterList, SPWeb web) {
 PrincipalContext principalContext = new PrincipalContext(ContextType.Domain, "TRONOX");
 GroupPrincipal groupPrincipal = GroupPrincipal.FindByIdentity(principalContext, IdentityType.Name, groupDisplayName);
 DirectoryEntry group = (DirectoryEntry)groupPrincipal.GetUnderlyingObject();
 object members = group.Invoke("Members", null);
  foreach (object member in (IEnumerable)members) {
    DirectoryEntry memberEntry = new DirectoryEntry(member);
    string ldapBase = memberEntry.Properties["msExchDynamicDLBaseDN"].Value.ToString();
    ldapBase = string.Format("LDAP://{0}", ldapBase);
    DirectoryEntry adRoot = new DirectoryEntry(ldapBase);
    DirectorySearcher search = new DirectorySearcher(adRoot, memberEntry.Properties["msExchDynamicDLFilter"].Value.ToString());
    SearchResultCollection results = search.FindAll();
    foreach (SearchResult result in results) {
       string loginName = string.Format("TRONOX\\{0}", result.Properties["samaccountname"].Value.ToString());
       try {
          SPUser user = web.EnsureUser(loginName);
             if (!masterList.Any(u => u.Name == user.Name)) {
                masterList.Add(user);
             }
       } catch {
           continue;
       }
    }
  }
 return masterList;
}

No comments:

Post a Comment