functions/azure/aad/Assert-AzureAdGroupMembership.ps1

# <copyright file="Assert-AzureAdGroupMembership.ps1" company="Endjin Limited">
# Copyright (c) Endjin Limited. All rights reserved.
# </copyright>

<#
.SYNOPSIS
Configures the membership of an AzureAD group.

.DESCRIPTION
Uses Azure PowerShell to manage AzureAD group membership.

.PARAMETER Name
The display name of the group.

.PARAMETER ObjectId
The objectId of the group.

.PARAMETER Members
The list of AzureAD objects that should be members of the group. These can be specified using
'DisplayName', 'ObjectId', 'ApplicationId' or 'UserPrincipalName'.

.PARAMETER StrictMode
When true, existing group members not specified in the 'RequiredMembers' parameters will be removed from the group.

.OUTPUTS
AzureAD group definition object

.EXAMPLE

Assert-AzureAdGroupMembership -Name "MyGroup" -RequiredMembers @("MyOtherGroup", "MyUser", "MyServicePrincipal")
Assert-AzureAdGroupMembership -Name "MyGroup" -RequiredMembers @("MyOtherGroup", "MyUser@nowhere.org", "be2a6313-cb3a-45ad-a70f-cbac2a8c565f")
Assert-AzureAdGroupMembership -Name "MyGroup" -RequiredMembers @("f7f0545c-82b5-4008-bebf-f73fb1d5a7f8", "MyUser@nowhere.org", "be2a6313-cb3a-45ad-a70f-cbac2a8c565f")

#>

function Assert-AzureAdGroupMembership
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true, ParameterSetName="ByName")]
        [string] $Name,

        [Parameter(Mandatory=$true, ParameterSetName="ByObjectId")]
        [string] $ObjectId,

        [Parameter()]
        [string[]] $RequiredMembers,

        [Parameter()]
        [ValidateSet("Security","Distribution")]
        [string] $GroupType = "Security",

        [Parameter()]
        [bool] $StrictMode
    )

    # Check whether we have a valid AzPowerShell connection, but no subscription-level access is required
    _EnsureAzureConnection -AzPowerShell -TenantOnly -ErrorAction Stop | Out-Null

    # Setup the parameters for 'Get-AzADGroup' based on whether we've been given a group Name or ObjectId
    $groupLookupSplat = $PSCmdlet.ParameterSetName -eq "ByName" ? @{DisplayName = $Name} : @{ObjectId = $ObjectId}
    [array]$groups = Get-AzADGroup @groupLookupSplat | `
        Where-Object { $_.SecurityEnabled -eq ($GroupType -eq "Security") }

    if (!$groups) {
        throw "The specified group could not be found: $($groupLookupSplat.Keys[0])=$($groupLookupSplat.Values[0])"
    }
    elseif ($groups.Count -gt 1) {
        throw "Found multiple matching groups: $($groups | % { "ObjectId=$($_.Id);"} )"
    }
    $group = $groups[0]

    Write-Information "Processing group membership for '$($group.DisplayName)' [ObjectId=$($group.Id)]"

    $existingMemberObjectIds = _getGroupMembers $group.id | Select-Object -ExpandProperty id

    # Required members can be specified in various forms:
    # - ObjectId (groups, users & service principals)
    # - DisplayName (groups, users & service principals)
    # - UserPrincipalName (users only)
    # - ApplicationId (service principals only)
    #
    # We need the ObjectId to add them to the group - this block handles
    # resolving the above forms into an ObjectId
    $requiredMemberObjectIds = @()
    foreach ($member in $RequiredMembers) {   
        # At this stage we don't know whether the required member is a user, group or service principal.
        # This helper will handle that lookup.
        $resolvedMember = Get-AzureAdDirectoryObject -Criterion $member -Single
        if ($resolvedMember) {
            $requiredMemberObjectIds += $resolvedMember.Id
        }
        else {
            Write-Warning "Skipping '$member' - not found in the directory"
        }
    }
  
    # Add any missing required members
    $membersToAdd = $requiredMemberObjectIds | Where-Object { $_ -notin $existingMemberObjectIds }
    if ($membersToAdd) {
        Write-Information "Adding members: $($membersToAdd -join ', ')"
        $group | Add-AzADGroupMember -MemberObjectId $membersToAdd
    }
    else {
        Write-Information "No members to add"
    }

    # Remove extraneous members, only when StrictMode is enabled
    if ($StrictMode) {
        $membersToRemove = $existingMemberObjectIds | Where-Object { $_ -notin $requiredMemberObjectIds }
        if ($membersToRemove) {
            Write-Information "Removing members: $($membersToRemove -join ', ')"
            $group | Remove-AzADGroupMember -MemberObjectId $membersToRemove
        }
        else {
            Write-Information "No members to remove"
        }
    }

}

function _getGroupMembers {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [guid] $GroupObjectId
    )

    # May-2022: Workaround a current limitation, whereby service principal group members are not returned by the v1.0 MS Graph API
    # ref: https://docs.microsoft.com/en-us/powershell/azure/troubleshooting?view=azps-7.5.0#get-azadgroupmember-doesnt-return-service-principals
    # ref: https://docs.microsoft.com/en-us/graph/api/group-list-members?view=graph-rest-1.0&tabs=http
    
    Invoke-AzRestMethod -Uri "https://graph.microsoft.com/beta/groups/$($GroupObjectId.Guid)/members" |
        Select-Object -ExpandProperty Content |
        ConvertFrom-Json |
        Select-Object -ExpandProperty value
}