EntraAuth.Graph.psm1

$script:ModuleRoot = $PSScriptRoot

class ServiceTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics] $Intrinsics, [object] $InputData) {
        if ($null -eq $InputData) {
            return @{ Graph = 'Graph' }
        }
        if ($InputData -is [hashtable]) {
            return $InputData
        }
        if ($InputData -is [string]) {
            return @{ Graph = $InputData }
        }
        if ($InputData.Graph) {
            return @{ Graph = $InputData.Graph }
        }
        return @{ Graph = $InputData -as [string] }
    }
}

function Invoke-GraphBatch {
    <#
    .SYNOPSIS
        Executes a batch graph request.
     
    .DESCRIPTION
        Executes a batch graph request.
        Expects the batches to be presized to its natural limit (20) and correctly designed.
 
        This function calls itself recursively on throttled requests.
     
    .PARAMETER ServiceMap
        Optional hashtable to map service names to specific EntraAuth service instances.
        Used for advanced scenarios where you want to use something other than the default Graph connection.
        Example: @{ Graph = 'GraphBeta' }
        This will switch all Graph API calls to use the beta Graph API.
     
    .PARAMETER Batch
        The set of requests to send in one batch.
        Is expected to be no more than 20 requests.
        https://learn.microsoft.com/en-us/graph/json-batching
     
    .PARAMETER Start
        When the batch stop was started.
        This is matched against the timeout in case of throttled requests.
     
    .PARAMETER Timeout
        How long as a maximum we are willing to wait before giving up retries on throttled requests.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command, to make sure all errors happen within the context of the caller
        and hence respect the ErrorActionPreference of the same.
     
    .EXAMPLE
        PS C:\> Invoke-GraphBatch -ServiceMap $services -Batch $batch.Value -Start (Get-Date) -Timeout '00:05:00' -Cmdlet $PSCmdlet
 
        Executes the provided requests in one bulk request, using the specified EntraAuth service connection.
        Will retry throttled requests for up to 5 minutes.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $ServiceMap,

        [Parameter(Mandatory = $true)]
        [object[]]
        $Batch,

        [Parameter(Mandatory = $true)]
        [DateTime]
        $Start,

        [Parameter(Mandatory = $true)]
        [TimeSpan]
        $Timeout,

        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    process {
        $innerResult = try {
            (EntraAuth\Invoke-EntraRequest -Service $ServiceMap.Graph -Path '$batch' -Method Post -Body @{ requests = $Batch } -ContentType 'application/json' -ErrorAction Stop).responses
        }
        catch {
            $Cmdlet.WriteError(
                [System.Management.Automation.ErrorRecord]::new(
                    [Exception]::new("Error sending batch: $($_.Exception.Message)", $_.Exception),
                    "$($_.FullyQualifiedErrorId)",
                    $_.CategoryInfo,
                    @{ requests = $Batch }
                )
            )
            return
        }

        $throttledRequests = $innerResult | Where-Object status -EQ 429
        $failedRequests = $innerResult | Where-Object { $_.status -ne 429 -and $_.status -in (400..499) }
        $successRequests = $innerResult | Where-Object status -In (200..299)

        #region Handle Failed Requests
        foreach ($failedRequest in $failedRequests) {
            $Cmdlet.WriteError(
                [System.Management.Automation.ErrorRecord]::new(
                    [Exception]::new("Error in batch request $($failedRequest.id): $($failedRequest.body.error.message)"),
                    ('{0}|{1}' -f $failedRequest.status, $failedRequest.error.code),
                    [System.Management.Automation.ErrorCategory]::NotSpecified,
                    ($Batch | Where-Object { $_.ID -eq $failedRequest.id })
                )
            )
        }
        #endregion Handle Failed Requests

        #region Handle Successes
        if ($successRequests) {
            $successRequests
        }
        #endregion Handle Successes

        #region Handle Throttled Requests
        if (-not $throttledRequests) {
            return
        }

        $throttledOrigin = $Batch | Where-Object { $_.id -in $throttledRequests.id }
        $interval = ($throttledRequests.Headers | Sort-Object 'Retry-After' | Select-Object -Last 1).'Retry-After'
        $limit = $Start.Add($Timeout)
        
        if ((Get-Date).AddSeconds($interval) -ge $limit) {
            $Cmdlet.WriteError(
                [System.Management.Automation.ErrorRecord]::new(
                    [Exception]::new("Retries for throttling exceeded, giving up on: $($throttledRequests.id -join ',')"),
                    "ThrottlingRetriesExhausted",
                    [System.Management.Automation.ErrorCategory]::LimitsExceeded,
                    $throttledOrigin
                )
            )
            return
        }

        
        $Cmdlet.WriteVerbose("Throttled requests detected, waiting $interval seconds before retrying")
        Start-Sleep -Seconds $interval

        Invoke-GraphBatch -ServiceMap $ServiceMap -Batch $throttledOrigin -Start $Start -Timeout $Timeout -Cmdlet $Cmdlet
        #endregion Handle Throttled Requests
    }
}

function Invoke-TerminatingException
{
<#
    .SYNOPSIS
        Throw a terminating exception in the context of the caller.
     
    .DESCRIPTION
        Throw a terminating exception in the context of the caller.
        Masks the actual code location from the end user in how the message will be displayed.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
     
    .PARAMETER Message
        The message to show the user.
     
    .PARAMETER Exception
        A nested exception to include in the exception object.
     
    .PARAMETER Category
        The category of the error.
     
    .PARAMETER ErrorRecord
        A full error record that was caught by the caller.
        Use this when you want to rethrow an existing error.
 
    .PARAMETER Target
        The target of the exception.
     
    .EXAMPLE
        PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module'
     
        Terminates the calling command, citing an unknown caller.
#>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        $Cmdlet,
        
        [string]
        $Message,
        
        [System.Exception]
        $Exception,
        
        [System.Management.Automation.ErrorCategory]
        $Category = [System.Management.Automation.ErrorCategory]::NotSpecified,
        
        [System.Management.Automation.ErrorRecord]
        $ErrorRecord,

        $Target
    )
    
    process{
        if ($ErrorRecord -and -not $Message) {
            $Cmdlet.ThrowTerminatingError($ErrorRecord)
        }
        
        $exceptionType = switch ($Category) {
            default { [System.Exception] }
            'InvalidArgument' { [System.ArgumentException] }
            'InvalidData' { [System.IO.InvalidDataException] }
            'AuthenticationError' { [System.Security.Authentication.AuthenticationException] }
            'InvalidOperation' { [System.InvalidOperationException] }
        }
        
        
        if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) }
        elseif ($ErrorRecord) { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) }
        else { $newException = $exceptionType::new($Message) }
        $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target)
        $Cmdlet.ThrowTerminatingError($record)
    }
}

function Invoke-EagBatchRequest {
    <#
    .SYNOPSIS
        Executes a graph Batch requests, sending multiple requests in a single invocation.
         
    .DESCRIPTION
        Executes a graph Batch requests, sending multiple requests in a single invocation.
        This allows optimizing code performance, where iteratively sending requests does not scale.
 
        There are two ways to call this command:
        + By providing the full request (by using the "-Request" parameter)
        + By Providing a Path with placeholders and for multiple items calculate the actual url based on inserting the values into the placeholder positions (by using the "-Path" parameter)
 
        See the documentation on the respective parameters or the example on how to use them.
 
        Up to 20 requests can be sent in one batch, but this command will automatically split larger workloads into separate
        sets of 20 before sending them.
     
    .PARAMETER Request
        The requests to send. Provide either a url string or a request hashtables with any combination of the following keys:
        + id: The numeric ID to use. Determines the order in which requests are processed in the server end.
        + method: The REST method to use (GET, POST, PATCH, ...)
        + url: The relative url to call
        + body: The body to provide with the request
        + headers: Headers to include with the request
        + dependsOn: The id of another request that must be successful, in order to run this request. Prevents executing requests invalidated by another request's failure.
 
        The only mandatory value to provide (whether as plain string or via hashtable) is the "url".
        The rest are filled by default values either provided via other parameters (Method, Body, Header) or the system (id) or omitted (dependsOn).
 
        In all cases, "url" should be the relative path - e.g. "users" - and NOT the absolute one (e.g. "https://graph.microsoft.com/v1.0/users")
 
        For more documentation on the properties, see the online documentation on batch requests:
        https://learn.microsoft.com/en-us/graph/json-batching
     
    .PARAMETER Path
        The path(s) to execute for each item in ArgumentList against.
        Example: "users/{0}/authentication/methods"
        Assuming a list of 50 user IDs, this will then insert the respective IDs into the url when building the request batches.
        After which it would execute the 50 paths in three separate batches (20 / 20 / 10 | Due to the 20 requests per batch limit enforced by the API).
 
        In combination with the "-Properties" parameter, it is also possible to insert multiple values per path.
        Example: "sites/{0}/lists/{1}/items?expand=fields"
        Assuming the parameter "-Properties" contains "'SiteID', 'ListID'" and the ArgumentList provides objects that contain those properties, this allows bulkrequesting all items from many lists in one batch.
 
        All requests generated from this parameter use the default Method, Body & Header provided via the parameters of the same name.
     
    .PARAMETER ArgumentList
        The list of values, for each of which for each "-Path" provided a request is sent.
        In combination with the "-Properties" parameter, you can also select one or more properties from these objects to format into the path,
        rather than inserting the full value of the argument.
     
    .PARAMETER Properties
        The properties from the arguments provided via "-ArgumentList" to format into the paths provided.
        This allows inserting multiple values into the request url.
     
    .PARAMETER Method
        The default method to use for batched requests, if not defined otherwise in the request.
        Defaults to "GET"
     
    .PARAMETER Body
        The default body to provide with batched requests, if not defined otherwise in the request.
        Defaults to not being specified at all.
         
    .PARAMETER Header
        The default header to provide with batched requests, if not defined otherwise in the request.
        Defaults to not being specified at all.
     
    .PARAMETER Timeout
        How long to retry batched requests that are being throttled.
        By default, requests are re-attempted until 5 minutes have expired (specifically, until the "notAfter" response would lead the next attempt beyond that time limit).
        Set to 0 minutes to never retry throttled requests.
     
    .PARAMETER Raw
        Do not process the responses provided by the batched requests.
        This will cause the batching metadata to be included with the actual result data.
        This can be useful to correlate responses to the original requests.
     
    .PARAMETER ServiceMap
        Optional hashtable to map service names to specific EntraAuth service instances.
        Used for advanced scenarios where you want to use something other than the default Graph connection.
        Example: @{ Graph = 'GraphBeta' }
        This will switch all Graph API calls to use the beta Graph API.
     
    .EXAMPLE
        PS C:\> Invoke-EagBatchRequest -Path 'users/{0}/authentication/methods' -ArgumentList $users.id
 
        Retrieves the authentication methods for all users in $users
 
    .EXAMPLE
        PS C:\> Invoke-EagBatchRequest -Path 'users/{0}/authentication/methods' -ArgumentList $users -Properties id -ServiceMap GraphBeta
 
        Retrieves the authentication methods for all users in $users while using the GraphBeta EntraAuth service.
 
    .EXAMPLE
        PS C:\> Invoke-EagBatchRequest -Path 'sites/{0}/lists/{1}/items?expand=fields' -ArgumentList $lists -Properties SiteID, ListID
 
        Retrieves all the items from all lists in $lists.
        Assumes that each object in $lists has the properties "SiteID" and "ListID" (not case sensitive).
 
    .EXAMPLE
        PS C:\> Invoke-EagBatchRequest -Request $requests -Method GET -Header @{ 'Content-Type' = 'application/json' }
 
        Executes all the requests provided in $requests, defaulting to the method "GET" and providing the content-type via header,
        unless otherwise specified in individual requests.
     
    .LINK
        https://learn.microsoft.com/en-us/graph/json-batching
    #>

    [CmdletBinding(DefaultParameterSetName = 'Request')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Request')]
        [object[]]
        $Request,

        [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
        [string[]]
        $Path,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
        [object[]]
        $ArgumentList,
        
        [Parameter(ParameterSetName = 'Path')]
        [Alias('Property')]
        [string[]]
        $Properties,

        [Microsoft.PowerShell.Commands.WebRequestMethod]
        $Method = 'Get',

        [hashtable]
        $Body,

        [hashtable]
        $Header,

        [timespan]
        $Timeout = '00:05:00',

        [switch]
        $Raw,

        [ArgumentCompleter({ (Get-EntraService | Where-Object Resource -Match 'graph\.microsoft\.com').Name })]
        [ServiceTransformAttribute()]
        [hashtable]
        $ServiceMap = @{}
    )
    begin {
        $services = $script:_serviceSelector.GetServiceMap($ServiceMap)
        Assert-EntraConnection -Cmdlet $PSCmdlet -Service $services.Graph

        $batchSize = 20 # Currently hardcoded API limit

        function ConvertFrom-PathRequest {
            [CmdletBinding()]
            param (
                [string]
                $Path,
        
                [object[]]
                $ArgumentList,
                
                [AllowEmptyCollection()]
                [string[]]
                $Properties,

                [Microsoft.PowerShell.Commands.WebRequestMethod]
                $Method = 'Get',

                [hashtable]
                $Body,

                [hashtable]
                $Header
            )

            $index = 1
            foreach ($item in $ArgumentList) {
                if (-not $Properties) { $values = $item }
                else {
                    $values = foreach ($property in $Properties) {
                        $item.$property
                    }
                }

                $request = @{
                    id     = "$index"
                    method = "$Method".ToUpper()
                    url    = $Path -f $values
                }
                if ($Body) { $request.body = $Body }
                if ($Header) { $request.headers = $Header }
                $request

                $index++
            }
        }

        function ConvertTo-BatchRequest {
            [CmdletBinding()]
            param (
                [object[]]
                $Request,

                [Microsoft.PowerShell.Commands.WebRequestMethod]
                $Method,

                $Cmdlet,

                [AllowNull()]
                [hashtable]
                $Body,
        
                [AllowNull()]
                [hashtable]
                $Header
            )
            $defaultMethod = "$Method".ToUpper()

            $results = @{}
            $requests = foreach ($entry in $Request) {
                $newRequest = @{
                    url    = ''
                    method = $defaultMethod
                    id     = 0
                }
                if ($Body) { $newRequest.body = $Body }
                if ($Header) { $newRequest.headers = $Header }
                if ($entry -is [string]) {
                    $newRequest.url = $entry
                    $newRequest
                    continue
                }

                if (-not $entry.url) {
                    Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Invalid batch request: No Url found! $entry" -Category InvalidArgument
                }
                $newRequest.url = $entry.url
                if ($entry.Method) {
                    $newRequest.method = "$($entry.Method)".ToUpper()
                }
                if ($entry.id -as [int]) {
                    $newRequest.id = $entry.id -as [int]
                    $results[($entry.id -as [int])] = $newRequest
                }
                if ($entry.body) {
                    $newRequest.body = $entry.body
                }
                if ($entry.headers) {
                    $newRequest.headers = $entry.headers
                }
                if ($entry.dependsOn) {
                    $newRequest.dependsOn
                }
                $newRequest
            }

            $index = 1
            $finalList = foreach ($requestItem in $requests) {
                if ($requestItem.id) {
                    $requestItem.id = $requestItem.id -as [string]
                    $requestItem
                    continue
                }
                $requestItem.id = $requestItem.id -as [string]

                while ($results[$index]) {
                    $index++
                }
                $requestItem.id = $index
                $results[$index] = $requestItem
                $requestItem
            }

            $finalList | Sort-Object { $_.id -as [int] }
        }
    }
    process {
        if ($Request) { $batchRequests = ConvertTo-BatchRequest -Request $Request -Method $Method -Body $Body -Header $Header -Cmdlet $PSCmdlet }
        else {
            $batchRequests = foreach ($pathEntry in $Path) {
                ConvertFrom-PathRequest -Path $pathEntry -ArgumentList $ArgumentList -Properties $Properties -Method $Method -Body $Body -Header $Header -Cmdlet $PSCmdlet
            }
        }

        $counter = [pscustomobject] @{ Value = 0 }
        $batches = $batchRequests | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } -AsHashTable

        foreach ($batch in ($batches.GetEnumerator() | Sort-Object -Property Key)) {
            Invoke-GraphBatch -ServiceMap $services -Batch $batch.Value -Start (Get-Date) -Timeout $Timeout -Cmdlet $PSCmdlet | ForEach-Object {
                if ($Raw) { $_ }
                elseif ($_.Body.Value) { $_.Body.Value }
                else { $_.Body }
            }
        }
    }
}

function Set-EagConnection {
    <#
    .SYNOPSIS
        Defines the default EntraAuth service connection to use for commands in EntraAuth.Graph.
         
    .DESCRIPTION
        Defines the default EntraAuth service connection to use for commands in EntraAuth.Graph.
        By default, all requests use the default, builtin "Graph" service connection.
 
        This command allows redirecting all requests this module uses to use another service connection.
        However, be aware, that this change is Runspace-wide and may impact other modules using it.
 
        Modules that want to use this module are advised to instead use the "-ServiceMap" parameter
        that is provided on all comands executing Graph requests.
        For example, defining this during your module import will cause all subsequent requests you
        send to execute against the service connection you defined, without impacting anybody outside of your module:
        $PSDefaultParameterValues['*-Eag*:ServiceMap'] = @{ Graph = 'MyModule.Graph' }
 
        For more information on what service connections mean, see the readme of EntraAuth:
        https://github.com/FriedrichWeinmann/EntraAuth
 
        For more information on how to best build a module on EntraAuth, see the related documentation:
        https://github.com/FriedrichWeinmann/EntraAuth/blob/master/docs/building-on-entraauth.md
     
    .PARAMETER Graph
        What service connection to use by default.
     
    .EXAMPLE
        PS C:\> Set-EagConnection -Graph 'Corp.Graph'
         
        Sets the 'Corp.Graph' service connection as the default connection to use.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ArgumentCompleter({ (Get-EntraService).Name })]
        [string]
        $Graph
    )
    process {
        $script:_services.Graph = $Graph
    }
}

$script:_services = @{
    Graph = 'Graph'
}

$script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services