Graph Change Notifications

Sequence diagram for the solution

In a regulated environment a common control is to have owners of security groups validate that the members of the group are as expected. Azure Active Directory makes this relatively simple: a group can have one or more owners and the Access Reviews feature in Premium P2 provides a simple automated way to generate the review and capture sign-off.

However, the reviews are inherently after the fact: running the risk of in-proper access for a period of time. We recently used Microsoft Graph Subscriptions and Delta APIs to provide more real time notification of group membership change to owners. In this post I’ll review the overall architecture and implementation.

Solution

The solution consists of a couple of components. As previous posts, I’m using the PowerShell Graph SDK to interact with the API.

Curating subscriptions

The first component is an Azure Automation Runbook, written in PowerShell, that creates and curates subscriptions to the graph API by unique group ID. To prevent a build up of stale subscriptions, Graph API expires subscriptions that aren’t actively updated. Hence the runbook is executed daily to refresh all subscriptions.

You’ll need some kind of store for the subscription IDs. This could be as simple as a CSV file on blob storage. In our case, I used a SharePoint List which gives a simple UI for viewing the state of the subscriptions and adding new ones.

Creating a subscription is pretty simple with the SDK. Given a group ID $groupID:

$tomorrow = (get-date).addHours(25)
$sub = new-mgsubscription -expirationdatetime $tomorrow -changeType "updated" `
    -clientstate <opaque ID> -resource "groups/$groupID" `
    -notificationURL <logic app webhook URL>

Per the docs, Graph API will make a synchronous call to the notification URL to ensure it’s available and complies with the required protocol.

Updating subscriptions to keep them current is equally simple:

update-mgsubscription -subscriptionID $subID -expirationdatetime $tomorrow

To detect what has changed, you’ll need a delta URI. These act as a point-in-time marker that, when invoked, will give all changes since that point-in-time along with a new token for the current point in time. The delta API permits a special token of latest that can be used to set the point-in-time to now.

Unfortunately the SDK doesn’t currently support the delta API directly, but we can call it using invoke-mggraphrequest. For example, the following gets the current delta token for a group with a given ID:

$delta = invoke-mggraphrequest -method GET -uri `
    "v1.0/groups/delta?`$select=members`$filter=id%20eq%20'$groupID'&`$deltaToken=latest"
$deltaLink = $delta.'@odata.deltaLink'

Be sure to store both the subscription ID and delta token that is returned, you’ll need this to refresh a subscription and get changes.

Responding to subscription notifications

Unfortunately, we cannot implement a subscription notification handler as a runbook since they lack the tight bindings to HTTP web-hooks to meet the desired protocol.

For our solution, we instead used a Logic App. These provide good support for invocation via WebHook and also can connect to automation runbooks and Teams.

The outline of the Logic App is shown below:

Logic apps screenshot

The first conditional checks whether this is a validation call from the Graph API or a ‘true’ invocation. Next it tests for a secret value (client state in the subscription above) that was set when the subscription was created.

Assuming the secret is correct, the logic app invokes a second runbook, discussed below, to get a list of added and removed users from the group (the ID of which was passed as part of the notification).

Once the runbook completes, the logic app iterates through the JSON response sending Teams notifications to each owner of the group. These teams notifications render as messages from Power Automate.

Getting what changed

The second runbook is invoked by web-hook from the logic app. On invocation it will:

Key pieces of this are below:

$delta = invoke-mggraphrequest -method GET -uri $oldToken
$deltaLink = $delta.'@odata.deltaLink'
if($delta.value.count -eq 0){
    write-output (convertto-json @{"result"="No change in group detected."})
} else {

    # get owners from graph
    $owners = [System.Collections.ArrayList]@()     
    $grpOwners = get-mggroupowner -groupid $groupGUID
    foreach($owner in $grpOwners){
        $resp = $owners.add($owner.AdditionalProperties.userPrincipalName)
    }
    $ids = [System.Collections.ArrayList]@()     
    $joined = @{}
    foreach($obj in $delta.Value.'members@delta'){
        $resp = $ids.add($obj.id)
        if($obj.containsKey('@removed')){
            $obj['Status'] = "Removed"
        } else {
            $obj['Status'] = 'Current'
        }
        $joined[$obj.id] = $obj
    }   
    # Take the user data returned from delta query and materialize to users
    $users = Invoke-MgGraphRequest -Method POST `
	    -uri "https://graph.microsoft.com/v1.0/directoryObjects/getByIds" `
 	    -body (convertto-json @{"ids"=$ids;"types"=@("user")})
    # Merge the two lists
    foreach($user in $users.value){
        $joined[$user.id].displayName = $user.displayName
        $joined[$user.id].mail = $user.mail
    }
    $addedMembers = [System.Collections.ArrayList]@()    
    $removedMembers = [System.Collections.ArrayList]@()  
    foreach($userkey in $joined.keys){
        $user = $joined[$userkey]
        if($user.status -eq "Removed"){
            $resp = $removedMembers.add(@{"DisplayName"=$user.DisplayName;`
                "Mail"=$user.mail})
        } else {
            $resp = $addedMembers.add(@{"DisplayName"=$user.DisplayName;`
                "Mail"=$user.mail})
        }
    }
    Write-Output (convertto-json -inputobject @{"added" = $addedMembers;`
        "removed" = $removedMembers;"owners" = $owners;"result"="OK";`
        "GroupName"=$group.DisplayName}) 
}   

Future work

Subscriptions to group change don’t currently support lifecycle notifications. Once they do, we’ll need to update this solution to account for these.

Teams doesn’t currently support web-hook incoming messages to individual chat, only to a channel. The currently employed workaround is to post via the PowerAutomate connector to each group owner. A more elegant approach would be to build and register a Teams bot and post using that identity.