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 ConvertFrom-PathRequest { <# .SYNOPSIS Convert a combination of path and argument into a batch request task. .DESCRIPTION Convert a combination of path and argument into a batch request task. .PARAMETER Path The path to add argument data to. .PARAMETER Argument The argument to insert values from into the path. .PARAMETER Parameters The parameters provided to the Invoke-EagBatchRequest. .PARAMETER TaskList The overall list of tasks to add the new task to. .PARAMETER Tracking Hashtable to track what batch request id was already assigned. Needs to be tracked to prevent accidentally assigning the same id multiple times. .EXAMPLE PS C:\> ConvertFrom-PathRequest -Path $pathItem -Argument $argumentItem -Parameters $parameters -TaskList $allTasks -Tracking $idTracking Combines $pathItem and $argumentItem into a batch request task. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Path, [Parameter(Mandatory = $true)] $Argument, [Parameter(Mandatory = $true)] [hashtable] $Parameters, [Parameter(Mandatory = $true)] [System.Collections.Generic.List[object]] $TaskList, [hashtable] $Tracking ) process { $task = [PSCustomObject]@{ PSTypeName = 'EntraAuth.Graph.BatchTask' # Core Settings Id = $Tracking.CurrentID Method = $Parameters.Method Url = $null Body = $Parameters.Body Header = $Parameters.Header DependsOn = $null Batch = $null # Operations Result = @() Start = $null WaitUntil = $null WaitLimit = $null # Metadata Argument = $Argument Path = $Path Request = $null Parameters = $Parameters } $Tracking.CurrentID += 1 $values = $Argument if ($Parameters.Properties) { $values = foreach ($property in $Parameters.Properties) { $Argument.$property } } $task.Url = $Path -f $values $batch = @{ id = $task.Id -as [string] method = "$($task.Method)".ToUpper() url = $task.Url } if ($task.Body) { $batch.body = $task.Body } if ($task.Header) { $batch.headers = $task.Header } $task.Batch = $batch $TaskList.Add($task) } } function ConvertTo-BatchRequest { <# .SYNOPSIS Converts raw batch requests provided by the user into a task object. .DESCRIPTION Converts raw batch requests provided by the user into a task object. Needed for later task processing (especially paging). Task object will be added to the task-list provided. .PARAMETER Request The Request object to convert. .PARAMETER Parameters The parameters provided to the Invoke-EagBatchRequest. .PARAMETER TaskList The overall list of tasks to add the new task to. .PARAMETER Tracking Hashtable to track what batch request id was already assigned. Needs to be tracked to prevent accidentally assigning the same id multiple times. .EXAMPLE PS C:\> ConvertTo-BatchRequest -Request $requestItem -Parameters $parameters -TaskList $allTasks -Tracking $idTracking Converts the specified request item into a task object and adds it to $allTasks. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $Request, [Parameter(Mandatory = $true)] [hashtable] $Parameters, [Parameter(Mandatory = $true)] [System.Collections.Generic.List[object]] $TaskList, [hashtable] $Tracking ) process { $task = [PSCustomObject]@{ PSTypeName = 'EntraAuth.Graph.BatchTask' # Core Settings Id = $null Method = $Parameters.Method Url = $null Body = $Parameters.Body Header = $Parameters.Header DependsOn = $null Batch = $null # Operations Result = @() Start = $null WaitUntil = $null WaitLimit = $null # Metadata Argument = $Request Path = $null Request = $Request Parameters = $Parameters } #region ID if ($Request.id) { $task.Id = $Request.Id -as [int] if ($task.Id -gt $Tracking.CurrentID) { $Tracking.CurrentID = $task.Id + 1 } } else { while ($Tracking.CurrentID -in $TaskList.Id) { $Tracking.CurrentID++ } $task.Id = $Tracking.CurrentID } $Tracking.CurrentID++ #endregion ID #region Other Metadata if ($Request.Method) { $task.Method = $Request.Method } if ($Request.Body) { $task.Body = $Request.Body } if ($Request.Headers) { $task.Header = $Request.Headers } if ($Request -is [string]) { $task.Url = $Request } elseif ($Request.Url) { $task.Url = $Request.url } elseif ($Request -is [uri]) { $task.Url = $Request -as [string] } else { Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Invalid batch request: No Url found! $Request" -Category InvalidArgument } if ($Request.DependsOn) { $task.DependsOn = $Request.DependsOn } #endregion Other Metadata $batch = @{ id = $task.Id -as [string] method = "$($task.Method)".ToUpper() url = $task.Url } if ($task.Body) { $batch.body = $task.Body } if ($task.Header) { $batch.headers = $task.Header } if ($null -ne $task.DependsOn) { $batch.dependsOn = @($task.DependsOn) } $task.Batch = $batch $TaskList.Add($task) } } 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. .PARAMETER Tasks The tasks to execute. Expects the result objects of either ConvertFrom-PathRequest or ConvertTo-BatchRequest. .PARAMETER TaskList The entire list of tasks that need batching. Tasks that have completed - including any paging that needs doing - should be removed from this list. .PARAMETER ServiceMap 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 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 -Tasks $tasks -TaskList $allTasks -ServiceMap $services -Cmdlet $PSCmdlet Executes all tasks in $tasks #> [CmdletBinding()] param ( [object[]] $Tasks, [System.Collections.Generic.List[object]] $TaskList, [hashtable] $ServiceMap, [Parameter(Mandatory = $true)] $Cmdlet ) process { $start = Get-Date $innerResult = try { (EntraAuth\Invoke-EntraRequest -Service $ServiceMap.Graph -Path '$batch' -Method Post -Body @{ requests = @($Tasks.Batch) } -ContentType 'application/json' -ErrorAction Stop).responses } catch { # This should happen only for bad requests (insufficient or bad request data), as otherwise the error is within the respective results on the individual batch members $Cmdlet.WriteError( [System.Management.Automation.ErrorRecord]::new( [Exception]::new("Error sending batch: $($_.Exception.Message)", $_.Exception), "$($_.FullyQualifiedErrorId)", $_.CategoryInfo.Category, @{ requests = @($Tasks.Batch) } ) ) # Remove workload to prevent infinite loop foreach ($task in $Tasks) { $null = $TaskList.Remove($task) } return } foreach ($result in $innerResult) { $task = @($Tasks).Where{ $_.batch.id -eq $result.id }[0] if (-not $task.Start) { $task.Start = $start $task.WaitLimit = $start.Add($task.Parameters.Timeout) } #region Case: Success if (200 -le $result.status -and 299 -ge $result.status) { # Update for paging or complete task if ($result.body.'@odata.nextLink' -and -not $task.Parameters.NoBatching) { $task.Batch.url = ($result.body.'@odata.nextLink' -replace '^https://' -split '/',3)[-1] } else { $null = $TaskList.Remove($task) } # Raw Output if ($task.Parameters.Raw) { $result continue } # Matched Output if ($task.Parameters.Matched) { $data = $result.body if ($data.PSObject.Properties.Name -contains 'value') { $data = $data.Value } if (-not $task.Result) { $task.Result = @($data) } else { $task.Result = $task.Result + @($data) } # Only return matched result after completing the batching if ($result.body.'@odata.nextLink') { continue } [PSCustomObject]@{ PSTypeName = 'EntraAuth.Graph.BatchResult' Id = "$($task.Id)" Argument = $task.Argument Success = $true Result = $task.Result Status = $result.status } continue } # Plain Output if ($result.body.PSObject.Properties.Name -contains 'value') { $result.body.value } else { $result.body } continue } #endregion Case: Success #region Case: Throttled if (429 -eq $result.status) { $task.WaitUntil = (Get-Date).AddSeconds($result.Headers.'Retry-After') continue } #endregion Case: Throttled #region Case: Failed if (400 -le $result.status -and 499 -ge $result.Status ) { $null = $TaskList.Remove($task) if ($task.Parameters.Raw) { $result continue } $Cmdlet.WriteError( [System.Management.Automation.ErrorRecord]::new( [Exception]::new("Error in batch request $($result.id): $($result.body.error.message)"), ('{0}|{1}' -f $result.status, $result.error.code), [System.Management.Automation.ErrorCategory]::NotSpecified, $task.Batch ) ) if ($task.Parameters.Matched) { [PSCustomObject]@{ PSTypeName = 'EntraAuth.Graph.BatchResult' Id = "$($task.Id)" Argument = $task.Batch Success = $false Result = $result.error Status = $result.status } } continue } #endregion Case: Failed #region Case: Other $null = $TaskList.Remove($task) if ($task.Parameters.Raw) { $result continue } if ($task.Parameters.Matched) { [PSCustomObject]@{ PSTypeName = 'EntraAuth.Graph.BatchResult' Id = "$($task.Id)" Argument = $task.Batch Success = $false Result = $result Status = $result.status } continue } Write-Warning "Unexpected response code: $($result.status) on request id $($result.Id) ($($task.Url))" #endregion Case: Other } } } 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 Matched Rather than returning the results alone, match the results to the input object. This will create a custom object with ... - Id: The batch ID (generally cosmetic). - Argument: The value provided to "-ArgumentList" or - if using the "-Request" parameter instead - the finalized batch request entry. - Result: The return value (if any) or the error result. - Success: Boolean truth of whether the request was successful. .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 NoPaging Disables paging for each individual request. If not specified, all batches that has more data will automatically page through all pages, until there is no more data. .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.id -Matched Retrieves the authentication methods for all users in $users. Will return a set of objects, matching the authentication methods to the ID of the user. .EXAMPLE PS C:\> Invoke-EagBatchRequest -Path 'users/{0}/authentication/methods' -ArgumentList $users -Properties id -Matched Retrieves the authentication methods for all users in $users. Will return a set of objects, matching the authentication methods to the user object. .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:\> $requests = @( @{ url = "users" method = "GET" }, @{ url = "users?`$filter=country eq 'Denmark' and accountEnabled eq true and startswith(jobTitle, 'TECH') and onPremisesSyncEnabled eq true&`$select=id,displayName,userPrincipalName&`$expand=memberOf&`$count=true" method = "GET" headers = @{ "ConsistencyLevel" = "eventual" "Content-Type" = "application/json" } }, @{ url = "users" method = "PATCH" body = @{ businessPhones = @( "+1 425 555 0109" ) officeLocation = "18/2111" } headers = @{ "Content-Type" = "application/json" } }, @{ url = "users/{user-id}" method = "DELETE" }, @{ url = "groups" method = "POST" body = @{ description = "Self help community for library" displayName = "Library Assist" groupTypes = @( "Unified" ) mailEnabled = $true mailNickname = "library" securityEnabled = $false } }, @{ url = "users/{user-id}/manager/´$ref" method = "PUT" body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/{manager-id}" } headers = @{ "Content-Type" = "application/json" } } ) 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. .EXAMPLE PS C:\> $requests = @( @{ url = 'users/12345'; method = 'GET' }, @{ url = 'users/67890'; method = 'GET' } ) PS C:\> Invoke-EagBatchRequest -Request $requests -Method GET -Raw Executes both requests in one batch, returning the raw response as provided. .LINK https://learn.microsoft.com/en-us/graph/json-batching #> [CmdletBinding()] 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] $Matched, [switch] $Raw, [switch] $NoPaging, [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 #region Utility Functions function Get-NextBatch { [CmdletBinding()] param ( $Tasks, $Cmdlet ) $count = 0 $now = Get-Date foreach ($task in $Tasks) { if ($task.WaitUntil -and $task.WaitUntil -gt $now) { continue } # Not ready yet if ($task.WaitUntil -and $task.WaitLimit -lt $now) { continue } # We waited too long already if ($task.WaitUntil -and $task.WaitLimit -lt $task.WaitUntil) { continue } # Waiting will take longer than we are willing to wait $task $count++ if ($count -gt 18) { break } } # Cleanup Throttling Limits foreach ($task in $Tasks.ToArray()) { if (-not $task.WaitUntil) { continue } if ($task.WaitLimit -gt $now -and $task.WaitUntil -lt $task.WaitLimit) { continue } $null = $Tasks.Remove($task) $Cmdlet.WriteError( [System.Management.Automation.ErrorRecord]::new( [Exception]::new("Retries for throttling exceeded, giving up on: $($task.id)"), "ThrottlingRetriesExhausted", [System.Management.Automation.ErrorCategory]::LimitsExceeded, $task ) ) } } #endregion Utility Functions $allTasks = [System.Collections.Generic.List[object]]::new() $idTracking = @{ CurrentID = 1 } $parameters = @{} foreach ($key in $PSBoundParameters.Keys) { $parameters[$key] = $PSBoundParameters.$Key } $parameters.Method = $Method $parameters.Timeout = $Timeout foreach ($requestItem in $Request) { ConvertTo-BatchRequest -Request $requestItem -Parameters $parameters -TaskList $allTasks -Tracking $idTracking } foreach ($pathItem in $Path) { foreach ($argumentItem in $ArgumentList) { ConvertFrom-PathRequest -Path $pathItem -Argument $argumentItem -Parameters $parameters -TaskList $allTasks -Tracking $idTracking } } } process { do { $tasks = Get-NextBatch -Tasks $allTasks -Cmdlet $PSCmdlet # Case: All remaining tasks have expired if ($allTasks.Count -lt 1) { return } # Case: The only remaining tasks are throttled and wait to continue if (-not $tasks) { Start-Sleep -Seconds 1 continue } Invoke-GraphBatch -Tasks $tasks -TaskList $allTasks -ServiceMap $services -Cmdlet $PSCmdlet } while ($allTasks.Count -gt 0) } } 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 |