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.
using Novell.Directory.Ldap;
using Novell.Directory.Ldap.Utilclass;
private void Search(string ldapHost,
string loginDN,
string password,
int ldapPort,
string searchBase,
string searchFilter)
{
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);
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 querypassword
: the password to the above accountldapPort
: the connection port (389)searchBase
: the starting point of the searchsearchFilter
: the filter query string
Important Types
LdapEntry
- the directory entryLdapConnection
- the connection objectLdapSearchResults
- the results collectionLdapAttributeSet
- a collection of attributesLdapAttribute
- a specific attributeLdapException
- 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.
....
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);
LdapSearchResults results = conn.Search(Settings.SearchBase,
LdapConnection.SCOPE_SUB,
filter,
attributes,
false);
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.
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
Console.WriteLine(Search.Exists("sAMAccountName", "neal.bailey"));
Find Users
foreach (LdapEntry user in Search.ForUsers())
Console.WriteLine(user.DN);
Find User Based on Attribute Value
if (Search.Exists("sAMAccountName", "neal.bailey"))
{
Console.WriteLine(Search.ForUser("sAMAccountName", "neal.bailey").DN);
}
Find Group
if (Search.Exists("cn", "FTP_USERS"))
{
Console.WriteLine(Search.ForGroup("cn", "FTP_USERS").DN);
}
Find Groups
foreach (LdapEntry group in Search.ForGroups()
Console.WriteLine(group.DN);
Find Groups Based On Custom Filter
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
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
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
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