AzureCommonStuff.psm1
function Connect-AzAccount2 { <# .SYNOPSIS Function for connecting to Azure using Connect-AzAccount command (Az.Accounts module). .DESCRIPTION Function for connecting to Azure using Connect-AzAccount command (Az.Accounts module). In case there is already existing valid connection, no new will be created. .PARAMETER credential Credentials (User or App) for connecting to Azure. For App credentials tenantId must be set too! .PARAMETER applicationId ID of the service principal that will be used for connection. .PARAMETER certificateThumbprint Thumbprint of the locally stored certificate that will be used for connection. Certificate has to be placed in personal machine store and user running this function has to have permission to read its private key. .PARAMETER servicePrincipal Switch for using App/Service Principal authentication instead of User auth. .PARAMETER tenantId Azure tenant ID. Mandatory when App authentication is used. .EXAMPLE Connect-AzAccount2 Authenticate to Azure interactively using user credentials. Doesn't work for accounts with MFA! .EXAMPLE $credential = get-credential Connect-AzAccount2 -credential $credential Authenticate to Azure using given user credentials. Doesn't work for accounts with MFA! .EXAMPLE $credential = get-credential Connect-AzAccount2 -servicePrincipal -credential $credential -tenantId 1234-1234-1234 Authenticate to Azure using given app credentials (service principal). .EXAMPLE $thumbprint = Get-ChildItem Cert:\LocalMachine\My | ? subject -EQ "CN=contoso.onmicrosoft.com" | select -ExpandProperty Thumbprint $null = Connect-AzAccount2 -ApplicationId 'cd2ae428-35f9-41b4-a527-71f2f8f1e5cf' -CertificateThumbprint $thumbprint -ServicePrincipal Authenticate using certificate. .NOTES Requires module Az.Accounts. #> [CmdletBinding()] param ( [System.Management.Automation.PSCredential] $credential, [string] $applicationId, [string] $certificateThumbprint, [switch] $servicePrincipal, [string] $tenantId = $_tenantId ) #region checks $azAccesstoken = Get-AzAccessToken -ErrorAction SilentlyContinue #region check whether there is valid existing session $tenantIsDomainName = $false $correctTenant = $false if ($azAccesstoken -and $azAccesstoken.ExpiresOn -gt [datetime]::now) { if ($tenantId -like "*.*") { $tenantIsDomainName = $true } if (($tenantIsDomainName -and $azAccesstoken.UserId -like "*@$tenantId") -or (!$tenantIsDomainName -and $azAccesstoken.TenantId -eq $tenantId)) { $correctTenant = $true } } #endregion check whether there is valid existing session #region check whether there is valid existing session created via required account $userId = $null $correctAccount = $false if ($azAccesstoken -and ($applicationId -or $credential) -and ($azAccesstoken.UserId -eq $applicationId -or $azAccesstoken.UserId -eq $credential.UserName)) { # there is an existing token that uses required account already $correctAccount = $true } if ($azAccesstoken -and !$applicationId -and !$credential) { # there is an existing token that can be used, because no explicit credentials were specified $correctAccount = $true } #endregion check whether there is valid existing session created via required account #endregion checks if ($azAccesstoken -and $correctTenant -and $correctAccount) { Write-Verbose "Already connected to the Azure using $($azAccesstoken.UserId)" return } else { if ($servicePrincipal -and !$tenantId) { throw "When servicePrincipal auth is used tenantId has to be set" } $param = @{} if ($servicePrincipal) { $param.servicePrincipal = $true } if ($tenantId) { $param.tenantId = $tenantId } if ($credential) { $param.credential = $credential } if ($applicationId) { $param.applicationId = $applicationId } if ($certificateThumbprint) { $param.certificateThumbprint = $certificateThumbprint } Connect-AzAccount @param } } function Connect-PnPOnline2 { <# .SYNOPSIS Proxy function for Connect-PnPOnline with some enhancements like: automatic MFA auth if MFA detected, skipping authentication if already authenticated etc. .DESCRIPTION Proxy function for Connect-PnPOnline with some enhancements like: automatic MFA auth if MFA detected, skipping authentication if already authenticated etc. .PARAMETER credential Credential object you want to use to authenticate to Sharepoint Online .PARAMETER appAuth Switch for using application authentication instead of the user one. .PARAMETER asMFAUser Switch for using user with MFA enabled authentication (i.e. interactive auth) .PARAMETER useWebLogin Switch for using WebLogin instead of Interactive authentication. - weblogin auth Legacy cookie based authentication. Notice this type of authentication is limited in its functionality. We will for instance not be able to acquire an access token for the Graph, and as a result none of the Graph related cmdlets will work. Also some of the functionality of the provisioning engine (Get-PnPSiteTemplate, Get-PnPTenantTemplate, Invoke-PnPSiteTemplate, Invoke-PnPTenantTemplate) will not work because of this reason. The cookies will in general expire within a few days and if you use -UseWebLogin within that time popup window will appear that will disappear immediately, this is expected. Use -ForceAuthentication to reset the authentication cookies and force a new login. - interactive auth Connects to the Azure AD, acquires an access token and allows PnP PowerShell to access both SharePoint and the Microsoft Graph. By default it will use the PnP Management Shell multi-tenant application behind the scenes, so make sure to run `Register-PnPManagementShellAccess` first. .PARAMETER url Your sharepoint online url ("https://contoso-admin.sharepoint.com") .EXAMPLE Connect-PnPOnline2 Connect to Sharepoint Online using user interactive authentication. .EXAMPLE Connect-PnPOnline2 -asMFAUser Connect to Sharepoint Online using (MFA-enabled) user interactive authentication. .EXAMPLE Connect-PnPOnline2 -appAuth Connect to Sharepoint Online using application interactive authentication. .EXAMPLE Connect-PnPOnline2 -appAuth -credential $cred Connect to Sharepoint Online using application non-interactive authentication. .EXAMPLE Connect-PnPOnline2 -credential $cred Connect to Sharepoint Online using (non-MFA enabled!) user non-interactive authentication. .NOTES Requires Pnp.PowerShell module. #> [CmdletBinding()] param ( [System.Management.Automation.PSCredential] $credential, [switch] $appAuth, [switch] $asMFAUser, [switch] $useWebLogin, [ValidateNotNullOrEmpty()] [string] $url = $_SPOConnectionUri ) if (!$url) { throw "Parameter 'url' cannot be empty." } if ($appAuth -and $asMFAUser) { Write-Warning "asMFAUser switch cannot be used with appAuth. Ignoring asMFAUser." $asMFAUser = $false } if ($credential -and $asMFAUser) { Write-Warning "When logging using MFA-enabled user, credentials cannot be passed i.e. it has to be interactive login" $credential = $null } try { $existingConnection = Get-PnPConnection -ea Stop } catch { Write-Verbose "There isn't any PNP connection" } if (!$existingConnection -or !($existingConnection | ? { $_.URL -like "$url*" }) -or ($useWebLogin -and $existingConnection.ConnectionType -ne "O365") -or (!$useWebLogin -and $existingConnection.ConnectionType -ne "TenantAdmin")) { Write-Verbose "Connecting to Sharepoint" if ($credential -and !$appAuth) { try { Connect-PnPOnline -Url $url -Credentials $credential -ea Stop } catch { if ($_ -match "you must use multi-factor authentication to access") { Write-Error "Account $($credential.UserName) has MFA enabled, therefore interactive logon is needed" Connect-PnPOnline -Url $url -Interactive -ForceAuthentication } else { throw $_ } } } elseif ($credential -and $appAuth) { Connect-PnPOnline -Url $url -ClientId $credential.UserName -ClientSecret $credential.GetNetworkCredential().password } else { # credential is missing if ($asMFAUser) { if ($useWebLogin) { # weblogin acquires ACS generated token, which will not work for things like exporting the site header and footer as it won't be able to acquire an access token for Graph Connect-PnPOnline -Url $url -UseWebLogin -ForceAuthentication } else { # interactive uses PnP Management Shell Azure app registration to connect as delegated permissions Connect-PnPOnline -Url $url -Interactive -ForceAuthentication } } elseif ($appAuth) { $credential = Get-Credential -Message "Using App auth. Enter ClientId and ClientSecret." Connect-PnPOnline -Url $url -ClientId $credential.UserName -ClientSecret $credential.GetNetworkCredential().password } else { Connect-PnPOnline -Url $url } } } else { Write-Verbose "Already connected to Sharepoint" } } function FilterBy-AzureScope { <# .SYNOPSIS Function for filtering of Azure resources based on their scope (typically saved in ResourceId). .DESCRIPTION Function for filtering of Azure resources based on their scope (typically saved in ResourceId). .PARAMETER pipelineInput Azure object(s). .PARAMETER scope Scope(s) that will be used to filter. .PARAMETER property Name of the Azure object property that contains its scope (typically ResourceId) .EXAMPLE $scope = "subscriptions/b6e5e819-g33c-4ecf-b021-5fbd3ff2fead/resourceGroups/local-azure-test", "/subscriptions/1a17a321-7c64-3050-8cc5-42329bdac82b/resourceGroups/AHCI-TEST" Search-AzGraph -Query $Query | FilterBy-AzureScope -scope $scope -Property ResourceId #> [CmdletBinding()] param ( [parameter(ValueFromPipeline = $true)] $pipelineInput, [string[]] $scope, [Parameter(Mandatory = $true)] [string] $property ) begin { # standardize the scope format $scope = $scope | ? { $_ } | % { $_.trim() -replace "\**$" -replace "/*$" -replace "^/*" } } process { foreach ($object in $pipelineInput) { $object | ? { if (!$scope) { return $true } else { foreach ($scp in $scope) { $scp = "/" + $scp + "/*" Write-Verbose "Comparing '$($_.$property)' against '$scp'" if ($_.$property -like $scp) { return $true } } return $false } } } } } function Get-AuthenticatedSPIdentityAppId { <# .SYNOPSIS Function returns application ID of the app used for authenticating against an Azure. .DESCRIPTION Function returns application ID of the app used for authenticating against an Azure. .EXAMPLE Get-AuthenticatedSPIdentityAppId Function returns application ID of the app used for authenticating against an Azure. #> [CmdletBinding()] param () function ConvertFrom-JWTToken { [cmdletbinding()] param([Parameter(Mandatory = $true)][string]$token) if ($token -match "^bearer ") { # get rid of "bearer " part $token = $token -replace "^bearer\s+" } #Validate as per https://tools.ietf.org/html/rfc7519 #Access and ID tokens are fine, Refresh tokens will not work if (!$token.Contains(".") -or !$token.StartsWith("eyJ")) { Write-Error "Invalid token" -ErrorAction Stop } #Payload $tokenPayload = $token.Split(".")[1].Replace('-', '+').Replace('_', '/') #Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 while ($tokenPayload.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $tokenPayload += "=" } #Convert to Byte array $tokenByteArray = [System.Convert]::FromBase64String($tokenPayload) #Convert to string array $tokenArray = [System.Text.Encoding]::ASCII.GetString($tokenByteArray) Write-Verbose "Decoded array in JSON format:" Write-Verbose $tokenArray #Convert from JSON to PSObject $tokobj = $tokenArray | ConvertFrom-Json Write-Verbose "Decoded Payload:" return $tokobj } $token = (Get-AzAccessToken -WarningAction SilentlyContinue).token $objectId = (ConvertFrom-JWTToken $token).oid Write-Verbose "Get AppId of app with $objectId ObjectId" (Get-AzADServicePrincipal -ObjectId $objectId -Select appid).AppId } function Invoke-AzureBatchRequest { <# .SYNOPSIS Function to invoke Azure Resource Manager Api batch request(s). .DESCRIPTION Function to invoke Azure Resource Manager Api batch request(s). Handles throttling and server-side errors. .PARAMETER batchRequest PSobject(s) representing the requests to be run in a batch. Can be created manually or via New-AzureBatchRequest. https://github.com/Azure/azure-sdk-for-python/issues/9271 .PARAMETER dontBeautifyResult Switch for returning original/non-modified batch request(s) results. By default batch-request-related properties like batch status, headers, nextlink, etc are stripped. To be able to filter returned objects by their originated request, new property 'RequestId' is added. .EXAMPLE $batch = ( @{ Name = "group" HttpMethod = "GET" URL = "https://management.azure.com/providers/Microsoft.Management/managementGroups/SOMEMGGROUP/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview" }, @{ Name = "subPim" HttpMethod = "GET" URL = "/subscriptions/f3b08c7f-99a9-4a70-ba56-1e877abb77f7/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" } ) Invoke-AzureBatchRequest -batchRequest $batch Invokes both requests in one batch. .EXAMPLE $batchRequest = New-AzureBatchRequest -url "/providers/Microsoft.Authorization/roleDefinitions?%24filter=type%20eq%20%27BuiltInRole%27&api-version=2022-05-01-preview", "/subscriptions/f3b08c7f-99a9-4a70-ba56-1e877abb77f7/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" Invoke-AzureBatchRequest -batchRequest $batchRequest Creates batch request object containing both urls & run it. .EXAMPLE $subscriptionId = (Get-AzSubscription | ? State -EQ 'Enabled').Id New-AzureBatchRequest -urlWithPlaceholder "https://management.azure.com/subscriptions/<placeholder>/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" -placeholder $subscriptionId | Invoke-AzureBatchRequest Creates batch request object containing dynamically generated urls for every id in the $subscriptionId array & run it. .NOTES Uses undocumented API https://github.com/Azure/azure-sdk-for-python/issues/9271 :). #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [PSObject[]]$batchRequest, [switch] $dontBeautifyResult ) begin { Write-Verbose "Total number of requests to process is $($batchRequest.count)" # api batch requests are limited to 20 requests $chunkSize = 20 # buffer to hold chunks of requests $requestChunk = [System.Collections.ArrayList]::new() # paginated or remotely failed requests that should be processed too, to get all the results $extraRequestChunk = [System.Collections.ArrayList]::new() # throttled requests that have to be repeated after given time $throttledRequestChunk = [System.Collections.ArrayList]::new() # check url validity $batchRequest.URL | % { if ($_ -notlike "https://management.azure.com/*" -and $_ -notlike "/*") { throw "url '$_' has to be relative (without the whole 'https://management.azure.com' part) or absolute!" } if ($_ -notlike "*/subscriptions/*" -and $_ -notlike "*/providers/Microsoft.Management/managementGroups/*" -and $_ -notlike "*/providers/Microsoft.ResourceGraph/*" -and $_ -notlike "*/resources/*" -and $_ -notlike "*/locations/*" -and $_ -notlike "*/tenants/*" -and $_ -notlike "*/bulkdelete/*") { throw "url '$_' is not valid. Is should starts with:`n'/subscriptions/{subscriptionId}'`n'/providers/Microsoft.Management/managementGroups/{entityId}'`n'/providers/Microsoft.ResourceGraph/{action}'`n'/resources, /locations, /tenants, /bulkdelete'!" } } function _processChunk { <# .SYNOPSIS Helper function with the main chunk-processing logic that invokes batch request. Based on request return code and availability of nextlink url it: - creates another request to get missing data - retry the request (with wait time in case of throttled request) #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Collections.ArrayList] $requestChunk ) $duplicityId = $requestChunk | Select-Object -ExpandProperty Name | Group-Object | ? { $_.Count -gt 1 } if ($duplicityId) { throw "Batch requests must have unique names. Name $(($duplicityId | select -Unique) -join ', ') is there more than once" } Write-Debug ($requestChunk | ConvertTo-Json) Write-Verbose "Processing batch of $($requestChunk.count) request(s):`n$(($requestChunk | sort Url | % {" - $($_.Name) - $($_.Url)"} ) -join "`n")" #region process given chunk of batch requests $start = Get-Date $payload = @{ requests = [array]$requestChunk } # invoke the batch $result = Invoke-AzRestMethod -Uri "https://management.azure.com/batch?api-version=2020-06-01" -Method POST -Payload ($payload | ConvertTo-Json -Depth 20) -ErrorAction Stop # check the batch status if ($result.StatusCode -notin 200, 202, 429) { $result throw "Batch failed with error code $($result.StatusCode) and message: $(($result.content | ConvertFrom-Json).Error.Message)" } elseif ($result.StatusCode -in 202, 429) { # whole batch has to be retried # TODO nastavit retryAfter a pridat vsechny uri do $throttledRequestChunk? # $retryAfter = ($result.Headers.RetryAfter.Delta).TotalSeconds Write-Warning "TODO! $($result.StatusCode) tzn zopakovat po $(($result.Headers.RetryAfter.Delta).TotalSeconds)" } $responses = ($result.content | ConvertFrom-Json).responses #region return the output if ($dontBeautifyResult) { # return original response $responses } else { # return just actually requested data without batch-related properties and enhance the returned object with 'RequestId' property for easier filtering foreach ($response in $responses) { if ($response.content.value) { # the result is stored in 'value' property $response.content.value | select -Property *, @{n = 'RequestId'; e = { $response.Name } } } else { # the result is stored in 'value' property, but no results were returned, skipping } } } #endregion return the output # check responses status $failedBatchJob = [System.Collections.ArrayList]::new() foreach ($response in $responses) { if ($response.httpStatusCode -eq 200) { # success #TODO vubec nevim jak tohle tady funguje # if ($response.body.'@odata.nextLink') { # # paginated (get remaining results by query returned NextLink URL) # Write-Verbose "Batch result for request '$($response.Name)' is paginated. Nextlink will be processed in the next batch" # $relativeNextLink = $response.body.'@odata.nextLink' -replace [regex]::Escape("https://management.azure.com") # # make a request object copy, so I can modify it without interfering with the original object # $nextLinkRequest = $requestChunk | ? Name -EQ $response.Name | ConvertTo-Json -Depth 10 | ConvertFrom-Json # # replace original URL with the nextLink # $nextLinkRequest.URL = $relativeNextLink # # add the request for later processing # $null = $extraRequestChunk.Add($nextLinkRequest) # } } elseif ($response.httpStatusCode -eq 429) { # throttled (will be repeated after given time) $jobRetryAfter = $response.Headers.'Retry-After' $throttledBatchRequest = $requestChunk | ? Name -EQ $response.Name Write-Verbose "Batch request with Id: '$($throttledBatchRequest.Name)', Url:'$($throttledBatchRequest.Url)' was throttled, hence will be repeated after $jobRetryAfter seconds" if ($jobRetryAfter -eq 0) { # request can be repeated without any delay #TIP for performance reasons adding to $extraRequestChunk batch (to avoid invocation of unnecessary batch job) $null = $extraRequestChunk.Add($throttledBatchRequest) } else { # request can be repeated after delay # add the request for later processing $null = $throttledRequestChunk.Add($throttledBatchRequest) } # get highest retry-after wait time if ($jobRetryAfter -gt $script:retryAfter) { Write-Verbose "Setting $jobRetryAfter retry-after time" $script:retryAfter = $jobRetryAfter } } elseif ($response.httpStatusCode -in 500, 502, 503, 504) { # some internal error on remote side (will be repeated) $problematicBatchRequest = $requestChunk | ? Name -EQ $response.Name Write-Verbose "Batch request with Id: '$($problematicBatchRequest.Name)', Url:'$($problematicBatchRequest.Url)' had internal error '$($response.httpStatusCode)', hence will be repeated" $null = $extraRequestChunk.Add($problematicBatchRequest) } else { # failed $failedBatchRequest = $requestChunk | ? Name -EQ $response.Name $response | fl * $null = $failedBatchJob.Add("- Name: '$($response.Name)', Url:'$($failedBatchRequest.Url)', StatusCode: '$($response.httpStatusCode)', Error: '$($response.body.error.message)'") } } # exit if critical failure occurred if ($failedBatchJob) { Write-Error "Following batch request(s) failed:`n$($failedBatchJob -join "`n")" } $end = Get-Date Write-Verbose "It took $((New-TimeSpan -Start $start -End $end).TotalSeconds) seconds to process the batch" #endregion process given chunk of batch requests } } process { foreach ($request in $batchRequest) { $null = $requestChunk.Add($request) # check if the buffer has reached the required chunk size if ($requestChunk.count -eq $chunkSize) { [int] $script:retryAfter = 0 _processChunk $requestChunk # clear the buffer $requestChunk.Clear() # process requests that need to be repeated (paginated, failed on remote server,...) if ($extraRequestChunk) { Write-Warning "Processing $($extraRequestChunk.count) paginated or server-side-failed request(s)" Invoke-AzureBatchRequest -batchRequest $extraRequestChunk -dontBeautifyResult:$dontBeautifyResult $extraRequestChunk.Clear() } # process throttled requests if ($throttledRequestChunk) { Write-Warning "Processing $($throttledRequestChunk.count) throttled request(s) with $script:retryAfter seconds wait time" Start-Sleep -Seconds $script:retryAfter Invoke-AzureBatchRequest -batchRequest $throttledRequestChunk -dontBeautifyResult:$dontBeautifyResult $throttledRequestChunk.Clear() } } } } end { # process any remaining requests in the buffer if ($requestChunk.Count -gt 0) { [int] $script:retryAfter = 0 _processChunk $requestChunk # process requests that need to be repeated (paginated, failed on remote server,...) if ($extraRequestChunk) { Write-Warning "Processing $($extraRequestChunk.count) paginated or server-side-failed request(s)" Invoke-AzureBatchRequest -batchRequest $extraRequestChunk -dontBeautifyResult:$dontBeautifyResult } # process throttled requests if ($throttledRequestChunk) { Write-Warning "Processing $($throttledRequestChunk.count) throttled request(s) with $script:retryAfter seconds wait time" Start-Sleep -Seconds $script:retryAfter Invoke-AzureBatchRequest -batchRequest $throttledRequestChunk -dontBeautifyResult:$dontBeautifyResult } } } } function New-AzureBatchRequest { <# .SYNOPSIS Function creates PSObject(s) representing request(s) that can be used in Azure Resource Manager Api batching. .DESCRIPTION Function creates PSObject(s) representing request(s) that can be used in Azure Resource Manager Api batching. PSObject will look like this: @{ Name = "mggroupperm" HttpMethod = "GET" URL = "https://management.azure.com/providers/Microsoft.Management/managementGroups/SOMEMGGROUP/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview" } Name = de-facto ID that has to be unique across the batch requests HttpMethod = method that will be used when sending the request URL = ARM api URL that should be requested .PARAMETER method Request method. By default GET. .PARAMETER url Request URL in absolute (https://management.azure.com/providers/Microsoft.Management/managementGroups/SOMEMGGROUP/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview) or relative form (/providers/Microsoft.Management/managementGroups/SOMEMGGROUP/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview) a.k.a. without the "https://management.azure.com" prefix. .PARAMETER urlWithPlaceholder Request URL in absolute (https://management.azure.com/providers/Microsoft.Management/managementGroups/<placeholder>/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview) or relative form (/providers/Microsoft.Management/managementGroups/<placeholder>/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview) that contains "<placeholder>" string. Relative form means without the "https://management.azure.com" prefix. For each value in the 'placeholder' parameter, new request url will be generated with such value used instead of the "<placeholder>" string. .PARAMETER placeholder Array of items (string, integers, ..) that will be used in the request url (defined in 'urlWithPlaceholder' parameter) instead of the "<placeholder>" string. .PARAMETER requestHeaderDetails RequestHeaderDetails (header) as a hashtable that should be added to each request in the batch. "requestHeaderDetails" = @{ "commandName" = "fx.Microsoft_Azure_AD.ServicesPermissions.getPermissions" } .PARAMETER name Name (Id) of the request. Can only be specified when only one URL is requested. By default random-generated-GUID. .EXAMPLE $batchRequest = New-AzureBatchRequest -url "/providers/Microsoft.Authorization/roleDefinitions?%24filter=type%20eq%20%27BuiltInRole%27&api-version=2022-05-01-preview", "/subscriptions/f3b08c7f-99a9-4a70-ba56-1e877abb77f7/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" Invoke-AzureBatchRequest -batchRequest $batchRequest Creates batch request object containing both urls & run it. .EXAMPLE $subscriptionId = (Get-AzSubscription | ? State -EQ 'Enabled').Id New-AzureBatchRequest -urlWithPlaceholder "https://management.azure.com/subscriptions/<placeholder>/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" -placeholder $subscriptionId | Invoke-AzureBatchRequest Creates batch request object containing dynamically generated urls for every id in the $subscriptionId array & run it. .EXAMPLE $subscriptionId = (Get-AzSubscription | ? State -EQ 'Enabled').Id $batchRequest = New-AzureBatchRequest -urlWithPlaceholder "https://management.azure.com/subscriptions/<placeholder>/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" -placeholder $subscriptionId # you need to process all requests by chunks of 20 items $payload = @{ requests = $batchRequest[0..19] } Invoke-AzRestMethod -Uri "https://management.azure.com/batch?api-version=2020-06-01" -Method POST -Payload ($payload | ConvertTo-Json -Depth 20) .NOTES Uses undocumented API https://github.com/Azure/azure-sdk-for-python/issues/9271 :). #> [CmdletBinding(DefaultParameterSetName = 'Default')] param ( [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH')] [string] $method = "GET", [Parameter(Mandatory = $true, ParameterSetName = "Url")] [string[]] $url, [Parameter(Mandatory = $true, ParameterSetName = "DynamicUrl")] [ValidateScript( { if ($_ -like "*<placeholder>*") { $true } else { throw "$_ doesn't contain '<placeholder>' string (that should be replaced by real value from `$placeholder then)" } })] [string] $urlWithPlaceholder, [Parameter(Mandatory = $true, ParameterSetName = "DynamicUrl")] $placeholder, [hashtable] $requestHeaderDetails, [Alias("id")] [string] $name ) if ($name -and @($url).count -gt 1) { throw "'name' parameter cannot be used with multiple urls" } if ($urlWithPlaceholder) { $url = $placeholder | % { $urlWithPlaceholder -replace "<placeholder>", $_ } } $url | % { # fix common mistake where there are multiple slashes $_ = $_ -replace "/{1,}", "/" #region url validity checks if ($_ -notlike "https://management.azure.com/*" -and $_ -notlike "/*") { throw "url '$_' has to be in the relative (without the 'https://management.azure.com' prefix and starting with the '/') or absolute form!" } if ($_ -notlike "*/subscriptions/*" -and $_ -notlike "*/providers/Microsoft.Management/managementGroups/*" -and $_ -notlike "*/providers/Microsoft.ResourceGraph/*" -and $_ -notlike "*/resources/*" -and $_ -notlike "*/locations/*" -and $_ -notlike "*/tenants/*" -and $_ -notlike "*/bulkdelete/*") { throw "url '$_' is not valid. Is should starts with:`n'/subscriptions/{subscriptionId}'`n'/providers/Microsoft.Management/managementGroups/{entityId}'`n'/providers/Microsoft.ResourceGraph/{action}'`n'/resources, /locations, /tenants, /bulkdelete'!" } #endregion url validity checks $property = [ordered]@{ HttpMethod = $method URL = $_ } if ($name) { $property.Name = $name } else { $property.Name = (New-Guid).Guid } if ($requestHeaderDetails) { $property.requestHeaderDetails = $requestHeaderDetails } New-Object -TypeName PSObject -Property $property } } function New-AzureDevOpsAuthHeader { <# .SYNOPSIS Function for getting authentication header for web requests against Azure DevOps. .DESCRIPTION Function for getting authentication header for web requests against Azure DevOps. .PARAMETER useMsal Switch to use MSAL authentication. Function uses Az token by default. .EXAMPLE $header = New-AzureDevOpsAuthHeader Invoke-WebRequest -Uri $uri -Headers $header .NOTES https://docs.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1 PowerShell module AzSK.ADO > ContextHelper.ps1 > GetCurrentContext https://stackoverflow.com/questions/56355274/getting-oauth-tokens-for-azure-devops-api-consumption https://stackoverflow.com/questions/52896114/use-azure-ad-token-to-authenticate-with-azure-devops #> [CmdletBinding()] param ( [switch] $useMsal ) # TODO oAuth auth https://github.com/microsoft/azure-devops-auth-samples/tree/master/OAuthWebSample # $msalToken = Get-MsalToken -TenantId $TenantID -ClientId $ClientID -UserCredential $Credential -Scopes ([String]::Concat($($ApplicationIdUri), '/user_impersonation')) -ErrorAction Stop $clientId = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1" # Visual Studio $adoResourceId = "499b84ac-1321-427f-aa17-267ca6975798" # Azure DevOps app ID if ($useMsal) { if (!(Get-Module MSAL.PS) -and !(Get-Module MSAL.PS -ListAvailable)) { throw "Module MSAL.PS is missing. Function $($MyInvocation.MyCommand) cannot continue" } $msalToken = Get-MsalToken -Scopes "$adoResourceId/.default" -ClientId $clientId if ($msalToken.accessToken) { $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "", $msalToken.accessToken))) $header = @{ 'Authorization' = "Basic $base64AuthInfo" 'Content-Type' = 'application/json' } } else { throw "Unable to obtain DevOps MSAL token" } } else { if (!(Get-Command 'Get-AzAccessToken' -ErrorAction silentlycontinue) -or !($azAccessToken = Get-AzAccessToken -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) -or $azAccessToken.ExpiresOn -lt [datetime]::now) { throw "$($MyInvocation.MyCommand): Authentication needed. Please call Connect-AzAccount." } $secureToken = (Get-AzAccessToken -ResourceUrl $adoResourceId -AsSecureString).Token $token = [PSCredential]::New('dummy', $secureToken).GetNetworkCredential().Password $header = @{ 'Authorization' = 'Bearer ' + $token 'Content-Type' = 'application/json' } } return $header } function Start-AzureSync { <# .SYNOPSIS Invoke Azure AD sync cycle command (Start-ADSyncSyncCycle) on the server where 'Azure AD Connect' is installed. .DESCRIPTION Invoke Azure AD sync cycle command (Start-ADSyncSyncCycle) on the server where 'Azure AD Connect' is installed. .PARAMETER Type Type of sync. Initial (full) or just delta. Delta is default. .PARAMETER ADSynchServer Name of the server where 'Azure AD Connect' is installed .EXAMPLE Start-AzureSync -ADSynchServer ADSYNCSERVER Invokes synchronization between on-premises AD and AzureAD on server ADSYNCSERVER by running command Start-ADSyncSyncCycle there. #> [Alias("Sync-ADtoAzure", "Start-AzureADSync")] [cmdletbinding()] param ( [ValidateSet('delta', 'initial')] [string] $type = 'delta', [ValidateNotNullOrEmpty()] [string] $ADSynchServer ) $ErrState = $false do { try { Invoke-Command -ScriptBlock { Start-ADSyncSyncCycle -PolicyType $using:type } -ComputerName $ADSynchServer -ErrorAction Stop | Out-Null $ErrState = $false } catch { $ErrState = $true Write-Warning "Start-AzureSync: Error in Sync:`n$_`nRetrying..." Start-Sleep 5 } } while ($ErrState -eq $true) } Export-ModuleMember -function Connect-AzAccount2, Connect-PnPOnline2, FilterBy-AzureScope, Get-AuthenticatedSPIdentityAppId, Invoke-AzureBatchRequest, New-AzureBatchRequest, New-AzureDevOpsAuthHeader, Start-AzureSync Export-ModuleMember -alias Start-AzureADSync, Sync-ADtoAzure |