Public/Get-AzPolicyAssignmentScan.ps1

function Get-AzPolicyAssignmentScan {
    <#
    .SYNOPSIS
        Scan Azure Policy assignments across management groups and subscriptions.
     
    .DESCRIPTION
        Retrieves all policy assignments from specified scope (Management Group or Subscription),
        optionally scanning child resources recursively.
         
        Expands PolicySet assignments into individual policies with resolved effects.
        Filters out excluded assignments by name pattern.
         
        ⚡ OPTIMIZED VERSION - Caches PolicySet expansions to avoid redundant API calls.
     
    .PARAMETER ManagementGroupId
        Management Group ID to scan.
        If specified with -Recursive, will also scan all child MGs and subscriptions.
        Mutually exclusive with -SubscriptionId.
     
    .PARAMETER SubscriptionId
        Subscription ID to scan.
        Mutually exclusive with -ManagementGroupId.
     
    .PARAMETER Recursive
        Scan all child management groups and subscriptions recursively.
        Default: $true
     
    .PARAMETER ExcludeAssignments
        Array of assignment names or patterns to exclude.
        Supports wildcards.
        Example: @("Microsoft cloud security benchmark", "ASC*")
     
    .PARAMETER DirectOnly
        Only return assignments directly scoped to the target (no inherited assignments).
        Default: $false
     
    .EXAMPLE
        # Scan entire tenant from root management group
        $assignments = Get-AzPolicyAssignmentScan -ManagementGroupId "MyRootMG" -Recursive
         
        Write-Host "Total assignments found: $($assignments.Count)"
        Write-Host "Total expanded policies: $(($assignments | ForEach-Object { $_.ExpandedPolicies }).Count)"
     
    .EXAMPLE
        # Scan specific subscription only
        $assignments = Get-AzPolicyAssignmentScan -SubscriptionId "12345678-1234-1234-1234-123456789012"
     
    .EXAMPLE
        # Scan MG with exclusions
        $assignments = Get-AzPolicyAssignmentScan -ManagementGroupId "MyMG" `
                                                   -ExcludeAssignments @("Microsoft cloud security benchmark", "ASC*")
     
    .EXAMPLE
        # Scan subscription with direct assignments only (no inherited)
        $assignments = Get-AzPolicyAssignmentScan -SubscriptionId "sub-id" -DirectOnly
     
    .OUTPUTS
        Array of PSCustomObject with properties:
        - AssignmentId: Full Azure resource ID of the assignment
        - AssignmentName: Short name of the assignment
        - AssignmentDisplayName: Display name
        - Scope: Scope where assignment is defined
        - PolicyDefinitionId: ID of the assigned policy/initiative
        - PolicySetDisplayName: Display name of initiative (if applicable)
        - IsPolicySet: Boolean indicating if this is an initiative
        - IsIndividualPolicy: Boolean indicating if this is a single policy
        - ExpandedPolicies: Array of individual policies with resolved effects:
          * PolicyDisplayName: Display name
          * PolicyDefinitionId: Resource ID
          * Effect: Resolved effect (from assignment parameters or definition default)
          * Version: Policy version
          * Category: Policy category
     
    .NOTES
        Requires Az.Resources module.
        Must be authenticated with Connect-AzAccount.
         
        Performance: Scanning large tenants with many assignments can take several minutes.
        Use -ExcludeAssignments to filter out known assignments you don't need.
    #>

    
    [CmdletBinding(DefaultParameterSetName = 'ManagementGroup')]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(ParameterSetName = 'ManagementGroup', Mandatory = $true)]
        [string]$ManagementGroupId,
        
        [Parameter(ParameterSetName = 'Subscription', Mandatory = $true)]
        [string]$SubscriptionId,
        
        [switch]$Recursive = $true,
        [string[]]$ExcludeAssignments = @(),
        [switch]$DirectOnly
    )
    
    begin {
        Write-Debug "Get-AzPolicyAssignmentScan: Starting assignment scan"
        Write-Debug " ManagementGroupId: $ManagementGroupId"
        Write-Debug " SubscriptionId: $SubscriptionId"
        Write-Debug " Recursive: $Recursive"
        Write-Debug " ExcludeAssignments: $($ExcludeAssignments -join ', ')"
        
        # Verify Az context
        try {
            $context = Get-AzContext
            if (-not $context) {
                throw "No Azure context found. Please run Connect-AzAccount"
            }
            Write-Verbose "Azure context: $($context.Account.Id) -> $($context.Subscription.Name)"
        } catch {
            throw "Failed to get Azure context: $($_.Exception.Message)"
        }
        
        # Use module-scoped caches (persistent across calls)
        # DO NOT reinitialize - reuse existing cache
        if (-not $script:PolicyDefinitionCache) {
            $script:PolicyDefinitionCache = @{}
        }
        if (-not $script:PolicySetCache) {
            $script:PolicySetCache = @{}
        }
    }
    
    process {
        try {
            $allAssignments = @()
            
            # Step 1: Collect assignments based on scope
            if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
                Write-Verbose "Scanning Management Group: $ManagementGroupId"
                
                # Get assignments from this MG
                $mgAssignments = Get-MgAssignments -ManagementGroupId $ManagementGroupId
                Write-Verbose " Direct MG assignments: $($mgAssignments.Count)"
                $allAssignments += $mgAssignments
                
                # Get assignments from child MGs and subs if recursive
if ($Recursive) {
    Write-Verbose " Scanning child management groups and subscriptions..."
    Write-Host " ├─ Discovering child Management Groups under $ManagementGroupId..." -ForegroundColor Cyan
    
    try {
        # ✅ Récupérer la hiérarchie complète en UNE SEULE fois (avec -Recurse)
        $targetMg = Get-AzManagementGroup -GroupName $ManagementGroupId -Expand -Recurse
        
        # Extraire tous les MGs enfants de la structure déjà chargée (pas d'appels API supplémentaires)
        function Get-ChildMgs($mg) {
            $results = @()
            if ($mg.Children) {
                foreach ($child in $mg.Children) {
                    if ($child.Type.Contains("Microsoft.Management/managementGroups")) {
                        $results += $child
                        # Récursif sur la structure déjà chargée (pas d'appel API)
                        $results += Get-ChildMgs $child
                    }
                }
            }
            return $results
        }
        
        $childManagementGroups = @(Get-ChildMgs $targetMg)
        
        Write-Host " │ ├─ Total child MGs found: $($childManagementGroups.Count)" -ForegroundColor Gray
        Write-Verbose " Child MGs: $(($childManagementGroups | ForEach-Object { $_.Name }) -join ', ')"
        
        # Scanner les assignments de chaque MG enfant
        $childMgAssignments = @()
        $mgCounter = 0
        
        Write-Host " │ ├─ [$($childManagementGroups.Count)] Scanning child MGs..." -ForegroundColor Gray
        
        foreach ($childMg in $childManagementGroups) {
            $mgCounter++
            
            $singleCallTimer = [System.Diagnostics.Stopwatch]::StartNew() # ⏱️ Mesurer le temps de chaque Get-AzPolicyAssignment
            Write-Verbose " [$mgCounter/$($childManagementGroups.Count)] Scanning child MG: $($childMg.Name)"
            try {
                $mgScope = "/providers/Microsoft.Management/managementGroups/$($childMg.Name)"
                $mgAssignments = Get-AzPolicyAssignment -Scope $mgScope -ErrorAction SilentlyContinue
                
                $singleCallTimer.Stop()
                if ($mgAssignments) {
                    $childMgAssignments += $mgAssignments
                    Write-Verbose " Found $($mgAssignments.Count) assignments in $($childMg.Name) (took $($singleCallTimer.ElapsedMilliseconds)ms)"
                } else {
                    Write-Verbose " No assignments in $($childMg.Name) (took $($singleCallTimer.ElapsedMilliseconds)ms)"
                }
            } catch {
                $singleCallTimer.Stop()
                Write-Warning "Failed to scan child MG '$($childMg.Name)' after $($singleCallTimer.ElapsedMilliseconds)ms: $($_.Exception.Message)"
            }
        }
        
        Write-Verbose " ⏱️ Total scan time: $([math]::Round(($childMgAssignments | Measure-Object).Count / $childManagementGroups.Count, 2)) avg assignments per MG"
        Write-Host " │ └─ Found $($childMgAssignments.Count) assignments across all child MGs" -ForegroundColor Gray
        
    } catch {
        Write-Warning "Failed to discover child Management Groups: $($_.Exception.Message)"
        $childMgAssignments = @()
    }
    
    Write-Verbose " Child MG assignments: $($childMgAssignments.Count)"
    $allAssignments += $childMgAssignments
                }
                
            } elseif ($PSCmdlet.ParameterSetName -eq 'Subscription') {
                Write-Verbose "Scanning Subscription: $SubscriptionId"
                
                $subAssignments = Get-SubAssignments -SubscriptionId $SubscriptionId -DirectOnly:$DirectOnly
                Write-Verbose " Subscription assignments: $($subAssignments.Count)"
                $allAssignments += $subAssignments
            }
            
            Write-Verbose "Total assignments collected: $($allAssignments.Count)"
            
            # Step 2: Apply exclusion filters
            if ($ExcludeAssignments.Count -gt 0) {
                Write-Verbose "Applying exclusion filters..."
                
                $filteredAssignments = $allAssignments | Where-Object {
                    $assignment = $_
                    $shouldExclude = $false
                    
                    foreach ($pattern in $ExcludeAssignments) {
                        if ($assignment.DisplayName -like $pattern -or $assignment.Name -like $pattern) {
                            $shouldExclude = $true
                            break
                        }
                    }
                    
                    -not $shouldExclude
                }
                
                $excludedCount = $allAssignments.Count - $filteredAssignments.Count
                Write-Verbose " Excluded assignments: $excludedCount"
                $allAssignments = $filteredAssignments
            }
            
            # Step 3: Expand PolicySets to individual policies
            Write-Verbose "Expanding PolicySet assignments to individual policies..."
            
            # ✅ OPTIMISATION: Cache des PolicySets déjà expandés
            $expandedSetsCache = @{}
            
            $expandedAssignments = @()
            $progressCounter = 0
            
            foreach ($assignment in $allAssignments) {
                $progressCounter++
                
                if ($progressCounter % 10 -eq 0) {
                    Write-Progress -Activity "Expanding Policy Assignments" `
                                   -Status "Processing assignment $progressCounter of $($allAssignments.Count)" `
                                   -PercentComplete (($progressCounter / $allAssignments.Count) * 100)
                }
                
                try {
                    $policySetId = $assignment.PolicyDefinitionId
                    $expandedPolicies = $null
                    
                    # ✅ Vérifier si ce PolicySet a déjà été expandé
                    if ($expandedSetsCache.ContainsKey($policySetId)) {
                        # Réutiliser l'expansion existante
                        $expandedPolicies = $expandedSetsCache[$policySetId]
                        Write-Debug "Reusing cached expansion for: $policySetId ($($expandedPolicies.Count) policies)"
                    }
                    else {
                        # Expander et mettre en cache
                        $expandedPolicies = Expand-PolicySetMembers -Assignment $assignment `
                                                                    -PolicyDefinitionCache $script:PolicyDefinitionCache `
                                                                    -PolicySetDefinitionCache $script:PolicySetCache
                        
                        # Mettre en cache seulement pour les PolicySets (pas les individual policies)
                        $isIndividualPolicy = $assignment.PolicyDefinitionId -like "*/policyDefinitions/*" -and 
                                              $assignment.PolicyDefinitionId -notlike "*/policySetDefinitions/*"
                        
                        if (-not $isIndividualPolicy -and $expandedPolicies.Count -gt 0) {
                            $expandedSetsCache[$policySetId] = $expandedPolicies
                            Write-Debug "Cached expansion for: $policySetId ($($expandedPolicies.Count) policies)"
                        }
                    }
                    
                    # Add expanded policies to assignment object
                    $expandedAssignment = [PSCustomObject]@{
                        AssignmentId = $assignment.Id
                        AssignmentName = $assignment.Name
                        AssignmentDisplayName = $assignment.DisplayName
                        Scope = $assignment.Scope
                        PolicyDefinitionId = $assignment.PolicyDefinitionId
                        PolicySetDisplayName = $assignment.PolicySetDisplayName
                        IsPolicySet = $assignment.IsPolicySet
                        IsIndividualPolicy = $assignment.IsIndividualPolicy
                        ExpandedPolicies = $expandedPolicies
                    }
                    
                    $expandedAssignments += $expandedAssignment
                    
                } catch {
                    Write-Warning "Failed to expand assignment '$($assignment.DisplayName)': $($_.Exception.Message)"
                    continue
                }
            }
            
            Write-Progress -Activity "Expanding Policy Assignments" -Completed
            
            # ✅ Stats sur le cache
            $uniqueSets = $expandedSetsCache.Count
            $cacheReuses = $expandedAssignments.Count - $uniqueSets
            Write-Verbose "Expansion cache stats: $uniqueSets unique PolicySets, $cacheReuses reuses"
            
            Write-Verbose "Expansion complete: $($expandedAssignments.Count) assignments with $(($expandedAssignments | ForEach-Object { $_.ExpandedPolicies.Count } | Measure-Object -Sum).Sum) total policies"
            
            Write-Debug "Get-AzPolicyAssignmentScan: Completed successfully"
            return $expandedAssignments
            
        } catch {
            Write-Error "Failed to scan policy assignments: $($_.Exception.Message)"
            throw
        }
    }
}