The hardest parts of talking to Active Directory (or LDAP) are first learning the query language, and second figuring out what is 'good' to query and what is not. This document will focus on how to talk to active directory in a way that is quick, accurate, and most importantly easy to understand.
LDAP Query Hell
What makes this hell is that the documentation on HOW to query LDAP is quite extensive, but documentation on WHAT to query is almost non-existent. The best tutorial I found on how to structure an LDAP query was available from Microsoft here. Unfortunately you just have to get used to the syntax. You'll notice that while it's a good explination of HOW to query LDAP, it doesn't tell you WHAT to query. This turns into pretty much a guessing game of obscure LDAP controls such as CN, OU, ObjectGUID, ANR, ObjectClass, etc etc etc. If anyone finds any concise documentation on this, please let me know. Furthermore, until you get pretty deep into it, there's nothing to let you know that SOME values are indexed, while others are not. This means that if you are searching (for example) on the ObjectClass property instead of ObjectCategory property, your queries are going to be slow and expensive. On top of all of this, some of the LDAP categories are buried in bit masks and sub-objects. A good example of this is the 'IsDisabled' property, which is actually a bitmask of the 'UserAccountControl' integer property assigned to a user account!
Thankfully, the System.DirectoryServices library makes most of this work much easier on the programmer, if not actually easy. Here I will document some simple controls that will allow you to browse the totality of the LDAP properties at once, as well as document the stickiest problems I encountered.
Easing Into LDAP Querying
The most essential query strings are:
- (&(ObjectCategory=Person)(CN="John Jones")). This query is extremely quick and extremely versatile. CN is short-hand for "Canonical Name" and while you think this is the same as "Display Name", it isn't, but I'm not getting into that right now. Accept that if you're looking for a user and you think you know his first and last name, this is the best query for you. This query accepts the * wildcard.
- (&(ObjectCategory=Person)(anr="jjones")). I have no idea what ANR stands for, but if you're looking for a way to query a user by logon identity, this is it.
- (&(ObjectGUID="[LDAP GUID]")). By far this is the best way to find any object in Active Directory. Lightning fast and 100% accurate, I recommend finding the GUIDs for your potential users and then re-searching Active Directory with it. This is the ONLY value in LDAP that is a guaranteed unique identifier for the lifetime of the object.
- (&(ObjectCategory=Group)(CN="Administrators")). This is a great way to find a specific group, again using the "CN" operator.
Why use the ObjectCategory qualifier instead of ObjectClass?
Just like a relational database, Active directory keeps indexes of some attributes, but not of other attributes. I've found that objectCategory and objectClass are extremely similar, so I recommend using ObjectCategory since it is an indexed value.
This is enough to get us started. Later, we'll talk about modifying the root lookup container and doing simultaneous recursive lookups, but these queries above will give us all the basic information we need out of Active Directory.
The Core System.DirectoryServices toolset
There are some fundemental tasks that DirectoryServices can perform:
- Connecting to AD
- Searching AD
- Modifying AD
This section will only cover the first two tasks, since I haven't yet explored the third.
Connecting to Active Directory
In order to understand connections, you really need to understand LDAP paths and query strings. Basically, every single object in Active Directory has a unique path. The path is combined with a query string to retrieve non-unique paths. So for example, If I know John Smith's direct path, I can use the following LDAP string to connect directly to his object:
LDAP://CN=John Smith,OU=Users,OU=SYND,DC=microsoft,DC=fabrikam,DC=com
This is important because when you connect to Active directory, you need to provide a starting point, and depending on what you're doing, you may want to start in different places.
The absolute best and broadest way I have found to connect to Active directory is like so:
Private Function CreateDirectoryEntry() As DirectoryEntry Dim RootDSE As String Dim DSESearcher As New DirectorySearcher() RootDSE = DSESearcher.SearchRoot.Path Dim de As New DirectoryEntry(RootDSE, "DOMAIN\user", "password") de.AuthenticationType = AuthenticationTypes.Secure 'optional Return de End Function
This code connects you directly to the root path of the machine where the code is run. Simple, clean and effective. I can hear you saying, "that's nice, but what if my enterprise has more than one domain? I want to search the whole directory, not just that one domain!!"
Well, tough luck. As far as I know, there is no way to connect directly to the 'entire directory.' However, using the code below you can mimic this by connecting to all the domains that you know of:
Private Function CreateDirectoryEntries(ByVal RootDomains() As String) As ArrayList 'takes a string array of domains like 'microsoft.fabrikam.com' Dim des As New ArrayList Try For Each domain As String In RootDomains domain = domain.Insert(0, "DC=") domain = domain.Replace(".", ", DC=") Dim ent As DirectoryEntry = New DirectoryEntry("LDAP://" + domain, "DOMAIN\user", "password") ent.AuthenticationType = AuthenticationTypes.Secure des.Add(ent) Next Return des 'returns an arraylist of directory entries Catch ex As Exception End Try Return Nothing End Function
This is why it's important to understand how the paths work. Finally, the most advanced way to connect to Active Directory is by manufacturing your own path. This is the fastest way to retrieve data on a particular object, so this comes in handy if you're grabbing a user's membership information or something else like that.
'--snip-- 'In this code, I'm passing in the DistinguishedName (ie the specific path) of a particular object. I have to check for backslashes first because backslashes are a special character in LDAP. For Each character As Char In DN_ESCAPE_CHARS If objectDn.Contains(character) Then objectDn = objectDn.Replace(character, "\" + character) End If Next ent = New DirectoryEntry("LDAP://" + objectDn, "DOMAIN\user", "password") ent.AuthenticationType = AuthenticationTypes.Secure '--snip--
Searching Active Directory
Now, we get to the meat. We're connected to AD but how do we get what we want? For me, I need to basically an entire copy of Active Directory in a relational database. This means that I need to query every object for every property. So, I started with something simple:
Public Function FindUserByCN(ByVal cn As String, Optional ByVal Attribs() As String = Nothing) As ArrayList 'This function takes a string that can be a partial or full CommonName, like 'Jerry*', '*Jerry*', or 'Jerry Seinfeld'. 'It will also fetch some attributes if you want them. Dim UserList As New ArrayList Dim de As DirectoryEntry = CreateDirectoryEntry() Dim Search As New DirectorySearcher(de) 'The DirectorySearcher is simply a step you have to take to get from your root path (de) to your object path (what you're looking for). With Search 'Setup the options .SearchRoot = de .Filter = "(&(ObjectCategory=Person)(CN=" + cn + "))" .PageSize = 1000 'PageSize is important. If you leave this at it's default (0) then your search will only return at maximum 1,000 records. by setting this to 1,000 it now returns 1,000 pages of 1,000 records. End With Dim results As SearchResultCollection = Search.FindAll 'Go Git Em! Using results 'It's important to dispose your SearchResultCollection!! Not doing so causes a memory leak!! If results Is Nothing Then UserList.Add("No users were found that match " + cn) Else For Each result As SearchResult In results If Attribs Is Nothing Then UserList.Add(New Guid(DirectCast(result.Properties("objectguid")(0), Byte())).ToString()) Else Dim userrow As New ArrayList userrow.Add(New Guid(DirectCast(result.Properties("objectguid")(0), Byte())).ToString()) For Each Attribute In Attribs userrow.Add(result.Properties(Attribute)(0)) 'This needs to be fixed. If the attribute isn't found in the collection and you try to access it's property array, you generate an exception. Next End If Next End If End Using Return UserList 'this returns an array of arrays (conceptually a table with rows and columns) with the found record's GUIDs and the requested attributes. End Function
This is the best way to search in my opinion, and it's easy to generalize this for whatever type of search you're doing. You can also create a directory entry object from the search result and then pull the properties off of that, but I found that they're available from the result, so I just pull them from there.
Update
I encountered a really frustrating problem with CLR / Active Directory this weekend. Everything was wroking fine, and then I started getting the following error:
Creating Directory Entries Failed: LDAP://DC=microsoft, DC=fabrikam, DC=com The server is not operational.
A full stack trace got me this:
System.Runtime.InteropServices.COMException (0x8007203A): The server is not operational. at System.DirectoryServices.DirectoryEntry.Bind(Boolean throwIfFail) at System.DirectoryServices.DirectoryEntry.Bind() at System.DirectoryServices.DirectoryEntry.get_AdsObject() at System.DirectoryServices.DirectorySearcher.FindAll(Boolean findMoreThanOne) at CLR_v2.SearchAD.FillGroupTable(String[] RequestedAttributes, SqlConnection conn, ArrayList Entries)
From what I can tell, for some reason the DirectoryEntry I'd created was 'expiring', but I couldn't find a good reason why. The closest I could get is that it's expiration had something to do with the credentials I was providing. To fix this, I simply switched my authentication type:
de.AuthenticationType = AuthenticationTypes.Securede.AuthenticationType = AuthenticationTypes.ReadonlyServer
This seems to have addressed the issue.