Querying Active Directory organizational units, groups, and members with F#

06 Aug 2014

I was recently faced with the task of projecting Active Directory containers onto domain types. The subset of Active Directory in question (illustrated below) holds courses, such as English, each subdivided into one or more classes (English A, B, C). Students are then added to each class. Finally, the purpose of my F# code is to construct a collection of courses containing classes containing students for later processing.

DC=Acme, DC=local
  OU=Acme
    OU=Administration and Access
      OU=Groups
        OU=Courses
          OU=English
            objectGUID (attribute)
            name (attribute)
            CN=English A
              objectGuid (attribute)
              name (attribute)
              member (attribute pointing to Students)      
            CN=English B
            CN=English C     
          OU=Math
          OU=Drama
          ...
    OU=User Accounts
      OU=Students
        CN=John Doe
          objectGUID (attribute)
          name (attribute)
          mail (attribute)
        CN=Jane Doe
        ...

First, I need to define the F# types to project Active Directory objects onto. These types define an aggregate with Course as its root and child types matching the hierarchy of containers in Active Directory:

open System
open System.Collections
open System.DirectoryServices

type Student =
    { Id: Guid
      Name: string
      Mail: string }

type Class =
    { Id: Guid
      Name: string
      Students: Student list }

type Course =
    { Id: Guid
      Name: string
      Classes: Class list }

Next, I need to project the hierarchical data of Active Directory onto the three newly defined F# types. Instead of creating a repository representing Active Directory, I went with a simpler query type (as in the Command/Query pattern):

type CoursesQuery(username: string, password: string, server: string) =
    let parseStudents (e: DirectoryEntry) =
        // accessing members through e.Properties.["member"] only yields
        // CommonName and path to object in string format. Problem is 
        // that CommonName isn't unique. Instead we follow the path 
        // and read properties of the Student object directly.
        let members = e.Invoke("members", null)
        [ for dn in (members :?> IEnumerable) do
            use e = new DirectoryEntry(dn)            
            yield { 
                Id = e.Guid
                Name = e.Properties.["name"].[0] |> string
                Mail = e.Properties.["mail"].[0] |> string } ]

    let parseClass (klass: DirectoryEntry) =
        { Id = klass.Guid
          Name = klass.Properties.["name"].[0] |> string
          Students = parseStudents klass }

    let parseCourse (sr: SearchResult) =
        { Id = Guid(sr.Properties.["objectGUID"].[0] :?> byte[])
          Name = sr.Properties.["name"].[0] |> string
          Classes =
            sr.GetDirectoryEntry().Children 
            |> Seq.cast 
            |> Seq.map parseClass
            |> Seq.toList }

    member __.Execute() =
        let coursesDirectory = 
            new DirectoryEntry(
                Username = username,
                Password = password,
                Path = sprintf 
                    "LDAP://%s/OU=Courses,OU=Groups,OU=Administration and Access,OU=Acme,DC=Acme,DC=local" server)

        use coursesSearcher = 
            new DirectorySearcher(
                SearchRoot = coursesDirectory, 
                Filter = "(ou=*)")

        coursesSearcher.FindAll()
        |> Seq.cast 
        |> Seq.map parseCourse
        |> Seq.toList

Calling Execute on CoursesQuery returns a list of Course types.

Depending on desired runtime characteristics, this sort of mapping may be too slow. The way the code traverses the containers results in significant request/reply network chatter. If performance is paramount, one should construct one or multiple LDAP queries. Traversing containers like above vs. using LDAP queries is like querying an SQL database with many small select queries versus using joins and doesn't scale.

Have a comment or question? Please drop me an email or tweet to @ronnieholm.