Querying Azure Active Directory through Azure Graph API with F#

15 Aug 2014

In the previous post I showed how to query a local Active Directory for courses, classes, and students using the LDAP protocol. As it turns out, this local Active Directory is also being one-way synchronized to Azure Active Directory using the DirSync tool. Hence in this post I investigate how to query Azure Active Directory for the same courses, classes, and users.

To reiterate from the previous post, here's the layout of the local Active Directory being synchronized to the cloud:

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
        ...

Azure Active Directory doesn't have feature parity with local Active Directory. Instead, it's a new service with its own (simpler) data model. What the DirSync tool does -- besides pushing changes at regular intervals -- is translate between the on-prem and in-cloud data models. For instance, only the levels of "English" and "English A" exist as groups in Azure Active Directory. Even worse, there's no way to tell that on-prem "English A" is contained in "English". To Azure Active Directory the hierarchy has been flattened and users are simple members of groups.

Another key difference between local and Azure Active Directory is that while the former may be queried using LDAP, the latter exposes its data only through REST-based OData web services protected by OAuth. That's where the Graph API comes in as a vehicle to craft OData URLs and marshaling to and from .NET types.

To better understand the flow, it's worth looking into how authentication and querying with the Graph API works. First you request an access token from the secure token service. This token must then be passed with every call to the Graph API. Under the hood the Graph API passes the access token as part of the request header.

Here's the code taking care of the authentication part:

(* 
  requires the following NuGet packages being installed:
    Microsoft.Azure.ActiveDirectory.GraphClient
    Microsoft.IdentityModel.Clients.ActiveDirectory

  loosely based on:
  https://github.com/AzureADSamples/ConsoleApp-GraphAPI-DotNet
*)

open System
open Microsoft.IdentityModel.Clients.ActiveDirectory
open Microsoft.Azure.ActiveDirectory.GraphClient

type AzureGraphConnectionCreator(tenantName: string, clientId: string, clientSecret: string) =
    // POST https://login.windows.net/acme.onmicrosoft.com/oauth2/token?api-version=1.0 HTTP/1.1
    member __.Create() =
        let authenticationContext = 
            AuthenticationContext(
                authority = "https://login.windows.net/" + tenantName, 
                validateAuthority = false)

        let authenticationResult = 
            authenticationContext.AcquireToken(
                resource = "https://graph.windows.net", 
                credential = ClientCredential(clientId, clientSecret))
        
        GraphConnection(
            accessToken = authenticationResult.AccessToken, 
            clientRequestId = Guid.NewGuid(), 
            graphSettings = 
                GraphSettings(
                    ApiVersion = "2013-11-08", 
                    GraphDomainName = "graph.windows.net"))

Now that I'm authenticated, I can query Azure Active Directory. However, because the containment relationship isn't preserved, I need to query by passing in a list of strings representing group display names to get unique group GUIDs back. I could also just read all groups this way and filter in memory, but this shows the FilterGenerator and expression trees in action:

type AzureADGroup =
    { Id: Guid
      Name: string }

type GroupQuery(connection: GraphConnection, groupNames: string list) =
    let parse (g: Group) = 
        { AzureADGroup.Id = Guid.Parse(g.ObjectId)
          Name = g.DisplayName }

    // With filter: GET https://graph.windows.net/3618fc02-6365-4318-a05e-7fa6883d47c8/groups?%24top=1&api-version=2013-11-08&%24filter=displayName%20eq%20'English%20A' HTTP/1.1
    // No filter:   GET https://graph.windows.net/3618fc02-6365-4318-a05e-7fa6883d47c8/groups?api-version=2013-11-08 HTTP/1.1
    member __.Execute() =
        groupNames |> List.map (fun name ->
            let displayNameFilter = 
                FilterGenerator(
                    Top = 1,
                    QueryFilter = 
                        ExpressionHelper
                            .CreateEqualsExpression(
                                typedefof<Group>, 
                                GraphProperty.DisplayName, 
                                name))
            let groups = 
                connection
                    .List<Group>(pageToken = null, filter = displayNameFilter)
                    .Results 
            if groups |> Seq.length = 0 then (name, None)
            else (groups |> Seq.head |> fun azureGroup -> (name, Some(parse azureGroup)))) |> Seq.toList

Next I want to query users and to avoid making per-user queries to fetch the groups that each user is a member of, I set the ExpandProperty. When this property is set, the result may contain at most 100 users and group memberships. Further results are retrieved in batches by following a continuation URL present in each batch. For as long as a continuation URL is included, I recursively follow it, building up a list of users and their group memberships:

type AzureADUser =
    { Id: Guid
      Name: string
      Groups: Guid list option }

type UserQuery(connection: GraphConnection) =
    let parseMemberOf (go: ChangeTrackingCollection<GraphObject>) =
        if go.Count = 0 then None
        else Some(go |> Seq.map (fun g -> Guid.Parse(g.ObjectId)) |> Seq.toList)

    let parseUser (u: User) =
        { Id = Guid.Parse(u.ObjectId)
          Name = u.DisplayName
          Groups = parseMemberOf u.MemberOf }            

    // First page:  GET https://graph.windows.net/3618fc02-6365-4318-a05e-7fa6883d47c8/users?%24top=100&api-version=2013-11-08&%24expand=memberOf&%24orderby=displayName HTTP/1.1
    // Second page: GET https://graph.windows.net/3618fc02-6365-4318-a05e-7fa6883d47c8/directoryObjects/$/Microsoft.WindowsAzure.ActiveDirectory.User?%24expand=memberOf&%24orderby=displayName&%24skiptoken=X'44537074020001000000103A5065726E696C6C65204272656E647929557365725F64363138383662632D363765642D343530302D613165362D3030346436313562643561382100000000000000000000'&%24top=100&api-version=2013-11-08 HTTP/1.1
    let rec fetchUsersWithContinuation (fg: FilterGenerator) (pr: PagedResults<User>) (au: AzureADUser list) =     
        if pr.Results.Count = 0 then None
        else 
            let users = au @ (pr.Results |> Seq.map parseUser |> Seq.toList)
            if pr.IsLastPage then Some users
            else fetchUsersWithContinuation fg (connection.List<User>(pr.PageToken, fg)) users

    member __.Execute() =
        let fg = 
            FilterGenerator(
                // Top is limited to 100 when used with ExpandProperty.
                // Setting Top higher results in an exception being thrown.
                Top = 100, 
                OrderByProperty = GraphProperty.DisplayName, 
                // only one property is allowed to be expanded as part of a single query
                ExpandProperty = LinkProperty.MemberOf)
        fetchUsersWithContinuation  fg (connection.List<User>(null, fg)) List.empty

Putting everything together, here's how to utilize the above query types and example of their output:

[<EntryPoint>]
let main argv = 
    let tenantName = "acme.onmicrosoft.com"
    let clientId = "e5728213-37af-4c34-96cd-1d08cf52ff12"
    let clientSecret = "vwvsQOWdzNUsbdIH9gB0xDpmVKbQa+6MGR9pJazhTwlM="
    
    let connectionCreator = AzureGraphConnectionCreator(tenantName, clientId, clientSecret)
    let connection = connectionCreator.Create()
    let groupQuery = GroupQuery(connection, ["English A"; "Math A"])
    let groupResult = groupQuery.Execute()
    printfn "%A" groupResult

    // [("English A", null); 
    //  ("Math A", Some {Id = 0bc039c3-0219-4e44-8e56-c6f4436d3cc4;
    //                   Name = "Math A";})]

    let userQuery = UserQuery(connection)
    let userResult = userQuery.Execute()
    printfn "%A" userResult

    // Some
    //  [{Id = ae205e56-28f8-41c6-9c0a-4b0b6dfde3a5;
    //    Name = "Jane Doe";
    //    Groups =
    //     Some
    //       [0bc039c3-0219-4e44-8e56-c6f4436d3cc4;      <-- in Math A class
    //        4d7fe458-3162-4aac-a001-30bc8b8095b4];};
    //   {Id = 2ae79ec3-af2b-4c36-a54c-c0da26549296;
    //    Name = "John Doe";
    //    Groups =
    //     Some
    //       [1034ae93-76ac-4c37-96ac-6dbd236ce2af;
    //        382979f6-f20b-4aff-b8d5-acbacbdffc74];};

    0

It took quite a bit of digging around getting the above code working, figuring out the changes to the Active Directory data model. Moving to the cloud requires both rethinking and re-writing of code.