MapAz.psm1

Add-Type -TypeDefinition @"
public class EnrichedOp {
    public string ResourceId;
    public string ResourceName;
    public string ResourceType;
    public string Operation;
    public string OperationType;
    public string UserId;
    public string UserName;
    public string Plane;
    public EnrichedOp(string rid, string rname, string rtype,string op, string optype, string uid, string uname, string pl) {
        ResourceId = rid;
        ResourceName = rname;
        ResourceType = rtype;
        Operation = op;
        OperationType= optype;
        UserId = uid;
        UserName = uname;
        Plane = pl;
    }
}
"@
 -Language CSharp

function Clear-MapAzScriptCache
{
    [CmdletBinding()]
    param()
    begin 
    {
        $global:allRoleDefinitions = @()
        $global:allRoleAssignments = @()
        $global:allResources = @()
        $global:allUsers = @()
        $global:allOperations = @()
        $global:allResourceactionProviders = @()
        $global:OpsByRoleAssignment = @{}
        $global:allGroupMembers = @()
    }
}

function Get-MapAzResourceProvider {
    [CmdletBinding()]
    param(
    [string]
    $NameSpace)

    begin{
        if (-not (Get-Variable -Name allResourceactionProviders -Scope Global -ErrorAction SilentlyContinue) -or -not $global:allResourceactionProviders){
            Write-Verbose 'Fetching all resource actionProviders...'
            $global:allResourceactionProviders += Get-AzResourceProvider -ErrorAction SilentlyContinue
        }
    }

    process {
        if ($NameSpace) {$global:allResourceactionProviders | Where-Object { $_.ProviderNamespace -like "$NameSpace*" }}
        else {$global:allResourceactionProviders}
    }
}

function Test-MapAzUserGroupMembership {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $GroupId,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $UserId
    )

    begin {
        if (-not (Get-Variable -Name allGroupMembers -Scope Global -ErrorAction SilentlyContinue) `
            -or -not $global:allGroupMembers)
        {
            Write-Verbose 'Caching all group members...'
            $global:allGroupMembers = @()

            # Fetch every AAD group once
            $allGroups = Get-AzADGroup

            foreach ($grp in $allGroups) {
                Write-Verbose " • Loading members of '$($grp.DisplayName)' ($($grp.Id))"
                $members = Get-AzADGroupMember -GroupObjectId $grp.Id -ErrorAction SilentlyContinue
                $global:allGroupMembers += [PSCustomObject]@{
                    GroupId = $grp.Id
                    Members = $members
                }
            }
        }
    }

    process {
        function Test-Member {
            param(
                [string]   $CurrentGroupId,
                [string]   $SearchUserId,
                [string[]] $Visited
            )

            if ($Visited -contains $CurrentGroupId) { return $false }
            $Visited += $CurrentGroupId

            # Get cached entry
            $entry = $global:allGroupMembers |
                     Where-Object { $_.GroupId -eq $CurrentGroupId }
            if (-not $entry) { return $false }

            $members = $entry.Members

            # Direct user?
            if ($members.Id -contains $SearchUserId) {
                return $true
            }

            # Recurse into nested groups
            $subGroups = $members | Where-Object { $_.ObjectType -eq 'Group' }
            foreach ($sg in $subGroups) {
                if (Test-Member -CurrentGroupId $sg.Id `
                                -SearchUserId $SearchUserId `
                                -Visited $Visited) {
                    return $true
                }
            }

            return $false
        }

        # Kickoff with empty visited list
        $isMember = Test-Member -CurrentGroupId $GroupId `
                                -SearchUserId $UserId `
                                -Visited @()

        # Output boolean
        $isMember
    }
}

function Get-MapAzProviderOperations {
    [CmdletBinding()]
    param(
    [ValidateNotNullOrEmpty()]
    [string]
    $action,
    [bool] 
    $IsDataAction)

    begin{
        if (-not (Get-Variable -Name allOperations -Scope Global -ErrorAction SilentlyContinue) -or -not $global:allOperations)
        {
            Write-Verbose 'Fetching all operations...'
            $global:allOperations += Get-AzProviderOperation -ErrorAction SilentlyContinue
            }
    }

    process {
        if ($action) {
            $global:allOperations.Where({
                $_.Operation -like $action -and $_.IsDataAction -eq $IsDataAction
            })
        }
        else {$global:allOperations}
    }
}

function Get-MapAzUsers {
    [CmdletBinding()]
    param(
    [ValidateNotNullOrEmpty()]
    [string]
    $userId,
    [ValidateNotNullOrEmpty()]
    [string]
    $userPrincipalName)

    begin{
        if (-not (Get-Variable -Name allUsers -Scope Global -ErrorAction SilentlyContinue) -or -not $global:allUsers)
        {
            Write-Host  'Fetching all users...'
            $global:allUsers = @()
            $global:allUsers += Get-AzAdUser
            Write-Host  'Fetching all service principals...'
            $global:allUsers += Get-AzADServicePrincipal
            Write-Host  'Fetching all groups...'
            $global:allUsers += Get-AzADGroup
            
            Write-Host  'Fetching all managed identities...'
            $subs = Get-AzSubscription
            foreach ($sub in $subs) {
                Write-Verbose "Switching to subscription $($sub.Name) ($($sub.Id))"
                Set-AzContext -Subscription $sub.Id | Out-Null
                $global:allUsers += Get-AzUserAssignedIdentity -ErrorAction SilentlyContinue
            }
        }
    }

    end {
        if ($UserId -or  $userPrincipalName) {
            $matches = $global:allUsers | Where-Object { ($_.Id -eq $UserId) -or ($_.UserPrincipalName -eq $userPrincipalName) }
            if (-not $matches) { Write-Warning "No user found with Id $UserId or UPN $userPrincipalName."}
            return $matches
        }           
        else {
            $global:allUsers
        }
    }
}

function Get-MapAzRoleDefinitions {
    [CmdletBinding()]
    param(
    [ValidateNotNullOrEmpty()]
    [string]
    $roleId)

    begin{
        if (-not (Get-Variable -Name allRoleDefinitions -Scope Global -ErrorAction SilentlyContinue) -or -not $global:allRoleDefinitions)
        {
            Write-Verbose 'Fetching all role definitions...'
            $subs = Get-AzSubscription
            $global:allRoleDefinitions = @()
            foreach ($sub in $subs) {
                Write-Verbose "Switching to subscription $($sub.Name) ($($sub.Id))"
                Set-AzContext -Subscription $sub.Id | Out-Null
                $global:allRoleDefinitions += Get-AzRoleDefinition -ErrorAction SilentlyContinue
            }
        }
    }

    process {
        $global:allRoleDefinitions | Where-Object { $_.Id -eq $roleId }
    }
}

function Get-MapAzRoleAssignments {
    [CmdletBinding()]
    param(
        [ValidateNotNullOrEmpty()]
        [string] $UserId,

        [ValidateNotNullOrEmpty()]
        [string] $UserName
    )

    begin {
        # If they passed -UserName, resolve it to an ObjectId
        if ($UserName) {
            Write-Verbose "Resolving user principal name '$UserName' to object ID..."
            $u = Get-MapAzUsers -userPrincipalName $UserName 
            $UserId = $u.Id
        }
        else
        {
            $u = Get-MapAzUsers -userId $UserId
            $UserName = $u.UserPrincipalName
        }

        if (-not (Get-Variable -Name allRoleAssignments -Scope Global -ErrorAction SilentlyContinue) `
            -or -not $global:allRoleAssignments)
        {
            Write-Verbose "Fetching all role assignments for $UserName ($UserId)"
            $global:allRoleAssignments = @()
            $subs = Get-AzSubscription
            foreach ($sub in $subs) {
                Write-Verbose "Switching to subscription $($sub.Name) ($($sub.Id))"
                Set-AzContext -Subscription $sub.Id | Out-Null

                $ra = Get-AzRoleAssignment -ErrorAction SilentlyContinue
                if (-not ($global:allRoleAssignments).Where({$_.RoleAssignmentId -eq $ra.RoleAssignmentId})){
                    $global:allRoleAssignments += $ra
                }
            }
        }
    }

    process {
        if (-not $UserId -and -not $UserName) {
            $global:allRoleAssignments
        }
        else {
            $global:allRoleAssignments |
              Where-Object {
                  ($_.ObjectId -eq $UserId) -or
                  ($_.ObjectType -eq 'Group' -and
                   (Test-MapAzUserGroupMembership -GroupId $_.ObjectId -UserId $UserId))
              }
        }
    }
}

function Get-MapAzProviderErrorInfo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$Message
    )
    process {
        foreach ($msg in $Message) {
            # extract the comma-separated api-versions inside single quotes
            $apiMatch = [regex]::Match($msg, "supported api-versions are '([^']+)'")
            $apiVersions = if ($apiMatch.Success) {
                $apiMatch.Groups[1].Value `
                  -split ',' `
                  | ForEach-Object { $_.Trim() } `
                  | Where-Object { $_ -ne '' }
            } else { @() }

            # extract the comma-separated locations inside single quotes
            $locMatch = [regex]::Match($msg, "supported locations are '([^']+)'")
            $locations = if ($locMatch.Success) {
                $locMatch.Groups[1].Value `
                  -split ',' `
                  | ForEach-Object { $_.Trim() } `
                  | Where-Object { $_ -ne '' }
            } else { @() }

            # output a structured object
            [PSCustomObject]@{
                Message     = $msg
                ApiVersions = $apiVersions
                Locations   = $locations
            }
        }
    }
}

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

        [Parameter(Mandatory, Position=1)]
        [string]$Token
    )

    begin {
        # Default API version
        $script:apiver = "2021-04-01"
    }

    process {
        $uriAndApi = $Uri + "?api-version=" + $script:apiver

        Write-Verbose "Trying to GET resource at URI: $uriAndApi"
        $httpResp = Invoke-WebRequest -Method GET -Uri $uriAndApi -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
        if ($httpResp.StatusCode -ne 200) {

            $statuscode = $httpResp.StatusCode
            $httpJson = $httpResp.Content | ConvertFrom-Json
            $errorCode = $httpJson.error.code

            Write-Debug "Got error $statuscode : $errorCode for URI: $uri"

            if ($errorCode -like "NoRegisteredProviderFound") {
                $errMsg = $httpJson.error.Message | Get-MapAzProviderErrorInfo
                if ($errMsg.ApiVersions) {
                    $script:apiver = $errMsg.ApiVersions[-1]
                    $uriAndApi = $Uri + "?api-version=" + $script:apiver

                    Write-Debug "Changing API versoin, trying URI: $uriAndApi"
                    $httpResp = Invoke-WebRequest -Method GET -Uri $uriAndApi -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
                }
            }
        }
        
        if ($httpResp.StatusCode -ne 200) {
            try{
                $httpJson = $httpResp.Content | ConvertFrom-Json
                $errorCode = $httpJson.error.code
                Write-Debug "Got error $statuscode : $errorCode for URI: $uri"
            }
            catch {
                Write-Debug "Http content is not JSON!"
            }
        }  

        $httpResp
    }
}



function Get-MapAzResourceViaRESTAPI {
    [CmdletBinding()]
    param([string]$Uri)

    begin {
        #Import-Module Az
        $script:apiver = "2021-04-01"
    }
    
    process {
        $uriAndApi = $Uri + "?api-version=" + $script:apiver

        Write-Verbose "Trying to GET resource at URI: $uriAndApi"
        $httpResp = Invoke-AzRestMethod -Method GET -Path $uriAndApi
        if ($httpResp.StatusCode -ne 200) {

            $statuscode = $httpResp.StatusCode
            $httpJson = $httpResp.Content | ConvertFrom-Json
            $errorCode = $httpJson.error.code

            Write-Debug "Got error $statuscode : $errorCode for URI: $uri"

            if ($errorCode -like "NoRegisteredProviderFound") {
                $errMsg = $httpJson.error.Message | Get-MapAzProviderErrorInfo
                if ($errMsg.ApiVersions) {
                    $script:apiver = $errMsg.ApiVersions[-1]
                    $uriAndApi = $Uri + "?api-version=" + $script:apiver

                    #Write-Debug "Changing API versoin, trying URI: $uriAndApi"
                    $httpResp = Invoke-AzRestMethod -Method GET -Path $uriAndApi
                }
            }
        }
        
        if ($httpResp.StatusCode -ne 200) {
            try{
                $httpJson = $httpResp.Content | ConvertFrom-Json
                $errorCode = $httpJson.error.code
                Write-Verbose "Got error $statuscode : $errorCode for URI: $uri"
            }
            catch {
                Write-Debug "Http content is not JSON!"
            }
        }  

        $httpResp
    }
}

function Get-MapAzResourceAndSubResources {
    [CmdletBinding()]
    param(
        [string]$SubscriptionId,
        [string]$ResGroup,
        [string]$ProviderName,
        [string]$ResourceName,
        [string]$ResourceType)

    begin {

        if (-not $global:allResources) { $global:allResources= @()}

    }

    process {
        Write-Host "Scanning sub resources for resource: $ResourceName at group: $ResGroup"

        foreach ($subResType in ((Get-MapAzResourceProvider -NameSpace $ProviderName).ResourceTypes.ResourceTypeName).Where({$_ -like "$ResourceType/*"})){
            $subType = $subResType.Replace("$ResourceType/","")
            $subResId = "/subscriptions/$SubscriptionId/resourceGroups/$ResGroup/providers/$ProviderName/$ResourceType/$ResourceName/$subType"
            if (-not (($global:allResources).ResourceId -contains $subResId)){
                Write-Verbose "Trying to get sub resource $subResId"
   
                $httpResp = Get-MapAzResourceViaRESTAPI -Uri $subResId
                if ($httpResp.StatusCode -eq 200) {
                    try {
                        $subResources = ($httpResp.Content | ConvertFrom-Json).value
                    }
                    catch {
                        Write-Debug "Failed to parse JSON for URI $($item.Uri): $($_.Exception.Message)"
                    }
                    foreach ($subResource in $subResources){
                        if (($subResource.PSObject.Properties.Name -contains "Id") -and ($subResource.PSObject.Properties.Name -contains "type")){ 
                             if($subResource.type -like "$ProviderName*"){
                                Write-Host "Found sub resource: $($subResource.Id)" -ForegroundColor Green
                                $results = Get-AzResource -ResourceId $subResource.Id -ErrorAction SilentlyContinue
                                foreach ($res in $results){
                                    if (($res.PSObject.Properties.Name -contains "ResourceType") -and $res.ResourceType){
                                        $global:allResources += $res
                                        $subrestype = $res.ResourceType.Replace("$ProviderName/","")
                                        Get-MapAzResourceAndSubResources -SubscriptionId $SubscriptionId -ResGroup $ResGroup -ProviderName $ProviderName -ResourceName $res.Name -ResourceType $subrestype
                                    }
                                    else {Write-Debug "No type for resource $($res.Id)"}
                                }
                            }
                            else {
                                Write-Debug "This is not a resource: $($subResource.Id)"
                                Write-Debug "breaking loop, assuming other elements are the same..."
                                break
                            }
                        }
                    }
                }
            }
        }
    }
}

function Get-MapAzResource {
    [CmdletBinding()]
    param(
        [Parameter(
            Position    = 0,
            HelpMessage = 'Scope to filter resources by ResourceId prefix'
        )]
        [string]$Scope,
        [Parameter(
            HelpMessage = 'Optional resource type to filter (e.g., Microsoft.Compute/virtualMachines)'
        )]
        [string]$ResType,
        [switch]$ScanSubResources
    )

    begin {
        if (-not (Get-Variable -Name allResources -Scope Global -ErrorAction SilentlyContinue) -or -not $global:allResources){

            Write-Verbose 'Fetching all subscriptions and resources...'
            $subs = Get-AzSubscription
            $global:allResources = @()
            foreach ($sub in $subs) {
                Write-Host "Switching to subscription $($sub.Name) ($($sub.Id))"
                Set-AzContext -Subscription $sub.Id | Out-Null

                $resources = Get-AzResource -ErrorAction SilentlyContinue
                foreach ($res in $resources){
                    if (-not (($global:allResources).ResourceId -contains $res.Id)){
                            $global:allResources += $res
                        }

                    if ($ScanSubResources.IsPresent){
                        $providerName = $res.ResourceType.Split("/")[0]
                        $resType = $res.ResourceType.Replace("$providerName/","")
                        Get-MapAzResourceAndSubResources -SubscriptionId $sub -ResGroup $res.ResourceGroupName -ProviderName $providerName -ResourceName $res.Name -ResourceType $resType
                    } 
                }                     
            }
                     
        }
                     
    }

    process {
        if ($Scope -or $ResType) {
            # Filter the fresh or cached list
            ($global:allResources).Where({
                ($_.ResourceId -like "$Scope*") -and ($_.ResourceType -like "$ResType*")
            }) 
        }
        else {
            # Return a shallow copy of the full list
            $global:allResources | ForEach-Object { $_ }
        }
    }
}

function Resolve-MapAzAccessPlane {
    [CmdletBinding()]
    param(
        [Parameter()]

        [string[]] $Actions,

        [Parameter()]
        [string] $ResourceScope,

        [Parameter(Mandatory)]
        [string]$Plane,

        [string[]] $NotActions,
        [bool]$IsDataAction
    )

    begin {
        $OpsByAction = @{}
    }

    process {

        $resAccessList = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($action in $Actions) {
            Write-Verbose "Processing action '$action'"

            # Parse action into namespace, provider path, and operation
            $parts = $action.Split('/')
            $ns    = $parts[0]
            $prov  = $parts[0..($parts.Length - 2)] -join '/'
            $op    = $parts[-1]

            # Cache provider operations once per namespace
            if (-not $OpsByAction.ContainsKey($ns)) {
                Write-Verbose "Fetching provider operations for namespace '$ns'"
                $OpsByAction[$action] = Get-MapAzProviderOperations -action $action -IsDataAction $IsDataAction
            }

            #Remove actions negated by not actions
            $allowedOps = ($OpsByAction[$action]).Where({
                            foreach ($notAction in $NotActions){
                                if ($_.Operation -like $notAction) {return $false}
                            }
                            return $true
                        })

            $nonResourceOperations = $allowedOps.Where({-not $_.ResourceName -and $_.IsDataAction -eq $false})
            foreach ($nonResOp in $nonResourceOperations){
                $resAccessList.Add([PSCustomObject]@{
                                    ResourceId     = $null
                                    ResourceType   = $null
                                    ResourceName   = $null
                                    Operation      = $nonResOp.Operation
                                    OperationType  = $nonResOp.Operation.Split('/')[-1]
                                    Plane          = "Control"
                                })
            }

            Write-Verbose "Getting resources of type '$prov' under '$ResourceScope'"
            $resources = Get-MapAzResource -Scope $ResourceScope -ResType $prov
            Write-Verbose "Found $($resources.Count) matching resources"

            $resources | Group-Object -Property ResourceType | ForEach-Object {
                $type  = $_.Name
                $group = $_.Group

                Write-Verbose "Filtering operations for resource type '$type'"
                $matches = $allowedOps.Where({$_.Operation -like "$type/$op"})
                Write-Verbose "Found $($matches.Count) matching operations for a resource of type '$type'"

                foreach ($r in $group) {
                    foreach ($mo in $matches) {
                            $resAccessList.Add([PSCustomObject]@{
                                ResourceId     = $r.ResourceId
                                ResourceType   = $type
                                ResourceName   = $r.Name
                                Operation      = $mo.Operation
                                OperationType  = $mo.Operation.Split('/')[-1]
                                Plane          = $Plane
                            })
                        }
                    }
                }
            }
        return $resAccessList | Sort-Object ResourceId, ResourceType ,ResourceName, Operation, OperationType, Plane -Unique
    }
}

function Get-MapAzRoleAssignmentAccess {
    [CmdletBinding(DefaultParameterSetName = 'ByUPN')]
    param(
        [Parameter(
            Mandatory        = $true,
            Position         = 0,
            HelpMessage      = 'Role Id'
        )]
        [ValidateNotNullOrEmpty()]
        $Assignment,
        [Parameter(
            Mandatory   = $false,
            HelpMessage = 'Optional scope to filter results (e.g. /subscriptions/<id>)'
        )]
        [string]$ResourceScope)

    begin {
        Write-Host  "Resolving assignments for role $($Assignment.RoleDefinitionName)"
       
        $resScope = if ($ResourceScope) { $ResourceScope.ToLower() } else { $null }

        $roleAccessList = [System.Collections.Generic.List[PSCustomObject]]::new()

        if (-not $global:OpsByRoleAssignment) {
            Write-Verbose "Initializing role assugment cache..."
            $global:OpsByRoleAssignment = @{}
        }
    }

    process {
        $roles = Get-MapAzRoleDefinitions -roleId $Assignment.RoleDefinitionId
        foreach ($role in $roles) {
            $scope = $Assignment.Scope
            if ( -not $global:OpsByRoleAssignment.ContainsKey($Assignment.RoleAssignmentId)){

                Write-Host  "Processing Role '$($role.Name)' for scope '$scope'"
                if (-not $resScope -or $resScope.StartsWith($scope.ToLower())) {
                    
                    $actions = $role.Actions
                    $notactions = $role.NotActions

                    Write-Verbose "Fetching control plane operations for role '$($Assignment.RoleAssignmentId)'"
                    Resolve-MapAzAccessPlane -Actions $actions -NotActions $notactions -ResourceScope $resScope -Plane "Control" -IsDataAction $false | 
                    ForEach-Object { $roleAccessList.Add($_) }

                    $dataActions = $role.DataActions
                    $notDataActions = $role.NotDataActions

                    Write-Verbose "Fetching data plane operations for role '$($Assignment.RoleAssignmentId)'"
                    Resolve-MapAzAccessPlane -Actions $dataActions -NotActions $notDataActions -ResourceScope $resScope -Plane "Data" -IsDataAction $true |
                    ForEach-Object { $roleAccessList.Add($_) }

                    $global:OpsByRoleAssignment[$Assignment.RoleAssignmentId] = $roleAccessList
                }
            }
            else {Write-Verbose "Role assigment already cached : '$($Assignment.RoleDefinitionName) : $($Assignment.RoleDefinitionId)'"}
        }
    }

    end {
        Write-Host  "Sorting and de-duplicating results for Role '$($Assignment.RoleDefinitionName)'"
        $result = $result |    Sort-Object ResourceId, ResourceName, Operation, OperationType -Unique
        Write-Host  "Total unique access entries: $($result.Count)"
        return $result
    }
}

function Get-MapAzUserAccess {
    [CmdletBinding(DefaultParameterSetName = 'ByUPN')]
    param(
        [Parameter(
            Mandatory        = $true,
            ParameterSetName = 'ByUPN',
            Position         = 0,
            HelpMessage      = 'User Principal Name (e.g. alice@contoso.com)'
        )]
        [ValidateNotNullOrEmpty()]
        [string]$UserPrincipalName,

        [Parameter(
            Mandatory        = $true,
            ParameterSetName = 'ById',
            Position         = 0,
            HelpMessage      = 'Azure AD ObjectId GUID'
        )]
        [ValidateNotNullOrEmpty()]
        [string]$UserId,

        [Parameter(
            Mandatory   = $false,
            HelpMessage = 'Optional scope to filter results (e.g. /subscriptions/<id>)'
        )]
        [string]$ResourceScope
    )

    begin {
        if ($PSCmdlet.ParameterSetName -eq 'ByUPN') {
        
            $userassignments = Get-MapAzRoleAssignments -UserName $UserPrincipalName -ErrorAction SilentlyContinue
            $currUserId = (Get-MapAzUsers -userPrincipalName $UserPrincipalName).Id 
            $currUserPrincipalName = $UserPrincipalName
        }
        else {
            $userassignments = Get-MapAzRoleAssignments -UserId $UserId -ErrorAction SilentlyContinue
            $currUserPrincipalName = (Get-MapAzUsers -userId $UserId).UserPrincipalName
            $currUserId = $UserId 

        }
        if (-not $userassignments) {
            Write-Warning "Could not find User Assignments for user $((@($UserPrincipalName, $UserId, ("Unknown")) | Where-Object { $_ -ne $null} | Select-Object -First 1))"
            return
        }
        Write-Verbose "Found $($userassignments.Count) assignment(s)."

        $resScope = if ($ResourceScope) { $ResourceScope.ToLower() } else { $null }
    }

    process {
        foreach ($assignment in $userassignments) {

            Get-MapAzRoleAssignmentAccess -Assignment $assignment -ResourceScope $resScope
        }
    }

    end {
        Write-Host  "Collecting assignments and their access for '$currUserPrincipalName':'$currUserId'"
        
        $assignmentCount = 0
        [int]$totalCount = 0
        foreach ($a in $userAssignments) {
            $ops = $global:OpsByRoleAssignment[$a.RoleAssignmentId]
            if ($ops) { 
                $totalCount += $ops.Count 
                $assignmentCount++
            }
        }

        [EnrichedOp[]]$results = New-Object EnrichedOp[] $totalCount
        [int]$idx = 0

        

        foreach ($a in $userAssignments) {
            $ops = $global:OpsByRoleAssignment[$a.RoleAssignmentId]
            if ($ops) {
                foreach ($op in $ops) {
                    $results[$idx++] = [EnrichedOp]::new(
                        $op.ResourceId,
                        $op.ResourceName,
                        $op.ResourceType,
                        $op.Operation,
                        $op.OperationType,
                        $currUserId,
                        $currUserPrincipalName,
                        $op.Plane
                    )
                }
            }
        }

        Write-Host  "Filtering duplicate operations..."

        $results = $results | Sort-Object UserId,UserName, ResourceId, ResourceName, ResourceType, Operation, OperationType -Unique

        Write-Host "Total assigned roles for $$currUserPrincipalName: $assignmentCount"
        Write-Host "Total unique access entries: $($results.Count)"
        return $results
    }
}

function Get-MapAzAllUsersAccess {
    [CmdletBinding()]
    param()
    
    begin {
            Write-Host  "Collecting assignments for all users and converting to access objects..."
    }
    process {
        $allAccess = Get-MapAzUsers | ForEach-Object {Get-MapAzUserAccess -UserId $_.Id}
    }

    end 
    {
        return $allAccess
    }
}


# Export the function when the module is imported
Export-ModuleMember -Function Get-MapAzUserAssigments,Get-MapAzResource,Test-MapAzUserGroupMembership,
                                Get-MapAzRoleAssignments,Clear-MapAzScriptCache,Get-MapAzUserAccess,
                                Get-MapAzUsers,Get-MapAzProviderOperations,Get-MapAzResourceProvider,
                                Get-MapAzAllUsersAccess,Get-MapAzResourceViaRESTAPI,Get-MapAzResourceAndSubResources,
                                Get-MapAzResourceViaNativeRESTAPI,Get-MapAzThrottleLimit,Set-MapAzThrottleLimit