Larry Steinle

February 21, 2011

AD Path Helper

Filed under: Active Directory,RegEx,VS.Net — Larry Steinle @ 11:57 am
Tags: , ,

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.

Path, DistinguishedName and RelativeDistinguishedName Classes

Path, DistinguishedName and RelativeDistinguishedName Classes

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.

Advertisement

Leave a Comment »

No comments yet.

RSS feed for comments on this post. TrackBack URI

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Create a free website or blog at WordPress.com.

%d bloggers like this: