Remediation/EntraID/ENT-GROUP-CREATION.ps1

# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk.
<#
.SYNOPSIS
    Reserves the creation of groups and teams in the target to a named security group (procedure ENT-GROUP-CREATION).
 
.DESCRIPTION
    Reads from the snapshot the assistant groups and accounts and the Global Administrators of the target: these are candidates.
    decisions\ENT-GROUP-CREATION.csv says who joins the group (Add or Exclude). A security group can join: Microsoft accepts nesting.
    While nobody is chosen, nothing is planned: the setting would block everyone.
    decisions\ENT-GROUP-CREATION-NAMING.csv says whether a global naming rule applies. The rule holds one pattern only: the source
    prefixes therefore live in the mapping plan (CON-SITES). With Apply, the pattern goes in Note.
 
    The update of Group.Unified sends the whole collection of values: a partial update would overwrite the others.
    Limits: the test with a regular account and the rights of the copy tool account stay to be done by a person.
    A Global Administrator cannot create a team from Teams without being a member of the group, which is why they are in the list.
#>

$c = Open-TcRemediation

$GroupName = 'Group and team creators'
$GroupUnifiedTemplate = '62375ab9-6b52-47ed-826b-58e47e0e304b'

function Get-CreatorGroup {
    param([string]$Target)
    $f = $GroupName -replace "'", "''"
    return (@(Invoke-TcGraph -Tenant $Target -Uri ("v1.0/groups?`$filter=displayName eq '{0}'&`$select=id,displayName" -f $f) -All) | Select-Object -First 1)
}

function Get-GroupSetting {
    param([string]$Target)
    return (@(Invoke-TcGraph -Tenant $Target -Uri 'v1.0/groupSettings' -All) | Where-Object { (Get-TcMember $_ 'templateId') -eq $GroupUnifiedTemplate } | Select-Object -First 1)
}

function Get-SettingValue {
    param($SettingRow, [string]$Name)
    return (Get-TcText (@($SettingRow.values) | Where-Object { (Get-TcMember $_ 'name') -eq $Name } | Select-Object -First 1) 'value')
}

foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t
    $openOne = ConvertTo-TcBoolean (Get-TcRemediationValue $t 'groupSettings' 'Group.Unified.EnableGroupCreation' 'true')
    Write-TcRemediationNote ("Read: group creation {0}, allowed group {1}" -f $(if ($openOne) { 'open to all' } else { 'restricted' }), (Get-TcRemediationValue $t 'groupSettings' 'Group.Unified.GroupCreationAllowedGroupId' ''))

    $candidates = @(
        Get-TcRemediationTable $t 'groups' | Where-Object { $_['Type'] -eq 'Security' -and $_['Name'] -match 'deputy' } | ForEach-Object {
            [ordered]@{ Type = 'Security group'; Name = $_['Name']; Id = $_['Id']; 'Detail' = ('{0} member(s)' -f $_['Members']); Proposal = $(if ([int]$_['Members'] -gt 0) { 'Add' } else { '' }) }
        }
        Get-TcRemediationTable $t 'users' | Where-Object { $_['Type'] -eq 'Member' -and $_['Job title'] -match 'deputy' } | ForEach-Object {
            [ordered]@{ Type = 'Account'; Name = $_['UPN']; Id = $_['Id']; 'Detail' = [string]$_['Job title']; Proposal = '' }
        }
        $admins = @(Get-TcRemediationTable $t 'adminRoles' | Where-Object { $_['Role'] -eq 'Global Administrator' -and $_['Principal type'] -eq 'user' -and -not (Test-TcProtectedAccount -Account ([string]$_['Principal'])) } | ForEach-Object { $_['Principal'] })
        Get-TcRemediationTable $t 'users' | Where-Object { $_['UPN'] -in $admins } | ForEach-Object {
            [ordered]@{ Type = 'Account'; Name = $_['UPN']; Id = $_['Id']; 'Detail' = 'Global Administrator'; Proposal = '' }
        }
    )
    Write-TcRemediationNote ("{0} candidate(s) to validate for the creators group" -f $candidates.Count)
    $decisions = @(Get-TcDecision -Name 'ENT-GROUP-CREATION' -Proposal $candidates -Key 'Type', 'Id' -Choice 'Add', 'Exclude')
    $additions = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Add' })

    $namingValue = [string](Get-TcRemediationValue $t 'groupSettings' 'Group.Unified.PrefixSuffixNamingRequirement' '')
    $naming = @(Get-TcDecision -Name 'ENT-GROUP-CREATION-NAMING' -Proposal @([ordered]@{ Subject = 'PrefixSuffixNamingRequirement'; 'Current value' = $namingValue; Proposal = 'No global rule' }) -Key 'Subject' -Choice 'Apply', 'No global rule') | Select-Object -First 1
    $reason = if ((Get-TcMember $naming 'Decision') -eq 'Apply') { (Get-TcText $naming 'Note') } else { '' }
    if ((Get-TcMember $naming 'Decision') -eq 'Apply' -and $reason -notmatch '\[GroupName\]') { Write-TcRemediationAlert 'Naming rule ignored: the Note column must carry the pattern, with [GroupName]'; $reason = '' }

    if (-not $additions.Count) { Write-TcRemediationAlert 'No creator kept: the restriction is not planned, it would block everyone'; continue }

    Add-TcGraphAction -Tenant $t -Method POST -Uri 'v1.0/groups' -Body @{ displayName = $GroupName; description = 'Accounts allowed to create Microsoft 365 groups and teams'; mailEnabled = $false; mailNickname = (Get-TcObjectName '-Group-Creators'); securityEnabled = $true } `
        -Object $GroupName -Description "Create the security group of allowed creators" `
        -Test { param($a) [bool](Get-CreatorGroup $a.tenant) }

    foreach ($d in $additions) {
        Add-TcBlockAction -Tenant $t -Service Graph -Object (Get-TcMember $d 'Name') -Description ("Add {0} to the creators group" -f (Get-TcMember $d 'Name')) -Data @{ Member = (Get-TcMember $d 'Id') } `
            -Test {
                param($a)
                $g = Get-CreatorGroup $a.tenant
                if (-not $g) { return $false }
                return $a.data.Member -in @(Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/groups/{0}/members?`$select=id" -f (Get-TcMember $g 'id')) -All | ForEach-Object { (Get-TcMember $_ 'id') })
            } `
            -Block {
                param($a)
                # a group just created can answer 400 while it replicates
                for ($test = 1; $test -le 6; $test++) {
                    $g = Get-CreatorGroup $a.tenant
                    try {
                        if (-not $g) { throw 'Creators group not yet visible' }
                        Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri ("v1.0/groups/{0}/members/`$ref" -f (Get-TcMember $g 'id')) -Body @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($a.data.Member)" } | Out-Null
                        break
                    }
                    catch { if ($test -eq 6) { throw }; Wait-TcRemediation -Seconds (5 * $test) }
                }
                $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = ("v1.0/groups/{0}/members/{1}/`$ref" -f (Get-TcMember $g 'id'), $a.data.Member) }
            }
    }

    Add-TcBlockAction -Tenant $t -Service Graph -Object 'Group.Unified' -Description "Reserve the creation of groups and teams to the designated group" -Data @{ Reason = $reason } -Critical `
        -Test {
            param($a)
            $g = Get-CreatorGroup $a.tenant
            $r = Get-GroupSetting $a.tenant
            if (-not $g -or -not $r) { return $false }
            $reasonOk = -not $a.data.Reason -or (Get-SettingValue $r 'PrefixSuffixNamingRequirement') -ceq $a.data.Reason
            return (Get-SettingValue $r 'EnableGroupCreation') -eq 'false' -and (Get-SettingValue $r 'GroupCreationAllowedGroupId') -eq (Get-TcMember $g 'id') -and $reasonOk
        } `
        -Block {
            param($a)
            $g = Get-CreatorGroup $a.tenant
            if (-not $g) { throw 'Creators group not found' }
            $wantedOne = [ordered]@{ EnableGroupCreation = 'false'; GroupCreationAllowedGroupId = (Get-TcMember $g 'id') }
            if ($a.data.Reason) { $wantedOne.PrefixSuffixNamingRequirement = $a.data.Reason }
            $r = Get-GroupSetting $a.tenant
            if (-not $r) {
                $n = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri 'v1.0/groupSettings' -Body @{ templateId = $GroupUnifiedTemplate; values = @($wantedOne.Keys | ForEach-Object { @{ name = $_; value = $wantedOne[$_] } }) }
                $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "v1.0/groupSettings/$((Get-TcMember $n 'id'))" }
                return
            }
            $before = @($r.values | ForEach-Object { @{ name = (Get-TcMember $_ 'name'); value = (Get-TcMember $_ 'value') } })
            $after = @($before | ForEach-Object { @{ name = (Get-TcMember $_ 'name'); value = $(if ($wantedOne.Contains((Get-TcMember $_ 'name'))) { $wantedOne[(Get-TcMember $_ 'name')] } else { (Get-TcMember $_ 'value') }) } })
            $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'PATCH'; uri = "v1.0/groupSettings/$((Get-TcMember $r 'id'))"; body = @{ values = $before } }
            Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri "v1.0/groupSettings/$((Get-TcMember $r 'id'))" -Body @{ values = $after } | Out-Null
        }

    Add-TcManualAction -Tenant $t -Object 'Copy tool account' -Description "Confirm that the copy tool account can create teams" `
        -Instruction @("In the settings of the chosen tool, check the account that creates the teams.", "If it does not have a sufficient admin role, add it to the group $GroupName before the copy.")
    Add-TcManualAction -Tenant $t -Object 'Creation test' -Description "Try creating a team with an ordinary account and with a member of the group" `
        -Instruction @('The change can take 30 minutes or more.', 'An ordinary account must no longer be able to create a team; a member of the group must be able to.')
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    $g = Get-CreatorGroup $t
    $r = Get-GroupSetting $t
    if (-not $g -or -not $r) { New-TcRemediationResult Warning 'creators group or Group.Unified setting missing'; return }
    $n = @(Invoke-TcGraph -Tenant $t -Uri ("v1.0/groups/{0}/members?`$select=id" -f (Get-TcMember $g 'id')) -All).Count
    New-TcRemediationResult OK "$n member(s) in the creators group"
    if ((Get-SettingValue $r 'EnableGroupCreation') -eq 'false' -and (Get-SettingValue $r 'GroupCreationAllowedGroupId') -eq (Get-TcMember $g 'id')) { New-TcRemediationResult OK 'creation reserved to the designated group' } else { New-TcRemediationResult Blocking 'Group.Unified does not have the expected values' }
    New-TcRemediationResult Info ("{0} value(s) kept in Group.Unified" -f @($r.values).Count)
}
Close-TcRemediation