_getAzureResourceIAMData.ps1
function _getAzureResourceIAMData { [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' } } function Search-AzGraph2 { <# .SYNOPSIS Function similar to Search-AzGraph, but with pagination support. .DESCRIPTION Function similar to Search-AzGraph, but with pagination support. .PARAMETER query KQL query to run against Azure Resource Manager. .EXAMPLE Search-AzGraph2 -query 'resources | where type =~ "microsoft.keyvault/vaults" | extend accessPolicies = properties.accessPolicies | where isnotnull(accessPolicies) and array_length(accessPolicies) > 0 | project name, resourceGroup, subscriptionId, accessPolicies' #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $query ) $batchSize = 1000 $skipResult = 0 while ($true) { $param = @{ Query = $query First = $batchSize UseTenantScope = $true } # handle pagination if ($skipResult -gt 0) { $param.SkipToken = $graphResult.SkipToken } $graphResult = Search-AzGraph @param # output the results $graphResult.data if ($graphResult.data.Count -lt $batchSize) { break } $skipResult += $skipResult + $batchSize } } function Get-AzureDirectoryObject { <# .SYNOPSIS Alternative for Get-MgDirectoryObjectById if you want to avoid Microsoft.Graph.DirectoryObjects module dependency. .DESCRIPTION Alternative for Get-MgDirectoryObjectById if you want to avoid Microsoft.Graph.DirectoryObjects module dependency. .PARAMETER id ID(s) of the Azure object(s). .EXAMPLE Get-AzureDirectoryObject -Id 'a5834928-0f19-292d-4a69-3fbc98fd84ef' #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [Alias("ids")] [string[]] $id ) if (!(Get-Command Get-MgContext -ErrorAction silentlycontinue) -or !(Get-MgContext)) { throw "$($MyInvocation.MyCommand): Authentication needed. Please call Connect-MgGraph." } # directoryObjects/microsoft.graph.getByIds can process only 1000 ids per request $chunkSize = 1000 # calculate the total number of chunks $totalChunks = [Math]::Ceiling($id.Count / $chunkSize) # process each chunk for ($i = 0; $i -lt $totalChunks; $i++) { # calculate the start index of the current chunk $startIndex = $i * $chunkSize # extract the current chunk $currentChunk = $id[$startIndex..($startIndex + $chunkSize - 1)] # process the current chunk Write-Verbose "Processing chunk $($i + 1) with items: $($currentChunk -join ', ')" $body = @{ "ids" = @($currentChunk) } Invoke-MgGraphRequest -Uri "v1.0/directoryObjects/microsoft.graph.getByIds" -Body ($body | ConvertTo-Json) -Method POST | Get-MgGraphAllPages | select *, @{Name = 'ObjectType'; Expression = { $_.'@odata.type' -replace "#microsoft.graph." } } -ExcludeProperty '@odata.type' } } function Get-MgGraphAllPages { <# .SYNOPSIS Function make sure that all api call pages are returned a.k.a. all results. .DESCRIPTION Function make sure that all api call pages are returned a.k.a. all results. .PARAMETER NextLink For internal use. .PARAMETER SearchResult For internal use. .PARAMETER AsHashTable Switch to return results as hashtable. By default returns pscustomobject. .EXAMPLE Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps" | Get-MgGraphAllPages .NOTES Based on https://dev.to/celadin/get-mggraphallpages-the-mggraph-missing-command-45b5. #> [CmdletBinding( ConfirmImpact = 'Medium', DefaultParameterSetName = 'SearchResult' )] param ( [Parameter(Mandatory = $true, ParameterSetName = 'NextLink', ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('@odata.nextLink')] [string] $NextLink , [Parameter(ParameterSetName = 'SearchResult', ValueFromPipeline = $true)] [PSObject] $SearchResult , [switch] $AsHashTable ) begin {} process { if (!$SearchResult) { return } if ($PSCmdlet.ParameterSetName -eq 'SearchResult') { # Set the current page to the search result provided $page = $SearchResult # Extract the NextLink $currentNextLink = $page.'@odata.nextLink' # We know this is a wrapper object if it has an "@odata.context" property #if (Get-Member -InputObject $page -Name '@odata.context' -Membertype Properties) { # MgGraph update - MgGraph returns hashtables, and almost always includes .context # instead, let's check for nextlinks specifically as a hashtable key if ($page.ContainsKey('@odata.count')) { Write-Verbose "First page value count: $($Page.'@odata.count')" } if ($page.ContainsKey('@odata.nextLink') -or $page.ContainsKey('value')) { $values = $page.value } else { # this will probably never fire anymore, but maybe. $values = $page } # Output the values if ($values) { if ($AsHashTable) { # Default returned objects are hashtables, so this makes for easy pscustomobject conversion on demand $values | Write-Output } else { $values | ForEach-Object { [pscustomobject]$_ } } } } while (-Not ([string]::IsNullOrWhiteSpace($currentNextLink))) { # Make the call to get the next page try { $page = Invoke-MgGraphRequest -Uri $currentNextLink -Method GET } catch { throw $_ } # Extract the NextLink $currentNextLink = $page.'@odata.nextLink' # Output the items in the page $values = $page.value if ($page.ContainsKey('@odata.count')) { Write-Verbose "Current page value count: $($Page.'@odata.count')" } if ($AsHashTable) { # Default returned objects are hashtables, so this makes for easy pscustomobject conversion on demand $values | Write-Output } else { $values | ForEach-Object { [pscustomobject]$_ } } } } end {} } #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 } |