command/Get-AzureResourceIAMData.ps1

function Get-AzureResourceIAMData {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $rootFolder
    )

    $assignmentsFolder = Join-Path -Path $rootFolder -ChildPath "RoleAssignments"
    $definitionsFolder = Join-Path -Path $rootFolder -ChildPath "RoleDefinitions"

    #region IAM Role assignments export
    #region helper functions
    function _scopeType {
        param ([string] $scope)

        if ($scope -match "^/$") {
            return 'root'
        } elseif ($scope -match "^/subscriptions/[^/]+$") {
            return 'subscription'
        } elseif ($scope -match "^/subscriptions/[^/]+/resourceGroups/[^/]+$") {
            return "resourceGroup"
        } elseif ($scope -match "^/subscriptions/[^/]+/resourceGroups/[^/]+/.+$") {
            return 'resource'
        } elseif ($scope -match "^/providers/Microsoft.Management/managementGroups/.+") {
            return 'managementGroup'
        } else {
            throw 'undefined type'
        }
    }
    #endregion helper functions

    #region build the query
    $query = @'
authorizationresources
| where type == "microsoft.authorization/roleassignments"
| extend scope = tostring(properties['scope'])
| extend principalType = tostring(properties['principalType'])
| extend principalId = tostring(properties['principalId'])
| extend roleDefinitionId = tolower(tostring(properties['roleDefinitionId']))
| extend managementGroupId = iif(
        properties['scope'] startswith "/providers/Microsoft.Management/managementGroups",
        tostring(split(properties['scope'], "/")[-1]),""
    )
| mv-expand createdOn = parse_json(properties).createdOn
| mv-expand updatedOn = parse_json(properties).updatedOn
| join kind=inner (
    authorizationresources
    | where type =~ 'microsoft.authorization/roledefinitions'
    | extend id = tolower(id)
    | project id, properties
) on $left.roleDefinitionId == $right.id
| mv-expand roleDefinitionName = parse_json(properties1).roleName
| join kind=leftouter (
    resourcecontainers
    | where type =~ 'microsoft.resources/subscriptions'
    | project-rename subscriptionName = name
    | project subscriptionId, subscriptionName
) on $left.subscriptionId == $right.subscriptionId
'@


    # define the query output
    $property = "createdOn", "updatedOn", "principalId", "principalType", "scope", "roleDefinitionName", "roleDefinitionId", "managementGroupId", "subscriptionId", "subscriptionName", "resourceGroup"
    $query += "`n| project $($property -join ',')"
    #endregion build the query

    #region run the query
    $kqlResult = Search-AzGraph2 -query $query

    # there can be duplicates with different createdOn/updatedOn, keep just the latest one
    $kqlResult = $kqlResult | Group-Object -Property ($property | ? {$_ -notin "createdOn", "updatedOn"}) | % {if ($_.count -eq 1) {$_.group} else {$_.group | sort updatedOn | select -First 1}}

    if (!$kqlResult) { return }
    #endregion run the query

    # get the principal name from its id
    $idToNameList = Get-AzureDirectoryObject -id ($kqlResult.principalId | select -Unique)

    $joinChar = "&"

    # output the final results
    $kqlResult | select @{n = 'PrincipalName'; e = { $id = $_.PrincipalId; $result = $idToNameList | ? Id -EQ $id; if ($result.DisplayName) { $result.DisplayName } else { $result.mailNickname } } }, PrincipalId, PrincipalType, RoleDefinitionName, RoleDefinitionId, Scope, @{ n = 'ScopeType'; e = { _scopeType $_.scope } }, ManagementGroupId, SubscriptionId, SubscriptionName, ResourceGroup, CreatedOn, UpdatedOn | % {
        $item = $_

        switch ($item.scopeType) {
            'root' {
                $outputPath = Join-Path -Path $assignmentsFolder -ChildPath "Root"
            }
            'managementGroup' {
                $outputPath = Join-Path -Path (Join-Path -Path $assignmentsFolder -ChildPath "ManagementGroups") -ChildPath $item.ManagementGroupId
            }
            'subscription' {
                $outputPath = Join-Path -Path (Join-Path -Path $assignmentsFolder -ChildPath "Subscriptions") -ChildPath $item.SubscriptionId
            }
            'resourceGroup' {
                $outputPath = Join-Path -Path (Join-Path -Path (Join-Path -Path $assignmentsFolder -ChildPath "Subscriptions") -ChildPath $item.SubscriptionId) -ChildPath $item.ResourceGroup
            }
            'resource' {
                # $folder = ($item.Scope.Split("/")[-3..-1] -join $joinChar)
                $folder = $item.Scope -replace "/", $joinChar
                $outputPath = Join-Path -Path (Join-Path -Path (Join-Path -Path (Join-Path -Path $assignmentsFolder -ChildPath "Subscriptions") -ChildPath $item.SubscriptionId) -ChildPath $item.ResourceGroup) -ChildPath $folder
            }
            default {
                throw "Undefined scope type $($item.scopeType)"
            }
        }

        $itemId = $item.principalId + $joinChar + ($item.roleDefinitionId).split("/")[-1]

        $outputFileName = Join-Path -Path $outputPath -ChildPath "$itemId.json"

        if ($outputFileName.Length -gt 255 -and (Get-ItemPropertyValue HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name LongPathsEnabled -ErrorAction SilentlyContinue) -ne 1) {
            throw "Output file path '$outputFileName' is longer than 255 characters. Enable long path support to continue!"
        }

        if (Test-Path $outputFileName -ErrorAction SilentlyContinue) {
            # this shouldn't happen!
            Write-Error "File $outputFileName already exists!"
            $outputFileName = $outputFileName + ".replace"
        }

        $item | ConvertTo-Json -depth 100 | Out-File (New-Item -Path $outputFileName -Force)
    }
    #endregion IAM Role assignments export

    #region IAM Role definitions export
    #region export built-in RBAC (IAM) roles
    New-AzureBatchRequest -url "https://management.azure.com/providers/Microsoft.Authorization/roleDefinitions?%24filter=type%20eq%20%27BuiltInRole%27&api-version=2022-05-01-preview" | Invoke-AzureBatchRequest | % {
        $result = $_
        $roleId = $result.name
        $outputPath = Join-Path -Path $definitionsFolder -ChildPath "BuiltInRole"
        $outputFileName = Join-Path -Path $outputPath -ChildPath "$roleId.json"
        $result | select * -ExcludeProperty RequestName | ConvertTo-Json -depth 100 | Out-File (New-Item -Path $outputFileName -Force)
    }
    #endregion export built-in RBAC (IAM) roles

    #region export custom RBAC (IAM) roles
    # custom roles are defined on subscription or management group level, so I need to get all subscriptions and management groups first
    # get all subscriptions and management groups
    $scopeList = Search-AzGraph2 -query "
ResourceContainers
| where type =~ 'microsoft.resources/subscriptions' or type =~ 'microsoft.management/managementgroups'
| project name, type, id
"


    # get all custom roles for each subscription and management group
    New-AzureBatchRequest -url "https://management.azure.com/<placeholder>/providers/Microsoft.Authorization/roleDefinitions?%24filter=type%20eq%20%27CustomRole%27&api-version=2022-05-01-preview" -placeholder $scopeList.id -placeholderAsId | Invoke-AzureBatchRequest | % {
        $result = $_
        $scopeId = ($result.RequestName).split("/")[-1]
        $roleId = $result.name

        if ($result.RequestName -like "/providers/Microsoft.Management/managementGroups/*") {
            $outputPath = Join-Path -Path (Join-Path -Path $definitionsFolder -ChildPath "CustomRole\ManagementGroups") -ChildPath $scopeId
        } elseif ($result.RequestName -like "/subscriptions/*") {
            $outputPath = Join-Path -Path (Join-Path -Path $definitionsFolder -ChildPath "CustomRole\Subscriptions") -ChildPath $scopeId
        } else {
            throw "Undefined scope type in $($result.RequestName)"
        }

        $outputFileName = Join-Path -Path $outputPath -ChildPath "$roleId.json"

        $result | select * -ExcludeProperty RequestName | ConvertTo-Json -depth 100 | Out-File (New-Item -Path $outputFileName -Force)
    }
    #endregion export custom RBAC (IAM) roles
    #endregion IAM Role definitions export
}