AzResourceGraph.psm1
#Region './Private/Assert-AzureConnection.ps1' -1 <# .SYNOPSIS Check that a valid token exists, otherwise tries to get one .DESCRIPTION Check that a valid token exists, otherwise tries to get one .PARAMETER TokenSplat A hashtable used to store parameters used for Get-AzToken .PARAMETER CertificatePath Path to certificate used for authentication .PARAMETER Resource The resouce/application the token will be used for .EXAMPLE Assert-AzureConnection #> function Assert-AzureConnection { [CmdletBinding()] param ( [Parameter(Mandatory)] [hashtable]$TokenSplat, [Parameter()] [string]$Resource = 'https://management.azure.com' ) if (Test-AzureToken -Token $script:Token -Resource $Resource -MinValid 15) { # If the token is valid, we can use that it return } $LocalTokenSplat = $TokenSplat.Clone() $LocalTokenSplat['Resource'] = $Resource $NotConnectedErrorMessage = 'Not connected to Azure. Please connect to Azure by running Connect-AzResourceGraph before running this command.' # Connect-AzResourceGraph has not been run and we can try to get a token based on credential precedence. if ($script:TokenSource -ne 'Module') { Write-Warning -Message 'No token found. Attempting to get a new token.' try { $NewToken = Get-AzToken @LocalTokenSplat -ErrorAction 'Stop' $script:Token = $NewToken # Only make assignment to script scope if no exception is thrown return } catch { Write-Error -Exception $_.Exception -Message $NotConnectedErrorMessage -ErrorAction 'Stop' } } # Connect-AzResourceGraph has ben run but the token is not valid, let's try to refresh it. Write-Verbose -Message 'No valid token found. Attempting to refresh the token.' try { if ($LocalTokenSplat.ContainsKey('ClientCertificatePath')) { # If platform is Windows, the Certificate can be a cert object, then get the object $Certificate = Get-Item -Path $LocalTokenSplat.ClientCertificatePath if ($Certificate.GetType().FullName -eq 'System.Security.Cryptography.X509Certificates.X509Certificate2') { $LocalTokenSplat['ClientCertificate'] = $Certificate $LocalTokenSplat.Remove('ClientCertificatePath') } } # If we reach this point, we know that we have run Connect command and that the token is not valid. # We can therefore safely remove the Interactive parameter from the local LocalTokenSplat if ($LocalTokenSplat.ContainsKey('Interactive')) { $LocalTokenSplat.Remove('Interactive') } $NewToken = Get-AzToken @LocalTokenSplat -ErrorAction 'Stop' $script:Token = $NewToken # Only make assignment to script scope if no exception is thrown } catch { Write-Error -Exception $_.Exception -Message $NotConnectedErrorMessage -ErrorAction 'Stop' } } #EndRegion './Private/Assert-AzureConnection.ps1' 78 #Region './Private/Get-AzResourceGraphPage.ps1' -1 <# .SYNOPSIS Will get one page from Azure Resource Graph .DESCRIPTION Will get one page from Azure Resource Graph. If the page is too large, it will re-try by splitting it into smaller pages recursively until it succeeds. .PARAMETER Uri Uri used to send request to .PARAMETER Body Body for the request as a hashtable .PARAMETER Headers Headers for the request as a hashtable .PARAMETER TotalRecords Number of records in the result in total .PARAMETER Output The result data of the query. This is a separate parameter to be able to pass it to .PARAMETER ResultHeaders Headers from the result response .EXAMPLE $PageParams = Get-AzResourceGraphPage @PageParams #> function Get-AzResourceGraphPage { [CmdletBinding()] param ( [string]$Uri, [hashtable]$Body, [hashtable]$Headers, [int]$TotalRecords, [System.Collections.ArrayList]$Output, [hashtable]$ResultHeaders ) # Check if we hit the quota limit if ($null -ne $ResultHeaders -and $ResultHeaders.ContainsKey('x-ms-user-quota-remaining') -and $ResultHeaders['x-ms-user-quota-remaining'][0] -lt 1 ) { # Hit the quota limit, wait before retrying $QuotaResetAfter = $ResultHeaders['x-ms-user-quota-resets-after'] | Select-Object -First 1 $SleepTime = [TimeSpan]$QuotaResetAfter Write-Warning "Quota limit reached. Waiting $($SleepTime.TotalMilliseconds) milliseconds before retrying." Start-Sleep -Milliseconds $SleepTime.TotalMilliseconds } # Check if we are at the end of the records if ($TotalRecords -gt 0 -and $Body['options']['$top'] -gt ($TotalRecords - $Body['options']['$skip'])) { $Body['options']['$top'] = $TotalRecords - $Body['options']['$skip'] } # Check if there are any more records to retrieve if ($Body['options']['$top'] -gt 0) { Write-Verbose "Retrieving next page of $($Body['options']['$top']) items." try { $PageParams = @{ Uri = $Uri Body = $Body Headers = $Headers TotalRecords = $TotalRecords Output = $Output } $Result = Invoke-WebRequest -Uri $Uri -Method 'POST' -Body ($Body | ConvertTo-Json -Compress) -Headers $Headers -ErrorAction 'Stop' $ResultData = $Result.Content | ConvertFrom-Json -Depth 100 $Body['options']['$skip'] += $ResultData.data.Count $PageParams['TotalRecords'] = $ResultData.totalRecords $Output.AddRange($ResultData.data) $PageParams['Output'] = $Output $PageParams['ResultHeaders'] = $Result.Headers Write-Verbose "Successfully retrieved $($Body['options']['$skip']) of $TotalRecords records." if ($TotalRecords -gt $Body['options']['$skip']) {Write-Verbose "Next batch sice: $($Body['options']['$top'])."} } catch { # If the error is due to payload size, reduce the batch size and call recursively $ErrorDetails = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction 'Stop' if ($ErrorDetails.error.details.code -eq 'ResponsePayloadTooLarge') { # There is a payload size limit of 16777216 bytes $ErrorPattern = 'Response payload size is (?<ResponseSize>\d+), and has exceeded the limit of (?<Limit>\d+).' + ' Please consider querying less data at a time and make paginated call if needed.' if ($ErrorDetails.error.details.message -match $ErrorPattern) { # Estimate new batch size based on the response size ratio to limit, add 1 to be on the safe side. $OriginalBatchSize = $Body['options']['$top'] $ReductionRatio = [Math]::Ceiling($Matches['ResponseSize'] / $Matches['Limit']) + 1 [int]$NewBatchSize = $Body['options']['$top'] / $ReductionRatio $Body['options']['$top'] = $NewBatchSize Write-Verbose "Response payload too large ($($Matches['ResponseSize'])). Retrying with smaller batch size: $($Body['options']['$top'])." for ($i = 0; $i -lt $ReductionRatio; $i++) { $PageParams['Body'] = $Body $PageParams = Get-AzResourceGraphPage @PageParams } Write-Verbose "Resetting batch size to original value: $OriginalBatchSize." $PageParams['Body']['options']['$top'] = $OriginalBatchSize } } } } return $PageParams } #EndRegion './Private/Get-AzResourceGraphPage.ps1' 107 #Region './Private/Test-AzureToken.ps1' -1 <# .SYNOPSIS Validates an Azure access token for audience and remaining lifetime. .DESCRIPTION Test-AzureToken checks whether the supplied Microsoft EntraID access token is still valid for a specified resource and for at least a minimum number of minutes. The function is used internally by AzResourceGraph to decide when to acquire or refresh tokens. .PARAMETER Token The access token object returned by Get-AzToken. May be $null; the function returns $false in that case. .PARAMETER Resource The audience (aud claim) the token must match. Defaults to https://management.azure.com. .PARAMETER MinValid The minimum amount of time, in minutes, that the token must remain valid. Default is 15 minutes. .OUTPUTS Boolean. Returns $true when the token is valid, otherwise $false. .EXAMPLE $token = Get-AzToken -ResourceUrl 'https://management.azure.com' Test-AzureToken -Token $token # -> $true .EXAMPLE # Fails if token expires in less than 60 minutes Test-AzureToken -Token $tok -MinValid 60 # -> $false #> function Test-AzureToken { param ( [Parameter(Mandatory)] [AllowNull()] $Token, [Parameter()] $Resource = 'https://management.azure.com', [Parameter()] $MinValid = 15 ) return ( $null -ne $Token -and $Token.ExpiresOn -ge [System.DateTimeOffset]::Now.AddMinutes($MinValid) -and $Token.Claims['aud'] -eq $Resource ) } #EndRegion './Private/Test-AzureToken.ps1' 53 #Region './Public/Connect-AzResourceGraph.ps1' -1 <# .SYNOPSIS Connects the current PowerShell session to Azure Resource Graph and caches an access token in memory. .DESCRIPTION Connect-AzResourceGraph authenticates to Entra ID using one of four flows: • Interactive user sign-in (default) • Service principal with client secret • Service principal with certificate • Managed Identity (system- or user-assigned) The obtained token is stored in module scope and automatically reused until it expires. All subsequent AzResourceGraph cmdlets rely on this cached token; therefore, call this function once at the beginning of your session or script. .PARAMETER Tenant EntraID tenant ID (GUID) or tenant domain name. .PARAMETER ClientId Application (service principal) client ID. Defaults to the public Azure PowerShell client ID `1950a258-227b-4e31-a9cf-717495945fc2` for interactive sign-in. .PARAMETER CertificatePath Path to a PFX or CER file, or a certificate thumbprint in the local certificate store, used for certificate-based service-principal auth. .PARAMETER ClientSecret Client secret string associated with the service principal. .PARAMETER ManagedIdentity Switch indicating that the command should acquire a token using the Azure Managed Identity assigned to the current VM / App Service / container. .EXAMPLE # 1. Interactive sign-in (prompts user) Connect-AzResourceGraph .EXAMPLE # 2. Managed Identity inside an Azure resource Connect-AzResourceGraph -ManagedIdentity .EXAMPLE # 3. Service principal with client secret Connect-AzResourceGraph -Tenant 'contoso.onmicrosoft.com' ` -ClientId '00000000-0000-0000-0000-000000000000' ` -ClientSecret (Get-Content '.\sp-secret.txt' -Raw) .EXAMPLE # 4. Service principal with certificate Connect-AzResourceGraph -Tenant '72f988bf-86f1-41af-91ab-2d7cd011db47' ` -ClientId '00000000-0000-0000-0000-000000000000' ` -CertificatePath '.\sp-cert.pfx' #> function Connect-AzResourceGraph { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( "PSAvoidDefaultValueForMandatoryParameter", "ClientId", Justification = "Client Id is only mandatory for certain auth flows." )] [CmdletBinding(DefaultParameterSetName = 'Interactive')] param ( [Parameter(ParameterSetName = 'ManagedIdentity')] [Parameter(ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'Certificate')] [Parameter(Mandatory, ParameterSetName = 'ClientSecret')] [ValidateNotNullOrEmpty()] [string]$Tenant, [Parameter(ParameterSetName = 'ManagedIdentity')] [Parameter(ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'Certificate')] [Parameter(Mandatory, ParameterSetName = 'ClientSecret')] [ValidateNotNullOrEmpty()] [string]$ClientId = '1950a258-227b-4e31-a9cf-717495945fc2', # Default Azure PowerShell ClientId [Parameter(Mandatory, ParameterSetName = 'Certificate')] [string]$CertificatePath, [Parameter(Mandatory, ParameterSetName = 'ClientSecret')] [string]$ClientSecret, [Parameter(Mandatory, ParameterSetName = 'ManagedIdentity')] [switch]$ManagedIdentity ) # Set up module-scoped variables for getting tokens $script:TokenSplat = @{} $script:CertificatePath = $null $script:TokenSplat['ClientId'] = $ClientId if ($PSBoundParameters.ContainsKey('Tenant')) { $script:TokenSplat['TenantId'] = $Tenant } if ($PSBoundParameters.ContainsKey('CertificatePath')) { $script:CertificatePath = $CertificatePath $Certificate = Get-Item $CertificatePath if ($Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) { $script:TokenSplat['ClientCertificate'] = Get-Item $CertificatePath } else { $script:TokenSplat['ClientCertificatePath'] = $CertificatePath } } if ($PSBoundParameters.ContainsKey('ClientSecret')) { $script:TokenSplat['ClientSecret'] = $ClientSecret } if ($PSCmdlet.ParameterSetName -eq 'Interactive') { $script:TokenSplat['Interactive'] = $true } if ($ManagedIdentity.IsPresent) { $script:TokenSplat['ManagedIdentity'] = $true } $script:Token = Get-AzToken @script:TokenSplat # Save the source of the token to module scope for AssertAzureConnection to know how to refresh it $script:TokenSource = 'Module' } #EndRegion './Public/Connect-AzResourceGraph.ps1' 121 #Region './Public/Search-AzResourceGraph.ps1' -1 <# .SYNOPSIS Runs an Azure Resource Graph query and outputs the results to the pipeline. .DESCRIPTION Search-AzResourceGraph executes a Kusto Query Language (KQL) statement against Azure Resource Graph. You can supply the query as a string or as the path to a text file. The command supports querying: • One or more subscriptions • One or more management groups • The currently connected tenant (by omitting both SubscriptionId and ManagementGroup) Results are paged transparently until the full data set is returned. Authentication is provided by a cached token populated through Connect-AzResourceGraph. .PARAMETER QueryPath Path to a file containing the KQL query. Specify either ‑QueryPath or ‑Query, not both. .PARAMETER Query KQL query string. Specify either ‑Query or ‑QueryPath, not both. .PARAMETER SubscriptionId One or more Azure subscription IDs (GUID) to scope the query. Cannot be used together with ‑ManagementGroup. .PARAMETER ManagementGroup One or more Azure Management Group IDs (e.g. “contoso-mg”) to scope the query. Cannot be used together with ‑SubscriptionId. .PARAMETER AuthorizationScopeFilter Controls how authorization scope is interpreted when evaluating the query. Valid values: AtScopeAboveAndBelow, AtScopeAndAbove, AtScopeAndBelow, AtScopeExact. Default is AtScopeAndBelow. .PARAMETER AllowPartialScopes Allow partial scopes in the query. Only applicable for tenant and management group level queries to decide whether to allow partial scopes for result in case the number of subscriptions exceed allowed limits. .PARAMETER PageSize Number of rows to request per page (1-1000). The function continues paging until all rows are retrieved. .EXAMPLE # Execute a query stored in a file against two subscriptions Search-AzResourceGraph -QueryPath '.\vm-details.kql' ` -SubscriptionId '11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222' .EXAMPLE # Inline query against a management group Search-AzResourceGraph -Query 'Resources | where type =~ "Microsoft.Compute/virtualMachines"' ` -ManagementGroup 'contoso-mg' .EXAMPLE # Tenant-wide query allowing partial scopes Search-AzResourceGraph -Query 'ResourceContainers | summarize count()' ` -AllowPartialScopes #> function Search-AzResourceGraph { [CmdletBinding(DefaultParameterSetName = 'String')] param( [Parameter(Mandatory, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string]$QueryPath, [Parameter(Mandatory, ParameterSetName = 'String')] [ValidateNotNullOrEmpty()] [string]$Query, [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'String')] [string[]]$SubscriptionId, [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'String')] [string[]]$ManagementGroup, [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'String')] [ValidateSet('AtScopeAboveAndBelow', 'AtScopeAndAbove', 'AtScopeAndBelow', 'AtScopeExact')] [string]$AuthorizationScopeFilter = 'AtScopeAndBelow', [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'String')] [switch]$AllowPartialScopes, [Parameter(ParameterSetName = 'Path')] [Parameter(ParameterSetName = 'String')] [ValidateRange(1, 1000)] [int]$PageSize = 1000 ) # Ensure only one of SubscriptionId or ManagementGroup is provided if ($PSBoundParameters.ContainsKey('SubscriptionId') -and $PSBoundParameters.ContainsKey('ManagementGroup')) { throw 'KQL Query can only be run against either a Subscription or a Management Group, not both.' } Assert-AzureConnection -TokenSplat $script:TokenSplat if ($PSCmdlet.ParameterSetName -eq 'Path') { $Query = Get-Content $QueryPath -Raw } $Uri = 'https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' $Body = @{ query = $Query options = @{ resultFormat = 'objectArray' authorizationScopeFilter = $AuthorizationScopeFilter allowPartialScopes = $AllowPartialScopes.IsPresent '$top' = $PageSize '$skip' = 0 } } if ($PSBoundParameters.ContainsKey('SubscriptionId')) { $Body['subscriptions'] = @($SubscriptionId) } if ($PSBoundParameters.ContainsKey('ManagementGroup')) { $Body['managementGroups'] = @($ManagementGroup) } $Headers = @{ 'Authorization' = "Bearer $($script:Token.Token)" 'Content-Type' = 'application/json' } $PageParams = @{ Uri = $Uri Body = $Body Headers = $Headers TotalRecords = 0 ResultHeaders = @{} Output = [System.Collections.ArrayList]::new() } while ($PageParams['TotalRecords'] -eq 0 -or $PageParams['TotalRecords'] -gt $PageParams['Body']['options']['$skip']) { $PageParams = Get-AzResourceGraphPage @PageParams Write-Verbose "Outputting $($PageParams.Output.Count) records." if ($PageParams.TotalRecords -eq 0) {return} Write-Output $PageParams.Output $PageParams.Output.Clear() } } #EndRegion './Public/Search-AzResourceGraph.ps1' 146 #Region './suffix.ps1' -1 $script:TokenSplat = @{} $script:TokenSource = 'Global' #EndRegion './suffix.ps1' 3 |