Remediation/EntraID/ACCESS-REVIEW.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
    Defines the periodic review of access and guest accounts of the target, according to the licenses present (task CLOSE-03, procedure ACCESS-REVIEW).
 
.DESCRIPTION
    Data read: licenses, users, admin roles and Azure subscriptions of the snapshot.
    The script produces three reports at each run: guests, admin roles and inactive member accounts. It serves as a periodic review, to
    run each quarter on a fresh snapshot.
 
    Licenses: a Graph access review needs Entra ID P2 or ID Governance, in sufficient number for all the targeted members or reviewers.
    Guests are billed per monthly active user and need a linked Azure subscription.
    The script compares the seats present with this need. With not enough licenses, it proposes the periodic report and refuses the review.
 
    decisions\ACCESS-REVIEW.csv: one row per scope, Periodic report or Graph review. The Graph review aims at the guests of Microsoft 365
    groups, each quarter, with the owners as reviewers. It needs a backup reviewer, entered in the column of that name. The decisions
    never apply on their own to accounts: no automatic deletion is turned on.
 
    Limits: the quarterly run is planned outside the script. Admin roles go through the report: their Graph review needs PIM, whose
    eligible assignments are not in the snapshot.
#>

$c = Open-TcRemediation

$DecisionFile = 'ACCESS-REVIEW'
$ReviewName = (Get-TcObjectName ' - Guests of Microsoft 365 groups')
$ReviewUri = 'v1.0/identityGovernance/accessReviews/definitions'
$GuestScope = 'Guests of Microsoft 365 groups'
$RoleScope = 'Admin roles'
$GovernancePlans = '\bAAD_PREMIUM_P2\b|Entra_(Identity|ID)_Governance|Entra_Suite'
$InactiveDays = [int](Get-TcRemediationOption 'InactiveDays')
$MonthsBetweenReviews = 3
$ReviewDays = 14

$existingItems = Read-TcDecisionEntry -Name $DecisionFile -Key @('Tenant', 'Scope')
$proposals = foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t 'Licenses and populations'
    $products = @(Get-TcRemediationTable $t 'licenses' | Where-Object { [string]$_['Plans'] -match $GovernancePlans -and [int]('0' + $_['Purchased']) -gt 0 })
    $seats = (Get-TcMember (@($products | ForEach-Object { [int]$_['Purchased'] }) | Measure-Object -Sum) 'Sum')
    $users = @(Get-TcRemediationTable $t 'users')
    $memberRows = @($users | Where-Object { $_['Type'] -eq 'Member' -and (ConvertTo-TcBoolean $_['Enabled']) })
    $guests = @($users | Where-Object { $_['Type'] -eq 'Guest' })
    $azure = @(Get-TcRemediationTable $t 'azureSubscriptions').Count
    if (-not $azure -and -not (Test-TcCollectionComplete $t 'azureSubscriptions')) { Write-TcRemediationAlert 'Azure subscriptions: collection incomplete, zero subscriptions proves nothing.' }
    Write-TcRemediationNote ("{0} P2 or ID Governance seat(s): {1}" -f [int]$seats, $(if ($products) { (@($products | ForEach-Object { '{0} ({1})' -f $_['SKU'], $_['Purchased'] }) -join ', ') } else { 'none' }))
    Write-TcRemediationNote ("{0} active member(s), {1} guest(s), {2} Azure subscription(s) read" -f $memberRows.Count, $guests.Count, $azure)
    $enough = [int]$seats -ge $memberRows.Count -and $azure -gt 0
    if (-not $enough) { Write-TcRemediationNote 'Not enough licenses for a Graph review: the periodic report is proposed' }

    $inactive = @($guests | Where-Object { "$($_['Days inactive'])" -eq '' -or [int]$_['Days inactive'] -ge $InactiveDays })
    Export-TcRemediationReport -Name "guests-$t" -Rows @($guests | ForEach-Object { [ordered]@{ Account = $_['UPN']; Name = $_['Name']; Email = $_['Email']; Active = $_['Enabled']; 'Invitation state' = $_['Invitation state']; Created = $_['Created']; 'Last sign-in' = $_['Last sign-in']; 'Days inactive' = $_['Days inactive']
                                                                                 'To review' = $(if ("$($_['Days inactive'])" -eq '' -or [int]$_['Days inactive'] -ge $InactiveDays) { 'Yes' } else { 'No' }) } }) | Out-Null
    Export-TcRemediationReport -Name "roles-admin-$t" -Rows @(Get-TcRemediationTable $t 'adminRoles' | ForEach-Object { [ordered]@{ Role = $_['Role']; Principal = $_['Principal']; Type = $_['Principal type']; Assignment = $_['Assignment']; Scope = $_['Scope'] } }) | Out-Null
    $inactiveMembers = @($memberRows | Where-Object { "$($_['Days inactive'])" -ne '' -and [int]$_['Days inactive'] -ge $InactiveDays })
    Export-TcRemediationReport -Name "inactive-members-$t" -Rows @($inactiveMembers | ForEach-Object { [ordered]@{ Account = $_['UPN']; Name = $_['Name']; 'Last sign-in' = $_['Last sign-in']; 'Days inactive' = $_['Days inactive']; Licenses = $_['License count'] } }) | Out-Null
    Write-TcRemediationNote ("{0} guest(s) to review out of {1}, {2} member(s) inactive for {3} days" -f $inactive.Count, $guests.Count, $inactiveMembers.Count, $InactiveDays)

    foreach ($scope in $GuestScope, $RoleScope) {
        $before = $existingItems[('{0}|{1}' -f $t, $scope).ToLowerInvariant()]
        [ordered]@{ Tenant = $t; Scope = $scope; 'P2 or ID Governance seats' = [int]$seats; 'Targeted members' = $memberRows.Count; Guests = $guests.Count; 'Azure subscriptions' = $azure
                    'Backup reviewer' = $(if ($before) { (Get-TcMember $before 'Backup reviewer') } else { '' })
                    Proposal = $(if ($enough -and $scope -eq $GuestScope) { 'Graph review' } else { 'Periodic report' }) }
    }
}
$decisions = @(Get-TcDecision -Name $DecisionFile -Proposal @($proposals | Where-Object { $_ }) -Key 'Tenant', 'Scope' -Choice 'Periodic report', 'Graph review')

foreach ($t in $c.Tenants) {
    Add-TcManualAction -Tenant $t -Object 'Periodic review' -Description 'Plan the quarterly review and name its owner' -Who 'Customer' `
        -Instruction @('Each quarter: refresh the snapshot, run this script again, hand the reports to the owner.', 'Removing a guest or a role stays a decision of the customer: this script touches no account.')

    $d = $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Scope') -eq $GuestScope -and (Get-TcMember $_ 'Decision') -eq 'Graph review' } | Select-Object -First 1
    if ($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Scope') -eq $RoleScope -and (Get-TcMember $_ 'Decision') -eq 'Graph review' }) { Write-TcRemediationAlert ("{0}: Graph review of roles not supported by this script: use PIM, in the portal" -f $t) }
    if (-not $d) { continue }
    if ([int](Get-TcMember $d 'P2 or ID Governance seats') -lt [int](Get-TcMember $d 'Targeted members') -or [int](Get-TcMember $d 'Azure subscriptions') -eq 0) { Write-TcRemediationAlert ("{0}: Graph review refused: {1} seat(s) for {2} targeted member(s), {3} Azure subscription(s) for guest billing" -f $t, (Get-TcMember $d 'P2 or ID Governance seats'), (Get-TcMember $d 'Targeted members'), (Get-TcMember $d 'Azure subscriptions')); continue }
    if (-not (Get-TcMember $d 'Backup reviewer')) { Write-TcRemediationAlert ("{0}: Graph review with no backup reviewer: fill in the column" -f $t); continue }
    Add-TcBlockAction -Tenant $t -Service Graph -Object $ReviewName -Description ("Create the quarterly access review of guests, backup reviewer {0}" -f (Get-TcMember $d 'Backup reviewer')) -Data @{ Backup = (Get-TcMember $d 'Backup reviewer') } `
        -Test { param($a) [bool]@(Invoke-TcGraph -Tenant $a.tenant -Uri $ReviewUri -All | Where-Object { (Get-TcMember $_ 'displayName') -eq $ReviewName }).Count } `
        -Block {
            param($a)
            $u = @(Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users?`$filter=userPrincipalName eq '{0}'&`$select=id" -f [uri]::EscapeDataString($a.data.Backup)) -All) | Select-Object -First 1
            if (-not $u) { throw "Backup reviewer not found: $($a.data.Backup)" }
            $start = (Get-TcRemediationNow).ToString('yyyy-MM-dd')
            $body = @{
                displayName = $ReviewName; descriptionForAdmins = 'Quarterly review of the guests of Microsoft 365 groups.'; descriptionForReviewers = 'Confirm that each guest still needs this access.'
                scope = @{ '@odata.type' = '#microsoft.graph.accessReviewQueryScope'; query = "./members/microsoft.graph.user/?`$count=true&`$filter=(userType eq 'Guest')"; queryType = 'MicrosoftGraph' }
                instanceEnumerationScope = @{ '@odata.type' = '#microsoft.graph.accessReviewQueryScope'; query = "/v1.0/groups?`$filter=(groupTypes/any(c:c+eq+'Unified'))&`$count=true"; queryType = 'MicrosoftGraph' }
                reviewers = @(@{ query = './owners'; queryType = 'MicrosoftGraph' })
                fallbackReviewers = @(@{ query = "/users/$((Get-TcMember $u 'id'))"; queryType = 'MicrosoftGraph' })
                settings = @{ mailNotificationsEnabled = $true; reminderNotificationsEnabled = $true; justificationRequiredOnApproval = $true; defaultDecisionEnabled = $false; defaultDecision = 'None'
                              instanceDurationInDays = $ReviewDays; autoApplyDecisionsEnabled = $false; recommendationsEnabled = $true
                              recurrence = @{ pattern = @{ type = 'absoluteMonthly'; interval = $MonthsBetweenReviews; month = 0; dayOfMonth = 0; daysOfWeek = @(); firstDayOfWeek = 'sunday'; index = 'first' }
                                           range = @{ type = 'noEnd'; numberOfOccurrences = 0; startDate = $start } } }
            }
            $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $ReviewUri -Body $body
            $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "$ReviewUri/$((Get-TcMember $r 'id'))" }
        }
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    if (-not ($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Graph review' -and (Get-TcMember $_ 'Scope') -eq $GuestScope })) { return }
    $r = @(Invoke-TcGraph -Tenant $t -Uri $ReviewUri -All | Where-Object { (Get-TcMember $_ 'displayName') -eq $ReviewName }) | Select-Object -First 1
    if ($r) { New-TcRemediationResult OK ("access review present, state {0}" -f (Get-TcMember $r 'status')) } else { New-TcRemediationResult Warning 'access review missing' }
}
Close-TcRemediation