In the previous post, Introduction to Active Directory, we learned that ADSI paths are used to organize objects in Active Directory. A path consists of a protocol, host name, port number and distinguished name (DN). A DN consists of one or more relative distinguished names (RDN). An RDN is a simple key/value type. Today we will create three helper classes to ensure that our paths are well-formed with escaped special characters.
Purpose
While an ADSI path can be managed as a simple string variable we have to make sure each RDN is built with properly encoded values. A gotcha to using the DirectoryEntry and DirectorySearcher objects from the System.DirectoryServices namespace is that these objects will return paths and distinguished names with descaped values. Whenever you want to reuse a path or distinguished name value you have to escape the values again before assigning it to the target object’s attribute.
The classes we will build will allow us to simply create a new instance of the Path or DistinguishedName class as appropriate, assign the value and then have the guarantee that the value is correctly escaped for future use.
Class Diagram
We will begin by creating the RelativeDistinguishedName class. This class will be responsible for tracking the attribute name and value. It is here that we will manage special character escaping.
Next we will create the DistinguishedName class. This class will be responsible for tracking one or more relative distinguished names. A simple regular expression will be used once again to assist with splitting the strings correctly so that the rdn’s can be extracted from a string value.
We will end with a Path class. Since the path class contains a distinguished name and will need all the behaviors of the DistinguishedName class it will inherit from the DistinguishedName class and add a few custom properties. Once again we will utilize a few simple regular expressions to extract the provider, host name, port number and distinguished name values from a string value.
Now that we know what we want to build lets start writing code!
RelativeDistinguishedName Class
Since the Relative Distinguished Name contains an attribute that can be defined as a type we will create an enumerator to represent the more common attribute types. One of the values included in this enumerator, other, will be used when we don’t have a predefined enumerator value to represent the attribute.
Public Enum AttributeTypes other domainComponent commonName organizationalUnitName organizationName streetAddress localityName stateOrProvinceName countryName userId End Enum
The RelativeDistinguishedName class will have a property for the attribute name and the attribute type enumerator. It will have a property called value that will represent the decoded text value. The RdnValue will represent the rdn text with the escaped value. There will be two helper classes to assist with escaping the value and de-escaping the RdnValue.
The EscapeValue routine will be smart enough to know when a value is already escaped modifying only the values that have not been properly escaped.
Imports System.Text.RegularExpressions Public Class RelativeDistinguishedName Public Sub New() End Sub Public Sub New(ByVal relativeDN As String) RdnValue = relativeDN End Sub Public Sub New(ByVal attribute As String, ByVal value As String) Me.Attribute = attribute Me.Value = value End Sub Public Property Attribute As String Get Return _Attribute End Get Set(ByVal value As String) _Attribute = value.Trim End Set End Property Private _Attribute As String Public Property AttributeType As AttributeTypes Get Dim attrType As AttributeTypes Select Case _Attribute.ToUpper Case "DC" : attrType = AttributeTypes.domainComponent Case "CN" : attrType = AttributeTypes.commonName Case "OU" : attrType = AttributeTypes.organizationalUnitName Case "O" : attrType = AttributeTypes.organizationName Case "STREET" : attrType = AttributeTypes.streetAddress Case "L" : attrType = AttributeTypes.localityName Case "ST" : attrType = AttributeTypes.stateOrProvinceName Case "C" : attrType = AttributeTypes.countryName Case "UID" : attrType = AttributeTypes.userId Case Else : attrType = AttributeTypes.other End Select Return attrType End Get Set(ByVal value As AttributeTypes) Select Case value Case AttributeTypes.domainComponent : Attribute = "DC" Case AttributeTypes.commonName : Attribute = "CN" Case AttributeTypes.organizationalUnitName : Attribute = "OU" Case AttributeTypes.organizationName : Attribute = "O" Case AttributeTypes.streetAddress : Attribute = "STREET" Case AttributeTypes.localityName : Attribute = "L" Case AttributeTypes.stateOrProvinceName : Attribute = "ST" Case AttributeTypes.countryName : Attribute = "C" Case AttributeTypes.userId : Attribute = "UID" End Select End Set End Property Public Property Value As String Get Return _Value End Get Set(ByVal value As String) _Value = value If _Value IsNot Nothing Then _Value = _Value.Trim _EscapedValue = EscapeValue(_Value) End Set End Property Private _Value As String Private _EscapedValue As String Public Property RdnValue As String Get Return _Attribute & "=" & _EscapedValue End Get Set(ByVal value As String) ThrowErrorOnInvalidText(value) Dim operatorIndex As Integer = value.IndexOf("=") Attribute = value.Substring(0, operatorIndex) Me.Value = DescapeValue(value.Substring(operatorIndex + 1)) End Set End Property Public Overrides Function ToString() As String Return RdnValue End Function Public Function EscapeValue(ByVal value As String) As String 'The RDN value can not begin or end with spaces. So begin by trimming the value Dim escapedText As String = value.Trim 'Escape LDAP Reserved Characters: , + " \ < > ; = / Dim matches As MatchCollection = Regex.Matches(value, "\\(,|\+|""|\\|<|>|;|=|/)|(,|\+|""|\\|<|>|;|=|/)", RegexOptions.IgnoreCase Or RegexOptions.Multiline) For matchIndex As Integer = matches.Count - 1 To 0 Step -1 Dim matchItem As Match = matches.Item(matchIndex) If matchItem.Length = 1 Then escapedText = escapedText.Substring(0, matchItem.Index) & "\" & escapedText.Substring(matchItem.Index) End If Next 'Convert Non-printable Characters to Escaped Hex Code Values Dim hexCode As String For ascIndex As Integer = 0 To 31 hexCode = Convert.ToString(ascIndex, 16) If hexCode.Length = 1 Then hexCode = "0" & hexCode escapedText = Regex.Replace(escapedText, Chr(ascIndex), "\" & hexCode.ToUpper, RegexOptions.IgnoreCase Or RegexOptions.Multiline) Next Return escapedText End Function Public Function DescapeValue(ByVal value As String) As String Dim plainText As String = value.Trim 'Convert Escaped Hex Code Values to Non-printable Characters Dim hexCode As String For ascIndex As Integer = 0 To 31 hexCode = Convert.ToString(ascIndex, 16) If hexCode.Length = 1 Then hexCode = "0" & hexCode plainText = Regex.Replace(plainText, "\\" & hexCode.ToUpper, Chr(ascIndex), RegexOptions.IgnoreCase Or RegexOptions.Multiline) Next 'Convert LDAP Reserved Characters to Regular Values: , + " \ < > ; = / Dim matches As MatchCollection = Regex.Matches(value, "\\(,|\+|""|\\|<|>|;|=|/)", RegexOptions.IgnoreCase Or RegexOptions.Multiline) For matchIndex As Integer = matches.Count - 1 To 0 Step -1 Dim matchItem As Match = matches.Item(matchIndex) plainText = plainText.Substring(0, matchItem.Index) & plainText.Substring(matchItem.Index + 1) Next Return plainText End Function Private Sub ThrowErrorOnInvalidText(ByVal value As String) If value Is Nothing OrElse value.IndexOf("=") < 1 Then Throw New ArgumentException("Value provided is not a valid relative distinguished name.", "relativeDN") End If End Sub End Class
DistinguishedName Class
The DistinguishedName class will track an array of RelativeDistinguishedNames. To assist with managing the array there will be a Push function that will add an RDN at the zero index, Pop function that will remove the RDN from the zero position, an InsertAt function that will insert the Rdn at a specific position and RemoveAt function that will remove the Rdn from a specific position.
A simple regular expression will be utilized to divide the distinguished name into its relative parts saving each RDN into the RDNs array. The RDNs property will provide access to both the escaped and descaped values. The DnValue property will store the escaped distinguished name value.
An interesting note about the distinguished name. It contains the domain name. The domain name is divided by the period. Each part of the domain name then becomes a domain component. So there is a ContextName property that will parse the domain name out of the domain component.
Imports System.Text.RegularExpressions Public Class DistinguishedName Public Sub New() End Sub Public Sub New(ByVal distinguishedName As String) DnValue = distinguishedName End Sub Public ReadOnly Property Parent As DistinguishedName Get Dim dn As String = String.Empty For rdnIndex As Integer = 1 To RDNs.Count - 1 dn &= RDNs(rdnIndex).ToString & "," Next If dn.EndsWith(",") Then dn = dn.Substring(0, dn.Length - 1) If String.IsNullOrWhiteSpace(dn) Then Return Nothing Else Return New DistinguishedName(dn) End If End Get End Property Public Property DnValue As String Get Dim dn As String = String.Empty For Each rdnItem As RelativeDistinguishedName In RDNs dn &= rdnItem.ToString & "," Next If dn.EndsWith(",") Then dn = dn.Substring(0, dn.Length - 1) Return dn End Get Set(ByVal value As String) 'Get relative distinguished names. Dim matches As MatchCollection = Regex.Matches(value, "(^|)(\\,|[^,])*", RegexOptions.IgnoreCase Or RegexOptions.Multiline) 'Count how large the rdn array needs to be. Dim rdnIndex As Integer = -1 For Each MatchItem As Match In matches If Not String.IsNullOrWhiteSpace(MatchItem.Value) Then rdnIndex += 1 Next 'Transfer parsed RDN values into array Dim rdn(rdnIndex) As RelativeDistinguishedName rdnIndex = -1 For Each MatchItem As Match In matches If Not String.IsNullOrWhiteSpace(MatchItem.Value) Then rdnIndex += 1 rdn(rdnIndex) = New RelativeDistinguishedName(MatchItem.Value) End If Next _RDNs = rdn End Set End Property Public Property RDNs As RelativeDistinguishedName() Public Property DomainContext As String Get Dim dc As String = String.Empty For Each rdnItem As RelativeDistinguishedName In RDNs If rdnItem.AttributeType = AttributeTypes.domainComponent Then dc &= rdnItem.Value End If Next Return dc End Get Set(ByVal value As String) Dim dcParts() As String = value.Split("."c) Dim rdnItem As RelativeDistinguishedName For rdnIndex As Integer = RDNs.Count - 1 To 0 Step -1 rdnItem = RDNs(rdnIndex) If rdnItem.AttributeType = AttributeTypes.domainComponent Then RemoveAt(rdnIndex) End If Next For Each dcPart As String In dcParts If Not String.IsNullOrWhiteSpace(dcPart) Then Push(New RelativeDistinguishedName("dc", dcPart)) End If Next End Set End Property Public Sub Push(ByVal rdn As RelativeDistinguishedName) InsertAt(0, rdn) End Sub Public Sub Pop() If RDNs.Length > 0 Then RemoveAt(0) End Sub Public Sub InsertAt(ByVal index As Integer, ByVal rdn As RelativeDistinguishedName) Dim rdnList() As RelativeDistinguishedName If RDNs Is Nothing OrElse RDNs.Length = 0 Then ReDim rdnList(0) rdnList(0) = rdn Else ReDim rdnList(RDNs.Length) Dim rdnIndex As Integer = 0 For intIndex As Integer = 0 To RDNs.Length - 1 If intIndex = index Then rdnList(rdnIndex) = rdn rdnIndex += 1 End If rdnList(rdnIndex) = RDNs(intIndex) rdnIndex += 1 Next 'If index is at the last position then insert at the end. If index >= (RDNs.Length - 1) Then RDNs(RDNs.Length - 1) = rdn End If End If 'Replace RDN list with new list. RDNs = Nothing RDNs = rdnList End Sub Public Sub RemoveAt(ByVal index As Integer) If RDNs Is Nothing Then Exit Sub Dim rdnList(RDNs.Length - 2) As RelativeDistinguishedName Dim rdnIndex As Integer = -1 For intIndex As Integer = 0 To RDNs.Length - 1 If intIndex <> index Then rdnIndex += 1 rdnList(rdnIndex) = RDNs(intIndex) End If Next 'Replace RDN list with new list. RDNs = Nothing RDNs = rdnList End Sub Public Overrides Function ToString() As String Return DnValue End Function End Class
Path Class
The path class will once again utilize a very simple regular expressions to parse out the provider, host name, port number and distinguished name from a string value. Since the path class is a type of distinguished name we will inherit from the DistinguishedName class overriding the Parent property and the ToString method.
Since the Parent property will be returning a Path instead of a DistingushedName class we will need to use the Shadows keyword. The Shadows keyword is used when the return type is a different type. The Overrides keyword is used when the return type is the same type.
The GetDn and SetDn methods will provide access to a copy of the underlying distinguished name class when we want just the distinguished name.
Imports System.Text.RegularExpressions Public Class Path Inherits DistinguishedName Public Sub New(ByVal path As String) PathValue = path End Sub Public Sub New(ByVal provider As String, ByVal hostName As String) Me.Provider = provider Me.HostName = hostName End Sub Public Sub New(ByVal provider As String, ByVal dn As DistinguishedName) Me.Provider = provider SetDN(dn) End Sub Public Sub New(ByVal provider As String, ByVal hostName As String, ByVal portNumber As String) Me.Provider = provider Me.HostName = hostName Me.PortNumber = portNumber End Sub Public Sub New(ByVal provider As String, ByVal hostName As String, ByVal dn As DistinguishedName) Me.Provider = provider Me.HostName = hostName SetDN(dn) End Sub Public Sub New(ByVal provider As String, ByVal hostName As String, ByVal portNumber As String, ByVal dn As DistinguishedName) Me.Provider = provider Me.HostName = hostName Me.PortNumber = portNumber SetDN(dn) End Sub Public Shadows ReadOnly Property Parent As Path Get If MyBase.Parent Is Nothing Then Return Nothing ElseIf String.IsNullOrWhiteSpace(HostName) Then Return New Path(String.Format("{0}://{1}", Provider, MyBase.Parent.ToString)) ElseIf String.IsNullOrWhiteSpace(PortNumber) Then Return New Path(String.Format("{0}://{1}/{2}", Provider, HostName, MyBase.Parent.ToString)) Else Return New Path(String.Format("{0}://{1}:{2}/{3}", Provider, HostName, PortNumber, MyBase.Parent.ToString)) End If End Get End Property Public Property PathValue As String Get If String.IsNullOrWhiteSpace(HostName) Then Return String.Format("{0}://{1}", Provider, DnValue) ElseIf String.IsNullOrWhiteSpace(PortNumber) Then Return String.Format("{0}://{1}/{2}", Provider, HostName, DnValue) Else Return String.Format("{0}://{1}:{2}/{3}", Provider, HostName, PortNumber, DnValue) End If End Get Set(ByVal value As String) Dim itemMatch As Match Dim connectionInfo As String = String.Empty Dim dnIndex As Integer = 0 'Get Connection Info (Provider://HostName:PortNumber/) itemMatch = Regex.Match(value, "^.*://(.*[:/]){0,1}", RegexOptions.IgnoreCase Or RegexOptions.Multiline) If itemMatch IsNot Nothing AndAlso Not String.IsNullOrWhiteSpace(itemMatch.Value) Then connectionInfo = itemMatch.Value End If 'Get Provider itemMatch = Regex.Match(connectionInfo, "^.*(://)", RegexOptions.IgnoreCase Or RegexOptions.Multiline) If itemMatch IsNot Nothing AndAlso Not String.IsNullOrWhiteSpace(itemMatch.Value) Then Provider = itemMatch.Value.Substring(0, itemMatch.Value.Length - 3) dnIndex = itemMatch.Index + itemMatch.Length End If 'Get Host Domain Name or Domain Controller itemMatch = Regex.Match(connectionInfo, "://[^:/]+", RegexOptions.IgnoreCase Or RegexOptions.Multiline) If itemMatch IsNot Nothing AndAlso Not String.IsNullOrWhiteSpace(itemMatch.Value) Then HostName = itemMatch.Value.Substring(3, itemMatch.Value.Length - 3) dnIndex = itemMatch.Index + itemMatch.Length End If 'Get Port Number itemMatch = Regex.Match(connectionInfo, ":[\d]+", RegexOptions.IgnoreCase Or RegexOptions.Multiline) If itemMatch IsNot Nothing AndAlso Not String.IsNullOrWhiteSpace(itemMatch.Value) Then PortNumber = itemMatch.Value.Substring(1) dnIndex = itemMatch.Index + itemMatch.Length End If 'Get Distinguished Name MyBase.DnValue = value.Substring(connectionInfo.Length).Trim End Set End Property Public Property Provider As String Get If String.IsNullOrWhiteSpace(_Provider) Then Return "LDAP" Else Return _Provider End If End Get Set(ByVal value As String) _Provider = value End Set End Property Private _Provider As String Public Property HostName As String Public Property PortNumber As String Public Overrides Function ToString() As String Return PathValue End Function Public Function GetDN() As DistinguishedName Return New DistinguishedName(DnValue) End Function Public Sub SetDN(ByVal dn As DistinguishedName) DnValue = dn.DnValue End Sub Public Shared Function IsPath(ByVal value As String) As Boolean 'Get Connection Info (Provider://HostName:PortNumber/) Dim itemMatch As Match = Regex.Match(value, "^.*://(.*[:/]){0,1}", RegexOptions.IgnoreCase Or RegexOptions.Multiline) If itemMatch Is Nothing OrElse String.IsNullOrWhiteSpace(itemMatch.Value) Then Return False Else Return True End If End Function End Class
Verification
Finally a simple test routine to verify that the code works as expected!
Private Sub TestPathClass() Dim rdn As New LarrySteinle.Library.Data.ActiveDirectory.RelativeDistinguishedName("cn", "Steinle, Larry" & vbCrLf & "Systems Analyst") If rdn.DescapeValue(rdn.ToString) = "cn=Steinle, Larry" & vbCrLf & "Systems Analyst" Then System.Diagnostics.Debug.WriteLine("RDN Worked") Else System.Diagnostics.Debug.WriteLine("RDN Failed") End If Dim dnText As String = "CN=Before\0D\0AAfter,OU=Test,DC=North America,DC=Fabrikam,DC=COM" Dim dn As New LarrySteinle.Library.Data.ActiveDirectory.DistinguishedName(dnText) If dn.ToString = dnText Then System.Diagnostics.Debug.WriteLine("DN Worked") Else System.Diagnostics.Debug.WriteLine("DN Failed") End If Dim pathText As String = "LDAP://America.Fabrikam.com:235/" & dnText Dim path As New LarrySteinle.Library.Data.ActiveDirectory.Path(pathText) If path.ToString = pathText Then System.Diagnostics.Debug.WriteLine("Path Worked") Else System.Diagnostics.Debug.WriteLine("Path Failed") End If If path.DnValue = dnText Then System.Diagnostics.Debug.WriteLine("Path DN Worked") Else System.Diagnostics.Debug.WriteLine("Path DN Failed") End If path.Push(New ActiveDirectory.RelativeDistinguishedName("cn", "test")) If path.DnValue = "cn=test," & dnText Then System.Diagnostics.Debug.WriteLine("Path DN Worked") Else System.Diagnostics.Debug.WriteLine("Path DN Failed") End If path.Pop() If path.DnValue = dnText Then System.Diagnostics.Debug.WriteLine("Path DN Worked") Else System.Diagnostics.Debug.WriteLine("Path DN Failed") End If End Sub
Summary
We have created three classes to help manage ADSI paths. These classes ensure that our paths are properly formatted by enforcing special character escape codes. These classes provide an important aid in working with Active Directory as both the DirectoryEntry and DirectorySearcher objects return the path and distinguished name with escape codes removed from the value. These classes provide a means to ensure that our values are properly encoded.
Leave a Reply