Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Effective Active Directory Queries Without System.Directory Services

4.00/5 (7 votes)
10 Aug 2007CPOL11 min read 1   952  
Searching Directory Services with Novell's LDAP library.

Introduction

Directory Services are a critical cog in the enterprise wheel, and with most organizations adopting Directory Services in some form, either to manage user accounts, provision email, or storage space, or simply report on the assets under their control, it is inevitable that most, if not all, of these tasks require automation. While there are existing libraries available in the .NET framework to interface with Directory Services, there have been complaints regarding the speed of these libraries and their questionable interoperability with competing platforms such as OpenLDAP and Novell Directory Services (NDS). Novell has recently released an Open Source library for interfacing with Directory Services that works the same across all X.500 compliant directories, and this library's usage, specifically its search usage, will be the focus of this article. Consider this article an extension of Howto: Everything in Active Directory via C#.

Background

At a recent conference I attended, I met a guy who has released a commercial Active Directory library (AD-Advantage) that apparently does a lot of interesting things in A/D. What I took away from this encounter was that Novell has released an open source LDAP library written entirely in managed code. Microsoft's System.DirectoryServices is not managed code as it is simply a layer that wraps around the existing ADSI COM interfaces. I have focused a large stake of my career on Active Directory so this news was really exciting to me. An open source LDAP interface that works with any X.500 compliant directory means true code portability across platforms. This article is a demonstration of implementing some of the features in the Novell.Directory.Ldap library.

The X.500 Standard

The X.500 standard was originally developed to support the X.400 standard which handles electronic mail and entry lookup. X.500 defines several protocols, but these specifics are outside the scope of this article. The important thing to understand about the X.500 standard is that this defines the principles and structure of the directory. The directory is comprised of a directory Information Tree, which is a hierarchical grouping of entries on a server, or several servers.

Each entry in the tree consists of a set of attributes, and each attribute has one or more values. The values contained within an attribute can consist of several data types, but the most common types used are Unicode-String, Integer, and Octet String. Attributes have the capacity to store individual values or multiple values. We usually refer to a string value with only one value as a single string attribute value such as the cn value, and we refer to a string value with multiple values as a multi-string attribute value such as the memberOf value.

Each entry in the tree contains a distinguishedName attribute which defines the entry's full location in the directory. The distinguishedName is derived from joining its Relative Distinguished Name (RDN) and the RDNs of all the parents up to the root of the tree. For example, a user named Neal.Bailey may have a distinguishedName, CN=Neal.Bailey,OU=USERS,OU=HOME,DC=BAILEYSOFT,DC=LOCAL, which clearly identifies the the exact location of the object in the tree. Using a schema viewing utility such as adsiedit.msc demonstrates this principle in a visual format.

The schematic structure of the directory is defined in the schema, and the schema defines the types of entries that can reside in the directory. It is important to understand that the objectCategory attribute of each entry links directly back to the schema entry that defines its type, and that the objectClass attribute defines the entry's type such as organizational unit, computer, user, group, etc.

The details will make more sense when you begin to formulate effective ADO search query filter strings.

Lightweight Directory Access Protocol (LDAP)

The Lightweight Directory Access Protocol (LDAP) is the protocol for searching and changing Directory Services over TCP/IP. Since LDAP is a standard, any application that conforms to the standard can access and manage the directory. Typically, LDAP services run on port 389 for clear text operations, and port 636 over SSL. The protocol typically accepts the following types of requests from clients:

  • Bind: authenticate
  • Search: locate and retrieve entries
  • Add: add a new entry
  • Delete: remove an entry
  • Modify: change and entry
  • Modify distinguishedName: move/rename
  • Abandon: abort a process
  • Unbind: close a connection

For this article, we'll focus on the search capability.

If you intend on using SSL over LDAP (ldaps) with the Novel.Directory.Ldap library, then you'll need the Mono.Security.dll.

Anatomy of an LDAP Query

While the entire goal of software development is abstraction (or the process of only exposing the members needed by the caller of the application), it is necessary to have a general understanding of Directory Services in order to formulate LDAP queries that are going to return the expected results. If you skimmed over the paragraphs above and do not have a strong understanding of the topics mentioned, I recommend re-reading them before proceeding.

A query language is an important aspect of any system, and Directory Services is no different. From an initial glance, it appears archaic, but once you begin executing queries, it will become more clear. Similar to database design, you generally want to think about all the different types of queries you're going to need in your application before you write a line of code. Additionally, there may be added benefit in creating a simple console application such as the one included in this article, strictly for the purpose of testing queries prior to integrating them into your solution.

There are three types of items you can have within a filter - operators, attributes, and substrings.

Operators

There are four operators that can be used in queries. There is the equals (=) operator, which tests equality. For example: (cn=Neal.Bailey). In addition, there are the range operators greater than (>=) and less than or equal to (<=). For example: (length<=15).

There is also the approximate equality operator which tests approximation equality (~=). Not all implementations support this, so I will not cover the approximation operator.

Attributes

You can specify attributes in a filter to determine if an entry possesses an attribute or not. For example, you use the equals operator (=) combined with the wildcard asterisk (*). For example, we have a custom attribute called ExtensionAttribute3, so we filter with (ExtensionAttribute3=*) to return the result set of entries that have this attribute.

Substrings

Substrings provide the ability to match specific entries with string values. This is the most common type of filter used today. When matching attribute values to strings, you can use the wildcard (*) asterisk to indicate any length of character beyond the initial specified string to be matched as well.

For example, supposing that you have security groups in various locations throughout the directory, and you need to know all the groups in the organization that grant users remote FTP access based on group membership. If these groups are named beginning with FTP (e.g., FTP_NRFK_SALES_GS), you can specify a filter like this: (cn=FTP_*). Further, if you're using a rigid naming convention, then you can even filter only the FTP groups in Norfolk (cn=FTP_NRFK_*). NOTE: if you have other entries such as users, printers, computers, etc., that match this string, they will be returned as well.

In addition, you can place several substrings together at multiple locations in the string, using the asterisk. (CN=*NRFK*GS) will match all entries that start with CN= and have the string NRFK followed by any series of characters up to GS.

Connecting Filters

You can combine multiple filters in a query as well. You can connect filters using operators ampersand (&), the vertical bar (|), and the exclamation mark (!). When you combine filters, you gain considerable control over the query itself.

(&(objectClass=group)(cn=FTP_*))

This filter consists of two different filters: (objectClass=group) and (cn=FTP_*). The ampersand operator is equivalent to AND. So, this query matches all groups that have FTP_* in its cn value. The fact that the filters are wrapped together with parenthesis indicates that it is a single query. The ! operator is equal to NOT, and the | is equal to OR.

These queries can get as complex as you'd like, and are extremely flexible. For example, below you can see a query that returns computers that are Windows XP but not Service Pack 2.

(&(&(objectCategory=computer)(!OperatingSystemServicePack=Service Pack 2)
                              (OperatingSystem=Windows XP Professional)))

In addition to the filter, you need to consider the search scope and base, which I'll cover in the following sections

For a comprehensive analysis on the query filter standard RFC1960, refer to the official documentation.

The Novell.Directory.Ldap Library

For the purpose of the example application, I have included the compiled library, but for licensing reasons, please visit Novell's site and download the library before using it for any other purpose than this sample application. Novell also provides some documentation and examples on their site. Since we're focused on searching the directory for this example, we'll first examine Novell's sample code, and later we'll abstract it in order to make it as easy to use as possible.

C#
using Novell.Directory.Ldap;
using Novell.Directory.Ldap.Utilclass;


private void Search(string ldapHost, //The Directory Server

                    string loginDN, //The distinguishedName of the account
                                    //with permissions to run this code

                    string password, //The password for above service account

                    int ldapPort, //The port to connect to

                    string searchBase, //The location to start the search 
                                       //(dc=baileysoft,dc=com)

                    string searchFilter) //The query filter

{
   
    try
    {
        LdapConnection conn = new LdapConnection();
        Console.WriteLine("Connecting to:" + ldapHost);
        conn.Connect(ldapHost, ldapPort);
        conn.Bind(loginDN, password);
        LdapSearchResults lsc = conn.Search(searchBase,
                                      LdapConnection.SCOPE_SUB,
                                      searchFilter,
                                      null,
                                      false);

        while (lsc.hasMore())
        {
            LdapEntry nextEntry = null;
            try
            {
                nextEntry = lsc.next();
            }
            catch (LdapException e)
            {
                Console.WriteLine("Error: " + e.LdapErrorMessage);
                // Exception is thrown, go for next entry

                continue;
            }
            Console.WriteLine("\n" + nextEntry.DN);
            LdapAttributeSet attributeSet = nextEntry.getAttributeSet();
            System.Collections.IEnumerator ienum = attributeSet.GetEnumerator();
            while (ienum.MoveNext())
            {
                LdapAttribute attribute = (LdapAttribute)ienum.Current;
                string attributeName = attribute.Name;
                string attributeVal = attribute.StringValue;
                if (!Base64.isLDIFSafe(attributeVal))
                {
                    byte[] tbyte = SupportClass.ToByteArray(attributeVal);
                    attributeVal = Base64.encode(SupportClass.ToSByteArray(tbyte));
                }
                Console.WriteLine(attributeName + "value:" + attributeVal);
            }
        }
        conn.Disconnect();
    }
    catch (LdapException e)
    {
        Console.WriteLine("Error:" + e.LdapErrorMessage);
        return;
    }
    catch (Exception e)
    {
        Console.WriteLine("Error:" + e.Message);
        return;
    }
}

The Method Explained

As you can see from the above sample from Novell, they have done a nice job abstracting all the gory details involved with using the LDAP protocol. However, it needs to be abstracted a great deal further to make it usable by non-LDAP experts.

Parameters

  • ldapHost: the Active Directory, NDS, or OpenLDAP provider (server IP)
  • loginDN: the distinguishedName of the service account, with permissions to make this query
  • password: the password to the above account
  • ldapPort: the connection port (389)
  • searchBase: the starting point of the search
  • searchFilter: the filter query string

Important Types

  • LdapEntry - the directory entry
  • LdapConnection - the connection object
  • LdapSearchResults - the results collection
  • LdapAttributeSet - a collection of attributes
  • LdapAttribute - a specific attribute
  • LdapException - LDAP error

Effective Abstraction

I can't think of many enterprise applications that do not require directory access, and since most organizations don't keep an LDAP expert on staff, we need to further abstract this code, so after its initial setup, it "just works" and any programmer can use it. Since our sample application focuses on searching the directory (presumably, a help desk application or web application), we're going to create a class called search and target the types of queries we'll need for our wrapper library. Note: Before I would ever ship an application such as this, I would abstract the entire library.

Abstracting the LDAP Settings

We certainly can't expect an ordinary developer to know what these settings are, so we want to store all the settings in a separate class we'll name settings.cs. I presume that these values would be stored locally in an encrypted XML file. For this example, these values are set when the application loads.

namespace Baileysoft.Services.Ldap
{
    public class Settings
    {
        public static string Server = null;
        public static int Port = 389;
        public static string ServiceAccountDn = null;
        public static string ServiceAccountPassword = null;
        public static string SearchBase = null;
    }
}

Abstracting the Search

For brevity, I'm not going to show the entire code for our search class, just the ForUser methods. From this example, you can see how searching for groups, directory entries, or any LDAP entry can be abstracted. The download source contains several more methods.

C#
....

public static LdapEntry ForUser(string key, string value)
{
   return ForLdapEntry(null, null, String.Format("(&(&(objectClass=user)" + 
          "(!(objectClass=computer)))({0}={1}))", key, value)); 
}

public static LdapEntry ForUser(string key, string value, string[] PropertiesToLoad)
{
   return ForLdapEntry(null, null, String.Format("(&(&(objectClass=user)" + 
          "(!(objectClass=computer)))({0}={1}))", key, value), PropertiesToLoad);
}

public static LdapEntry ForLdapEntry(string key, string value, 
                                     string filter, string[] attributes)
{
     
   LdapEntry searchResult = null;
   LdapConnection conn = new LdapConnection();
   conn.Connect(Settings.Server, Settings.Port);
   conn.Bind(Settings.ServiceAccountDn, Settings.ServiceAccountPassword);

   //Search

   LdapSearchResults results = conn.Search(Settings.SearchBase, //search base

                                           LdapConnection.SCOPE_SUB, //scope 

                                           filter, //filter

                                           attributes, //attributes 

                                           false); //types only 


            while (results.hasMore())
            {
                try
                {
                    searchResult = results.next();
                    break;
                }
                catch (LdapException e)
                {
                    Console.WriteLine(e.Message);
                }
            }
            conn.Disconnect();
            return searchResult;
}
....

As you can see, we have effectively encapsulated the search functionality of the existing library so that it can be easily leveraged by everyone on the team that needs access to the directory.

Using the New Search Wrapper

With the new wrapper, you have a powerful set of tools for searching the directory. Let's walk through some of the features.

Setup the Connection Details

Again, I must stress that this be done automatically when the application executes and it originate from an encrypted XML file that has been preconfigured by a directory administrator.

C#
Settings.Server = "192.168.2.1";
Settings.ServiceAccountDn = "CN=Neal T. Bailey,OU=USERS,OU=HOME,DC=baileysoft,DC=com";
Settings.ServiceAccountPassword = "Cr@zyP@ss";
Settings.SearchBase = "dc=baileysoft,dc=com";

Note: These methods are interchangeable between users, groups, ou's, printers, etc. Below is a simple demonstration of actually using the wrapper.

Check for an Entry's Existence

C#
Console.WriteLine(Search.Exists("sAMAccountName", "neal.bailey"));

Find Users

C#
foreach (LdapEntry user in Search.ForUsers())
    Console.WriteLine(user.DN);

Find User Based on Attribute Value

C#
if (Search.Exists("sAMAccountName", "neal.bailey"))
{
    Console.WriteLine(Search.ForUser("sAMAccountName", "neal.bailey").DN);
}

Find Group

C#
if (Search.Exists("cn", "FTP_USERS"))
{
    Console.WriteLine(Search.ForGroup("cn", "FTP_USERS").DN);
}

Find Groups

C#
foreach (LdapEntry group in Search.ForGroups()
    Console.WriteLine(group.DN);

Find Groups Based On Custom Filter

C#
foreach (LdapEntry ftpGroup in Search.ForLdapEntries(null, null, 
         "(&(objectClass=group)(cn=*FTP*))"))
    Console.WriteLine(ftpGroup.DN);

Note: The methods below need to be abstracted further. These are to demonstrate attribute values enumeration.

Get Attribute Value for an Entry

C#
//Get Single String Attribute Value for User

if (Search.Exists("sAMAccountName", "neal.bailey"))
{
     LdapEntry user = Search.ForUser("sAMAccountName", "neal.bailey");
     LdapAttribute cn = user.getAttribute("cn");
     Console.WriteLine(cn.Name + ": " + cn.StringValue);
}

Get Multi-string Attribute Values for an Entry

C#
//Get Multi-string Attribute Values 

if (Search.Exists("sAMAccountName", "neal.bailey"))
{
    LdapEntry user = Search.ForUser("sAMAccountName", "neal.bailey");
    LdapAttribute members = user.getAttribute("memberOf");
    if (members != null)
    {
       System.Collections.IEnumerator parser = members.StringValues;
       while (parser.MoveNext())
       {
          Console.WriteLine(members.Name + ": " + parser.Current);
       }
    }
}

Load Specified Attribute Values

C#
//Specify the properties to load on search

if (Search.Exists("sAMAccountName", "neal.bailey"))
{
    string[] attribs = { "name", "userPrincipalName", "createTimeStamp" };
    LdapEntry user = Search.ForUser("sAMAccountName", "neal.bailey", attribs);

    LdapAttributeSet foundAttribs = user.getAttributeSet();
    System.Collections.IEnumerator ienum = foundAttribs.GetEnumerator();
    while (ienum.MoveNext())
    {
         LdapAttribute attribute = (LdapAttribute)ienum.Current;
         string attributeName = attribute.Name;
         string attributeVal = attribute.StringValue;
         Console.WriteLine(attributeName + ": " + attributeVal);
    }
}

Synchronous vs. Asynchronous Search

The Novell library contains handling for both types of search. I have not been able to test the Asynchronous search yet. Refer to the Novell documentation for code examples.

SSL Connections

I have not had the requirement yet to perform LDAPS:// operations, so I cannot assist here. Again, refer to the official Novell documentation.

Benchmarks

At present, I have not had sufficient time to benchmark this library against System.DirectoryServices, so I cannot make claims that this library is any more fast than the .NET library. I have created the unit tests, and plan to perform this testing in the next few weeks.

History

  • Submitted - 08/10/2007.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)