Retrieve Bitlocker keys stored in AzureAD with PowerShell

Sample output

Bitlocker keys can be stored in Active Directory and in Azure Active Directory too – but querying the latter is a bit trickier than usual. The following script will export all Bitlocker recovery keys (from your Azure Active Directory tenant) to an HTML table.

TL;DR
1. Ensure that you meet the following prerequisites:
– you have adequate rights in AzureAD (Global Admin for example 🙂 )
– the following PowerShell modules are installed: AzureRM, AzureAD
2. Run the script below (make sure that the path is valid).
– the script will prompt for AzureAD credentials twice, it is normal (scipt explained later)

The scipt itself:

$exportFile = "C:\TEMP\BitLockerReport.html"

#Install-Module AzureRM
Import-Module AzureRM.Profile
Login-AzureRmAccount

#Prepare Context - REQUIRES TENANT ADMIN
$context = Get-AzureRmContext
    $tenantId = $context.Tenant.Id
    $refreshToken = @($context.TokenCache.ReadItems() | Where-Object {$_.tenantId -eq $tenantId -and $_.ExpiresOn -gt (Get-Date)})[0].RefreshToken
    $body = "grant_type=refresh_token&refresh_token=$($refreshToken)&resource=74658136-14ec-4630-ad9b-26e160ff0fc6"
    $apiToken = Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded'
    $header = @{
        'Authorization'          = 'Bearer ' + $apiToken.access_token
        'X-Requested-With'       = 'XMLHttpRequest'
        'x-ms-client-request-id' = [guid]::NewGuid()
        'x-ms-correlation-id'    = [guid]::NewGuid()
    }


Connect-AzureAD
$AzureADDevices = Get-AzureADDevice -all $true | ? {$_.deviceostype -eq "Windows"}

# Retrieve BitLocker keys
$deviceRecords = @()

$deviceRecords = foreach ($device in $AzureADDevices) {
        $url = "https://main.iam.ad.ext.azure.com/api/Device/$($device.objectId)"
        $deviceRecord = Invoke-RestMethod -Uri $url -Headers $header -Method Get
        $deviceRecord
    }

$Devices_BitlockerKey = $deviceRecords.Where({$_.BitlockerKey.count -ge 1})

$obj_report_Bitlocker = foreach ($device in $Devices_BitlockerKey){
    foreach ($BLKey in $device.BitlockerKey){
        [pscustomobject]@{
            DisplayName = $device.DisplayName
            driveType = $BLKey.drivetype
            keyID = $BLKey.keyIdentifier
            recoveryKey = $BLKey.recoveryKey
            }
    }
}
#HTML report

<#-- Create HTML report --#>
$body = $null

$body += "<p><b>AzureAD Bitlocker key report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Device</th>
    <th>DriveType</th>
    <th>KeyID</th>
    <th>RecoveryKey</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report_Bitlocker){
"<tr><td>" + $obj.DisplayName + " </td>"
    "<td>" + $obj.DriveType + " </td>"
    "<td>" + $obj.KeyID + " </td>"
"<td>" + $obj.RecoveryKey + "</td></tr>"
}
  
$body += "</table>"

$body > $exportFile

Explained

In the first line, $exportFile defines the output filename as a variable (it will be used at the end of the script).
$exportFile = "C:\TEMP\BitLockerReport.html"

Next, we will connect to the Azure Resource Manager (AzureRM) – to install the module, PowerShell should be running in elevated mode
Install-Module AzureRM
Import-Module AzureRM.Profile
Login-AzureRmAccount

Next, we are going to prepare the Rest API headers used to gather information. It basically consists of retrieving a RefreshToken, which is then used to create an Access Token. This token will grant access to resources (Authorization header).
$context = Get-AzureRmContext
$tenantId = $context.Tenant.Id
$refreshToken = @($context.TokenCache.ReadItems() | Where-Object {$_.tenantId -eq $tenantId -and $_.ExpiresOn -gt (Get-Date)})[0].RefreshToken
$body = "grant_type=refresh_token&refresh_token=$($refreshToken)&resource=74658136-14ec-4630-ad9b-26e160ff0fc6"
$apiToken = Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded'
$header = @{
'Authorization' = 'Bearer ' + $apiToken.access_token
'X-Requested-With' = 'XMLHttpRequest'
'x-ms-client-request-id' = [guid]::NewGuid()
'x-ms-correlation-id' = [guid]::NewGuid()
}

Next, we connect to AzureAD and query all AzureAD device with Windows operating system (Bitlocker applies only on Windows, so other devices are irrelevant):
Connect-AzureAD
$AzureADDevices = Get-AzureADDevice -all $true | ? {$_.deviceostype -eq "Windows"}

The following lines are basically the essence of the script: for each device it queries the old Azure endpoint for information (I’m sure that GraphAPI can be used as well)
$deviceRecords = @()
$deviceRecords = foreach ($device in $AzureADDevices) {
$url = "https://main.iam.ad.ext.azure.com/api/Device/$($device.objectId)"
$deviceRecord = Invoke-RestMethod -Uri $url -Headers $header -Method Get
$deviceRecord
}

And that was the only part which required the Access Token. NOTE: if you’re testing some parts of the script, make sure to run the previous block (starting with Get-AzureRMContext) as access tokens expire after 1 hour and you may experience errors.
Now, we filter out the devices that have Bitlocker information:
$Devices_BitlockerKey = $deviceRecords.Where({$_.BitlockerKey.count -ge 1})

The next block is basically creating a custom object to store the neccessary information as of my needs. The report should contain the following information on each line:
– Device DisplayName
– Drive Type
– Recovery Key ID
– Recovery Key
Because a device can have multiple key ids (OS disk, data disk, etc.), I need to loop through each device’s each BitlockerKey property, so that information can be printed to a simple table (no multivalued cells):
$obj_report_Bitlocker = foreach ($device in $Devices_BitlockerKey){
foreach ($BLKey in $device.BitlockerKey){
[pscustomobject]@{
DisplayName = $device.DisplayName
driveType = $BLKey.drivetype
keyID = $BLKey.keyIdentifier
recoveryKey = $BLKey.recoveryKey
}
}
}

Now that information is ready to be exported, we add the HTML tags to the information and export it:

$body = $null

$body += "<p><b>AzureAD Bitlocker key report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Device</th>
    <th>DriveType</th>
    <th>KeyID</th>
    <th>RecoveryKey</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report_Bitlocker){
"<tr><td>" + $obj.DisplayName + " </td>"
    "<td>" + $obj.DriveType + " </td>"
    "<td>" + $obj.KeyID + " </td>"
"<td>" + $obj.RecoveryKey + "</td></tr>"
}
  
$body += "</table>"

$body > $exportFile

And that’s it 🙂

Deploy Teams with custom settings (automatic login, run in background, etc.)

This blogpost is about deploying Teams with custom settings, like automatic startup, automatic login, open in background and so on. To make auto-logon work, AzureAD join is a prerequisite.

TL;DR
– Make sure AzureAD seamless SSO is set up (link)
– Download Teams installers from here (link)
– Create a folder in NETLOGON (or other share that can be accessed by users) and place the installers there
– Deploy the script below as user logon script, correct paths in the first two lines
+1: Use Microsoft’s script (link) as startup script to create firewall exceptions

$Teams_x64_installer = "\\domain.com\Netlogon\Teams\x64\Teams_windows_x64.exe"
$Teams_x86_installer = "\\domain.com\Netlogon\Teams\x86\Teams_windows.exe"
#OSCheck
if ((gwmi -query "select Caption from Win32_OperatingSystem").Caption -notmatch "Windows 10"){exit}

#Check office version
        $bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\14.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness
        if($bitness -eq $null) {
        $bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\15.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
        if($bitness -eq $null) {
        $bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\16.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
        if($bitness -eq $null) {
        $bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\14.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
        if($bitness -eq $null) {
        $bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\15.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
        if($bitness -eq $null) {
        $bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\16.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
#if no Office found, set bitness to match OS architecture
if ($bitness -eq $null) {
$bitness = "x"+(gwmi -query "select osarchitecture from win32_operatingsystem").osarchitecture.substring(0,2)
}

#select installer
switch ($bitness){
    "x64"{$EXE_teamsInstall = $Teams_x64_installer}
    "x86"{$EXE_teamsInstall = $Teams_x86_installer}
}

#Check if Teams is already present, install if needed 
    If (Test-Path "$env:USERPROFILE\Appdata\Local\Microsoft\Teams\Update.exe"){}else{
        #Install Teams
        $Command_teamsInstall = "$EXE_teamsInstall -s"
        Invoke-Expression $Command_teamsInstall
        
	#Wait for install process to finish
        while (Get-Process | ? {$_.Path -eq "$EXE_teamsInstall"}){
        Start-Sleep 1}
    }

$desktopConfig = "$env:appdata\Microsoft\Teams\desktop-config.json"

##If it was not previously backed up, then back it up and modify configuration
if (Test-Path "$desktopConfig.original"){}else{
 #Create backup
 Copy-Item $desktopConfig -Destination "$desktopConfig.original"
 
 #Import JSON config
 $JSON_desktopConfig = Get-Content $desktopConfig -Raw | ConvertFrom-Json
 
 #Set JSON config 
  $JSON_desktopConfig.isAppFirstRun = $false
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isForeground" -Value $false -Force
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isMaximized" -Value $false -Force
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "teamsUrlProtocolsRegistered" -Value $true -Force
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "lyncUrlProtocolsRegistered" -Value $true -Force
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "appPreferenceSettings" -Value "" -Force
  $JSON_desktopConfig.appPreferenceSettings = [pscustomobject]@{
        openAsHidden = $true
        openAtLogin = $true
        runningOnClose = $true
        }
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isLoggedOut" -Value $false -Force
  $JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "pastModernAuthSucceeded" -Value $true -Force

  #Export JSON config
  $JSON_desktopConfig | ConvertTo-Json -Depth 20 | Set-Content $desktopConfig

}

#If it is not set to run automatically, then change this behavior
    if (Get-ItemProperty HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\ -Name com.squirrel.Teams.Teams -ErrorAction SilentlyContinue){}else{
    #Create startup registry setting
    New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\" -name "com.squirrel.Teams.Teams" -PropertyType String  -Value ("$env:USERPROFILE" + '\Appdata\Local\Microsoft\Teams\Update.exe --processStart "Teams.exe" --process-start-args "--system-initiated"')
}

Explained

To enable automatic login, AzureAD seamless SSO is needed. It’s very easy to set up, just follow Microsoft’s guide (link)

Downloading Teams EXE installers is also very straightforward:

https://teams.microsoft.com/uswe-01/downloads#allDevicesSection

Next step is to place these installers to a shared folder where users have at least READ permission. I prefer to use the domains NETLOGON folder for this:

Next, prepare the deployment script. This one consists of the following:

First, the paths of the exe files are defined in separate variables:
$Teams_x64_installer = "\domain.com\Netlogon\Teams\x64\Teams_windows_x64.exe"
$Teams_x86_installer = "\domain.com\Netlogon\Teams\x86\Teams_windows.exe"

Then, there is an OS check (it will install only on Windows 10):
if ((gwmi -query "select Caption from Win32_OperatingSystem").Caption -notmatch "Windows 10"){exit}

The following lines are looking for Office (Outlook exactly) to determine the bitness – so that the script will install the same version of Teams:
$bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\14.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness
if($bitness -eq $null) {
$bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\15.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
if($bitness -eq $null) {
$bitness = (get-itemproperty HKLM:\Software\Microsoft\Office\16.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
if($bitness -eq $null) {
$bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\14.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
if($bitness -eq $null) {
$bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\15.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}
if($bitness -eq $null) {
$bitness = (get-itemproperty HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\16.0\Outlook -name Bitness -ErrorAction SilentlyContinue).Bitness}

If Office is not found, the matching OS architecture will be used:
if ($bitness -eq $null) {$bitness = "x"+(gwmi -query "select osarchitecture from win32_operatingsystem").osarchitecture.substring(0,2)}

Based on the determined architecture, $EXE_teamsInstall variable is defined:
switch ($bitness){
"x64"{$EXE_teamsInstall = $Teams_x64_installer}
"x86"{$EXE_teamsInstall = $Teams_x86_installer}
}

Then the script is checking if Teams is already installed. If not, the installer invokes the installation command and waits for the process to finish:
If (Test-Path "$env:USERPROFILE\Appdata\Local\Microsoft\Teams\Update.exe"){}else{
#Install Teams
$Command_teamsInstall = "$EXE_teamsInstall -s"
Invoke-Expression $Command_teamsInstall
#Wait for install process to finish
while (Get-Process | ? {$_.Path -eq "$EXE_teamsInstall"}){
Start-Sleep 1}
}

The following step is to configure the Teams client. Teams stores its configuration data in a JSON file, this is what we have to manipulate. You can find an awesome walkthrough by Dr Scripto (link). My version works as follows:

First, the configuration JSON file is defined in $desktopConfig variable:
$desktopConfig = "$env:appdata\Microsoft\Teams\desktop-config.json"

Then the script checks if there is a backup file (desktop-config.json.original).
if (Test-Path "$desktopConfig.original"){}else{

If it finds one, it assumes that the script already ran. If there is no backup file, it creates a backup:
Copy-Item $desktopConfig -Destination "$desktopConfig.original"

Then comes the JSON manipulation:
– import the config to a variable:
$JSON_desktopConfig = Get-Content $desktopConfig -Raw | ConvertFrom-Json
– make the following changes: disable first run wizard, open in backup, keep the app running on close, omit first time login screen:
$JSON_desktopConfig.isAppFirstRun = $false
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isForeground" -Value $false -Force
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isMaximized" -Value $false -Force
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "teamsUrlProtocolsRegistered" -Value $true -Force
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "lyncUrlProtocolsRegistered" -Value $true -Force
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "appPreferenceSettings" -Value "" -Force
$JSON_desktopConfig.appPreferenceSettings = [pscustomobject]@{
openAsHidden = $true
openAtLogin = $true
runningOnClose = $true
}
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "isLoggedOut" -Value $false -Force
$JSON_desktopConfig | Add-Member -MemberType NoteProperty -Name "pastModernAuthSucceeded" -Value $true -Force

-the changes then get exported to the configuration file:
$JSON_desktopConfig | ConvertTo-Json -Depth 20 | Set-Content $desktopConfig

The last step is to make the app run automatically by creating a registry key (if it does not exist):
if (Get-ItemProperty HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\ -Name com.squirrel.Teams.Teams -ErrorAction SilentlyContinue){}else{
#Create startup registry setting
New-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\" -name "com.squirrel.Teams.Teams" -PropertyType String -Value ("$env:USERPROFILE" + '\Appdata\Local\Microsoft\Teams\Update.exe --processStart "Teams.exe" --process-start-args "--system-initiated"')
}

Now that the script is ready, it is time to deploy the script as a user logon script:

Make sure the user has Teams licence too 🙂

Auditing Azure and Office365 administrator activity using PowerShell

Tracking changes made by administrators in Azure and/or Office365 can be difficult and I’m sure that there are a lot of alternatives to query this information – but I wanted to share this basic script, even though several parts of it may need improvement.

TL;DR:
1. Make sure you have ExchangeOnlineManagement PS module installed
2. Check the values of the variables, make changes if needed
3. Run the script, use an AAD admin account when connecting to ExchangeOnline
4. Results will be exported to $outputDirectory in HTML format (C:\Office365_AuditExport by default)

The script itself:

$startDate = (get-date).AddDays(-30)
$endDate = (Get-date)
$outputDirectory = "C:\Office365_AuditExport\"
$str_date = Get-Date -Format yyyyMMdd_HHmm

<## Gather information ##>
<# --Create PowerShell Session-- #>
Connect-ExchangeOnline

<#-- Get administrative users -- #>
$administrators = Get-RoleGroup | % {Get-RoleGroupMember $_.Name | ? {$_.RecipientType -ne "group"} } | % {$_.windowsLiveId} | select -Unique
$userIds = $administrators -join ","

<#-- Set RecordTypes
https://docs.microsoft.com/en-us/microsoft-365/compliance/detailed-properties-in-the-office-365-audit-log?view=o365-worldwide
#>

$recordtypes = "ExchangeAdmin","MicrosoftTeams","MicrosoftTeamsAdmin","SecurityComplianceCenterEOPCmdlet","SharePoint","SharePointSharingOperation","SkypeForBusinessCmdlets"

<#-- Load audit events --#>
$auditevents = foreach ($recordtype in $recordtypes){
    Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType $recordtype
    Clear-Variable recordtype
    }

<# Load AzureAD Admin events (recordtype = 8) --#>
$azureAD_adminAuditEvents = Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType 8

<#-- Create report object --#>
$obj_report =  $auditevents.auditdata | ConvertFrom-Json | ? {($_.usertype -eq 2) -and ($_.operation -notmatch "Get-")} | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,@{Label='parameters';Expression={($_.parameters | % {" -" + $_.Name + " " + $_.value}) -join ""} }

<#-- Add AzureAD admin records --#>
$obj_AzureADreport = $azureAD_adminAuditEvents.auditdata | ConvertFrom-Json | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,id

<#-- Create HTML report --#>
$body = $null

$body += "<p><b>Admin Activity report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Paramaters</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Parameters + "</td></tr>"
}
  
$body += "</table>"
$body += "<p><b>Admin Activity report (AzureAD)</b></p>"


$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Id</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_AzureADreport){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Id + "</td></tr>"
}
  
$body += "</table>"

#Export
if (!(Test-Path $outputDirectory)){mkdir $outputDirectory}
$body > $outputDirectory\O365Audit_$str_date.html

Explained

The first four lines contains the values of the variables used later. The time range queried is the last 30 days ($startDate, $endDate) and the report will be exported to C:\Office365_AuditExport folder ($outputDirectory). The last variable will be used as a suffix for the filename:
$startDate = (get-date).AddDays(-30)
$endDate = (Get-date)
$outputDirectory = "C:\Office365_AuditExport\"
$str_date = Get-Date -Format yyyyMMdd_HHmm

Then we connect to Exchange Online:
Connect-ExchangeOnline

The next step is to query the roles in Exchange and store the members of these roles in a variable – if the member is not a group:
$administrators = Get-RoleGroup | % {Get-RoleGroupMember $_.Name | ? {$_.RecipientType -ne "group"} } | % {$_.windowsLiveId} | select -Unique

These userIDs can be joined to a string, separated with a comma, because the Search-UnifiedAuditLog cmdlet accepts it in this form:
$userIds = $administrators -join ","

Next, we set the record types we want to query. Possible values can be found here (link):
$recordtypes = "ExchangeAdmin","MicrosoftTeams","MicrosoftTeamsAdmin","SecurityComplianceCenterEOPCmdlet","SharePoint","SharePointSharingOperation","SkypeForBusinessCmdlets"

Now we are ready to load the audit logs. The maximum result size is limited to 5000 records for a search session by default, you can overcome this issue with several methods (example):
$auditevents = foreach ($recordtype in $recordtypes){
Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType $recordtype
Clear-Variable recordtype
}

Azure Active Directory events use a different schema so they are loaded in a separate variable:
$azureAD_adminAuditEvents = Search-UnifiedAuditLog -StartDate $startDate -EndDate $enddate -UserIds $userIds -ResultSize 5000 -RecordType 8

Now that the informations are loaded, it’s time to convert it and filter out unnecessary events (like commands with Get verb):
$obj_report = $auditevents.auditdata | ConvertFrom-Json | ? {($_.usertype -eq 2) -and ($_.operation -notmatch "Get-")} | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,@{Label='parameters';Expression={($_.parameters | % {" -" + $_.Name + " " + $_.value}) -join ""} }

The same applies to the AzureAD events:
$obj_AzureADreport = $azureAD_adminAuditEvents.auditdata | ConvertFrom-Json | select workload,userid,@{Label='TimeStamp';Expression={[datetime]$_.creationtime}},operation,id

The event data loaded in $obj_report and $obj_AzureADreport is then inserted to HTML code:

$body = $null

$body += "<p><b>Admin Activity report</b></p>"
$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Paramaters</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_report){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Parameters + "</td></tr>"
}
  
$body += "</table>"
$body += "<p><b>Admin Activity report (AzureAD)</b></p>"


$body += @"
<table style=width:100% border="1">
  <tr>
    <th>Workload</th>
    <th>UserID</th>
    <th>TimeStamp</th>
    <th>Operation</th>
    <th>Id</th>
  </tr>
"@

$body +=  foreach ($obj in $obj_AzureADreport){
"<tr><td>" + $obj.Workload + " </td>"
    "<td>" + $obj.UserID + " </td>"
    "<td>" + $obj.TimeStamp + " </td>"
    "<td>" + $obj.Operation + " </td>"
"<td>" + $obj.Id + "</td></tr>"
}
  
$body += "</table>"

The last step is to export the HTML report to the previously defined directory:
if (!(Test-Path $outputDirectory)){mkdir $outputDirectory}
$body > $outputDirectory\O365Audit_$str_date.html

The result should look like this:

The script can be improved in several ways, but it can be a good start or at least serve as inspiration. Probably I will share an improved version later…

Deploy AlwaysOn VPN profiles as SCCM Application

The official Microsoft documentation (link) recommends deploying the connection scripts as packages, but I thought that it would be fancy to deploy them as applications – because the application model is more powerful and offers several features that can’t be achieved with packages.

TL;DR:
Device Tunnel (details)
1. Create the application which runs the configuration script: powershell.exe -ExecutionPolicy Bypass -File ".\DeviceTunnel_installer.ps1"
2. Detection method: PowerShell script,
Get-VPNConnection -AllUserConnection "DEVICE TUNNEL" -ErrorAction SilentlyContinue
3. User Experience: Install for system; Whether or not a user is logged on; Minimized
4. Requirement: Only Windows 10
Deploy the application to a device collection.

User Tunnel (details)
1. Create the application which runs the configuration script: powershell.exe -ExecutionPolicy Bypass -File ".\UserTunnel_installer.ps1"
2. Detection method: PowerShell script,
Get-VPNConnection "USER TUNNEL" -ErrorAction SilentlyContinue
3. User Experience: Install for system; Only when a user is logged on; Minimized
4. Requirement: Only Windows 10
Deploy the application to a user collection.
Notice that the user experience is set to “Install for system”, but the application is deployed to a User collection. Based on my testing, it causes the program to run in SYSTEM context (admin rights!), but the detection method runs in the current user context (which is good in our case – and bad if you were trying to write a detection script which requires admin rights…).

Cheat: Scoped User Tunnel deployment (eg. notebooks only) (details)
If you want to limit the deployment of the User Tunnel to only selected devices, here is a workaround:
1. Create another application which creates a registry key, eg.:
HKEY_LOCAL_MACHINE\Software\AOVPN\UserTunnelDeploymentEnabled (DWORD) = 1
2. Configure this application as a dependency for the User Tunnel application with automatic install disabled
3. Deploy this application to those devices on which you want to enable the User Tunnel deployment

Extended edition

Device Tunnel
In the following steps I’m creating an application which configures an AlwaysOn VPN device tunnel, named F12_AOVPNDevice.

For scripts, information should be manually specified
Just the usual general informations
The deployment type is Script Installer
Give a name to the deployment type too

On the Content page, browse for the folder where the script can be found. The following installation program is specified in my case:

powershell -ExecutionPolicy Bypass -File ".\F12_AOVPN_D1.ps1"

The application detection method will be a PowerShell script, with the following content:
Get-VPNConnection -AllUserConnection "F12_AOVPNDevice" -ErrorAction SilentlyContinue

On the User Experience tab, configure the settings as follows:

On the Requirements page, one requirement will be specified: only Windows 10 (32/64 bits) operating systems are required:

The process is straightforward from this point. After creating the application, deploy it to the device collection. For testing purposes, I deployed it as Available:

After successful installation, the VPN connection can be verified several ways – I chose PowerShell:

User Tunnel

Deploying the user tunnel is almost the same, although there are some aspects to consider:
– If the application was successfully installed for a user on the computer, it may take some time for another user on the same machine (The next Application Deployment Evaluation cycle + User Policy Evaluation Cycle). Microsoft’s approach (deploy the package once for every user who logs on) is better in other ways: the profile will be installed immediately for users logging on for the first time; there is no need to evaluate user policies; etc.
– Deploying the application to a User Collection means that users will receive the VPN profile anywhere they log on – even on their PCs that doesn’t need it (cheat below).
– The detection method is only looking for a VPN connection with a particular name – make sure that this name is unique, or fine tune the detection method to check the server address too…

So let’s start:

Everything almost the same so far. I forgot to take screenshot of the Content page, but you can see my settings on the Summary screenshot below.
On the Detection Method page, the PowerShell script is a bit different:
Get-VPNConnection "F12_AOVPN_User" -ErrorAction SilentlyContinue
There is no -AllUserConnection parameter, because user tunnels are user specific.

On User Experience page, choose “Install for system” as installation behavior, so the script will run in SYSTEM context. The logon requirement should be set to “Only when a user is logged on”. I’m not sure if RDP sessions (or Hyper-V enhanced sessions) satisfy this criteria, but keep in mind that console sessions are required.

Now everything is ready, the application can be deployed to a user collection.

Cheat: Scoped User Tunnel deployment
If you want to restrict the user tunnel deployment to certain computers, there can be some standard ways (SCCM Global Conditions?) or uncommon solutions like this.

Before moving to this solution, consider the official Microsoft recommended deployment method (package for every user who logs on) as this can be deployed to device collections too. The only disadvantage is that anybody logging on the computer will receive this setting – but keep in mind that User Tunnel connection request are handled by NPS – so even if a not intended user receives the VPN settings, the connection won’t establish.

So the idea behind this workaround is to create a registry key on the target system to determine if User Tunnels can be deployed. The registry configuration is a separate SCCM application which can be used as a dependency for the User Tunnel application… not too elegant, but it works.

Create a separate application (Enable User Tunnel) with the following deployment type:

On Content page speficy the source directory and the installation program
powershell.exe -executionpolicy bypass -File ".\Enable_UserTunnel_Deployment.ps1"

Enable_UserTunnel_Deployment.ps1 content:
$key = 'registry::HKEY_LOCAL_MACHINE\SOFTWARE\F12_AOVPN'
$property = "UserTunnelDeploymentEnabled"
$value = 1
if (!(Test-Path $key)){New-Item -Path $key >> $null}
New-ItemProperty -Path $key -Name $property -PropertyType DWORD -Value $value -Force >> $null

The Detection method will consist of checking the registry setting:

After creating the application, modify the User Tunnel application so it will have this deployment type as a dependency. Navigate to the Dependencies tab of the deployment type and click on “Add..” and select this new application as dependency:

Make sure you unchecked the Auto Install box.

The next step is to deploy “Enable User Tunnel Deployment” application to those devices on which you want to enable user tunnel deployment. On those devices where this application is not installed, Software Center will not install the user tunnel with the following status: “This software is not applicable to your device”

Now I install the application on the computer:

After some time you can retry the installation of the user tunnel:

Restore deleted private Android application in Intune

This blogpost will describe the steps to restore a deleted Android private application in Intune.

TL;DR:
1. Login to https://play.google.com/work
2. In Admin Settings, find your Organization ID and search by this ID on top of the page
3. Click on the desired application and Approve it
4. Intune – Apps – All apps – Add – Managed Google Play app – Select
(Alternatively: Tenant administration – Connectors and tokens – Managed Google Play)
5. Click Sync, wait a minute then refresh the Apps page

Extended edition:

For demo purposes I “accidentally” delete my private Android app “F12HU Hello”

As you can see, Intune will tell you that it will programatically un-approve the application from managed Google Play

Don’t try to upload the APK again, because you can’t upload an application with the same package name (unique identifier of the application)

You have to Approve your application again from the Google play console. Go to https://play.google.com/work and log in with your Play account, then navigate to Admin Settings where you can find your Organization ID

Copy this ID in the search bar on top of the page and search for applications, you will see your private apps

Click on the app and select Approve, select your settings and you are done

Now, if you go back to Intune navigate to Tenant administration – Connectors and tokens – Managed Google Play and click on Sync

The application will now appear on the Apps page

adminconsent AlwaysOn VPN Android AOVPN APK Azure AzureAD AzureAD App Proxy AzureRM Bitlocker conditional access Exchange Exchange Online Get-AzureADMSConditionalAccessPolicy GPO Intune managed Google Play Nextcloud Office365 PowerShell Report SCCM Teams