2

Evening all, I have a script block below to add a user to an application in OKTA. It runs perfectly if I define a user ($login) and run the script, however if I wrap the script block below in a ForEach loop it hangs indefinitely without proceeding past the $jsonBody = $body | ConvertTo-Json -compress -depth 10 line. I came to this conclusion by adding numbered Write-Host lines after each command. I have tried removing the -Compress and -depth switches which results in an Error 400 bad request meaning the body for Invoke-Webrequest is badly formed. Can you see any reason for this hanging?

$body = @()
$userRequest = "https://$org/api/v1/users?q=$login"
$WebResponse = Invoke-WebRequest -Headers $headers -Method Get -Uri $userRequest
$user = $webresponse | ConvertFrom-Json
$UserID = $user.id
$Body = @{
            id = $UserID
            scope = "USER"
            credentials = @{
            userName = $login
            }
         }
$jsonBody = $body | ConvertTo-Json -compress -depth 10
$UpdateRequest = "https://$org/api/v1/apps/$AppID/users"
Invoke-WebRequest -Headers $headers -Body $jsonBody -Method POST -Uri $UpdateRequest
Write-Host "$login added to $AppName successfully" -ForegroundColor Green  

ForEach loop example below with method to check where it failed:

$logins = Get-Content "C:\ScriptRepository\Users.txt"

ForEach ($login in $logins) {
    $body = @()
    Write-Host "1" -ForegroundColor Green
    $userRequest = "https://$org/api/v1/users?q=$login"
    Write-Host "2" -ForegroundColor Green
    $WebResponse = Invoke-WebRequest -Headers $headers -Method Get -Uri $userRequest
    Write-Host "3" -ForegroundColor Green
    $user = $webresponse | ConvertFrom-Json
    Write-Host "4" -ForegroundColor Green
    $UserID = $user.id
    Write-Host "5" -ForegroundColor Green
    $Body = @{
                id = $UserID
                scope = "USER"
                credentials = @{
                userName = $login
                }
             }
    Write-Host "6" -ForegroundColor Green
    $jsonBody = $body | ConvertTo-Json -Compress -Depth 10
    Write-Host "7" -ForegroundColor Green
    $UpdateRequest = "https://$org/api/v1/apps/$AppID/users"
    Write-Host "8" -ForegroundColor Green
    Invoke-WebRequest -Headers $headers -Body $jsonBody -Method POST -Uri $UpdateRequest
    Write-Host "$login added to $AppName successfully" -ForegroundColor Green          
}

Below is the content of $jsonBody if I remove -compress -depth 10 from ConvertTo-Json, I have anonymised the email:

{
    "scope":  "USER",
    "credentials":  {
                        "userName":  {
                                         "value":  "user.email@add.com",
                                         "PSPath":  "C:\\ScriptRepository\\Users.txt",
                                         "PSParentPath":  "C:\\ScriptRepository",
                                         "PSChildName":  "Users.txt",
                                         "PSDrive":  "C",
                                         "PSProvider":  "Microsoft.PowerShell.Core\\FileSystem",
                                         "ReadCount":  2
                                     }
                    },
    "id":  "00u1230u44rbpE4z70i7"
}
  • 2
    I think you are going to have to show us how you coded that loop. – zdan Jul 06 '22 at 23:54
  • 1
    The current sample look fine (aka nothing to make it hang). I also think your error come from how you do it within the loop. You'll have to show us some loop. The things that might make it fail at a Depth 10 would be because somewhat you have a value that recurse on itself and have a lot of data within it. In any case, I recommend you to print the Json Body to look at it and compare with the reference doc to see how it is different. – Sage Pourpre Jul 07 '22 at 02:28
  • Added ForEach example above, please note that this script does work when run for one user and not using a ForEach loop. The content of the imported text file is 2 email addresses, one per line. – Alexa Strange Jul 07 '22 at 06:44
  • 1
    @Santiago - The conversion back to json is required as the body is badly formed without it causing an error 400 bad request. – Alexa Strange Jul 07 '22 at 08:06
  • 1
    I wonder if write-host is buffering and it's not stopping where you think. If you comment out the call to invoke-webrequest does the script still hang? – Tom Jul 07 '22 at 11:01
  • 1
    If you add diagnositc lines like ```write-host ($user.id | ConvertTo-Json -Compress -Depth 10)``` and ```write-host ($login | ConvertTo-Json -Compress -Depth 10)``` do *they* complete successfully? Could you show example (anonymised) output from those commands so we can see what they look like? – mclayton Jul 07 '22 at 13:01
  • @Tom - It does still hang unfortunately – Alexa Strange Jul 07 '22 at 13:38
  • @mclayton - `write-host ($user.id | ConvertTo-Json -Compress -Depth 10)` gives me the correct userID I would expect. `write-host ($login | ConvertTo-Json -Compress -Depth 10)` hangs the script. If I run it outside of a ForEach loop I get the expected username contained in $login – Alexa Strange Jul 07 '22 at 13:42
  • Can you experiment with parameters to ```write-host ($login | ConvertTo-Json ...)``` and see if the ```-Compress``` or ```-Depth``` changes the behaviour? It would be good if you can also provide an actual value for ```$login``` that causes problems so we can try to reproduce it locally... – mclayton Jul 07 '22 at 14:10
  • @mclayton - I have experimented with `-compress` and `-depth` it either hangs as before or produces an error 400 (bad request) which means the $jsonBody is not correctly formed. The value of `$login` is simply an email address with no special characters other than the @ symbol. – Alexa Strange Jul 07 '22 at 14:58
  • I’m unable to reproduce the issue here with I’m the information you’ve provided. Can you please show us the value of ```$jsonbody``` that gives a 400 response? Add it to your question rather than in a comment… – mclayton Jul 07 '22 at 15:06
  • 2
    Might be related to this: https://github.com/PowerShell/PowerShell/issues/6388. Basically, ```Get-Content``` returns a deep psobject, *not* a string, so you might want to cast ```$login``` to a real string to shrug off all the additional metadata that also gets serialised.. – mclayton Jul 07 '22 at 15:55
  • @mclayton - Added to question as requested. – Alexa Strange Jul 07 '22 at 16:16
  • @mclayton - Is that not done with the -raw switch? I have seen this article looking for possible solutions and gave it a try with same results. – Alexa Strange Jul 07 '22 at 16:17

2 Answers2

2

To add some background information to your own effective solution:

  • In Windows PowerShell, ConvertTo-Json doesn't serialize strings as expected if they are decorated with ETS properties, as happens when text files are read with Get-Content.

    • Instead of serializing decorated strings as just string (e.g., "foo", they are serialized as custom objects whose .value property contains the original string, alongside the ETS properties.

    • The objects in these ETS properties, which contain metadata about the file each string was read from, are deeply nested and not designed for JSON serialization. To prevent "runaway" serialization, PowerShell limits the object-graph recursion depth to 2 levels by default. Your use of -Depth 10 caused such runaway serialization, which either takes an excessive amount and memory to complete, or, with circular references in the object graph, keeps running until it runs out of memory.

    • As an aside: While the default serialization depth of 2 happens to be useful for objects not designed for JSON serialization (to minimize the risk of runaway serialization), it is a perennial pitfall when using objects that are, for which you do not want a fixed depth to be enforced, as it results in data loss by truncation. In Windows PowerShell, this truncation is, unfortunately, quiet, whereas in PowerShell (Core) 7.1+ you now at least get a warning - see this answer for background information.

  • To serialize such decorated strings as just strings, simply call their .ToString() method to get their undecorated value (alternatively, but more obscurely, access their .psobject.BaseObject property, using the intrinsic psobject property).

    • See below for a more efficient alternative that eliminates the decorations at the Get-Content level.

This problem no longer occurs in PowerShell (Core) 7.1+, where decorated strings ([string]) and date values ([datetime]) now serialize just like undecorated ones; however, instances of all other types that have ETS properties are still serialized as custom objects, as described above - see GitHub issue #5797 for the discussion that led to this change.


Efficient alternative to individual .ToString() calls on the lines returned by Get-Content:

An efficient and conceptually simply solution is one of the ones Santiago Squarzon mentioned in a comment:

# Note the -ReadCount 0
# Each value of $login in the loop is then undecorated.
foreach ($login in (Get-Content -ReadCount 0 "C:\ScriptRepository\Users.txt")) {
  # ...
}
  • -ReadCount 0 reads all lines at once into a single array, rather than streaming the lines, one by one, only to be collected when captured in a variable or used in an expression.

  • Aside from being much faster than letting the lines stream, it is only the array as a whole being returned that gets decorated with the ETS properties, whereas its elements (the strings representing the lines) remain undecorated.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    @mklemet0 - Thankyou for taking the time to explain how this works, it is much appreciated. This will likely be the one I use when adding 48000 users to the application in the script. – Alexa Strange Jul 07 '22 at 18:47
0

Ok so finally sorted by using this :

$logins = Get-Content "C:\ScriptRepository\Users.txt" | %{$_.PSObject.BaseObject}