Src/Private/Get-AbrEntraIDGovernance.ps1

function Get-AbrEntraIDGovernance {
    [CmdletBinding()]
    param ([Parameter(Position=0,Mandatory)][string]$TenantId)

    begin {
        Write-PScriboMessage "Collecting Identity Governance data for tenant $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'Governance'
    }

    process {
        Section -Style Heading1 'Identity Governance' {
            Paragraph "The following section documents the Identity Governance configuration for tenant $TenantId, including Access Reviews, Entitlement Management, and Terms of Use."
            BlankLine

            #region Access Reviews
            Section -Style Heading2 'Access Reviews' {
                Paragraph "Access Reviews allow periodic verification that users still require the access they have been granted. They are a key control for privileged role hygiene and guest access management."
                BlankLine

                try {
                    Write-Host " - Retrieving Access Review definitions..."
                    $ReviewsResp = Invoke-MgGraphRequest -Method GET `
                        -Uri 'https://graph.microsoft.com/v1.0/identityGovernance/accessReviews/definitions?$top=999' `
                        -ErrorAction Stop

                    $AllReviews = $ReviewsResp.value
                    while ($ReviewsResp.'@odata.nextLink') {
                        $ReviewsResp = Invoke-MgGraphRequest -Method GET -Uri $ReviewsResp.'@odata.nextLink' -ErrorAction Stop
                        $AllReviews += $ReviewsResp.value
                    }

                    if ($AllReviews -and @($AllReviews).Count -gt 0) {
                        $ActiveReviews    = @($AllReviews | Where-Object { $_.status -eq 'InProgress' }).Count
                        $CompletedReviews = @($AllReviews | Where-Object { $_.status -eq 'Completed' }).Count
                        $RecurringReviews = @($AllReviews | Where-Object { $_.settings.recurrence -ne $null }).Count

                        $ARSumObj = [System.Collections.ArrayList]::new()
                        $arSumInObj = [ordered] @{
                            'Total Access Review Definitions' = @($AllReviews).Count
                            'Active (In Progress)'           = $ActiveReviews
                            'Completed'                      = $CompletedReviews
                            'Recurring Reviews'              = $RecurringReviews
                        }
                        $ARSumObj.Add([pscustomobject]$arSumInObj) | Out-Null
                        $ARSumTableParams = @{ Name = "Access Review Summary - $TenantId"; List = $true; ColumnWidths = 55, 45 }
                        if ($Report.ShowTableCaptions) { $ARSumTableParams['Caption'] = "- $($ARSumTableParams.Name)" }
                        $ARSumObj | Table @ARSumTableParams

                        if ($InfoLevel.Governance -ge 2) {
                            $ARDetailObj = [System.Collections.ArrayList]::new()
                            foreach ($Review in ($AllReviews | Sort-Object displayName | Select-Object -First 50)) {
                                $arInObj = [ordered] @{
                                    'Review Name'     = $Review.displayName
                                    'Status'          = $Review.status
                                    'Scope'           = if ($Review.scope.query) { $Review.scope.query } else { '--' }
                                    'Recurring'       = if ($Review.settings.recurrence) { 'Yes' } else { 'No' }
                                    'Auto-Apply'      = if ($Review.settings.autoApplyDecisionsEnabled) { 'Yes' } else { 'No' }
                                    'Created'         = if ($Review.createdDateTime) { ([datetime]$Review.createdDateTime).ToString('yyyy-MM-dd') } else { '--' }
                                }
                                $ARDetailObj.Add([pscustomobject]$arInObj) | Out-Null
                            }
                            $null = (& {
                                if ($HealthCheck.EntraID.Roles) {
                                    $null = ($ARDetailObj | Where-Object { $_.'Auto-Apply' -eq 'No' } | Set-Style -Style Warning | Out-Null)
                                }
                            })
                            $ARDetailTableParams = @{ Name = "Access Review Definitions - $TenantId"; List = $false; ColumnWidths = 24, 12, 26, 10, 10, 12 }
                            if ($Report.ShowTableCaptions) { $ARDetailTableParams['Caption'] = "- $($ARDetailTableParams.Name)" }
                            $ARDetailObj | Table @ARDetailTableParams
                            $null = ($script:ExcelSheets['Access Reviews'] = $ARDetailObj)
                        }
                        $null = ($script:ExcelSheets['Access Review Summary'] = $ARSumObj)
                    } else {
                        Paragraph "[FAIL] No Access Reviews are configured in tenant $TenantId. Access Reviews are required to periodically verify that privileged role assignments and guest access are still appropriate."
                        $null = (& { if ($HealthCheck.EntraID.Roles) {
                            # placeholder paragraph already written above
                        }})
                    }
                } catch {
                    Write-AbrSectionError -Section 'Access Reviews' -Message "Access Reviews require IdentityGovernance licence (Entra ID P2 or Governance). Error: $($_.Exception.Message)"
                }

                # ACSC + CIS
                BlankLine
                $HasReviews = ($AllReviews -and @($AllReviews).Count -gt 0)

                # Privileged role reviews detection
                $HasPrivRoleReviews = $false
                $PrivRoleReviewDetail = 'No access reviews found targeting privileged directory roles.'
                if ($HasReviews) {
                    foreach ($Rev in $AllReviews) {
                        $isRoleReview = $false
                        try {
                            $scope = $Rev.Scope.AdditionalProperties
                            if ($scope -and ($scope['principalScopes'] -or $scope['resourceScopes'])) { $isRoleReview = $true }
                        } catch {}
                        if ($Rev.DisplayName -match 'admin|role|privileged|global|GA') { $isRoleReview = $true }
                        if ($isRoleReview) { $HasPrivRoleReviews = $true; break }
                    }
                    if ($HasPrivRoleReviews) { $PrivRoleReviewDetail = "Privileged role review(s) detected. Verify targets include Global Admin and other sensitive roles. [OK]" }
                    else { $PrivRoleReviewDetail = "No reviews appear to target privileged roles. Create access reviews for Global Admin, Privileged Role Admin, etc. [WARN]" }
                }

                # PIM approval checks for GA and PRA
                $GAApprovalRequired = $false;  $GAApprovalState  = 'Not verified (PIM data unavailable)'
                $PRAApprovalRequired = $false; $PRAApprovalState = 'Not verified (PIM data unavailable)'
                try {
                    $GARoleId  = '62e90394-69f5-4237-9190-012177145e10'  # Global Administrator
                    $PRARoleId = 'e8611ab8-c189-46e8-94e1-60213ab1f814'  # Privileged Role Administrator
                    foreach ($RoleId in @($GARoleId, $PRARoleId)) {
                        $PolicyAssign = Invoke-MgGraphRequest -Method GET `
                            -Uri "https://graph.microsoft.com/v1.0/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$RoleId'" `
                            -ErrorAction SilentlyContinue
                        if ($PolicyAssign -and $PolicyAssign.value -and $PolicyAssign.value.Count -gt 0) {
                            $PolicyId = $PolicyAssign.value[0].policyId
                            $Rules = Invoke-MgGraphRequest -Method GET `
                                -Uri "https://graph.microsoft.com/v1.0/policies/roleManagementPolicies/$PolicyId/rules" `
                                -ErrorAction SilentlyContinue
                            if ($Rules -and $Rules.value) {
                                $ApprovalRule = $Rules.value | Where-Object { $_.'@odata.type' -like '*approvalRule*' }
                                $IsRequired = ($ApprovalRule -and $ApprovalRule.setting.isApprovalRequired -eq $true)
                                if ($RoleId -eq $GARoleId)  { $GAApprovalRequired  = $IsRequired; $GAApprovalState  = if ($IsRequired) { 'Approval required [OK]' } else { 'No approval required [WARN]' } }
                                if ($RoleId -eq $PRARoleId) { $PRAApprovalRequired = $IsRequired; $PRAApprovalState = if ($IsRequired) { 'Approval required [OK]' } else { 'No approval required [WARN]' } }
                            }
                        }
                    }
                } catch {
                    Write-AbrDebugLog "PIM approval check skipped: $($_.Exception.Message)" 'WARN' 'GOVERNANCE'
                }
                if ($script:IncludeACSCe8) {
                    Paragraph "ACSC Essential Eight Maturity Level Assessment -- Access Reviews:"
                    BlankLine
                    #region ACSC E8 Assessment (definitions from Src/Compliance/ACSC.E8.json)
                    $_ComplianceVars = @{
                        'HasReviews'          = $HasReviews
                        'RecurringReviews'    = $RecurringReviews
                        'ReviewCount'         = $ReviewCount
                        'HasPrivRoleReviews'  = $HasPrivRoleReviews
                        'PrivRoleReviewDetail'= $PrivRoleReviewDetail
                        'GAApprovalRequired'  = $GAApprovalRequired
                        'GAApprovalState'     = $GAApprovalState
                        'PRAApprovalRequired' = $PRAApprovalRequired
                        'PRAApprovalState'    = $PRAApprovalState
                    }
                    $E8ARChecks = Build-AbrComplianceChecks `
                        -Definitions (Get-AbrE8Checks -Section 'Governance') `
                        -Framework E8 `
                        -CallerVariables $_ComplianceVars
                    New-AbrE8AssessmentTable -Checks $E8ARChecks -Name 'Access Reviews' -TenantId $TenantId
                    # Consolidated into ACSC E8 Assessment sheet
                    if ($E8ARChecks) { $null = $script:E8AllChecks.AddRange([object[]](@($E8ARChecks | Select-Object @{N='Section';E={'Governance'}}, ML, Control, Status, Detail ))) }
                    #endregion
                }
                if ($script:IncludeCISBaseline) {
                    BlankLine
                    Paragraph "CIS Microsoft 365 Foundations Benchmark Assessment -- Access Reviews:"
                    BlankLine
                    #region CIS Assessment (definitions from Src/Compliance/CIS.M365.json)
                    $CISARChecks = Build-AbrComplianceChecks `
                        -Definitions (Get-AbrCISChecks -Section 'Governance') `
                        -Framework CIS `
                        -CallerVariables $_ComplianceVars
                    New-AbrCISAssessmentTable -Checks $CISARChecks -Name 'Access Reviews' -TenantId $TenantId
                    # Consolidated into CIS Assessment sheet
                    if ($CISARChecks) { $null = $script:CISAllChecks.AddRange([object[]](@($CISARChecks | Select-Object @{N='Section';E={'Governance'}}, CISControl, Level, Status, Detail ))) }
                    #endregion
                }
            }
            #endregion

            #region Entitlement Management
            Section -Style Heading2 'Entitlement Management' {
                Paragraph "Entitlement Management provides access packages for structured, time-limited access to resources. It is an Entra ID P2 / Governance feature."
                BlankLine

                try {
                    Write-Host " - Retrieving Entitlement Management access packages..."
                    $APResp = Invoke-MgGraphRequest -Method GET `
                        -Uri 'https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages?$top=999' `
                        -ErrorAction Stop

                    $AllPackages = $APResp.value
                    if ($AllPackages -and @($AllPackages).Count -gt 0) {
                        $APObj = [System.Collections.ArrayList]::new()
                        foreach ($AP in ($AllPackages | Sort-Object displayName | Select-Object -First 50)) {
                            $apInObj = [ordered] @{
                                'Package Name'  = $AP.displayName
                                'Description'   = if ($AP.description) { $AP.description.Substring(0,[Math]::Min(80,$AP.description.Length)) } else { '--' }
                                'Hidden'        = if ($AP.isHidden) { 'Yes' } else { 'No' }
                                'Created'       = if ($AP.createdDateTime) { ([datetime]$AP.createdDateTime).ToString('yyyy-MM-dd') } else { '--' }
                            }
                            $APObj.Add([pscustomobject]$apInObj) | Out-Null
                        }
                        $APTableParams = @{ Name = "Access Packages - $TenantId"; List = $false; ColumnWidths = 24, 46, 10, 12 }
                        if ($Report.ShowTableCaptions) { $APTableParams['Caption'] = "- $($APTableParams.Name)" }
                        $APObj | Table @APTableParams
                        $null = ($script:ExcelSheets['Access Packages'] = $APObj)
                        Paragraph "$(@($AllPackages).Count) access package(s) configured. Entitlement Management provides structured, policy-governed access to groups, apps, and SharePoint sites with automatic expiry and access reviews."
                    } else {
                        Paragraph "[INFO] No Access Packages are configured. Entitlement Management access packages provide structured, time-limited access with automatic expiry — recommended for ML3 privileged access management."
                    }
                } catch {
                    Write-AbrSectionError -Section 'Entitlement Management' -Message "Entitlement Management requires Entra ID Governance licence. Error: $($_.Exception.Message)"
                }
            }
            #endregion

            #region Terms of Use
            Section -Style Heading2 'Terms of Use' {
                Paragraph "Terms of Use policies require users to accept usage terms before accessing resources. They can be enforced via Conditional Access."
                BlankLine

                try {
                    Write-Host " - Retrieving Terms of Use policies..."
                    $TOUResp = Invoke-MgGraphRequest -Method GET `
                        -Uri 'https://graph.microsoft.com/v1.0/agreements' `
                        -ErrorAction Stop

                    $AllTOU = $TOUResp.value
                    if ($AllTOU -and @($AllTOU).Count -gt 0) {
                        $TOUObj = [System.Collections.ArrayList]::new()
                        foreach ($TOU in ($AllTOU | Sort-Object displayName)) {
                            $touInObj = [ordered] @{
                                'Name'                         = $TOU.displayName
                                'Re-acceptance Required'       = if ($TOU.isPerDeviceAcceptanceRequired) { 'Per device' } elseif ($TOU.isViewingBeforeAcceptanceRequired) { 'Must view before accept' } else { 'Standard' }
                                'Expiry After Acceptance'      = if ($TOU.termsExpiration) { $TOU.termsExpiration.frequency } else { 'No expiry' }
                                'Enforced via CA'              = '[INFO] Check Conditional Access policies for TermsOfUse grant control'
                            }
                            $TOUObj.Add([pscustomobject]$touInObj) | Out-Null
                        }
                        $TOUTableParams = @{ Name = "Terms of Use Policies - $TenantId"; List = $false; ColumnWidths = 25, 22, 20, 33 }
                        if ($Report.ShowTableCaptions) { $TOUTableParams['Caption'] = "- $($TOUTableParams.Name)" }
                        $TOUObj | Table @TOUTableParams
                        $null = ($script:ExcelSheets['Terms of Use'] = $TOUObj)
                    } else {
                        Paragraph "[INFO] No Terms of Use policies are configured in tenant $TenantId. Terms of Use can enforce acceptable use acknowledgement for specific user groups or external/guest users."
                    }
                } catch {
                    Write-AbrSectionError -Section 'Terms of Use' -Message "$($_.Exception.Message)"
                }
            }
            #endregion

        } # end Section Governance
    }

    end { Show-AbrDebugExecutionTime -End -TitleMessage 'Governance' }
}