Multi-threaded Exchange Online With Powershell Core - C#

Viewed 667

I'm writing a C# application that will involve collecting data from Exchange Online using the PowerShell V2 module. After clients perform an admin consent, I will make PowerShell connections to their environments using a multi-threaded C# app running on a Windows virtual machine. I'm using Net 5.0 and PowerShell 7.x. I need to use multiple threads because collecting data from a single tenant can be a lengthy process.

The problem is that while the app runs fine, if I try to run the application for two tenants at the same time using multiple threads, there is collision. The module does not appear to be thread-safe.

I've built a service that gets injected via .Net DI as a transient. This service creates a HostedRunspace class that performs state management for PowerShell.

public class HostedRunspace : IDisposable
{
    private Runspace runspace;
 
    public void Initialize(string[] modulesToLoad = null)
    {

        InitialSessionState defaultSessionState = InitialSessionState.CreateDefault();
        defaultSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.RemoteSigned;
        
        if (modulesToLoad != null)
        {
            foreach (string moduleName in modulesToLoad)
            {
                defaultSessionState.ImportPSModule(moduleName);
            }
        }

        runspace = RunspaceFactory.CreateRunspace(defaultSessionState);
        
        runspace.ThreadOptions = PSThreadOptions.UseNewThread;
        runspace.ApartmentState = ApartmentState.STA;

        runspace.Open();
    }

    public async Task<List<PSObject>> RunScript(string scriptContents, Dictionary<string, object> scriptParameters = null)
    {
        if (runspace == null)
        {
            throw new ApplicationException("Runspace must be initialized before calling RunScript().");
        }

        PSDataCollection<PSObject> pipelineObjects;
        
        using (System.Management.Automation.PowerShell ps = System.Management.Automation.PowerShell.Create(runspace))
        {
            ps.AddScript(scriptContents);

            if (scriptParameters != null)
            {
                ps.AddParameters(scriptParameters);
            }

            ps.Streams.Error.DataAdded += Error_DataAdded;
            ps.Streams.Warning.DataAdded += Warning_DataAdded;
            ps.Streams.Information.DataAdded += Information_DataAdded;

            // execute the script and await the result.
            pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
            
            // print the resulting pipeline objects to the console.
            Console.WriteLine("----- Pipeline Output below this point -----");
            foreach (PSObject item in pipelineObjects)
            {
                Console.WriteLine(item.BaseObject.ToString());
            }
        }

        List<PSObject> psObjects = new List<PSObject>();
        foreach (PSObject pipelineObject in pipelineObjects)
        {
            psObjects.Add(pipelineObject);
        }

        return psObjects;
    }

When it becomes time to collect a tenant's PowerShell data, a new thread is created like so:

IOnlineDataTaskRunner taskRunner = serviceProvider.GetRequiredService<IOnlineDataTaskRunner>();
Thread thread = new Thread(() => taskRunner.RunAsync(dataTask));
thread.Start();

Here I'm getting a transient version of my PowerShell service, which will itself new up a HostedRunspace. I create a new thread, provide it some configuration and start the thread.

When the thread runs, I first must connect to Exchange Online which I do using a certificate.

string command = $"Connect-ExchangeOnline -CertificateThumbprint \"{Thumbprint}\" -AppId \"{ClientId}\" -ShowBanner:$false -Organization {tenant}"; 
await runspace.RunScript(command);

Then, after this, I perform a variety of other data retrieval tasks using the PowerShell Module, including retrieving mailbox information, storage size, etc. These are also executed via

await runspace.RunScript(command);

As stated above, if I run one thread at a time, there is no issue. But if I connect thread 1 to tenant A and thread 2 to tenant B, the initial Connect-ExchangeOnline will take place with no issues.

But then if you retrieve mailbox information, for example, both threads will pull data for whichever tenant connected last. This indicates that there may be a threading issue with the module or perhaps with my implementation.

2 Answers

I don't have a second Tenant, but I do have a second user account with different permissions so that's what I'll use in my suggestions. I tested a couple ways of handling multiple exo connections, and here's what worked:

Using Powershell jobs via Start-Job. Jobs take place in their own sessions and have some level of isolation:

Start-Job -Name admin01 -ScriptBlock {
    Connect-ExchangeOnline -UserPrincipalName admin01@domain.com
    # Sleep so that second connection happens before this Get-Mailbox
    Start-Sleep -Seconds 10; 
    Get-Mailbox user02@domain.com  ## should succeed as admin
}
Start-Job -Name user01  -ScriptBlock {
    Connect-ExchangeOnline -UserPrincipalName user01@domain.com
    Get-Mailbox user02@domain.com  ## should error due to access as user
}

Receive-Job -Name admin01  ## returns mailbox
Receive-Job -Name user01   ## returns error

It looks like Connect-ExchangeOnline uses PSSession objects to store your connection, which you can re-import to verify you are connected to the correct tenant e.g.:

Get-PSSession | ft -AutoSize

Id Name                            ComputerName          ComputerType  State  ConfigurationName  Availability
-- ----                            ------------          ------------  -----  -----------------  ------------
 5 ExchangeOnlineInternalSession_1 outlook.office365.com RemoteMachine Opened Microsoft.Exchange    Available
 6 ExchangeOnlineInternalSession_2 outlook.office365.com RemoteMachine Opened Microsoft.Exchange    Available

So I was able to use Import-PSSession to load a specific connection for a command:

# First, run as admin01
Connect-ExchangeOnline -UserPrincipalName admin01@domain.com
Get-Mailbox user02@domain.com  ## Success

# Then, run as user01
Connect-ExchangeOnline -UserPrincipalName user01@domain.com
Get-Mailbox user02@domain.com  ## Error

# Then, run as admin01 again:
Import-PSSession (Get-PSSession 5) -AllowClobber
Get-Mailbox user02@domain.com  ## Success

Finally, just running two separate powershell instances works as well.

I'm not very familiar with .net, but my guess is you're currently either re-using the SessionState when starting a new thread, or you're sharing your runspace across threads.

I'm not a 100% sure, but you can try the following:

Do a Task.Run and create the reference to InitialSessionState there.

The reason behind this (speculation ahead!): the InitialSessionState.CreateDefault() method is static, but not thread static (source). Which could mean that you only create one InitialSessionState and share it among all the instances of your async Tasks (remember that an async Task is not necessarily a new Thread). Which means that the same InitialSessionState is shared between your tasks. Thus resulting in logging on with one client and then the next. This could explain the behaviour.

Disclaimer: I read this and this is the first thing that came to mind. I have not tested this in any way.

Related