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