How to get notified when changes appears in a specific Azure AD Group using Azure Functions and Microsoft Graph API

In a previous article, I wrote about how to get the changes done for an Azure AD Groups using delta queries and scheduled calls to a specific url.
While I was writing it I found the subscription section in the documentation and It could also help to achieve the same goal.

The process is slightly different than with the delta queries. In this case we have to create a notification subscription on a specific resource in Azure. In our case a specific group by specifying the group ID.

Once the notification is created, a webhook receives the notification message when a change appears in the group with the necessary information (User Added, User Removed…)

A limitation exists though is that the subscription is only valid for a certain amount of time (~3 days). As a workaround, I created a specific Azure Functions used to automatically check and renew the subscription before it expires, so the process never stops working.

Here’s what the process looks like

Notification using azure functions

The Create/Renew subscription Azure function

The first function is a timer triggered azure function that runs every xx minutes (your choice. 30 in my case) to check if there is a subscription to renew or to create based on a configuration file. Here’s the code of the function

$FunctionDirectory = Get-Item $EXECUTION_CONTEXT_FUNCTIONDIRECTORY
$AssetsDirectory = $FunctionDirectory.Parent.GetDirectories("_") 

# Load config file locally
Write-Output " - Load config file localy"
$configFilePath = Join-Path -Path  $AssetsDirectory.FullName -ChildPath "appconfig.json"
$appConfig = Get-Content -Raw  $configFilePath | ConvertFrom-Json

if($appConfig){
    
    # GET method: each querystring parameter is its own variable
    $query = "https://graph.microsoft.com/v1.0/subscriptions"
    $tenantId = $env:TENANTID
    $clientId = $env:APPCLIENTID
    $clientSecret = $env:APPCLIENTSECRET
    
	#Get an access token from MS Graph using an AppID/AppSecret that have Group.Read.All permissions
    $accessToken = Get-GraphAccessToken -AppID $clientId -AppSecret $clientSecret -TokenAuthURI "https://login.microsoftonline.com/$tenantId/oauth2/token"

    $result = @()
    if($accessToken){
		# Get all the existing subscriptions created by this AppId
        $resultQuery = Send-GraphApiRequest -Uri $query -accessToken $accessToken -method Get
        $result = $resultQuery.Results | convertfrom-json
    }

    $Enabledsubscriptions = $appConfig.NotificationGroupsConfig | Where-Object {$_.Enabled -eq $true}
    $Disabledsubscriptions = $appConfig.NotificationGroupsConfig | Where-Object {$_.Enabled -eq $false}

    Write-Output $resultQuery

    if($null -ne $result){
        # Check existing subscription to renew = update expirationDateTime
        $existingSubscriptionsToRenew = [array]($result | where-object {$_.resource.replace("Groups/","") -in $Enabledsubscriptions.SourceGroupId})
        Write-Output "Checking $($existingSubscriptionsToRenew.count) existing subscriptions to renew"
        $currentDate = Get-Date
        $expirationDateTime = ((get-date).AddMinutes(4230)).ToString("yyyy-MM-ddTHH:mm:ssZ")
        $headers = @{
            "Content-Type" = "application/json"
            "Authorization" = "Bearer $accessToken"
        }
        foreach($sub in $existingSubscriptionsToRenew){
            $subExpirationDate = ([DateTime]$sub.expirationDateTime).AddHours(-5)
            if($currentDate -gt $subExpirationDate){
                
                try{
                    Write-Output "- Updating subscription: $($sub.id)"
                    #update subscription
                    $param = @{
                        "expirationDateTime"= "$expirationDateTime"
                    }
                    $body = new-object -typeName psobject -property $param
                    $bodyJson = Convertto-json $body
                    $uri = "$query/{0}" -f $sub.id
                    
                    Invoke-RestMethod -method PATCH -uri $uri -headers $headers -body $bodyJson
                }
                catch{
                    Write-Output "Unable to update subscription: $($sub.id)"
                    Write-Output "$($_.Exception.Message)"
                }
            }
            else{
                Write-Output " - Subscription with Id $($sub.id) does not need an update"
            }
        }

        # Creating new subscriptions
        if($result.count -gt 0){
            $subscriptionToCreate = [array]($Enabledsubscriptions | where-object {$_.SourceGroupId -notin $result.resource.replace("Groups/", "")})
        }
        else{
            $subscriptionToCreate = [array]($Enabledsubscriptions)
        }
        
        Write-Output "Creating $($subscriptionToCreate.count) new subscriptions"
        $currentDate = Get-Date
        $expirationDateTime = ((get-date).AddMinutes(4230)).ToString("yyyy-MM-ddTHH:mm:ssZ")
        $headers = @{
            "Content-Type" = "application/json"
            "Authorization" = "Bearer $accessToken"
        }
        foreach($newsub in $subscriptionToCreate){
            try{
                $subscriptionObj = [ordered]@{
                    "changeType"= "updated"
                    "notificationUrl"= "$($newsub.notificationUrl)"
                    "resource"= "Groups/$($newsub.SourceGroupId)"
                    "expirationDateTime"= "$expirationDateTime"
                }
                
                $body = new-object -typeName psobject -property $subscriptionObj
                $bodyJson = Convertto-Json $body
                Write-Output $bodyJson
                
                Invoke-RestMethod -method POST -uri $query -headers $headers -body $bodyJson
            }
            catch{
                Write-Output "Unable to create subscription for group: $($newsub.SourceGroupId)"
                Write-Output "$($_.Exception.Message)"
            }
        }

        # Delete existing subscriptions based on parameter file
        $existingSubscriptionsToDelete = [array]($result | where-object {$_.resource.replace("Groups/","") -in $Disabledsubscriptions.SourceGroupId})
        Write-Output "Deleting $($existingSubscriptionsToDelete.count) existing subscriptions"
        foreach($subtoDelete in $existingSubscriptionsToDelete){
            try{
                Write-Output "- Deleting subscription: $($subtoDelete.id) on resource $($subtoDelete.resource)"

                $uri = "$query/{0}" -f $subtoDelete.id
                Invoke-RestMethod -method DELETE -uri $uri -headers $headers
            }
            catch{
                Write-Output "Unable to delete subscription $($subtoDelete.id) on resource $($subtoDelete.resource)"
                Write-Output "$($_.Exception.Message)"
            }
        }
    }
}
else{
    Write-Output "The configuration file is null check the parameters: ConfigFilePath=$configFilePath"
}

The configuration file

The configuration file is a JSON that contains every information needed to create a subscription on a specific group. As it is JSON you can adapt it to your need.

{
    "NotificationGroupsConfig": [
        {
            "SourceGroupId": "[GUID of the first group]",
            "NotificationUrl": "https://myfunctionapp.azurewebsites.net/api/myfirstwebhook",
            "Enabled": true
        },
        {
            "SourceGroupId": "[GUID of the second group]",
            "NotificationUrl": "https://myfunctionapp.azurewebsites.net/api/anotherwbhook",
            "Enabled": true
        }
	]
}

The webhook that will receive the notification message

The webhook that will receive the notification is an Http Triggered Azure function. It has 2 purposes.

The first one is to assess the subscription by sending back a specific token sent during the subscription process.
The second one is to process the messages received when the subscription is live.

# Validation token received when a subscription is created
# this token needs to be sent back with a status code 200 and a content-type plain/text
if ($req_query_validationToken) 
{
    $validationToken = $req_query_validationToken
    write-Output "validationToken = $validationToken"
    $message = "{ `"headers`":{`"content-type`":`"text/plain`"}, `"body`":`"$validationToken`", `"statusCode`":`200`}"
    Write-Output $message
    [System.IO.File]::WriteAllText($res,$message)
}
# Message received and processed
else{
    $resourceData = $requestBody.Value[0].ResourceData
    $requestData = $requestBody.Value[0]
    # the changes on the membership of the group
	$membersDelta = $resourceData."members@delta"
    $userIdsremovedFromSourceGroup = [array]($membersDelta | where-object {$_.psobject.properties.name -contains "@removed"} | select-object id)
    $userIdsToAddedToSourceGroup = [array]($membersDelta | where-object {$_.psobject.properties.name -notcontains "@removed"} | select-object id)
    
    # Add any actions needed on the users (Licensing, permissions, ...)    
}

That’s it !! when a user is added to or removed from the group, the webhook will receive a notification of which user has been added/removed. The rest is up to you and can be used for example for:
– Migration of specific users to another system
– Actions that needs to be taken automatically on specific users
– Specific needs of licensing for example that needs an approval process
– ….

Leave a comment