Expanding Group Membership
A common issue that developers face is how to unroll or expand the group membership of a given group. Given that groups can contain a variety of objects, such as users, computers, or other groups, what appears to be a very simple idea can quickly grow complex. Compounding this issue is the fact that since groups can contain other groups, membership often can be duplicated when fully expanded, and even circular. These particular problems lend themselves well to a recursive solution. We will tackle this problem slightly differently, depending on the version of the framework that is being used, but the general algorithm will remain the same.
Warning: ADsPath Is Not the Same As DN
A subtle point that developers often overlook when using the IsMember method or the PropertyValueCollection.Contains method is the format that must be presented as an argument. For adding directly to the member attribute, this format is the DN format. When invoking the IADsGroup methods, the format used is the ADsPath format, which includes "LDAP://" (DirectoryEntry.Path is this format). Often we see developers switch between the two formats and become frustrated when it won't work. We should also note that using the Invoke method does not require an explicit CommitChanges call, but that adding to the member attribute naturally does.
- Create a Hashtable to track unique members and groups.
- Starting with the first group, expand the direct membership.
- Determine which members are groups.
- Recurse each group member with the previous algorithm, starting at step #2.
- Add all nongroup members into a collection.
This approach leads us to two implementations of group expansion: one for version 1.1 and one for version 2.0 of the .NET Framework.
Using .NET Version 2.0
Using attribute scope queries (ASQs) allows us to shorten the amount of code we would otherwise need to employ. Since the member attribute is a DN-format attribute, it naturally lends itself to using this type of query. A nice side effect of using ASQs is that the method avoids the need to use range retrieval for large groups. Listing 11.6 shows an example of a class that uses this technique to recursively unroll a group and retrieve all the members. The entire class is available on the book's web site.
Listing 11.6. Expanding Membership in Version 2.0
public class GroupExpander2 { DirectoryEntry group; ArrayList members; Hashtable processed; public GroupExpander2(DirectoryEntry group) { if (group == null) throw new ArgumentNullException("group"); this.group = group; this.processed = new Hashtable(); this.processed.Add( this.group.Properties[ "distinguishedName"][0].ToString(), null ); this.members = Expand(this.group); } public ArrayList Members { get { return this.members; } } private ArrayList Expand(DirectoryEntry group) { ArrayList al = new ArrayList(5000); string oc = "objectClass"; DirectorySearcher ds = new DirectorySearcher( group, "(objectClass=*)", new string[] { "member", "distinguishedName", "objectClass" }, SearchScope.Base ); ds.AttributeScopeQuery = "member"; ds.PageSize = 1000; using (SearchResultCollection src=ds.FindAll()) { string dn = null; foreach (SearchResult sr in src) { dn = (string) sr.Properties["distinguishedName"][0]; if (!this.processed.ContainsKey(dn)) { this.processed.Add(dn, null); //oc == "objectClass", we had to //truncate to fit in book. //if it is a group, do this recursively if (sr.Properties[oc].Contains("group")) { SetNewPath(this.group, dn); al.AddRange(Expand(this.group)); } else al.Add(dn); } } } return al; } //we will use IADsPathName utility function instead //of parsing string values. This particular function //allows us to replace only the DN portion of a path //and leave the server and port information intact private void SetNewPath(DirectoryEntry entry, string dn) { IAdsPathname pathCracker = (IAdsPathname)new Pathname(); pathCracker.Set(entry.Path, 1); pathCracker.Set(dn, 4); entry.Path = pathCracker.Retrieve(5); } } |
We can determine which members are groups by inspecting the objectClass attribute for the group class. This also has the nice side effect that any custom classes that have been derived from the group class will be handled as well.
We are also using the IADsPathName utility interface in Listing 11.6. This interface is a handy way to parse and manipulate paths in LDAP. While we do not explicitly show the COM interop declarations needed to use this interface, they will be included as part of the downloadable code on this book's web site.
Using .NET Version 1.1
Since we do not have the option of using ASQ searches in this version, we must rely on using a DirectorySearcher instance to find our objects by membership. Listing 11.7 shows one such class that has been abbreviated to fit in this book.
Listing 11.7. Expanding Membership in Version 1.1
public class GroupExpander { DirectoryEntry searchRoot; ArrayList members; Hashtable processed; const string DN_ATTRIB = "distinguishedName"; public GroupExpander( DirectoryEntry group, DirectoryEntry searchRoot) { if (group == null) throw new ArgumentNullException("group"); //a null searchRoot can lead to unexpected //behavior, especially with ADAM if (searchRoot == null) throw new ArgumentNullException("group"); this.searchRoot = searchRoot; this.processed = new Hashtable(); this.members = Expand(group); } public ArrayList Members { get{ return this.members; } } public static ArrayList RangeExpansion( DirectoryEntry entry, string attribute) { //RangeExpansion method as shown in Chapter 6 } private ArrayList Expand(DirectoryEntry group) { ArrayList al = new ArrayList(5000); string dn = group.Properties[DN_ATTRIB][0].ToString(); if (!this.processed.ContainsKey(dn)) { this.processed.Add(dn,null); } //first we find all members of nested //groups, then the direct members string filter = String.Format( "(&(objectClass=group)(memberOf={0}))", dn ); DirectorySearcher ds = new DirectorySearcher( this.searchRoot, filter ); using (SearchResultCollection src=ds.FindAll()) { string srDN = null; foreach (SearchResult sr in src) { srDN = (string)sr.Properties[DN_ATTRIB][0]; if (!this.processed.ContainsKey(srDN)) { using (DirectoryEntry grp = sr.GetDirectoryEntry()) { al.AddRange(Expand(grp)); } } } } foreach(string member in RangeExpansion(group,"member")) { //in case our nested groups contained the //same members, we need to check for uniqueness if (!this.processed.ContainsKey(member)) { this.processed.Add(member, null); al.Add(member); } } return al; } } |
Listing 11.7 detects nested groups by searching for group objects that have direct membership to the inspected group via the memberOf attribute. Since we are performing this task recursively, we can get deeply nested and even circular-referenced groups. Other than not using ASQ, the big difference between Listings 11.6 and 11.7 is that we are passing in a reference to the SearchRoot we should use to find other nested groups. Typically, we will use the root of the partition, but it could be anywhere. Passing in the SearchRoot is necessary in order to avoid all sorts of nasty and fragile code to detect where a search should be rooted. Finally, as Listing 11.7 demonstrates, we must use range retrieval to expand the member attribute for large groups. We have omitted the code implementation for the RangeExpansion method, but it is identical to that shown in Listing 6.8, in Chapter 6.
It turns out that both of these methods are fairly fast when performed on small to mid-size (less than 20,000 members) groups. For unknown reasons at the time of this writing, the version 2.0 method had difficulty scaling to very large groups (more than 20,000 members) and will perform badly. Given that it is generally regarded as a bad practice to have groups that contain this many direct members, we are hopeful that none of our readers will actually be affected by this.