Automate Qlik Sense logins from PowerShell

Tech deep-dive where we look at how users can be automatically logged into Qlik Sense to generate realistic, artificial load on the system. PowerShell and Selenium is used to automate Chrome.

Automate Qlik Sense logins from PowerShell
Photo by Giampiero Fanni / Unsplash

During recent work to add monitoring and auto-release of Qlik Sense access licenses I had a need to automatically log in Sense users and assigning various licenses types to the correct users.
Generating artificial load on the Sense environment, in other words.

This can be done using Qlik's very useful Qlik Sense Enterprise Scalability Tools, but those tools need to be installed and configured, which can be a bit challenging.

I wanted a stand-alone solution written in PowerShell exclusively, to make it portable and easy to get started with.

Turned out to be possible - but not without challenges.

This post dives into the details of using PowerShell to interact with Qlik Sense, using both header authentication, as well as using Selenium to really pretend to be a user logging into Sense with a username and password, using Qlik's standard form based login page.
💡
Please check out this post too for information about Butler's new feature that monitor and automatically release Qlik Sense Enterprise access licenses.

Requirements & goal

The goal was to

  • Create 60 users in Active Directory, then sync them to the LAB user directory in Sense.
  • Log in 20 users and assign professional licenses to them using a license allocation rule.
  • Log in another 20 users and assign analyzer licenses to them using another license allocation rule.
  • Log in another 20 users and give them access via analyzer capacity licenses, also handled by a license allocation rule.
  • A general requirement was that each user should be logged in with some random delay (a few seconds) after the previous login.

Create users in Active directory using PowerShell

Here PowerShell is used to create users with account name testuser_<num> and username Testuser<num>.

Note that all users get the same password. This is of course a really bad idea for general use, but given the current test it's considered ok.

# Import the Active Directory module
Import-Module ActiveDirectory

# Loop through and create new users
for ($i = 1 $i -le 60; $i++) {
    $username = "testuser_$i"
    $firstname = "Testuser$i"
    $password = (ConvertTo-SecureString "abc123" -AsPlainText -Force)
    $ou = "OU=Users,DC=mycompany,DC=net" # Replace with your own OU/DC information

    $userParams = @{
        GivenName = "$firstname"
        Surname = ""
        Name = "$firstname"
        SamAccountName = "$username"
        UserPrincipalName = "$username"
        AccountPassword = $password
        Enabled = $true
    }

    Write-Host "Creating user $username"
    New-ADUser @userParams
}

Running it looks pretty boring, but gets the job done:

PS C:\tool\script> .\create_testusers.ps1
Creating user testuser_1
Creating user testuser_2
Creating user testuser_3
Creating user testuser_4
Creating user testuser_5
Creating user testuser_6
Creating user testuser_7
Creating user testuser_8
Creating user testuser_9
...
...

License allocation rules

Professional and Analyzer

Users with these licenses types will make a very basic https request to the Qlik Sense hub. The users won't open any apps or anything - just connecting via the Sense proxy service will trigger the license allocation, using these rules:

  • Users testuser_1 to testuser_20 will get professional licenses.
  • Users testuser_21 to testuser_40 will get analyzer licenses.

The rules are very basic:

((user.userDirectory="LAB") and (user.userId="testuser_1" or user.userId="testuser_2" or user.userId="testuser_3" or user.userId="testuser_4" or user.userId="testuser_5" or user.userId="testuser_6" or user.userId="testuser_7" or user.userId="testuser_8" or user.userId="testuser_9" or user.userId="testuser_10" or user.userId="testuser_11" or user.userId="testuser_12" or user.userId="testuser_13" or user.userId="testuser_14" or user.userId="testuser_15" or user.userId="testuser_16" or user.userId="testuser_17" or user.userId="testuser_18" or user.userId="testuser_19" or user.userId="testuser_20"))

License allocation condition for Professional licenses

((user.userDirectory="LAB") and (user.userId="testuser_21" or user.userId="testuser_22" or user.userId="testuser_23" or user.userId="testuser_24" or user.userId="testuser_25" or user.userId="testuser_26" or user.userId="testuser_27" or user.userId="testuser_28" or user.userId="testuser_29" or user.userId="testuser_30" or user.userId="testuser_31" or user.userId="testuser_32" or user.userId="testuser_33" or user.userId="testuser_34" or user.userId="testuser_35" or user.userId="testuser_36" or user.userId="testuser_37" or user.userId="testuser_38" or user.userId="testuser_39" or user.userId="testuser_40"))

License allocation condition for Analyzer licenses

Analyzer capacity

Users will only consume time chunks (in Sense's standard six minute blocks) from the available license pool once they open an actual app. Just accessing the Sense hub won't do it - an actual app has to be opened.

For that reason Selenium is used to emulate real user interactions with a web page.

The license allocation rule is very similar to the previous ones:

((user.userDirectory="LAB") and (user.userId="testuser_41" or user.userId="testuser_42" or user.userId="testuser_43" or user.userId="testuser_44" or user.userId="testuser_45" or user.userId="testuser_46" or user.userId="testuser_47" or user.userId="testuser_48" or user.userId="testuser_49" or user.userId="testuser_50" or user.userId="testuser_51" or user.userId="testuser_52" or user.userId="testuser_53" or user.userId="testuser_54" or user.userId="testuser_55" or user.userId="testuser_56" or user.userId="testuser_57" or user.userId="testuser_58" or user.userId="testuser_59" or user.userId="testuser_60"))

License allocation condition for Analyzer Capacity licenses

Opening the hub using header authentication

Let's assume that none of testuser_1 to testuser_40 have a license allocated to them.

When those users make a https call to Qlik Sense using header authentication (why header auth? Because it's easy to set up... but it's not for production scenarios, typically) they get professional and analyzer licenses, respectively.

A Sense virtual proxy with header auth has been set up with a prefix of hdr, resulting in a Sense URL of "https://qliksense.ptarmiganlabs.net/hdr/hub".

For the users that will receive professional licenses (i.e. testuser_1 to testuser_20) the PowerShell script looks like this:

# Create a 16 character random string
$randomString = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 16 | % {[char]$_})
Write-Host "Generated random string: $randomString"

# Define the base URL
$baseUrl = "https://qliksense.ptarmiganlabs.net/hdr/hub"

# Append the URL with the Xrfkey parameter and random string
$urlWithParams = $baseUrl + "?Xrfkey=$randomString"

# Define the headers
$headers = @{
    "X-Qlik-Xrfkey" = $randomString
}

# Loop over 20 users
for ($i = 1; $i -le 20; $i++) {
    # Construct the username
    $username = "testuser_$i"
    Write-Host "Processing user: $username"

    # Add the username to the headers
    $headers["X-Qlik-User"] = $username

    # Perform a GET request with the required parameters and headers
    Write-Host "Sending GET request ${$i}, username=$username"
    Write-Host "URL: $urlWithParams"

    $response = Invoke-WebRequest -Uri $urlWithParams -Headers $headers -Method GET
    Write-Host "Response:"
    $response.StatusCode
    
    # Sleep for 3-10 seconds
    $sleepTime = Get-Random -Minimum 3 -Maximum 10
    Write-Host "Sleeping for $sleepTime seconds"
    Start-Sleep -Seconds $sleepTime
}

PowerShell script to make 20 users open the Qlik Sense hub, using header authentication

Note how the user ID is sent in the X-Qlik-User http header in the script above.

The script for the Analyzer users is identical, except that it loops from 21 to 40 instead.

Using Selenium from PowerShell

This turned out to be somewhat of a challenge, but in the end possible after all.

  • The PowerShell script tries to install everything needed, Chrome being the exception. A recent/latest Chrome version is assumed to be installed before running the script.
  • The script has been tested on Windows 11, running in Visual Studio Code's (VSC) embedded PowerShell environment.
  • I could not get the script to work in a stand-alone PowerShell environment. Visual Studio Code does something behind the scenes that makes .Net packages available to Selenium... Running in VSC works well though.
  • The script will
    • Download and install Nuget (Windows package manager from Microsoft)
    • Download and install Selenium WebDriver. Used to automate/remote-control Chrome.
    • Get the correct Selenium Chrome Driver, matching the installed version of Chrome.
    • Loop over all 20 users, opening a specific Sense app for each of them.

A different virtual proxy is used compared to the previous script.
It uses form based authentication, which means that Sense will present a login web page that Selenium can use and navigate.

Selenium outputs a bunch of warnings in the console window, but nothing to worry about it seems.

Looks like this:

The script is a bit more complex than previous one:

# Ensure nuget folder exists
$nugetFolderPath = Join-Path $PSScriptRoot -ChildPath 'nuget'
if (-not (Test-Path $nugetFolderPath)) {
    Write-Host "Creating nuget folder..."
    New-Item -ItemType Directory -Path $nugetFolderPath | Out-Null
    Write-Host "Nuget folder created at: $nugetFolderPath"
}

# Check if NuGet is already installed and update to the latest version if it exists
$nugetFilePath = Join-Path $nugetFolderPath -ChildPath 'nuget.exe'
if (-not (Test-Path $nugetFilePath)) {
    Write-Host "Downloading NuGet..."
    Invoke-WebRequest 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetFilePath
    Write-Host "NuGet downloaded and installed at: $nugetFilePath"
} else {
    Write-Host "NuGet is already installed at: $nugetFilePath"
    & $nugetFilePath update -self
    Write-Host "NuGet updated to the latest version"
}


# Install Selenium WebDriver
Write-Host "Installing Selenium WebDriver..."
$seleniumWebDriverPath = Join-Path $PSScriptRoot -ChildPath 'selenium'
& $nugetFilePath install Selenium.WebDriver -OutputDirectory $seleniumWebDriverPath -Source "https://api.nuget.org/v3/index.json" | Out-Null
Write-Host "Selenium WebDriver installed at: $seleniumWebDriverPath"

# Capture the version number of the installed Selenium WebDriver in a variable
$seleniumVersion = Get-ChildItem -Path $seleniumWebDriverPath -Directory | Where-Object { $_.Name -like 'Selenium.WebDriver*' } | Select-Object -ExpandProperty Name -First 1
$seleniumVersion = $seleniumVersion -replace 'Selenium.WebDriver.'
Write-Host "Installed Selenium WebDriver version: $seleniumVersion"


# Install Selenium Chrome.WebDriver using Selenium's manager tool
# Get full path where chromedriver.exe should be placed
$chromeDriverDestPath = Join-Path "$seleniumWebDriverPath\Selenium.WebDriver.$seleniumVersion" "chromedriver.exe"

# Check if chromedriver.exe already exists
if (-not (Test-Path $chromeDriverDestPath)) {
    Write-Host "Downloading Chrome WebDriver..."

    # Easiest is to use Selenium manager to get the correct Chrome driver version (it will look at the installed Chrome version and get the associated driver)
    # Define the selenium manager path
    $seleniumManagerPath = ".\selenium\Selenium.WebDriver.4.19.0\manager\windows\selenium-manager.exe"

    # Command to get the chromedriver path
    $chromeDriverPathCommand = "$seleniumManagerPath --driver chromedriver"

    # Run the command and capture output
    $output = & cmd /c $chromeDriverPathCommand

    # Extract chromedriver path from the output
    $chromeDriverSourcePath = $output | Select-String -Pattern 'Driver path: (.*)' | ForEach-Object { $_.Matches.Groups[1].Value }

    # Copy the chromedriver.exe to the correct location
    Copy-Item -Path $chromeDriverSourcePath -Destination $chromeDriverDestPath -Force

    Write-Host "Chrome WebDriver installed at: $chromeDriverDestPath"
} else {
    Write-Host "Chrome WebDriver is already installed at: $chromeDriverDestPath"
}



# Explicitly set the path to the ChromeDriver executable
$workingPath1 = "$($PSScriptRoot)\selenium\Selenium.WebDriver.$seleniumVersion"
$driverPath = "$workingPath1"
$env:PATH += ";$driverPath"
Write-Host "Working path 1: $driverPath"



# Show contents of PATH env variable to make sure the ChromeDriver path is included
$env:PATH -split ';'


# Load Selenium WebDriver
try {
    Add-Type -Path "$workingPath1\lib\netstandard2.0\WebDriver.dll"
}
catch [System.Exception] {
    Write-Host "LoaderException: $($_.Exception.Message)"
    foreach ($ex in $_.Exception.LoaderExceptions) {
        Write-Host "LoaderException: $($ex.Message)"
    }
}
catch {
    Write-Host "An error occurred: $_.Exception.Message"
}

# Make sure folder to store browser userdata exists
$userdataPath = Join-Path $PSScriptRoot -ChildPath 'userdata'
if (-not (Test-Path $userdataPath)) {
    Write-Host "Creating userdata folder..."
    New-Item -ItemType Directory -Path $userdataPath | Out-Null
    Write-Host "Userdata folder created at: $userdataPath"
}

# Set up common Chrome options
$chromeOptions = New-Object OpenQA.Selenium.Chrome.ChromeOptions
$chromeOptions.AddArgument("ignore-certificate-errors")
$chromeOptions.AddArgument("--user-data-dir=$userdataPath")
$chromeOptions.AddArgument("--no-sandbox")
$chromeOptions.AddArgument("--disable-dev-shm-usage")
$chromeOptions.AddArgument("--disable-gpu")
$chromeOptions.AddArgument("--disable-infobars")
$chromeOptions.AddArgument("--disable-popup-blocking")
$chromeOptions.AcceptInsecureCertificates = $True

# Loop over 20 users that will be assigned Analyzer Capacity licenses 
for ($i = 41; $i -le 60; $i++) {
    # Instantiate the ChromeDriver with the specified options and path
    $chromeDriver = New-Object OpenQA.Selenium.Chrome.ChromeDriver($driverPath, $chromeOptions)

    Write-Host "ChromeDriver started"

    # Construct the username
    $username = "testuser_$i"
    Write-Host "Processing user: $username"

    # Navigate to the URL
    $siteURL = "https://qliksense.ptarmiganlabs.net/form/sense/app/6bf8f41f-31d0-40b0-9e59-95ec8839eb57/overview"
    $chromeDriver.Navigate().GoToUrl($siteURL)
    Start-Sleep -Seconds 2  # Wait for the page to load

    # Locate the first input box and type "testuser_41"
    $firstInputBox = $chromeDriver.FindElement([OpenQA.Selenium.By]::Id("username-input"))

    # Type username
    $firstInputBox.SendKeys("LAB\$($username)")

    # Locate the next input box and type pwd
    $nextInputBox = $chromeDriver.FindElement([OpenQA.Selenium.By]::Id("password-input"))
    $nextInputBox.SendKeys("abc123")

    # Simulate pressing the Enter key
    $nextInputBox.SendKeys([OpenQA.Selenium.Keys]::Enter)

    Start-Sleep -Seconds 8  # Wait for the page to load


    #  Cleanup
    $chromeDriver.Close()
    $chromeDriver.Quit()
}

PowerShell script to automatically log in 20 users into Qlik Sense Enterprise.