Public/Get-PSUAzAccountAccessInSubscriptions.ps1

function Get-PSUAzAccountAccessInSubscriptions {
    <#
    .SYNOPSIS
        Retrieves Azure role assignments for a specified user across filtered subscriptions.
 
    .DESCRIPTION
        This function analyzes Azure role assignments for a user by checking their direct assignments
        and transitive group memberships across filtered subscriptions.
 
    .PARAMETER UserPrincipalName
        (Mandatory) The User Principal Name (UPN) of the user to analyze.
 
    .PARAMETER OutputCsv
        (Optional) Path where the CSV output file will be saved.
        Default value is "C:\Temp\account-access-by-subscription.csv".
 
    .PARAMETER OutputJson
        (Optional) Switch parameter to enable JSON output format.
 
    .PARAMETER JsonDepth
        (Optional) Depth level for JSON serialization.
        Default value is 6.
 
    .PARAMETER SubscriptionFilter
        (Optional) Array of subscription name patterns to filter by.
        Default value is @('*Non-Prod*').
 
    .EXAMPLE
        Get-PSUAzAccountAccessInSubscriptions -UserPrincipalName "user@domain.com"
 
    .NOTES
        Author: Lakshmanachari Panuganti
        Requires: Az.Accounts, Az.Resources modules
 
    .LINK
        https://github.com/lakshmanachari-panuganti/OMG.PSUtilities/tree/main/OMG.PSUtilities.AzureCore
        https://www.linkedin.com/in/lakshmanachari-panuganti/
        https://www.powershellgallery.com/packages/OMG.PSUtilities.AzureCore
        https://learn.microsoft.com/en-us/powershell/module/az.resources/get-azroleassignment
 
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]  [string]   $UserPrincipalName,
        [Parameter(Mandatory = $false)] [string]   $OutputCsv           = "C:\Temp\account-access-by-subscription.csv",
        [Parameter(Mandatory = $false)] [switch]   $OutputJson,
        [Parameter(Mandatory = $false)] [int]      $JsonDepth           = 6,
        [Parameter(Mandatory = $false)] [string[]] $SubscriptionFilter  = @('*Non-Prod*')
    )

    # 1) Graph lookups
    $me      = Get-PSUGraphUser -Upn $UserPrincipalName
    $groups  = Get-PSUTransitiveGroups -UserId $me.Id
    $principalIdsToCheck = @($me.Id) + ($groups | Select-Object -ExpandProperty Id -ErrorAction SilentlyContinue | Where-Object { $_ })

    # 2) Subscriptions filter
    $subs     = Get-AzSubscription
    $patterns = if ($SubscriptionFilter -and $SubscriptionFilter.Count -gt 0) { $SubscriptionFilter } else { @('*') }
    $filteredSubs = $subs | Where-Object {
        foreach ($p in $patterns) {
            if ($null -ne $p -and $p -ne '' -and ($_.Name -like $p -or $_.Id -like $p)) { return $true }
        }
        return $false
    }
    if (-not $filteredSubs -or $filteredSubs.Count -eq 0) {
        Write-Warning "No subscriptions matched filter"
        return @()
    }

    # 3) Collect assignments & normalize principal IDs
    $assignmentsBySub    = @{}
    $unknownPrincipalIds = [System.Collections.Generic.HashSet[string]]::new()

    foreach ($s in $filteredSubs) {
        $subScope = "/subscriptions/$($s.Id)"
        try {
            $assigns = Get-AzRoleAssignment -Scope $subScope -ErrorAction Stop
        } catch {
            Write-Host "Get-AzRoleAssignment failed for $($s.Id): $_"
            $assigns = @()
        }

        foreach ($a in $assigns) {
            # Try helper first
            $norm = Get-PSUAssignmentPrincipalId -Assignment $a

            # Fallback if PrincipalId missing but ObjectId exists
            if (-not $norm.PrincipalId -and $a.ObjectId) {
                $norm = [PSCustomObject]@{
                    PrincipalId   = $a.ObjectId
                    PrincipalType = $a.ObjectType
                    AssignmentId  = if ($a.Id) { $a.Id } else { $a.RoleAssignmentName }
                }
            }

            # Add normalized props to $a
            $a | Add-Member -NotePropertyName PSU_PrincipalId   -NotePropertyValue $norm.PrincipalId   -Force
            $a | Add-Member -NotePropertyName PSU_PrincipalType -NotePropertyValue $norm.PrincipalType -Force
            $a | Add-Member -NotePropertyName PSU_AssignmentId  -NotePropertyValue $norm.AssignmentId  -Force
        }

        $matched = $assigns | Where-Object { $_.PSU_PrincipalId -and $principalIdsToCheck -contains $_.PSU_PrincipalId }
        $assignmentsBySub[$s.Id] = $matched

        foreach ($a in $matched) {
            $PSU_PrincipalId = $a.PSU_PrincipalId
            if ($PSU_PrincipalId -and $PSU_PrincipalId -ne $me.Id -and -not ($groups | Where-Object { $_.Id -eq $PSU_PrincipalId })) {
                $null = $unknownPrincipalIds.Add($PSU_PrincipalId)
            }
        }
    }

    # 4) Resolve principals
    $resolvedCache = @{}
    $resolvedCache[$me.Id] = @{ DisplayName = $me.DisplayName; Type = 'User' }
    foreach ($g in $groups) {
        if ($g.Id) {
            $resolvedCache[$g.Id] = @{ DisplayName = $g.DisplayName; Type = 'Group' }
        }
    }

    if ($unknownPrincipalIds.Count -gt 0) {
        $objs = Get-PSUBatchDirectoryObjects -Ids $unknownPrincipalIds
        foreach ($o in $objs) {
            $odata   = $o.'@odata.type' -or ($o.AdditionalProperties.'@odata.type' -as [string])
            $display = $o.displayName -or $o.AdditionalProperties.displayName -or $o.userPrincipalName -or $o.mail
            $type    = if ($odata -like '*group*') { 'Group' }
                       elseif ($odata -like '*user*') { 'User' }
                       else { 'DirectoryObject' }
            $resolvedCache[$o.id] = @{ DisplayName = $display; Type = $type }
        }
    }

    # 5) Build output
    $rows = foreach ($s in $filteredSubs) {
        $matchedAssignments = $assignmentsBySub[$s.Id] | ForEach-Object {
            $roleDef = Get-AzRoleDefinition -Id $_.RoleDefinitionId -ErrorAction SilentlyContinue
            [PSCustomObject]@{
                PrincipalId          = $_.PSU_PrincipalId
                PrincipalDisplayName = $resolvedCache[$_.PSU_PrincipalId].DisplayName
                PrincipalType        = $resolvedCache[$_.PSU_PrincipalId].Type
                RoleDefinitionName   = $_.RoleDefinitionName
                RoleDefinitionId     = $_.RoleDefinitionId
                RoleDefinition       = $roleDef
                Scope                = $_.Scope
                ScopeLevel           = if ($_.Scope -match '^/subscriptions/[^/]+$') { 'Subscription' }
                                        elseif ($_.Scope -match '^/subscriptions/[^/]+/resourceGroups/[^/]+$') { 'ResourceGroup' }
                                        else { 'Resource' }
                ResourceGroup        = if ($_.Scope -match '/resourceGroups/([^/]+)') { $matches[1] } else { '' }
                ResourceType         = if ($_.Scope -match 'providers/([^/]+/[^/]+)$') { $matches[1] } else { '' }
                ResourceName         = if ($_.Scope -match 'providers/[^/]+/[^/]+/(.+)$') { $matches[1] } else { '' }
                AssignmentObjectId   = if ($_.PSU_AssignmentId) { $_.PSU_AssignmentId } 
                                        elseif ($_.RoleAssignmentId) { $_.RoleAssignmentId } 
                                        elseif ($_.Id) { $_.Id } 
                                        else { $null }

                Condition            = $_.Condition
                Description          = $_.Description
            }
        }

        [PSCustomObject]@{
            SubscriptionId            = $s.Id
            SubscriptionName          = $s.Name
            MatchedAssignmentsCount   = $matchedAssignments.Count
            MatchedAssignmentsJson    = ($matchedAssignments | ConvertTo-Json -Depth $JsonDepth -Compress)
        }
    }

    # 6) Export
    try {
        $rows | Select-Object SubscriptionId, SubscriptionName, MatchedAssignmentsCount, MatchedAssignmentsJson |
            Export-Csv -Path $OutputCsv -NoTypeInformation -Force -Encoding UTF8
    } catch {
        Write-Warning "Export CSV failed: $_"
    }

    if ($OutputJson) {
        $full = @{ GeneratedAt = (Get-Date).ToString("o"); User = $UserPrincipalName; Results = $rows }
        $jsonPath = [System.IO.Path]::ChangeExtension($OutputCsv, '.full.json')
        try {
            $full | ConvertTo-Json -Depth ($JsonDepth + 2) | Out-File -FilePath $jsonPath -Encoding UTF8
            Write-Output "JSON: $(Resolve-Path $jsonPath)"
        } catch {
            Write-Warning "Failed to write JSON: $_"
        }
    }

    return $rows
}