{"id":7130,"date":"2023-06-24T14:40:06","date_gmt":"2023-06-24T13:40:06","guid":{"rendered":"https:\/\/rakhesh.com\/?p=7130"},"modified":"2023-06-24T21:43:20","modified_gmt":"2023-06-24T20:43:20","slug":"graph-api-group-delta-changes","status":"publish","type":"post","link":"https:\/\/rakhesh.com\/azure\/graph-api-group-delta-changes\/","title":{"rendered":"Graph API group delta changes"},"content":{"rendered":"

Long time no posts!<\/p>\n

The other day at work I had to setup an automation that synced group membership changes in one group to another.<\/p>\n

Typically, I would have read the membership of the source group, membership of the destination group, and done a quick compare of them to add\/ remove elements. I script in PowerShell and thanks to a StackOverflow post<\/a> I came across in the past I use the following code to do this:<\/p>\n

# Compare with new list. This makes use of System.Linq of .NET. It calls the Enumerable.Except method. \r\n# This method returns those elements in first that don't appear in second. It doesn't return those elements in second that don't appear in first. \r\n$ignoreCaseComparer = [System.StringComparer]::OrdinalIgnoreCase # this is to make linq ignore case; thanks https:\/\/stackoverflow.com\/a\/66662395\r\n$addMembers = [string[]][Linq.Enumerable]::Except([string[]]$newMembers,[string[]]$existingMembers,$ignoreCaseComparer)\r\n$removeMembers = [string[]][Linq.Enumerable]::Except([string[]]$existingMembers,[string[]]$newMembers,$ignoreCaseComparer)\r\n<\/pre>\n

This is slow though because I have some 100+ groups to do with this and while the actual comparision is fast I still have to make all those Graph API calls to get membership and such, which eventually leads to throttling and disconnects etc. It’s not bad, and I don’t plan on replacing a working thing with something else, but with the new task I had to do I wanted to try something different.<\/p>\n

For one, while previously I wanted to do a full sync – i.e. remove anything in the destination group that’s not present in the source group – this time I only wanted a one way sync. Any additions\/ deletions in the source group must be made in the destination, but I don’t care if anyone adds\/ removes entries in the destination group itself. That is to say, if my source group contains users A, B, and C; and the destination group too contains the same. Then I add D and E, I want these to sync over to the destination; but now if someone removes E from the destination I am OK with that. The source group will thus contain A, B, C, D, E, while the destination contains A, B, C, D. If someone adds F to the destination group, I am fine with that stayin on… even though the source group doesn’t have it.<\/p>\n

The best approach for this seems to be the group: delta Graph API<\/a> call. It’s a bit of a weird call coz you don’t actually run this against a particular group. You could filter<\/em> against a particular group, but by default it just gives everything. For example:<\/p>\n

> Invoke-MgGraphRequest -Method 'GET' -Uri 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta'\r\n\r\nName                           Value\r\n----                           -----\r\nvalue                          {a883ddc6-91ce-4559-b93a-05e112d7450b, 9c9c577c-77e0-4d47-b758-94d7925a0efb, 780dfcbb-9c04-445c-9f74-380631e98e64, 4938ba57-5e96-4a3e-b\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfiMLS-StzG-D5D2tgCYt_jY0rgHwzTVfhrAjD\u2026\r\n<\/pre>\n

The output contains value<\/code> contains everything. Here’s an example entry from the results:<\/p>\n

Name                           Value\r\n----                           -----\r\nsecurityIdentifier             S-1-12-1-2827214278-1163497934-3775216313-189126418\r\ndisplayName                    All Company\r\nmailEnabled                    True\r\nvisibility                     Public\r\ncreationOptions                {YammerProvisioning}\r\nid                             a883ddc6-91ce-4559-b93a-05e112d7450b\r\ngroupTypes                     {Unified}\r\nproxyAddresses                 {SMTP:allcompany@rak.onmicrosoft.com}\r\nrenewedDateTime                04\/07\/2021 13:52:38\r\ncreatedDateTime                04\/07\/2021 13:52:38\r\nsecurityEnabled                False\r\nmembers@delta                  {c38e8340-f903-4e63-9624-3dee3241d1f3}\r\nmailNickname                   allcompany\r\nmail                           allcompany@rak.onmicrosoft.com\r\nresourceBehaviorOptions        {CalendarMemberReadOnly}\r\ndescription                    This is the default group for everyone in the network<\/pre>\n

This is a group that currently exists in my tenant. Notice the members@delta<\/code> entry? Here’s what that contains:<\/p>\n

Name                           Value\r\n----                           -----\r\n@odata.type                    #microsoft.graph.user\r\nid                             c38e8340-f903-4e63-9624-3dee3241d1f3<\/pre>\n

If I look at the group, it has just one user and its id matches the output of members@delta<\/code>.<\/p>\n

This gives us an idea of the output of this API. It shows every group addition and deletion in the tenant, along with any group membership additions and deletions as well as any change in other information. One can “replay” this in a way to get to the same state as things are currently.<\/p>\n

Here’s what another entry looks like:<\/p>\n

Name                           Value\r\n----                           -----\r\n@removed                       {[reason, deleted]}\r\nid                             615396b9-7bb9-4729-882e-5e151307ffce<\/pre>\n

I couldn’t find that id anywhere in my tenant. Until I went through the audit logs and realized this is a group I deleted a few days ago (oddly it didn’t appear in the “Deleted Groups” section either). This is what I was talking about – the output includes even groups that don’t exist any more.The @removed<\/code> property tells me the reason too – the group was deleted.<\/p>\n

The output seems to be limited to additions\/ deletions only, not other changes. For instance, I renamed a group but there was no property capturing this fact.<\/p>\n

Going back to the original output:<\/p>\n

Name                           Value\r\n----                           -----\r\nvalue                          {a883ddc6-91ce-4559-b93a-05e112d7450b, 9c9c577c-77e0-4d47-b758-94d7925a0efb, 780dfcbb-9c04-445c-9f74-380631e98e64, 4938ba57-5e96-4a3e-b\u2026\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfhHBeVcfzhAA6TDWKzjjAgvV3SAck9TQ6juBn\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups<\/pre>\n

There’s also an @odata.nextLink<\/code> property. This is a link that can be called to get the next set of results. If I do that, I see the following (I am doing the first step again so I capture @odata.nextLink)<\/code>:<\/p>\n

> $results = Invoke-MgGraphRequest -Method 'GET' -Uri 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta'\r\n\r\n> $results\r\n\r\nName                           Value\r\n----                           -----\r\nvalue                          {a883ddc6-91ce-4559-b93a-05e112d7450b, 9c9c577c-77e0-4d47-b758-94d7925a0efb, a6131882-b110-4dc6-b1ea-e8c3c5cfccdb, 780dfcbb-9c04-445c-9\u2026\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfiTHbOzloTkSNn4jstzTvRmMEIDvlyo2u8u8P\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups\r\n\r\n> Invoke-MgGraphRequest -Method 'GET' -Uri $results.'@odata.nextLink'\r\n\r\nName                           Value\r\n----                           -----\r\n@odata.deltaLink               https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$deltatoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfieYyXuWJtQh6zgTE8gNR82IVgOAiUGfs5np\u2026\r\nvalue                          {}\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups<\/pre>\n

In the second run, I don’t get a @odata.nextLink<\/code> any more. Instead I get @odata.deltaLink<\/code>. What does this mean?<\/p>\n

The way the delta API works is that the output includes these tokens – a skip token ($skiptoken<\/code>) and a delta token ($deltatoken<\/code>). The skip token is an indicator of how much output was returned; so calling the API again with the skip token gives you the next set of output with a new skip token. The idea is you keep calling the API with skip tokens until you get all the output and skip token is empty.<\/p>\n

To make things easy, the API gives you a new URL each time with the skip token added. That’s what @odata.nextLink<\/code> is, which is why the URL looks like https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken<\/strong>=eKgfrPQSvBw5VPEZGw9FfwaG4...<\/code> – the API is being helpful.<\/p>\n

Similarly, the delta token is a token that captures the point in time or state<\/em> of the output. You only get the delta token when you get the final set of results – i.e. when there is no more skip token. So essentially, you keep polling the API with skip tokens until you get no skip token; at which point you should have a delta token and so you capture that somewhere. The next time you call the API – which can be immediately or after how many every hours or days – if you do so with the delta token, it will give you all the changes since the previous invocation<\/em>. Nice, huh? And at the end of that you will get a new delta token which can be used in subsequent invocations to get all the changes since then.<\/p>\n

This is what @odata.deltaLink contains – which is why the URL looks like https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$deltatoken<\/strong>=eKgfrPQSvBw5VPEZGw9Ff..<\/code>.<\/p>\n

All this and more can be found in the delta API docs\u00a0 <\/a>by the way.<\/p>\n

Here’s me invoking the URL with the delta token.<\/p>\n

> $results2 = Invoke-MgGraphRequest -Method 'GET' -Uri $results.'@odata.nextLink'\r\n> Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink'\r\n\r\n\r\nName                           Value\r\n----                           -----\r\n@odata.deltaLink               https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$deltatoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-Sjtnfjgpof7dJCb2aqzjcTwppBUAQChgCTiB18yZ\u2026\r\nvalue                          {}\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups<\/pre>\n

No changes, hence value<\/code> is empty.<\/p>\n

Let me go and rename a group now and run the same URL.<\/p>\n

> Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink'\r\n\r\nName                           Value\r\n----                           -----\r\n@odata.deltaLink               https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$deltatoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfgromWFp-OJzQCD997FZeF1KK9Z2rjh0NcDI\u2026\r\nvalue                          {a6131882-b110-4dc6-b1ea-e8c3c5cfccdb}\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups<\/pre>\n

Nice! There’s the group Id. Let’s look at the value<\/code>:<\/p>\n

> (Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink').value\r\n\r\nName                           Value\r\n----                           -----\r\nvisibility\r\nsecurityEnabled                True\r\nmailNickname                   00000000-0000-0000-0000-000000000000\r\nsecurityIdentifier             S-1-12-1-2786269314-1304867088-3286821553-3687632837\r\ndescription                    Members of this group are allowed to run Office Scripts in Power Platform\r\ncreatedDateTime                14\/06\/2022 10:04:00\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\nrenewedDateTime                14\/06\/2022 10:04:00\r\nmailEnabled                    False\r\ndisplayName                    Power Platform - Allow Office Scripts<\/pre>\n

Hmm, not super helpful. I know what I changed in this case – the displayName – but there’s no way to glean that from the output. For comparison, here’s the output from the original delta query with none of the tokens. Since I stored that in a variable I can refer to it here:<\/p>\n

$results.Value[2]\r\n\r\nName                           Value\r\n----                           -----\r\nvisibility\r\nsecurityEnabled                True\r\nmailNickname                   00000000-0000-0000-0000-000000000000\r\nsecurityIdentifier             S-1-12-1-2786269314-1304867088-3286821553-3687632837\r\ndescription                    Members of this group are allowed to run Office Scripts in Power Platform\r\ncreatedDateTime                14\/06\/2022 10:04:00\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\nrenewedDateTime                14\/06\/2022 10:04:00\r\nmailEnabled                    False\r\ndisplayName                    Power Platform - Allow Office Scripts 2<\/pre>\n

Turns out there’s a way to get just the difference.<\/a><\/p>\n

> (Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink' -Headers @{\"Prefer\" = \"return=minimal\"}).value\r\n\r\nName                           Value\r\n----                           -----\r\nvisibility\r\ndisplayName                    Power Platform - Allow Office Scripts\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\nsecurityIdentifier             S-1-12-1-2786269314-1304867088-3286821553-3687632837<\/pre>\n

I was hoping it might tell me what the previous value is, but it doesn’t. Instead all this one does is show the changed properties, along with standard output like the id.<\/p>\n

Here’s the same after I changed the description too:<\/p>\n

> (Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink' -Headers @{\"Prefer\" = \"return=minimal\"}).value\r\n\r\nName                           Value\r\n----                           -----\r\ndescription                    Members of this group are allowed to run Office Scripts in Power Platform - TEST\r\nvisibility\r\ndisplayName                    Power Platform - Allow Office Scripts\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\nsecurityIdentifier             S-1-12-1-2786269314-1304867088-3286821553-3687632837<\/pre>\n

Now let me add someone and try with the same URL:<\/p>\n

> (Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.deltaLink' -Headers @{\"Prefer\" = \"return=minimal\"}).value\r\n\r\nName                           Value\r\n----                           -----\r\ndisplayName                    Power Platform - Allow Office Scripts\r\ndescription                    Members of this group are allowed to run Office Scripts in Power Platform - TEST\r\nvisibility\r\nmembers@delta                  {b269b48d-afa4-49ed-a26e-d684531b62c7}\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\nsecurityIdentifier             S-1-12-1-2786269314-1304867088-3286821553-3687632837<\/pre>\n

Kind of obvious, but like with other Graph API calls one can focus on the properties to be tracked. Say I am not interested in any other changes except the display name. In this case my initial query will have to mention that:<\/p>\n

https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$select=displayName<\/pre>\n

And subequent delta queries will only show changes<\/a> to that.<\/p>\n

Ditto for just group membership changes, which is what I am interested in. $select=members<\/code><\/p>\n

> $results = Invoke-MgGraphRequest -Method 'GET' -Uri 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$select=members'\r\n> $results\r\n\r\nName                           Value\r\n----                           -----\r\nvalue                          {a883ddc6-91ce-4559-b93a-05e112d7450b, 9c9c577c-77e0-4d47-b758-94d7925a0efb, 780dfcbb-9c04-445c-9f74-380631e98e64, 4938ba57-5e96-4a3e-b\u2026\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=47I-s40Sdr-3gWyfmEvd9O1xwM5sgBeb0tvvFd6567qEtR3qX7jYDWZn9bYhQ9HpBrE2NFEsgz5BV4\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups(members)\r\n\r\n> $results2 = Invoke-MgGraphRequest -Method 'GET' -Uri $results.'@odata.nextLink'\r\n> $results2\r\n\r\nName                           Value\r\n----                           -----\r\nvalue                          {}\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=eKgfrPQSvBw5VPEZGw9FfwaG4hGdROSHnSBQ-SjtnfgYzRcmJIOdjBJG3bXHoTmh1SDzwU_MPDIB8c\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups\r\n\r\n> $results3 = Invoke-MgGraphRequest -Method 'GET' -Uri $results2.'@odata.nextLink'\r\n> $results3\r\n\r\nName                           Value\r\n----                           -----\r\n@odata.deltaLink               https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$deltatoken=47I-s40Sdr-3gWyfmEvd9O1xwM5sgBeb0tvvFd6567oHQlCb5AO_yN7ySxFZukNpHQQWE95UwSyvV\u2026\r\nvalue                          {}\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups<\/pre>\n

What is the actual output in the value<\/code> field?<\/p>\n

$results.Value\r\n\r\nName                           Value\r\n----                           -----\r\nmembers@delta                  {c38e8340-f903-4e63-9624-3dee3241d1f3}\r\nid                             a883ddc6-91ce-4559-b93a-05e112d7450b\r\nmembers@delta                  {c38e8340-f903-4e63-9624-3dee3241d1f3}\r\nid                             9c9c577c-77e0-4d47-b758-94d7925a0efb\r\nmembers@delta                  {b269b48d-afa4-49ed-a26e-d684531b62c7, 0e262fa3-9c95-4455-b6c0-b75c50e6c274}\r\nid                             780dfcbb-9c04-445c-9f74-380631e98e64\r\nid                             4938ba57-5e96-4a3e-b069-a066dd194a55\r\nid                             fac2e82c-01eb-4b71-aecb-ce7c4edebe33\r\nmembers@delta                  {b269b48d-afa4-49ed-a26e-d684531b62c7, c38e8340-f903-4e63-9624-3dee3241d1f3}\r\nid                             49cf8d72-1227-481b-9458-c212f5837760\r\nmembers@delta                  {c38e8340-f903-4e63-9624-3dee3241d1f3}\r\nid                             648cce96-5e79-440d-b6b3-4352c4bf182f\r\nmembers@delta                  {0e262fa3-9c95-4455-b6c0-b75c50e6c274, c38e8340-f903-4e63-9624-3dee3241d1f3, 208becde-72bc-4d66-b57a-b31254e7edc1, d75c5832-4a63-4ffb-8\u2026\r\nid                             62c9d890-046a-49d7-a8fe-045e82948880\r\nid                             4dcac9d8-c86c-4a27-b900-7e873d496c99\r\nid                             e464452d-c63e-4669-881a-d7613aaf11fa\r\nid                             98f792dd-bb9f-4245-b927-7300993c8e8d\r\nid                             9bfd969d-30eb-4ee9-9ad6-1c9cbfc62d77\r\nmembers@delta                  {b269b48d-afa4-49ed-a26e-d684531b62c7, 0e262fa3-9c95-4455-b6c0-b75c50e6c274, 8b14e387-2321-4f44-b3c9-8b5837911d8f}\r\nid                             83e46fb6-9557-416c-97c4-2b960f0be57a\r\nmembers@delta                  {b269b48d-afa4-49ed-a26e-d684531b62c7}\r\nid                             a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\n@removed                       {[reason, deleted]}\r\nid                             615396b9-7bb9-4729-882e-5e151307ffce<\/pre>\n

How can I do this better? Need a loop! \ud83d\ude0a<\/p>\n

# Initial call\r\n$results = Invoke-MgGraphRequest -Method 'GET' -Uri 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$select=members'\r\n\r\nwhile ($null -ne $results.'@odata.nextLink') {\r\n    # Process each result from the API call\r\n    foreach ($result in $results.value) {\r\n        Write-Host \"Group id: $($result.id)\"\r\n\r\n        # Are there any membership changes?\r\n        if ($result.'members@delta') {\r\n            # If so process each change\r\n            foreach ($member in $result.'members@delta') {\r\n                # Only process users though, not groups or other membership changes\r\n                if ($member.'@odata.type' -eq '#microsoft.graph.user') {\r\n                    # If it doesn't have a '@removed' property then the member was added\r\n                    if ($null -eq $member.'@removed') {\r\n                        Write-Host \"+++ Add user: $($member.id) +++\"\r\n                    # Else it was removed\r\n                    } else {\r\n                        Write-Host \"--- Remove user: $($member.id) ---\"\r\n                    }\r\n                }\r\n            }\r\n        } elseif ($result.'@removed') {\r\n            Write-Host \"The group was deleted\"\r\n        } else {\r\n            Write-Host \"No change\"\r\n        }\r\n    }\r\n\r\n    # Make the next API call\r\n    $results = Invoke-MgGraphRequest -Method 'GET' $results.'@odata.nextLink'\r\n}\r\n\r\n$deltaLink = $results.'@odata.deltaLink'\r\nWrite-Host \"Next time call $deltaLink\"<\/pre>\n

Here’s a sample output:<\/p>\n

Group id: a883ddc6-91ce-4559-b93a-05e112d7450b\r\n+++ Add user: c38e8340-f903-4e63-9624-3dee3241d1f3 +++\r\nGroup id: 9c9c577c-77e0-4d47-b758-94d7925a0efb\r\n+++ Add user: c38e8340-f903-4e63-9624-3dee3241d1f3 +++\r\nGroup id: 780dfcbb-9c04-445c-9f74-380631e98e64\r\n+++ Add user: b269b48d-afa4-49ed-a26e-d684531b62c7 +++\r\n+++ Add user: 0e262fa3-9c95-4455-b6c0-b75c50e6c274 +++\r\nGroup id: 4938ba57-5e96-4a3e-b069-a066dd194a55\r\nNo change\r\nGroup id: fac2e82c-01eb-4b71-aecb-ce7c4edebe33\r\nNo change\r\nGroup id: 49cf8d72-1227-481b-9458-c212f5837760\r\n+++ Add user: b269b48d-afa4-49ed-a26e-d684531b62c7 +++\r\n+++ Add user: c38e8340-f903-4e63-9624-3dee3241d1f3 +++\r\nGroup id: 648cce96-5e79-440d-b6b3-4352c4bf182f\r\n+++ Add user: c38e8340-f903-4e63-9624-3dee3241d1f3 +++\r\nGroup id: 62c9d890-046a-49d7-a8fe-045e82948880\r\n+++ Add user: 0e262fa3-9c95-4455-b6c0-b75c50e6c274 +++\r\n+++ Add user: c38e8340-f903-4e63-9624-3dee3241d1f3 +++\r\n<snip>\r\nGroup id: 9bfd969d-30eb-4ee9-9ad6-1c9cbfc62d77\r\nNo change\r\nGroup id: 83e46fb6-9557-416c-97c4-2b960f0be57a\r\n+++ Add user: b269b48d-afa4-49ed-a26e-d684531b62c7 +++\r\n+++ Add user: 0e262fa3-9c95-4455-b6c0-b75c50e6c274 +++\r\n+++ Add user: 8b14e387-2321-4f44-b3c9-8b5837911d8f +++\r\nGroup id: a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\r\n+++ Add user: b269b48d-afa4-49ed-a26e-d684531b62c7 +++\r\nGroup id: 615396b9-7bb9-4729-882e-5e151307ffce\r\nThe group was deleted<\/pre>\n

So far I have been dealing with multiple groups. Let’s focus on one now. To do that you filter by the group id.<\/p>\n

> Invoke-MgGraphRequest -Method 'GET' -Uri 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$select=members&$filter= id eq ''a6131882-b110-4dc6-b1ea-e8c3c5cfccdb'''\r\n\r\nName                           Value\r\n----                           -----\r\nvalue                          {a6131882-b110-4dc6-b1ea-e8c3c5cfccdb}\r\n@odata.nextLink                https:\/\/graph.microsoft.com\/v1.0\/groups\/delta?$skiptoken=M4aXDbhIMw-MLPzz_VXZ_nV4Rv4MCjA0v433FRA-W_pRssMgNCzjL1a22LsXOKV9qZNbhF-02u2iAj\u2026\r\n@odata.context                 https:\/\/graph.microsoft.com\/v1.0\/$metadata#groups(members)<\/pre>\n

I have to do $filter= id eq 'a6131882-b110-4dc6-b1ea-e8c3c5cfccdb'<\/code> but since I am already within single quotes I got to escape these. Thus I put $filter= id eq ''a6131882-b110-4dc6-b1ea-e8c3c5cfccdb''<\/code> (to escape the single quote put it twice).<\/p>\n

A brief digression on the Get-MgGroupDelta<\/code> cmdlet.<\/p>\n

It looks to be useful but is pretty useless actually. Here’s how its default output looks like:<\/p>\n

Id                                   DisplayName                           Description                                                                      GroupTypes\r\n--                                   -----------                           -----------                                                                      ----------\r\na883ddc6-91ce-4559-b93a-05e112d7450b All Company                           This is the default group for everyone in the network                            {Unified}\r\n9c9c577c-77e0-4d47-b758-94d7925a0efb Give Planning Team                    Give Planning Team                                                               {Unified}\r\na6131882-b110-4dc6-b1ea-e8c3c5cfccdb Power Platform - Allow Office Scripts Members of this group are allowed to run Office Scripts in Power Platform - TEST\r\n780dfcbb-9c04-445c-9f74-380631e98e64 ExchangeAccessPolicy\r\n4938ba57-5e96-4a3e-b069-a066dd194a55 MailSec Group\r\nfac2e82c-01eb-4b71-aecb-ce7c4edebe33 Password Reset Self\r\n49cf8d72-1227-481b-9458-c212f5837760 Forms Site                            Forms Site                                                                       {Unified}\r\n648cce96-5e79-440d-b6b3-4352c4bf182f Test Team                                                                                                              {Unified}<\/pre>\n

Nice. The equivalent to get a single group would be:<\/p>\n

> Get-MgGroupDelta -Filter \"id eq 'a6131882-b110-4dc6-b1ea-e8c3c5cfccdb'\"\r\n\r\nId                                   DisplayName                           Description                                                                      GroupTypes\r\n--                                   -----------                           -----------                                                                      ----------\r\na6131882-b110-4dc6-b1ea-e8c3c5cfccdb Power Platform - Allow Office Scripts Members of this group are allowed to run Office Scripts in Power Platform - TEST<\/pre>\n

So far so good. The beauty of using this cmdlet is that one doesn’t have to bother with @odata.nextLink<\/code> any more. The cmdlet will loop over that and return all results. Any deltas are under AdditionalProperties<\/code>.<\/p>\n

> $results = Get-MgGroupDelta -Filter \"id eq 'a6131882-b110-4dc6-b1ea-e8c3c5cfccdb'\"\r\n> $results.AdditionalProperties.'members@delta'\r\n\r\nKey         Value\r\n---         -----\r\n@odata.type #microsoft.graph.user\r\nid          b269b48d-afa4-49ed-a26e-d684531b62c7<\/pre>\n

But where do I get the delta link for the next invocation? No idea. Or how do I invoke this cmdlet in a delta run? No idea. The help page is useless, and I couldn’t find anything in Google or GitHub. Eventually I gave up.<\/p>\n

Back to what we were doing. Here’s the equivalent code for a single group id.<\/p>\n

$sourceGroupId = \"a6131882-b110-4dc6-b1ea-e8c3c5cfccdb\"\r\n$graphURL = 'https:\/\/graph.microsoft.com\/v1.0\/groups\/delta\/?$filter=id eq ''' + $sourceGroupId + '''&$select=members'\r\n\r\n# Initial call\r\n$results = Invoke-MgGraphRequest -Method 'GET' -Uri $graphURL\r\n\r\nwhile ($null -ne $results.'@odata.nextLink') {\r\n    # Process each result from the API call\r\n    foreach ($result in $results.value) {\r\n        Write-Host \"Group id: $($result.id)\"\r\n\r\n        # Are there any membership changes?\r\n        if ($result.'members@delta') {\r\n            # If so process each change\r\n            foreach ($member in $result.'members@delta') {\r\n                # Only process users though, not groups or other membership changes\r\n                if ($member.'@odata.type' -eq '#microsoft.graph.user') {\r\n                    # If it doesn't have a '@removed' property then the member was added\r\n                    if ($null -eq $member.'@removed') {\r\n                        Write-Host \"+++ Add user: $($member.id) +++\"\r\n                    # Else it was removed\r\n                    } else {\r\n                        Write-Host \"--- Remove user: $($member.id) ---\"\r\n                    }\r\n                }\r\n            }\r\n        } elseif ($result.'@removed') {\r\n            Write-Host \"The group was deleted\"\r\n        } else {\r\n            Write-Host \"No change\"\r\n        }\r\n    }\r\n\r\n    # Make the next API call\r\n    $results = Invoke-MgGraphRequest -Method 'GET' $results.'@odata.nextLink'\r\n}<\/pre>\n

In my case I had to do this for about 40+ groups. Not a problem, I can loop over the ids. But what do I do with the delta links? How do I store it for the next time around?<\/p>\n

Typically I would use SharePoint for such a task. In fact, I would read the group names from there and store the link in one of the columns. But this was an Azure Function I was expecting to run quite often and for some reason I didn’t want to depend on SharePoint. I felt it could be an additional point of failure – Graph or PnP.PowerShell might make a call and fail and I don’t want the group syncs to fail because of that. I wanted something local to the Function itself where I store the delta links temporarily.<\/p>\n

I suppose I could have gone with storing them as files in the storage account associated with the Function. But then I thought let me use Azure Table storage<\/a>. This too is present in the storage account after all.<\/p>\n

I am not going to go into the details of that here. I’ll probably blog it later. As of now I spent most of my weekend morning writing the current post. \ud83d\ude31<\/p>\n

Update<\/strong>: Continued<\/a>…<\/p>\n","protected":false},"excerpt":{"rendered":"

Long time no posts! The other day at work I had to setup an automation that synced group membership changes in one group to another. Typically, I would have read the membership of the source group, membership of the destination group, and done a quick compare of them to add\/ remove elements. I script in … Continue reading Graph API group delta changes<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"highway","enabled":false}}},"categories":[887],"tags":[762,1016,1015],"jetpack_publicize_connections":[],"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7130"}],"collection":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/comments?post=7130"}],"version-history":[{"count":14,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7130\/revisions"}],"predecessor-version":[{"id":7148,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/posts\/7130\/revisions\/7148"}],"wp:attachment":[{"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/media?parent=7130"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/categories?post=7130"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rakhesh.com\/wp-json\/wp\/v2\/tags?post=7130"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}