AzureGroupStuff.psm1

function Get-AzureGroupMemberRecursive {
    <#
    .SYNOPSIS
    Function for getting Azure group members recursively.
 
    .DESCRIPTION
    Function for getting Azure group members recursively.
 
    Some advanced filtering options are available.
 
    .PARAMETER id
    Id of the group whose members you want to retrieve.
 
    .PARAMETER excludeDisabled
    Switch for excluding disabled members from the output.
 
    .PARAMETER includeNestedGroup
    Switch for including nested groups in the output (otherwise just their members will be included).
 
    .PARAMETER allowedMemberType
    What type of members should be outputted.
 
    Available options: 'User', 'Device', 'All'.
 
    By default 'All'.
 
    .EXAMPLE
    Get-AzureGroupMemberRecursive -groupId 330a6343-da12-4999-bf87-a0ae60a68bbc
 
    .NOTES
    Requires following graph modules: Microsoft.Graph.Groups, Microsoft.Graph.Authentication, Microsoft.Graph.DirectoryObjects
    #>


    [Alias("Get-MgGroupMemberRecursive")]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [Alias("GroupId")]
        [guid] $id,

        [switch] $excludeDisabled,

        [switch] $includeNestedGroup,

        [ValidateSet('User', 'Device', 'All')]
        [string] $allowedMemberType = 'All'
    )

    if (!(Get-Command Get-MgContext -ErrorAction silentlycontinue) -or !(Get-MgContext)) {
        throw "$($MyInvocation.MyCommand): The context is invalid. Please login using Connect-MgGraph."
    }

    # list of ids of objects that were already written out, to skip duplicities
    $outputted = New-Object System.Collections.ArrayList

    foreach ($member in (Get-MgGroupMember -GroupId $id -All)) {
        $memberType = $member.AdditionalProperties["@odata.type"].split('.')[-1]
        $memberId = $member.Id

        if ($memberType -eq "group") {
            if ($includeNestedGroup) {
                if ($member.Id -notin $outputted) {
                    $null = $outputted.add($member.Id)
                    $member | Expand-MgAdditionalProperties
                } else {
                    # duplicity
                }
            }

            $param = @{
                allowedMemberType = $allowedMemberType
            }
            if ($excludeDisabled) { $param.excludeDisabled = $true }
            if ($includeNestedGroup) { $param.includeNestedGroup = $true }

            Write-Verbose "Expanding members of group $memberId"
            Get-AzureGroupMemberRecursive -id $memberId @param
        } else {
            if ($allowedMemberType -ne 'All' -and $memberType -ne $allowedMemberType) {
                Write-Verbose "Skipping $memberType member $memberId, because not of $allowedMemberType type."
                continue
            }

            if ($excludeDisabled) {
                $accountEnabled = (Get-MgDirectoryObject -DirectoryObjectId $memberId -Property accountEnabled).AdditionalProperties.accountEnabled
                if (!$accountEnabled) {
                    Write-Verbose "Skipping $memberType member $memberId, because not enabled."
                    continue
                }
            }
            if ($member.Id -notin $outputted) {
                $null = $outputted.add($member.Id)
                $member | Expand-MgAdditionalProperties
            } else {
                # duplicity
            }
        }
    }
}

function Get-AzureGroupSettings {
    <#
    .SYNOPSIS
    Function for getting group settings.
    Official Get-MgGroup -Property Settings doesn't return anything for some reason.
 
    .DESCRIPTION
    Function for getting group settings.
    Official Get-MgGroup -Property Settings doesn't return anything for some reason.
 
    .PARAMETER groupId
    Group ID.
 
    .EXAMPLE
    Get-AzureGroupSettings -groupId 01c19ec3-e1bb-44f3-ab36-86071b745375
 
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $groupId
    )

    Invoke-MgGraphRequest -Uri "v1.0/groups/$groupId/settings" -OutputType PSObject | select -exp value | select *, @{n = 'ValuesAsObject'; e = {
            # return settings values as proper hashtable
            $hash = @{}
            $_.Values | % { $hash.($_.name) = $_.value }
            $hash
        }
    } #-ExcludeProperty Values
}

function Set-AzureRingGroup {
    <#
    .SYNOPSIS
    Function for dynamically setting members of specified "ring" groups based on the provided users list (members of the rootGroup) and the members per group percent ratio (ringGroupConfig).
 
    Useful if you want to deploy some feature gradually (ring by ring).
 
    "Ring" group concept is inspired by Intune Autopatch deployment rings.
 
    .DESCRIPTION
    Function for dynamically setting members of specified "ring" groups based on the provided users list (members of the rootGroup) and the members per group percent ratio (ringGroupConfig).
 
    Useful if you want to deploy some feature gradually (ring by ring).
 
    "Ring" group concept is inspired by Intune Autopatch deployment rings.
 
    With each function run, members and their ratio is checked and a rebalance of members is made if needed.
 
    Ring groups can contain only accounts that are members of the root group too!
 
    Ring groups description will be automatically updated with each run of this function. It will contain date of the last update and some generated text about how many percent of the root group this group contains.
 
    .PARAMETER rootGroup
    Id of the Azure group which members should be distributed across all ring groups based on the percent weight specified in the "ringGroupConfig".
 
    Members are searched recursively! Only users or devices accounts are used based on 'memberType'.
 
    .PARAMETER ringGroupConfig
    Ordered hashtable where keys are IDs of the Azure "ring" groups and values are integers representing percent of the "rootGroup" group members this "ring" group should contain.
    Sum of the values must be 100 at total.
 
    Example:
    [ordered]@{
        'bcf239e9-6a5e-4de0-baf4-c14bda4c0571' = 5 # ring_1
        '19fe5c4c-7568-43a3-bd21-f95cb5547366' = 15 # ring_2
        '0db6da9f-c224-4252-a7dc-c31d55b3acb3' = 80 # ring_3
    }
 
    .PARAMETER forceRecalculate
    Use if you want to force members check even though count of the root group members is the same as of all ring groups members (to overwrite manual edits etc)
 
    .PARAMETER firstRingGroupMembersSetManually
    Switch to specify that first group in ringGroupConfig is being manually set a.k.a skipped in re-balancing process.
    Therefore its value in ringGroupConfig must be set to 0 (because members are added manually).
    Percent weight (specified in ringGroupConfig) of the rest of the ring groups is used only for re-balancing users that are non-first-ring-group members.
 
    .PARAMETER skipUnderscoreInNameCheck
    Switch for skipping check that all "ring" groups that have dynamically set members have '_' prefix in their name (name convention).
 
    .PARAMETER includeDisabled
    Switch for including also disabled members of the root group, otherwise just enabled will be used to fill the "ring" groups.
 
    .PARAMETER skipDescriptionUpdate
    Switch for not modifying ring groups description.
 
    .PARAMETER memberType
    Type of the "rootGroup" you want to set on "ring" groups.
 
    Possible values: User, Device.
 
    By default 'User'.
 
    .EXAMPLE
    # group whose members will be distributed between ring groups
    $rootGroup = "330a6543-da12-4999-bf87-a0ae60g28bbc"
    # ring groups configuration
    $ringGroupConfig = [ordered]@{
        # manually set members
        '9e6be2e2-c050-4887-b14c-e612a1b4bb48' = 0 # ring_0
        # automatically set members
        'bcf239e9-6a5e-4de0-baf4-c14bda4c0a71' = 5 # ring_1
        '19fe5c4c-7568-43a3-bd21-f95cb5547766' = 15 # ring_2
        '0db6da9f-c224-4252-a7dc-c31d55b9acb3' = 80 # ring_3
    }
 
    Set-AzureRingGroup -rootGroup $rootGroup -ringGroupConfig $ringGroupConfig -firstRingGroupMembersSetManually
 
    Members of the root group (minus members of the first "ring" group) will be distributed across rest of the "ring" groups by percent ratio selected in the $ringGroupConfig.
    Members of the first "ring" group stay intact.
    In case current "ring" groups members count doesn't correspond to the percent specified in the $ringGroupConfig, members will be removed/added accordingly.
 
    .EXAMPLE
    # group whose members will be distributed between ring groups
    $rootGroup = "330a6543-da12-4999-bf87-a0ae60g28bbc"
    # ring groups configuration
    $ringGroupConfig = [ordered]@{
        'bcf239e9-6a5e-4de0-baf4-c14bda4c0a71' = 5 # ring_1
        '19fe5c4c-7568-43a3-bd21-f95cb5547766' = 15 # ring_2
        '0db6da9f-c224-4252-a7dc-c31d55b9acb3' = 80 # ring_3
    }
 
    Set-AzureRingGroup -rootGroup $rootGroup -ringGroupConfig $ringGroupConfig
 
    Members of the root group will be distributed across the "ring" groups by percent ratio selected in the $ringGroupConfig.
    In case current "ring" groups members count doesn't correspond to the percent specified in the $ringGroupConfig, members will be removed/added accordingly.
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [guid] $rootGroup,

        [Parameter(Mandatory = $true)]
        [System.Collections.Specialized.OrderedDictionary] $ringGroupConfig,

        [switch] $forceRecalculate,

        [switch] $firstRingGroupMembersSetManually,

        [switch] $skipUnderscoreInNameCheck,

        [switch] $includeDisabled,

        [switch] $skipDescriptionUpdate,

        [ValidateSet('User', 'Device')]
        [string] $memberType = 'User'
    )

    if (!(Get-Command Get-MgContext -ErrorAction silentlycontinue) -or !(Get-MgContext)) {
        throw "$($MyInvocation.MyCommand): Authentication needed. Please call Connect-MgGraph."
    }

    #region functions
    function _getGroupName {
        param ($id)

        return (Get-MgGroup -GroupId $id -Property displayname).displayname
    }

    function _getMemberName {
        param ($id)

        return (Get-MgDirectoryObject -DirectoryObjectId $id).AdditionalProperties.displayName
    }

    function _setRingGroupsDescription {
        "Updating ring groups description"
        $ringGroupConfig.Keys | % {
            $groupId = $_

            $value = $ringGroupConfig.$groupId
            $ring0GroupId = $($ringGroupConfig.Keys)[0]

            if ($firstRingGroupMembersSetManually -and $groupId -eq $ring0GroupId) {
                $description = "Contains selected $($memberType.ToLower()) members of the $(_getGroupName $rootGroup) group. Members are assigned manually. Last processed at $(Get-Date -Format 'yyyy.MM.dd_HH:mm')"
            } else {
                $description = "Contains cca $value% $($memberType.ToLower()) members of the $(_getGroupName $rootGroup) group. Members are assigned programmatically. Last processed at $(Get-Date -Format 'yyyy.MM.dd_HH:mm')"
            }

            Update-MgGroup -GroupId $groupId -Description $description
        }
    }
    #endregion functions

    if ($firstRingGroupMembersSetManually) {
        # first ring group has manually set members
        # some exceptions in checks etc needs to be made
        $ring0GroupId = $($ringGroupConfig.Keys)[0]
    } else {
        # first ring group has automatically set members (as the rest of the ring groups)
        # no extra treatment is needed
        $ring0GroupId = $null
    }

    #region checks
    # all groups exists
    $allGroupId = @()
    $allGroupId += $rootGroup
    $ringGroupConfig.Keys | % { $allGroupId += $_ }
    $allGroupId | % {
        $groupId = $_

        try {
            $null = [guid] $groupId
        } catch {
            throw "$groupId isn't valid group ID"
        }

        try {
            $null = Get-MgGroup -GroupId $groupId -Property displayname -ErrorAction Stop
        } catch {
            throw "Group with ID $groupId that is defined in `$ringGroupConfig doesn't exist"
        }
    }

    # all automatically filled ring groups should have '_' prefix (naming convention)
    if (!$skipUnderscoreInNameCheck) {
        $ringGroupConfig.Keys | % {
            $groupId = $_

            if (!$firstRingGroupMembersSetManually -or $groupId -ne $ring0GroupId) {
                $groupName = _getGroupName $groupId

                if ($groupName -notlike "_*") {
                    throw "Group $groupName ($groupId) doesn't have prefix '_'. It has dynamically set members therefore it should!"
                }
            }
        }
    }

    # beta ring group has 0% set as assigned members count
    if ($firstRingGroupMembersSetManually -and $ringGroupConfig[0] -ne 0) {
        throw "First group in `$ringGroupConfig is manually filled a.k.a. value must be set to 0 (now $($ringGroupConfig[0]))"
    }

    # sum of all ring groups assigned members percent is 100% at total
    $ringGroupPercentSum = $ringGroupConfig.Values | Measure-Object -Sum | select -ExpandProperty Sum
    if ($ringGroupPercentSum -ne 100) {
        throw "Total sum of groups percent has to be 100 (now $ringGroupPercentSum)"
    }
    #endregion checks

    # make a note that group was processed, by updating its description
    if (!$skipDescriptionUpdate) {
        _setRingGroupsDescription
    }

    # get all users/devices that should be assigned to the "ring" groups
    $rootGroupMember = Get-AzureGroupMemberRecursive -id $rootGroup -excludeDisabled:(!$includeDisabled) -allowedMemberType $memberType

    #region cleanup of members that are no longer in the root group or are placed in more than one group
    $memberOccurrence = @{}
    $ringGroupConfig.Keys | % {
        $groupId = $_
        Get-MgGroupMember -GroupId $groupId -All -Property Id | % {
            $memberId = $_.Id
            if ($memberId -notin $rootGroupMember.Id) {
                Write-Warning "Removing group's $(_getGroupName $groupId) member $(_getMemberName $memberId) (not in the root group)"
                Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $memberId
            } else {
                if ($memberOccurrence.$memberId) {
                    Write-Warning "Removing group's $(_getGroupName $groupId) member $(_getMemberName $memberId) (already member of the group $(_getGroupName $memberOccurrence.$memberId))"
                    Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $memberId
                } else {
                    $memberOccurrence.$memberId = $groupId
                }
            }
        }
    }
    #endregion cleanup of members that are no longer in the root group or are placed in more than one group

    $ringGroupsMember = $ringGroupConfig.Keys | % { Get-MgGroupMember -GroupId $_ -All -Property Id }

    $rootGroupMemberCount = $rootGroupMember.count
    $ringGroupsMemberCount = $ringGroupsMember.count
    if ($firstRingGroupMembersSetManually) {
        # set percent weight is calculated from all available members except the manually set members of the test (ring0) group
        $ring0GroupMember = Get-MgGroupMember -GroupId $ring0GroupId -All -Property Id
        $assignableRingGroupsMemberCount = $rootGroupMemberCount - $ring0GroupMember.count
    } else {
        $assignableRingGroupsMemberCount = $rootGroupMemberCount
    }

    if ($rootGroupMemberCount -eq $ringGroupsMemberCount -and !$forceRecalculate) {
        return "No change in members count detected. Exiting"
    }

    # contains users/devices that are members of the root group, but not of any ring group
    # plus users/devices that were removed from any ring group for redundancy a.k.a. should be relocate to another ring group
    $memberToRelocateList = New-Object System.Collections.ArrayList
    $rootGroupMember.Id | % {
        if ($_ -notin $ringGroupsMember.Id) {
            $null = $memberToRelocateList.Add($_)
        }
    }

    # hashtable with group ids and number of members that should be added
    $groupWithMissingMember = @{}

    # remove obsolete/redundancy ring group members
    if ($assignableRingGroupsMemberCount -ne 0) {
        foreach ($groupId in $ringGroupConfig.Keys) {
            if ($firstRingGroupMembersSetManually -and $groupId -eq $ring0GroupId) {
                # ring0 group is manually filled, hence no checks on members count are needed
                continue
            }

            $groupMember = Get-MgGroupMember -GroupId $groupId -All -Property Id
            $groupCurrentMemberCount = $groupMember.count
            if ($groupCurrentMemberCount) {
                $groupCurrentWeight = [math]::round($groupCurrentMemberCount / $assignableRingGroupsMemberCount * 100)
            } else {
                $groupCurrentWeight = 0
            }

            $groupRequiredWeight = $ringGroupConfig.$groupId
            $groupRequiredMemberCount = [math]::round($assignableRingGroupsMemberCount / 100 * $groupRequiredWeight)
            if ($groupRequiredMemberCount -eq 0 -and $groupRequiredWeight -gt 0) {
                # assign at least one member
                $groupRequiredMemberCount = 1
            }

            if ($groupCurrentMemberCount -ne $groupRequiredMemberCount) {
                "Group $(_getGroupName $groupId) ($groupCurrentMemberCount member(s)) should contain $groupRequiredWeight% ($groupRequiredMemberCount member(s)) of all assignable ($assignableRingGroupsMemberCount) users/devices, but contains $groupCurrentWeight%"

                if ($groupCurrentMemberCount -gt $groupRequiredMemberCount) {
                    # remove some random users/devices
                    $memberToRelocate = Get-Random -InputObject $groupMember.Id -Count ($groupCurrentMemberCount - $groupRequiredMemberCount)

                    $memberToRelocate | % {
                        $memberId = $_

                        Write-Warning "Removing group's $(_getGroupName $groupId) member $(_getMemberName $memberId) (is over the set limit)"

                        Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $memberId

                        $null = $memberToRelocateList.Add($memberId)
                    }
                } else {
                    # make a note about how many members should be added (later, because at first I need to free up/remove them from their current groups)
                    $groupWithMissingMember.$groupId = $groupRequiredMemberCount - $groupCurrentMemberCount
                }
            }
        }
    }

    # add new members to ring groups that have less members than required
    if ($groupWithMissingMember.Keys) {
        # add some random users/devices from the pool of available users/devices
        # start with the group with least required members, because of the rounding there might not be enough of them for all groups and you want to have the testing groups filled
        foreach ($groupId in ($groupWithMissingMember.Keys | Sort-Object -Property { $ringGroupConfig.$_ })) {
            $memberToRelocateCount = $groupWithMissingMember.$groupId
            if ($memberToRelocateList.count -eq 0) {
                Write-Warning "There is not enough members left. Adding no members to the group $(_getGroupName $groupId) instead of $memberToRelocateCount"
            } else {
                if ($memberToRelocateList.count -lt $memberToRelocateCount) {
                    Write-Warning "There is not enough members left. Adding $($memberToRelocateList.count) instead of $memberToRelocateCount to the group $(_getGroupName $groupId)"
                    $memberToRelocateCount = $memberToRelocateList.count
                }

                $memberToAdd = Get-Random -InputObject $memberToRelocateList -Count $memberToRelocateCount

                $memberToAdd | % {
                    $memberId = $_

                    Write-Warning "Adding member $(_getMemberName $memberId) to the group $(_getGroupName $groupId)"

                    $params = @{
                        "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$memberId"
                    }
                    New-MgGroupMemberByRef -GroupId $groupId -BodyParameter $params

                    $null = $memberToRelocateList.Remove($memberId)
                }
            }
        }
    }

    if ($memberToRelocateList) {
        # this shouldn't happen?
        throw "There are still some unassigned users/devices left?!"
    }
}

Export-ModuleMember -function Get-AzureGroupMemberRecursive, Get-AzureGroupSettings, Set-AzureRingGroup

Export-ModuleMember -alias Get-MgGroupMemberRecursive