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 |