Got Directory Services?

New Ways to Manage Active Directory using the .NET Framework 2.0

Ethan Wilansky

This article discusses:

  • Enhancements in the System.DirectoryServices namespace
  • Using the InvokeSet and InvokeGet methods to manage ADSI properties
  • Reading and writing Active Directory security descriptors
  • Using extended search capabilities in Active Directory
This article uses the following technologies:
.NET Framework 2.0, Active Directory

Code download available at:DirectoryServices.exe(183 KB)

Contents

Welcome InvokeGet and InvokeSet
Active Directory Security Descriptors
Finding Your Way with DirectorySearcher
Tombstone Search
ExtendedDN Search
Attribute Scoped Query
Virtual List View Search
What Else?

As more and more large enterprises use Active Directory®, Microsoft has been investing time and effort to make it simpler to install, deploy, and manage. Devs will see enhancements to the System.DirectoryServices namespace in the NET Framework 2.0. In addition, Microsoft has added two new namespaces under System.DirectoryServices:ActiveDirectory , which provides Active Directory management, and Protocols, which lets you work with the Lightweight Directory Access Protocol (LDAP) 3.0 and Directory Services Markup Language (DSML) 2.0 standards.

Just as in the .NET Framework 1.x, System.DirectoryServices is a managed code layer on top of Active Directory Service Interfaces (ADSI). Note that only some of the classes in the ActiveDirectory namespace and none of the Protocols namespace rely on the ADSI layer. When you needed to get to an ADSI interface that wasn't available from managed code, the simplest approach was to use COM interop. In the Microsoft® .NET Framework 2.0, the need to use COM interop is significantly reduced. This article explores a number of these improvements in managed code, from calling ADSI properties, to interacting with security descriptors, and finally through using some powerful extended search capabilities.

Welcome InvokeGet and InvokeSet

Reading and writing attributes of directory objects is a common management activity in directories such as Active Directory. To read and write attributes with the .NET Framework, you first bind to the directory object using the DirectoryEntry class (part of the System.DirectoryServices namespace) and then use the PropertyValueCollection class to read or write values. After writing a value, you commit the change to the directory by calling the CommitChanges method of the DirectoryEntry class. Figure 1 shows a typical example of a read and write operation on a single-valued attribute.

Figure 1 Reading and Writing an Attribute

using System; using System.DirectoryServices; // Bind to the directory and set the path to an object DirectoryEntry de = new DirectoryEntry(); de.Path = "LDAP://CN=User1,OU=TechWriters,DC=Fabrikam,DC=Com"; // Display the value of the displayName attribute Console.WriteLine("The displayName attribute is: {0}", de.Properties["displayName"].Value); // Set a value on the displayName attribute and commit the // change to Active Directory de.Properties["displayName"].Value = "User One"; de.CommitChanges();

In both reading and writing attributes, System.DirectoryServices is relying on the ADSI IADs core interface. The beauty of relying on IADs is the consistency it affords from attribute to attribute. In both cases, "displayName" is the lDAPDisplayName of the attribute to read or write. Every attribute in Active Directory has an associated lDAPDisplayName.

Reading and writing multivalued attributes is almost as easy. After binding to a directory object, as shown in Figure 1, use a foreach loop to iterate through the contents of a multivalued attribute. To write a multivalued attribute, use the PropertyValueCollection Add or Insert methods. To add multiple values, use the AddRange method. And, as you guessed, to delete values use the Remove method. This snippet demonstrates how to use the AddRange method to write two values to the otherTelephone multivalued attribute:

... //bind to a directory object as shown in the previous code snippet //Add more than one additional value using the AddRange method de.Properties["otherTelephone"].AddRange( new object[] { "<phone number 1>", "<phone number 2>" }); de.CommitChanges();

When it comes to managing directory attributes, the details are most important. There are three characteristics of an attribute that complicate their reading and writing:

  • An attribute's syntax
  • Operations the system lets you perform on that attribute
  • The meaning of the data in the attribute

See the Platform SDK Active Directory schema topics for more information about these characteristics. In addition, the .NET Framework 2.0 includes the new System.DirectoryServices.ActiveDirectory namespace that you can use to interrogate attributeSchema objects. This namespace can help you with the first two attribute characteristics. The third characteristic, the meaning of the data in the attribute, might require that you read the SDK documentation to understand the data.

The Active Directory schema contains the following major categories of data types: simple, string, time, and object reference. Within each of these major categories, Active Directory defines multiple attribute syntaxes (28 in all), such as Boolean and integer simple data types, NTSecurityDescriptor and OctetString string data types, and GeneralizedTime and UTCTime date data types. You can get a full list of the data types from the Platform SDK in the topic Syntaxes for Active Directory Attributes or by reviewing the contents of the ADSTypeEnum enumeration. There are a variety of syntax choices of which you need to be cognizant before writing code to read attributes.

Consider the following code that reads and displays the value of the userAccountControl attribute:

Console.WriteLine("userAccountControl: {0}", de.Properties["userAccountControl"].Value);

The userAccountControl attribute is an integer data type whose return value will be something like 544. Unless you know a little bit more about the attribute, this decimal value is meaningless. It turns out that the userAccountControl attribute is a bit mask for various settings associated with a user account. You set flags (defined in the ADSI ADS_USER_FLAG enumeration) by changing a bit value in userAccountControl. For example, you can check whether a user account is disabled by evaluating the second bit in the userAccountControl attribute (ADS_UF_ACCOUNTDISABLE flag), as the following code demonstrates:

using System; using System.DirectoryServices; using ActiveDs; //this is for getting to the AD user flag enumeration ... bool isDisabled; isDisabled = ((int)de.Properties["userAccountControl"].Value & (int)ADS_USER_FLAG.ADS_UF_ACCOUNTDISABLE) != 0; Console.WriteLine("Account disabled: {0}", isDisabled);

An example of setting the bits in userAccountControl by using the PropertyValueCollection.Value property is included in the code download for this article. (Before compiling and running the code, make sure that you read the "About the Code" sidebar.)

Note that the previous code snippet uses the ADSI ActiveDs type library. This ADSI type library exposes, among other things, the ADS_USER_FLAG enumeration. While you can set a constant to the value of ADS_UF_ACCOUNTDISABLE, it's more elegant to take advantage of this and other enumerations in ActiveDs if an enumeration isn't already exposed through managed code.

About the Code

The accompanying code download includes a sample app that covers the features explored in the article. I wrote it as a console application to keep the code as straightforward as possible. After you compile the application and run it, a series of switches will appear. You can call any of these switches to run the samples. Here are important caveats before compiling and running this application:

  1. The code is not meant to be a production-ready utility. There is very little error-handling in the code.
  2. You need to change the path values in the program.cs file to valid paths in your implementation of Active Directory.
  3. Your computer needs to be a member of the domain where the code will run.
  4. You need to run this on Active Directory, not ADAM.
  5. You need to be logged on as administrator or equivalent.
  6. Make sure the .NET Framework 2.0 is installed on the computer where you will compile and run the code.
  7. Run the InvokeSet switch before running the InvokeGet switch.

So far, all of the examples have shown reasonably simple uses of the PropertyValueCollection class for read and write operations. However, this is just scratching the surface. There are some attributes that cannot be read nor set using the underlying ADSI IADs core interface alone. An excellent example of this is the user account lockout status. The ADSI documentation suggests that Active Directory stores lockout status in the fifth bit of the userAccountControl attribute. This likely applies to the lockout status of SAMAccounts, but not Active Directory accounts. Instead, you have to read the value of the lockoutTime attribute to determine if and when an account was locked. The lockoutTime attribute stores a Universal Time Coordinate (UTC) value in 100-nanosecond increments representing exactly when the account was locked after a start date of 1/1/1601. The attribute syntax for lockoutTime is LargeInteger. The PropertyValueCollection class does not provide a method for converting the large integer into a readable value. Instead, you have to help the PropertyValueCollection class by using COM interop to get to the ADSI IADs LargeInteger interface. Figure 2 shows code that performs this task.

Figure 2 Reading the lockoutTime Attribute

//COM Interop for accessing the IADsLargeInteger interface using ActiveDs; using System.DirectoryServices; public static void GetLockoutTime(DirectoryEntry de) { // Change this to a valid ADSPath in your implementation of // Active Directory. de.Path = "LDAP://CN=user1,OU=TechWriters,DC=Fabrikam,DC=Com"; if (de.Properties.Contains("lockoutTime")) { IADsLargeInteger int64Value = (IADsLargeInteger)de.Properties["lockoutTime"].Value; // This is the standard formula for converting the large integer // (it's highpart and lowpart) into a readable value. long largeInt = (long)((uint) int64Value.LowPart + (((long) int64Value.HighPart) << 32 )); if (largeInt != 0) { Console.WriteLine("Lockout Status {0}", largeInt); //pad the date with the starting date of 1\1\1601 DateTime dtmPad = new DateTime(1601, 1, 1); DateTime dtm = new DateTime(largeInt); Console.WriteLine("DC reported time account locked is {0}", dtm.AddTicks(dtmPad.Ticks).ToLocalTime().ToString()); } else Console.WriteLine("lockoutTime is set to: {0}", largeInt); } else Console.WriteLine("lockoutTime is not set"); }

The purpose of this code, however, is not to show you how to read the lockoutTime attribute or to show you how to read attributes stored as large integers. Instead, it's a preamble to my proposal that sometimes sticking with the IADs core interface isn't worth the heavy lifting.

Thankfully, there's another option: using ADSI properties to read values instead of Active Directory attributes. For example, you can determine whether an account is locked by using the IsAccountLocked property of the IADsUser persistent interface. To read this property using the .NET Framework 1.1, you have to use the Type.InvokeMember method and reflection, as shown in the following code snippet:

using System.DirectoryServices; using System.Reflection; ... object ads = de.NativeObject; Type type = ads.GetType(); bool isLocked = (bool)type.InvokeMember ("IsAccountLocked", BindingFlags.GetProperty, null, ads, null); Console.WriteLine("The IsAccountLocked property is: {0}", isLocked);

This is a fair amount of work to retrieve a single property and, thankfully, the .NET Framework 2.0 makes this very easy by exposing the InvokeGet and InvokeSet methods for reading and writing properties of ADSI objects. In contrast to the code in Figure 2 and the snippet just shown, here's what it takes to read and display the IsAccountLocked property using InvokeGet:

using System.DirectoryServices; ... Console.WriteLine("Account locked: {0}", de.InvokeGet("IsAccountLocked"));

Writing a property is just as easy. InvokeSet takes two parameters, the first being the property name and the second being the value you want to set. Active Directory restricts you to reading the underlying lockoutTime attribute and setting it to 0 or unlocked. Therefore, InvokeSet takes one parameter—in this case, the Boolean false:

de.InvokeSet("IsAccountLocked", false); de.CommitChanges();

In general, I don't recommend using ADSI properties because they do not provide access to all attributes associated with an Active Directory object, and their names often diverge from the underlying lDAPDisplayName of the attribute they modify. In some cases this name divergence is necessary because a single Active Directory attribute might store values for multiple settings in Active Directory even though an ADSI property represents only one of those settings. The userAccountControl attribute and the IsAccountDisabled property provide a perfect example of that divergence. However, for attributes that are difficult to read and write any other way, InvokeGet and InvokeSet are welcome additions to the DirectoryEntry class. Additional examples appear in this article's code download. For example, the code download demonstrates using InvokeGet and InvokeSet respectively for reading and writing terminal services user profile settings contained in the userParameters attribute and reading and writing multivalued properties.

Active Directory Security Descriptors

In the .NET Framework 1.x, you modified Active Directory security descriptors using COM interop for accessing three of the four ADSI Security interfaces (IADsSecurityDescriptor, IADsAccessControlList, and IADsAccessControlEntry). Probably one of the most significant improvements in System.DirectoryServices moving from the .NET Framework version 1.x to version 2.0 is the ability you now have to modify Active Directory and Active Directory Application Mode (ADAM) security descriptors from managed code.

If you aren't already painfully familiar with security descriptors, I suggest you take a look at Keith Brown's Security Briefs column in the January 2005 issue of MSDN®Magazine and Mark Novak's "Safety in Windows" article in the November 2004 issue of MSDN Magazine. Although these two articles are not primers on security descriptors, they do provide insights into the basis for a lot of the improvements in Active Directory security descriptor management.

Figure 3 shows the structure of an Active Directory security descriptor and the primary .NET Framework 2.0 namespaces and classes that you use to interact with it.

Figure 3 Security Descriptor Structure

Figure 3** Security Descriptor Structure **

The Active Directory ntSecurityDescriptor attribute is a complex structure. Working with the attribute from managed code requires a variety of classes in the System.DirectoryServices, System.Security.Principal, and System.Security.AccessControl namespaces. This multiple namespace involvement is the result of work Microsoft undertook in building the managed Access Control List (ACL) classes. These classes provide a unified approach for modifying security descriptors. While all this class involvement might make you think that interacting with the ntSecurityDescriptor attribute is difficult, it's not so bad.

Prior to working with the security descriptor, you must bind to an Active Directory object, as I showed in my first code snippet. After binding to an object, you retrieve the security descriptor by using the new System.DirectoryServices ObjectSecurity property. But not so fast! Before you call ObjectSecurity, you need to decide if reading from or writing to the System Access Control List (SACL) is important to you. The SACL contains Access Control Entries (ACEs) for applying auditing permissions to an Active Directory object.

If reading the SACL is important, you employ the services of the DirectoryEntryConfguration class. This class, new in the .NET Framework 2.0, calls the ADSI IADsObjectOptions interface for accessing provider-specific options. Currently IADsObjectOptions supports Active Directory and, in this case, you use it to retrieve the SACL from the security descriptor. The following code demonstrates how you get the SACL and then get the ntSecurityDescriptor prior to performing Active Directory security descriptor management operations:

//include the SACL of the security descriptor de.Options.SecurityMasks = SecurityMasks.Owner | SecurityMasks.Group | SecurityMasks.Dacl | SecurityMasks.Sacl; // Get the Active Directory security descriptor by using the // ObjectSecurity property ActiveDirectorySecurity sd = de.ObjectSecurity;

Figure 4 shows how you can read properties of the security descriptor. After binding to the Active Directory object and retrieving its security descriptor, use the GetGroup and GetOwner methods to get the primary group and security descriptor owner. The return value in both cases is a SecurityIdentifier that you can easily display as a Windows NT® user account using the translate method of the SecurityIdentifier class, as the code demonstrates.

Figure 4 Reading the Security Descriptor

using System; using System.DirectoryServices; using System.Security.Principal; public static void ADSReadACLsExp( DirectoryEntry de, ActiveDirectorySecurity sd) { Console.WriteLine("\r\nACLs of DE"); //The return value of GetGroup is a SID string grpSID = sd.GetGroup((typeof(SecurityIdentifier))).Value; Console.WriteLine("Group SID: {0}", grpSID); //Similar operation to get the SD owner. Uses GetOwner string ownerSID = sd.GetOwner((typeof(SecurityIdentifier))).Value; Console.WriteLine("Owner SID: {0}", ownerSID); /* GetGroup and GetOwner return the SID in Security Descriptor * Definition Language (SDDL) format. An efficient way to return the * account name is to use the Translate method to convert the * SecurityIdentifier to an NTAccount, then call the value property * of the NTAccount class to retrieve the account name. */ NTAccount groupAcctName = (NTAccount)sd.GetGroup(typeof(SecurityIdentifier)) .Translate(typeof(NTAccount)); Console.WriteLine("Primary Group Account name:\t{0}", groupAcctName.Value); NTAccount ownerAcctName = (NTAccount)sd.GetOwner(typeof(SecurityIdentifier)) .Translate(typeof(NTAccount)); Console.WriteLine("Owner Account name:\t\t{0}", ownerAcctName.Value); //get the AccessRules for this Active Directory object's SID //(boolean values) //Windows Server 2003 correctly orders ALL ACEs when a modified //security descriptor is written back to the property cache Console.WriteLine("ACEs in DACL are properly ordered:\t{0}", sd.AreAccessRulesCanonical); //this means whether permissions are inherited or not. //if it's true, then inheritance is disabled. Console.WriteLine("ACE inheritance in DACL is disabled:\t{0}", sd.AreAccessRulesProtected); Console.WriteLine("ACEs in SACL are properly ordered:\t{0}", sd.AreAuditRulesCanonical); Console.WriteLine("ACE inheritance in SACL is disabled:\t{0}", sd.AreAuditRulesProtected); }

The remaining properties appearing in Figure 4 provide information on whether the security descriptor contains correctly ordered ACEs and whether the ACEs are inherited. Windows Server™ 2003 correctly orders the ACEs while in Windows 2000 Server you must write back ACEs from the local property cache to the security descriptor in the proper order. Therefore, it's worthwhile to check the AreAccessRulesCanonical and AreAuditRulesCanonical properties when managing and reading security descriptors on Windows 2000 Server.

An Active Directory security descriptor contains two ACLs: the Discretionary Access Control List (DACL) and the SACL. These lists contain ACEs, as Figure 3 shows. In the case of the DACL, they are access permissions, and in the case of the SACL, they are audit permissions. Managing these two types of ACEs is essentially the same in managed code with some small differences.

The solution that you can download with this article includes examples for reading and writing ACEs in an ACL. Rather than spending too much time on the entire code for reading and writing ACEs, I'll point out several important differences between reading ACEs in a DACL versus a SACL.

There are two methods for accessing the ACEs in a security descriptor, GetAccessRules for ACEs in a DACL and GetAuditRules for ACEs in an SACL. These methods allow you to specify whether you want to get only explicitly assigned ACEs, only inherited ACEs, or both. The following code demonstrates how you get both explicitly assigned and inherited ACEs from a security descriptor named sd:

sd.GetAccessRules(true, true, typeof(SecurityIdentifier))

The GetAccessRules method returns a collection of authorization rules (AuthorizationRuleCollection) while the GetAuditRules method returns a collection of audit rules (AuditRuleCollection). In either case, begin by verifying that the Count property of the collection is greater than 0. If so, call the GetType method to determine whether the ACEs returned are either ActiveDirectoryAccessRule or ActiveDirectoryAuditRule types. With that information, you can then iterate the collection and call applicable properties of the ACE. For instance, for an ActiveDirectoryAccessRule, you call the AccessControlType property to determine if the ACE is an Allow or Deny type while if the ACE is an ActiveDirectoryAuditRule, you call the AuditFlags property to determine if the ACE is a Success or Failure type.

Finding Your Way with DirectorySearcher

System.DirectoryServices search capabilities in the .NET Framework 2.0 provide powerful new features that are not present in the .NET Framework 1.0. In the following section I will explore these four new search capabilities:

  • Directory Synchronization (DirSync) search
  • Tombstone search
  • Attribute Scoped Query (ASQ) search
  • Virtual List View (VLV) search

Directory Synchronization search is an interesting feature that is new to System.DirectoryServices in the .NET Framework 2.0. This feature provides an effective approach for tracking changes occurring in an Active Directory domain, schema, or configuration partition. DirSync is a managed code layer on top of the ADSI DirSync control. The control is an LDAP server extension designed to specifically search for changes in Active Directory.

A DirSync operation is a two-step process. First, you capture a snapshot of a partition and then store it as a blob data (a cookie). Next, you read the cookie back and compare it to the same partition at a later time. You should save a cookie in whatever data store you will use to perform comparison or synchronization operations with Active Directory (for example, in SQL Server).

To keep this example straightforward, Figure 5 demonstrates how to capture a snapshot of the partition to the file system. Like all search operations from managed code, you use the DirectorySearcher class to create an object for configuring and executing the search. The DirectorySearcher requires a directory entry object. In the case of a DirSync search, the directory entry object must begin at the root of a partition. For example, to begin a search at the root of the fabrikam.com domain, the path property of the directory entry object is:

de.Path = "LDAP://DC=Fabrikam,DC=Com";

Figure 5 Taking a Snapshot for a Later DirSync Search

public static void DirSyncSnapShot(DirectoryEntry de) { //Create a DirectorySearcher object and specify //the root of the domain for the search using(DirectorySearcher srch = new DirectorySearcher(de)) { //Optionally, limit the search using a filter srch.Filter = "ou=tech*"; //Set the search to the entire subtree srch.SearchScope = SearchScope.Subtree; //Create a DirectorySynchronization object to begin //a DirSync operation - take a snapshot of the directory srch.DirectorySynchronization = new DirectorySynchronization(); Console.WriteLine( "The OUs within scope for this sync. search are:"); using(SearchResultCollection results = srch.FindAll()) { foreach (SearchResult res in results) { Console.WriteLine("\t{0}", res.Path); } } //Create a file and return a FileStream object in preparation //for storing data in the file. If the file exists, it will be //overwritten. BinaryFormatter bFormat = new BinaryFormatter(); using(FileStream fsStream = new FileStream("ADSync.data", FileMode.Create)) { //Serialize the data to the steam. To get the data for //the cookie, call the GetDirectorySynchronizationCookie //method. bFormat.Serialize(fsStream, srch.DirectorySynchronization. GetDirectorySynchronizationCookie()); } } }

To create the DirectorySearcher object, provide the DirectorySearcher constructor with the directory entry object representing the root of a partition. Following the creation of the DirectorySearcher, you can set properties on the object. For example, Figure 5 shows how to set the Filter property to limit the search to a set of organizational units (OUs) and how to set the search scope to Subtree to search everything below the starting point of the search.

Next, create a DirectorySynchronization object from the DirectorySearcher to create a cookie to hold the search results. After iterating the SearchResult collection, you need to store the DirSync cookie somewhere. In Figure 5, I use a FileStream object and a BinaryFormatter object to create a file stream, and then serialize the data to the stream in binary format. The GetDirectorySynchronizationCookie method of the DirectorySynchronization object is responsible for providing the data to the stream.

Following the snapshot operation and after a change or two in directory objects within scope of the original capture operation, you run a DirSync search again to detect changes. Figure 6 shows the code for comparing the Active Directory partition to the stored cookie on the file system. The method returns a Boolean value so that you can inform the user if no change was detected. This code snippet shows an example of calling code for the DirSyncChanges class:

if (!DirSynch.DirSyncChanges(de)) Console.WriteLine("No changes detected");

Figure 6 Performing a DirSync Search to Detect Changes

public static bool DirSyncChanges(DirectoryEntry de) { bool changesDetected = false; //Open the data source containing the snapshot. BinaryFormatter bFormat = new BinaryFormatter(); byte[] cookie; using(FileStream fsStream = new FileStream("ADSync.data", FileMode.Open)) { //Deserialize the data in the file stream cookie = (byte[])bFormat.Deserialize(fsStream); } //Initialize a new instance of the DirectorySynchronization class and //pass it the cookie that you deserialized using the BinaryFormatter using (DirectorySynchronization syncData = new DirectorySynchronization(cookie)) { //Create a DirectorySearcher object DirectorySearcher srch = new DirectorySearcher(de); //Set the DirectorySearcher object DirectorySynchronization //property equal to the cookie you retrieved from a data store. srch.DirectorySynchronization = syncData; //Example option to specify that the replicate changes right is //unnecessary, but limit results to objects and attributes to //which the caller has rights to read syncData.Option = DirectorySynchronizationOptions.ObjectSecurity; //Iterate results using(SearchResultCollection results = srch.FindAll()) foreach (SearchResult res in results) { //Entering loop means a change was detected changesDetected = true; ResultPropertyCollection deltaAttribs; deltaAttribs = res.Properties; Console.WriteLine( "adsPath of created or changed object: {0}\n", res.Path); Console.WriteLine("New or changed attributes\n"); foreach (string deltaAttrib in deltaAttribs.PropertyNames) { switch (deltaAttrib.ToLower()) { case "objectguid": case "adspath": case "instancetype": case "distinguishedname": break; default: Console.Write(deltaAttrib); foreach (object attribVals in deltaAttribs[deltaAttrib]) { Console.WriteLine(": {0}", attribVals); } break; } } } Console.WriteLine("\n{0}\n", new string('-', 10)); } return changesDetected; }

To start the comparison operation, load the cookie (the prior snapshot of the directory partition) from the data store. In Figure 5, the code uses the file system as the data store and it creates a file named ADSync.data. Therefore, as shown in Figure 6, create a FileStream object to open the cookie stored in the ADSync.data file. Next, create a BinaryFormatter object and deserialize the data in the file stream. With the deserialized binary data in memory stored in a variable named cookie, call the DirectorySynchronization constructor and this time pass in the cookie as the first parameter of the constructor to create a DirectorySynchronization object named syncData. The DirectorySynchronization class also contains an Option property to control exactly what data is returned in a DirSync search. The ObjectSecurity object appearing in Figure 6 relaxes the requirement that the caller running this search must have the replicate change right to see detected changes. However, it also limits search results to objects and attributes to which the caller has permission to read.

Now that you have a DirectorySynchronization object representing the original snapshot, create a DirectorySearcher object and provide it with the directory entry object representing the partition to compare to the cookie. Be sure that the root partition you provide as a parameter to the DirectorySearcher constructor is the same partition used for cookie creation. Next, set the DirectorySynchronization property of the DirectorySearcher object equal to syncData (the DirectorySynchronization object representing the original snapshot of the partition) and iterate the collection of search results. If there isn't anything to iterate, the changesDetected variable initialized in the beginning of the class code does not get set to true and the class returns false to indicate that no changes were detected.

Inside of the foreach loop, you might notice a little more is happening than a typical iteration of a SearchResultCollection object. The directory synchronization results will always contain the ADsPath and the objectGUID, instanceType and distinguishedName attributes of an object. This information is useful in a directory synchronization operation, but might not be as interesting to you for simply displaying the detected changes in a directory partition. Therefore, after returning the ADsPath of the new or changed object, the code skips repeating the ADsPath and doesn't list the three attributes returned by default for any new or changed object.

The code in Figure 6 does not do anything special to deal with complex attributes. For example, suppose you modify the security descriptor of an object. When you run the code, it will tell you that the ntSecurityDescriptor has changed, but it won't tell you exactly how it changed. As I mentioned earlier, you must accommodate the special characteristics of attributes, such as attribute syntax, in order to read or write to them in your code. Using various examples included with this article's code download should provide you with a starting point for getting to the details of changes occurring in complex attributes.

Tombstone Search

While DirSync search in managed code is a very useful feature for auditing new object creation and changes to existing objects, there are times when you might also want to determine when or if objects were deleted from a directory. This is where the new Tombstone search feature in the .NET Framework 2.0 comes in quite handy. Tombstone search enables the DirectorySearcher to return deleted objects that remain in the directory. Deleted objects are not typically returned by the DirectorySearcher. The default retention policy for deleted directory objects in Active Directory is 60 days. If the retention period for an object hasn't expired, you can use the Tombstone search feature in managed code to read information about the deleted object, as shown in Figure 7.

Figure 7 Performing a Tombstone Search

public static void deletedObjs(DirectoryEntry de) { de.AuthenticationType = AuthenticationTypes.FastBind | AuthenticationTypes.Secure; using(DirectorySearcher srch = new DirectorySearcher(de)) { //Return only deleted objects srch.Filter = "isDeleted=TRUE"; //Search OneLevel if you know your starting point. srch.SearchScope = SearchScope.OneLevel; string[] attribs = new string[] { "lastknownparent", "whencreated", "whenchanged"}; srch.PropertiesToLoad.AddRange(attribs); // Instruct the DirectorySearcher to return deleted objects srch.Tombstone = true; Console.WriteLine("Objects in the deleted objects container"); Console.WriteLine("New Relative Distinguished Name (RDN) is:"); string ADsPathFormat = @"<original RDN name>\OADEL:<previous objectGUID string>," + "<dn of current location>"; Console.WriteLine(new string('_', ADsPathFormat.Length) + Environment.NewLine); using(SearchResultsCollection results = srch.FindAll()) { foreach (SearchResult res in results) { if (res.Path.ToLower() == de.Path.ToLower()) { Console.WriteLine("Deleted Objects Container:"); Console.WriteLine("ADsPath: {0}\n", res.Path); } else { Console.WriteLine("ADsPath: {0}", res.Path); Console.WriteLine("LastKnownParent:\t{0}", res.Properties["lastknownparent"][0]); DateTime dt = DateTime.Parse( res.Properties["whencreated"][0].ToString()); Console.WriteLine("WhenCreated (local):\t{0}\n", dt.ToLocalTime()); } } } } }

To begin a Tombstone search, use the DirectorySearcher class to create an object for configuring and executing the search. The directory entry object for a Tombstone search can be the domain partition. However, it's more efficient to start the search in the Deleted Objects container. This is the container where all deleted objects (orphans) are stored for the default retention period. For example, the following directory entry path specifies this special container in the fabrikam.com domain:

de.Path = "LDAP://cn=Deleted Objects,DC=Fabrikam,DC=Com";

Before you attempt to iterate the SearchResultCollection object, the code must specify the FastBind option of the System.DirectoryServices.AuthenticationTypes enumeration. This instructs ADSI not to verify that the object actually exists in Active Directory before executing the query. FastBind serves other purposes as well, but for the goal of performing a Tombstone search, the other capabilities are not essential to running the code. Setting the Filter property of the DirectorySearcher to "isDeleted=TRUE" causes the DirectorySearcher to return only deleted items. Setting the SearchScope property of the DirectorySearcher to OneLevel is appropriate in this case because Active Directory stores all deleted objects in the Deleted Objects container. You'll see in Figure 7 that I'm only interested in a handful of returned attributes about each object. I specify these attributes in which I'm interested by using the PropertiesToLoad.AddRange method. The next part of the code is essential to performing a search for deleted objects. That is, setting the Tombstone property of the DirectorySearcher object to true. If you don't do this, the DirectorySearcher will not return deleted objects in the search results.

After configuring the DirectorySearcher for a Tombstone search, the code retrieves the SearchResultCollection using the FindAll method. In the ensuing iteration of the search results, I do massage the data a little so that the code returns the whenCreated attribute in local time.

If you run the code, you will notice a somewhat odd ADsPath. The ADsPath format of a deleted object is in this form:

LDAP://<original RDN name>\OADEL:<previous objectGUID string>, <DN of current location>

Active Directory stores the deleted object with its original relative distinguished name (RDN) and its original GUID followed by the fully qualified DN of its current location. For example, the following results show the ADsPath for a group named group1. The output appears on lines one and two:

ADsPath: LDAP://CN=group1\0ADEL:939a7dd5-02be-4616-abeb-59751b315f0f, CN=Deleted Objects,DC=FABRIKAM,DC=COM LastKnownParent: OU=HR,DC=FABRIKAM,DC=COM WhenCreated (local): 7/21/2005 11:06:23 AM

Notice the other attributes that I've chosen to return in the code. The LastKnownParent attribute indicates the last place where the object existed prior to being deleted. The WhenCreated value indicates when the object was created in the Deleted Objects folder, which indicates the approximate time that the object was deleted from Active Directory.

ExtendedDN Search

The next search capability I'll explore is ExtendedDN search. In most cases anyone who works with System.DirectoryServices is used to looking at an object's distinguishedName attribute in distinguished name format. For example, the DN of User1 in the HR OU of the fabrikam.com domain is:

CN=User1,OU=HR,DC=fabrikam,DC=com

This common format is useful, but there are times you might also want to read the SID of a security principal or the GUID of any object in Active Directory. Granted, you can bind to an object in the directory and use the new InvokeGet method to easily get the GUID property of an object from the IAD's core interface or use the SecurityIdentifier class to translate an SID into an account name. However, neither of these approaches use the high-performance search facility built into ADSI and extended to System.DirectoryServices. This is where ExtendedDN search becomes useful. Using the ExtendedDN enumeration, you can specify any of these DN formats: None, Standard, or HexString. None is the default distinguished name format. Standard returns a GUID, SID, and DN in string format. HexString returns the GUID and SID in hex format. Here's an example of a User1 account in Standard format:

<GUID=9564482a-4822-4da4-bd38-ab6a740dc2d8>;<SID=S-1-5-21-2422933499- 3002364838-2613214872-1173>;CN=User1,OU=TechWriters,DC=FABRIKAM,DC=COM

And here is the same account in HexFormat:

<GUID=2a4864952248a44dbd38ab6a740dc2d8>;<SID=010500000000000515000000f b076b90a673f4b2987ec29b95040000>;CN=User1,OU=TechWriters,DC=FABRIKAM,D C=COM

The code download includes examples that demonstrate how you perform an ExtendedDN search against objects in an OU and objects in the special Deleted Objects container. The essential element for performing an ExtendedDN search is specifying the ExtendedDN property of the DirectorySearcher and providing a value equal to one of the three values in the ExtendedDN enumeration. The code in Figure 8 shows how you can use the String.Split method to create a string array and display the GUID and SID as separate string values.

Figure 8 Splitting Contents of an ExtendedDN Search Result

... using(SearchResultCollection results = srch.FindAll()) { foreach (SearchResult res in results) { Console.WriteLine("{0}\n", res.Properties["distinguishedName"][0].ToString()); Console.WriteLine("\nADsPath: {0}", res.Path); String extDn = res.Properties["distinguishedName"][0].ToString(); String[] dnParts = extDn.Split(new char[] { ';' }); if (dnParts.Length == 1) { Console.WriteLine(extDn); } else { Console.WriteLine(dnParts[0]); Console.WriteLine(dnParts[1]); Console.WriteLine(new string('-', 30)); } } }

Attribute Scoped Query

Another valuable feature added to the DirectorySearcher in the .NET Framework 2.0 is the ASQ. In a typical search, you set the search scope to somewhere in a partition, such as the domain partition or an OU. ASQ brings more granularity to search by allowing you to set an attribute as the starting point for a search operation. This is particularly useful for large multivalued attributes, such as the member attribute of an Active Directory group object. Here's a simple search example using ASQ to read the members attribute of a group:

public static void ForMembers(DirectoryEntry de) { using(DirectorySearcher srch = new DirectorySearcher(de)) { srch.SearchScope = SearchScope.Base; srch.AttributeScopeQuery = "member"; srch.PropertiesToLoad.Add("cn"); using(SearchResultCollection results = srch.FindAll()) { foreach (SearchResult res in results) { Console.WriteLine(res.Properties["cn"][0]); } } } }

The DirectoryEntry for an ASQ search is an Active Directory leaf object, such as a user account or group. For example, the code for DirectoryEntry.Path property for the ForMembers code snippet could be the following:

de.Path = "LDAP://CN=administrators,cn=Builtin,DC=Fabrikam,DC=Com";

Notice that I scoped the search to SearchScope.Base. This is critical because a scope of OneLevel or Subtree is meaningless when scoping a query to an attribute. If you specify anything other than Base, your code will throw an exception. To scope a query to an attribute, you set the AttributeScopeQuery to the name of the attribute.

Virtual List View Search

The last new search feature in the .NET Framework 2.0 I'll discuss here is the Virtual List View search. The purpose of a VLV search is to retrieve a small subset of a large data set returned by ADSI. This is a convenience feature so that users get good performance when searching for a lot of information in a large directory. VLV avoids overburdening a user's local cache with an enormous data set. For a little more information on this feature, see Keith Brown's Security Briefs column mentioned earlier. In addition, I've included a working example of this capability in the download.

What Else?

As this article demonstrates, there are quite a few significant enhancements in the System.DirectoryServices namespace. Other enhancements, such as the SecurityMask search capability, are worth exploring. SecurityMask provides a high-performance approach to returning security descriptor information about objects specified in your search.

Beyond System.DirectoryServices, take a close look at the System.DirectoryServices.ActiveDirectory and System.DirectoryServices.Protocols namespaces. System.DirectoryServices.ActiveDirectory improves access to Active Directory and ADAM with classes designed for how system administrators manage Active Directory. For example, you can use classes in this namespace to manage replication, trust relationships, and the schema. The System.DirectoryServices.Protocols namespace provides classes for working with the LDAP 3.0 and DSML 2.0 standards. As you can see, Microsoft has been hard at work enhancing access to directories and, as expected, significantly improving access to Active Directory and ADAM from managed code.

Ethan Wilansky is a Directory Services MVP and a chief technologist for EDS in its Next Generation Technologies and Architectures practice. He has authored or coauthored more than a dozen books for Microsoft, has just completed coauthoring the Microsoft Shell (MSH) language reference, and is a contributing editor for Windows IT Pro.