Documenting with PowerShell: Documenting mobile devices

This was a request by a friend in one of the peer groups I operate in. He wanted a method to document which mobile devices exist in a O365 tenant and put them into IT-Glue as configurations. I figured I’d help him real quick and blog about it.

The IT-Glue version creates configurations of each asset in O365 and documents the serial number etc. The HTML version is a simple overview of which devices exist. This should help in localizing which mobile devices are completely managed, or only partially too.

IT-Glue version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$APIKEy = "LangeITGlueAPIKeyHier"
$APIEndpoint = "https://api.eu.itglue.com"
######### Secrets #########
$CustomerTenant = "Client.onmicrosoft.com"
$ITGlueORGID = "123456"
########################## Script Settings  ############################

If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

$Configurationtype = (Get-ITGlueConfigurationTypes).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a configuration type"

$Configurationstatus = (Get-ITGlueConfigurationStatuses).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a status type"

$manafacture = (Get-ITGlueManufacturers).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a manufacturer"


foreach ($device in $devicesList) {
    $Existing = Get-ITGlueConfigurations -filter_serial_number $device.deviceId
    if ($Existing) { write-host "$($device.displayname) exists, skipping."; continue }
    $dataobj = @{
        type       = "configurations"
        attributes = @{
            "organization-id"         = $ITGlueOrgID
            "name"                    = $device.displayName
            "configuration-type-id"   = $configurationtype.id
            "configuration-status-id" = $Configurationstatus.id
            "manufacturer-id"         = $manafacture.id
            "serial-number"           = $device.deviceId
        }
    }
    New-ITGlueConfigurations -organization_id $ITGlueORGID -data $dataobj
    $dataobj = $null
}


So this version requires a little manual work – You’ll need to select the device type, and the manufacturer once. I often just select the bulk and fix the few manual ones later on.

HTML version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$CustomerTenant = "ClientDomain.onmicrosoft.com"
########################## Script Settings  ############################


$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

New-HTML {   
    New-HTMLTab -Name 'Mobile Devices' {

        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Android Devices' {
                New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "Android" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId)
            }
                New-HTMLSection -HeaderText "Apple Devices" {
                    New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "iOS" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId) 
                }
            }
        }
     
    } -FilePath "C:\temp\doc.html" -Online -ShowHTML

And as requested so many times before; the eventual output looks like this:

So that’s it for today! as always, Happy PowerShelling.

Documenting with PowerShell: Hyper-v and physical server settings

So a while back I helped people documenting their physical servers, the biggest complaint about that blog was that “at a glance” you couldn’t really see what the server did, if it was a cluster member or not, and how the physical layout of the server was.

For this, I decided to rewrite that blog just a little – Especially now that my friend Gavin Stone (gavsto.com) made a pretty cool PowerShell function to get ‘cards’ into IT-Glue. To show you exactly what I mean, I think a screenshot works best.

IT-Glue Cards example

So having these cards opens op wonderful ways to display data in IT-Glue that isn’t just a boring table. I’ve grown to call these “glance cards” internally, but, lets get to the entire script!

IT-Glue version

So the IT-Glue version does the same as always; it creates a Flexible Asset for you, and uploads the data to IT-Glue. The script differs slightly from the earlier Hyper-v script; it also collects the current RAID and physical disk status, as that’s always useful information to have. I’ve only customized this for Dell servers, but it should be straightforward to change that to HP, or others.

########################## IT-Glue ############################
$OrgID = "ORGANIZATIONIDHERE"
$APIKEy = "ITGlueAPIKeyHere"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Physical Host - Autodoc v2"
$Description = "A network one-page document that displays the physical host settings, hyper-v virtual machines, etc."
$logoURL = 'https://google.com/coollogo.png'
#some layout options, change if you want colours to be different or do not like the whitespace.
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################
#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Host name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "At a glance"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Virtual Machines"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Network Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Replication Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Host Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Physical Host Configuration"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

function New-BootstrapSinglePanel {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [ValidateSet('active', 'success', 'info', 'warning', 'danger', 'blank')]
        [string]$PanelShading,
    
        [Parameter(Mandatory)]
        [string]$PanelTitle,
    
        [Parameter(Mandatory)]
        [string]$PanelContent,
    
        [switch]$ContentAsBadge,
    
        [string]$PanelAdditionalDetail,
    
        [Parameter(Mandatory)]
        [int]$PanelSize = 3
    )
    
    if ($PanelShading -ne 'Blank') {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel panel-$PanelShading`">"
    }
    else {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel`">"
    }
    
    $PanelTitle = "<div class=`"panel-heading`"><h3 class=`"panel-title text-center`">$PanelTitle</h3></div>"
    
    
    if ($PSBoundParameters.ContainsKey('ContentAsBadge')) {
        $PanelContent = "<div class=`"panel-body text-center`"><h4><span class=`"label label-$PanelShading`">$PanelContent</span></h4>$PanelAdditionalDetail</div>"
    }
    else {
        $PanelContent = "<div class=`"panel-body text-center`"><h4>$PanelContent</h4>$PanelAdditionalDetail</div>"
    }
    $PanelEnd = "</div></div>"
    $FinalPanelHTML = "{0}{1}{2}{3}" -f $PanelStart, $PanelTitle, $PanelContent, $PanelEnd
    return $FinalPanelHTML
} 

function New-AtAGlancecard {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [boolean]$Enabled,
    
        [Parameter(Mandatory)]
        [string]$PanelContent
    )
    if ($enabled) {
        New-BootstrapSinglePanel -PanelShading "success" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3
    }
    else {
        New-BootstrapSinglePanel -PanelShading "danger" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3

    }

}

write-host "Start documentation process." -foregroundColor green


$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount | ConvertTo-Html -Fragment | Out-String
$VirtualMachines = $TableHeader + ($VirtualMachines -replace $TableStyling) + $Whitespace
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS | convertto-html -Fragment -PreContent "<h3>Network Switches</h3>" | Out-String
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } } | ConvertTo-Html -Fragment -PreContent "<br><h3>VM Network Settings</h3>" | Out-String
$NetworkSettings = $TableHeader + ($NetworkSwitches -replace $TableStyling) + ($VMNetworkSettings -replace $TableStyling) + $Whitespace
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType | convertto-html -Fragment | Out-String
$ReplicationSettings = $TableHeader + ($ReplicationSettings -replace $TableStyling) + $Whitespace
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled | convertto-html -Fragment -as List | Out-String

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}
$ATaGlanceHTML = foreach ($Hash in $AtAGlanceHash.GetEnumerator()) {
    New-AtAGlancecard -Enabled $hash.value -PanelContent $hash.name
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {

    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media | convertto-html -Fragment)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object {$_.'> ID' -in $DiskNumbers} |  convertto-html -Fragment)
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}



$FlexAssetBody =
@{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'host-name'                   = $env:COMPUTERNAME
            'at-a-glance'                 = ($ATaGlanceHTML | out-string)
            'virtual-machines'            = $VirtualMachines
            'network-settings'            = $NetworkSettings
            'replication-settings'        = $ReplicationSettings
            'host-settings'               = $HostSettings
            'physical-host-configuration' = $PhysicalConfig
        }
    }
}
 

write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $OrgID).data | Where-Object { $_.attributes.traits.'host-name' -eq $ENV:computername } | Select-Object -last 1
#If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
if (!$ExistingFlexAsset) {
    $FlexAssetBody.attributes.add('organization-id', $OrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Hyper-v into IT-Glue organisation $OrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Hyper-v into IT-Glue organisation $OrgID"  -ForegroundColor Green
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

HTML Version

Of course there are people without IT-Glue and for them I have the HTML version right here. We’re using PSWriteHTML to get a nice looking overview. This doesn’t include the glance cards right now.


#Grabbing PsWriteHTML module and documenting
write-host "Start documentation process." -foregroundColor green
install-module PsWriteHtml -force

$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS 
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } }
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType 
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {
    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object { $_.'> ID' -in $DiskNumbers })
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}


New-HTML {
   
    New-HTMLTab -Name 'Hyper-V Settings' {
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Virtual Machines' {
                New-HTMLTable -DataTable $VirtualMachines
            }
            New-HTMLSection -HeaderText "Virtual Network Settings" {
                New-HTMLTable -DataTable $VMNetworkSettings
            }
        }
    
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Replication Settings' {
                New-HTMLTable -DataTable $ReplicationSettings
            }

            New-HTMLSection -HeaderText "Host Settings" {
                New-HTMLTable -DataTable $HostSettings
            }
        }
        New-HTMLSection -HeaderText "Physical Settings" {
            New-HTMLTable -DataTable $PhysicalConfig
        }
    }
    
} -FilePath "C:\temp\doc.html" -Online

And this script gives you this pretty result:

And that’s it! as always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring network traffic

My holidays are over, and it’s back to blogging! I hope you all enjoyed the previous webinar I did in my holidays. For me it was a lot of fun and I’m doing a more advanced one soon, when I find some time. for now I’m going to be quite busy with other speaking engagements such as a couple of Solarwinds Events, and Huntress Hack_It!

In any case, let’s get back to our regular scheduled program: Monitoring and documentation scripts! 🙂 This time we’re tackling three issues in one; We’re going to monitor traffic usage to see if a connection isn’t saturated. We’re also going to check if the NIC speed is correct and we’re going to check if the connection is metered and if it is alert on it.

The use case for this can be pretty diverse; the connection saturation can help you find if the machine isn’t flooding the network or internet connection. The connection speed of course speaks for itself; these days everything should be full duplex gigabit, and it helps in finding old devices or devices looped through a voip phone.

The metered connection one is just a safety measure – Sometimes you’re remotely working on a machine that’s metered and you download an ISO, or a large update and the client won’t be too happy… 🙂

Monitoring bandwidth usage

$BandwidthAlertThreshold = "800" #megabits per second

$Counter = 0
$UsedBandwidth = do {
    $counter ++
    (Get-CimInstance -Query "Select BytesTotalPersec from Win32_PerfFormattedData_Tcpip_NetworkInterface" | Select-Object BytesTotalPerSec).BytesTotalPerSec / 1Mb * 8
} while ($counter -le 10)

$AvgBandwidth = [math]::round(($UsedBandwidth | Measure-Object -Average).average, 2)
$BandwidthAlert = if ($AvgBandwidth -gt $BandwidthAlertThreshold) { "Unhealthy - Bandwidth is at $AvgBandwidth" } else { "Healthy" }

This creates a poll of 10 intances, which is a little more than 5 seconds. It’ll show how much bandwidth the current machine is using. If the threshold is passed than it alerts that a unhealthy state has been reached. This is also great for localizing which machine is using most bandwidth on a network.

Monitoring link speeds


$Linkspeeds = (Get-NetAdapter -Physical | Where-Object { $_.MediaType -eq "802.3" -and $_.status -ne "Disconnected" })

$LinkspeedState = foreach ($Linkspeed in $Linkspeeds) {
    if ($Linkspeed.speed -lt 1000000000) { "$($Linkspeed.name) linkspeed is lower than 1000mb" }
}
if (!$Linkspeeds) { $LinkspeedState = "No physical links found" }
if (!$LinkspeedState) { $LinkspeedState = "Healthy" }

So this alerts on any machine that is connected to any port that is not 1gbps. In these days, I’m pretty sure you want everything gigabit connected. 🙂

Monitoring Metered Connections

[void][Windows.Networking.Connectivity.NetworkInformation, Windows, ContentType = WindowsRuntime]
$MeteredConnections = [Windows.Networking.Connectivity.NetworkInformation]::GetInternetConnectionProfile().GetConnectionCost() | Where-Object {$_.Networkcosttype -ne "Unrestricted"}

$RunningOnMetered = if($MeteredConnections){
    "Unhealthy - Currently running on a metered connection."
    $MeteredConnections
} else {
    "Healthy - Not running on metered connection"
}

So this is one that’s good to train your engineers to check if you have a lot of mobile users, it gives you some extra info if a user is on the road or not.

And that’s it! As always, Happy PowerShelling!

Powershell for beginners webinar Part 2

Lots of people joined the webinar, It was super cool and hopefully I taught you all something.

The recording can be found here. I’m still sorting through the Q&A but gonna get a bite to eat first, feel free to add questions as the Q&A is still open, we have a total of 124 questions right now so It’s gonna take me some time.

The scripts can be found at https://github.com/KelvinTegelaar/Webinars.

I’ll be following this up with an intermediate session on a later date. I hope you guys enjoyed it because I really did! 🙂

Monitoring with PowerShell: Monitoring legacy authentication logons

This is going to be the last blog for a couple of weeks, as I’m going to be enjoying some vacation time. I’ll be seeing you all soon! 🙂

So Microsoft has announced a while back that legacy authentication is no longer going to be supported, due to COVID we had some extra time to prepare our clients for this change as its been postponed to July of 2021. We’ve helped all of our clients move to modern authentication last year but I understood there is still a bit of a struggle for other MSPs to achieve this.

With a P1 subscription retrieving the Legacy Authentication reports is very straightforward, but I’ve created these methods for users without P1 in mind. You have to keep in mind that this method is slightly less accurate than using the P1 sign in report.

There’s two scripts in this case; one to detect if Azure Security Defaults are enabled, which disables Legacy Authentication for you. And another to detect if there are apps being used that might use legacy authentication. You’ll still have to do some manual investigation and move users over to newer apps, but it should help you tackle modern auth in no time. 🙂

Detecting if Azure Security Defaults are enabled

Before running this script, you’ll have to add some permissions to your Secure Application Model, do that by performing this:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Reports” and click on “Policy.read.all”. Click on add permission
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.
######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'Your-TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$SecDefaults = foreach ($Tenant in $Tenants) {
    write-host "Processing $($Tenant.DisplayName)"
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    $Enabled = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method get -ContentType "application/json").IsEnabled
    [PSCustomObject]@{
        TenantName                  = $tenant.DisplayName
        'Security Defaults Enabled' = $enabled
    }
}

$SecDefaults

Detecting Legacy Application Usage

So finding legacy application usage is the biggest step in finding out if users are still using Legacy Authentication, for that use the following script:

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'Your-TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$LegacyAuth = foreach ($Tenant in $Tenants) {
    write-host "Processing tenant $($tenant.displayname)"
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    $VersionReport = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageVersionsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv
    $LegacyClients = if ($versionreport.'Outlook 2007' -or $versionreport.'Outlook 2010' -or $versionreport.'Outlook 2013') {
        $VersionReport
    }
    $AppReports = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageAppsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv

    $LegacyApplications = if ($AppReports.'Other For Mobile' -or $AppReports.'POP3 App' -or $AppReports.'SMTP App' -or $AppReports.'IMAP4 App' -or $AppReports.'Mail For Mac') {
        $AppReports
    }
    $UserDetails = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageUserDetail(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv

    [PSCustomObject]@{
        Tenant        = $tenant.DisplayName
        LegacyClients = $LegacyClients
        LegacyApps    = $LegacyApplications
        UserDetails   = $UserDetails
    }

}

if ($LegacyAuth.LegacyClients -or $LegacyAuth.LegacyApps) {
    write-host "Unhealthy - Clients with legacy authenticaiton or Legacy clients have been detected"
    $LegacyAuth | Where-Object {$_.LegacyClients -ne $null -or $_.LegacyApps -ne $null}
}

And that’s it! It might not be as accurate as the normal P1 report, but it surely helps in capturing Legacy Authentication. As always, Happy PowerShelling!

Monitoring with PowerShell: user experience issues & Unifi EOL Monitoring

This time I’m tackling two blogs in one go again as both are fairly small and straightforward.

Monitoring user Experience

So I’ve been focussed on a lot of documentation, server, and office365 issues lately and I was kind of ‘forgetting’ to also blog about another key experience indicator users have; their own workstation.

Monitoring workstations is becoming more and more important with the flow of users into cloud environments. Just monitoring RAM, CPU, Disk space, etc is no longer really a thing you should focus on. These days you should be looking more into security monitoring and user experience. I’ve blogged about the Windows Experience Index and Diskspeed before as early indicators something might be wrong.

This time we’re going to delve a little deeper into experience monitoring; specifically application crashes and hangs. Windows has an internal monitoring system for this called the “System Reliability Index”. Each time a application hang, or crash occurs this is logged to the index. We can retrieve this information using PowerShell to report on with our RMM.

Windows Reliability script

$ExpectedIndex = "6.0"
$ExpectedTimetoRun = (get-date).AddDays(-1)
$Metrics = Get-CimInstance -ClassName win32_reliabilitystabilitymetrics | Select-Object -First 1
$Records = Get-CimInstance -ClassName win32_reliabilityRecords | Where-Object { $_.TimeGenerated -gt $Metrics.StartMeasurementDate }

$CombinedMetrics = [PSCustomObject]@{
    SystemStabilityIndex = $Metrics.SystemStabilityIndex
    'Start Date'         = $Metrics.StartMeasurementDate
    'End Date'           = $Metrics.EndMeasurementDate
    'Stability Records'  = $Records
}

if ($CombinedMetrics.SystemStabilityIndex -lt $ExpectedIndex) { 
    write-host "The system stability index is higher than expected. This computer might not be performing in an optimal state. The following records have been found:"
    $CombinedMetrics.'Stability Records'
}

if ($CombinedMetrics.'Start Date' -lt $ExpectedTimetoRun) {
    write-host "The system stability index has not been updated since $($CombinedMetrics.'Start Date'). This could indicate an issue with event logging or WMI."
}

So this script actually helps you out in finding where applications are crashing or hanging often. This should help you localize workstations that are performing poorly and are giving a bad user experience. The great thing is that this also monitors those pesky “Outlook is not responding” issues as these are logged too.

Unifi EOL monitoring

So this one was request by a friend that quickly wanted to find all his unsupported devices for replacement. Replace the URL and credentials with your own and you should be good to go. 🙂

##########
#Find EOL devices
$UnifiBaseUri = "https://yoururl:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "appassword"
##############
$UniFiCredentials = @{
    username = $UnifiUser
    password = $UnifiPassword
    remember = $true
} | ConvertTo-Json
 
 
write-host "Logging in to Unifi API." -ForegroundColor Green
try {
    Invoke-RestMethod -Uri "$UnifiBaseUri/login" -Method POST -Body $uniFiCredentials -SessionVariable websession
}
catch {
    write-host "Failed to log in on the Unifi API. Error was: $($_.Exception.Message)" -ForegroundColor Red
}
write-host "Collecting sites from Unifi API." -ForegroundColor Green
try {
    $sites = (Invoke-RestMethod -Uri "$UnifiBaseUri/self/sites" -WebSession $websession).data
}
catch {
    write-host "Failed to collect the sites. Error was: $($_.Exception.Message)" -ForegroundColor Red
}
 
$AllDevices = foreach ($site in $sites) {
    $Devices = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession).data
    $Devices | Select-Object _id, name, ip, mac, model, type, version, unsupported,@{label = 'site'; expression = { $site.desc } }
}

$AllDevices | Where-Object { $_.unsupported -ne $false } | Out-GridView

And that’s it! As always, happy PowerShelling!

Monitoring with PowerShell: Monitoring O365 alerts

So today we’re tackling two blogs in one again, we’re going to be focussing on two different types of alerting policies.

The first getting alerts for M365 or Azure P1/P2 users via the Graph API, these alerts are pretty cool as you can see if users are connecting from tor nodes/VPN nodes, or from stuff like malware linked IPs. Monitoring these alerts actively via your RMM helps you in making sure that users are as safe as they can be.

Unfortunately, I know not everyone has the luxury of a M365, or P1 subscription. To help those users out we’re going get the existing protection alerts for all tenants, and forward them from “TenantsAdmin” to an actual e-mail address managed by you. So, lets get started!

Monitoring Alerts with Graph

This script uses the Secure Application Model, You’ll also need to add some extra permissions to your Secure App. To do that execute the following steps:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Security” and click on “SecurityEvents.Read.All”. Click on add permission.
  • Search for “Security” and click on “and SecurityEvents.ReadWrite.All”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

After you’ve done this, execute the script below using your RMM, from there you’ll be able to see all open alerts. 🙂

######### Secrets #########
$ApplicationId = 'YourAppID'
$ApplicationSecret = 'YourAppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'YourVeryLongRefreshToken'
$SkipList = "Bla.onmicrosoft.com", "Bla2.onmicrosoft.com"
######### Secrets #########


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)


Try {
    $aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'
}
catch {
    write-DRRMAlert "Could not get tokens: $($_.Exception.Message)"
    exit 1
}
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken

$customers = Get-MsolPartnerContract -All | Where-Object { $_.DefaultDomainName -notin $skiplist }

$OpenAlerts = foreach ($customer in $customers) {
    write-host "Getting started for $($Customer.name)" -foregroundcolor green
    try {
        $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
        $Header = @{
            Authorization = "Bearer $($graphToken.AccessToken)"
        }
    }
    catch {
        "Could not connect to tenant $($customer.name). Error:  $($_.Exception.Message)"
        continue
    }
    (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/security/Alerts" -Headers $Header -Method Get -ContentType "application/json").value | Select-Object title,category, description, createddatetime,userstates,status, @{label="Username";expression={$_.userstates.userprincipalname}}

}

if(!$OpenAlerts){
    $OpenAlerts = "Healthy - No open alerts found"
}

$OpenAlerts 

Now lets move on to the e-mail alerts.

Setting Protection Alerts

So the biggest problem with the default protection alerts is that they can only be sent to “Tenant Admins” a group of administrators in the tenant. For Microsoft Partners this group often does not have licensed user.

To tackle this issue we’re going to grab all rules that reference the TenantsAdmins group, we’re going to copy that rule, set the e-mail address to one of our choosing. We’re leaving the original rule intact because those might be used by local administrators or in co-managed environments.

######### Secrets #########
$ApplicationId         = 'ApplicationID'
$ApplicationSecret     = 'AppSecret'
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "YourUPN"
$AlertingEmail = "O365Alerts@YourDomain.com"
######### Secrets #########

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
 
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
 
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    $customerId = $customer.DefaultDomainName
    write-host "Processing $customerId"
    try {
        $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
        $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
        $SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic -erroraction stop -WarningAction SilentlyContinue
        $null = import-pssession $SccSession -disablenamechecking -allowclobber -CommandName "Get-ActivityAlert", "New-ActivityAlert", "Get-ProtectionAlert", "New-protectionalert", "Set-activityAlert", "Set-protectionalert"
    }
    catch {
        Write-Host "Could not get tokens or log in to session for $($customer.DefaultDomainName): $($_.Exception.Message)"
        continue
    }
    
    $ProtectionAlerts = Get-ProtectionAlert | Where-Object { $_.NotifyUser -eq "TenantAdmins" -and $_.disabled -eq $false }
    
    ForEach ($ProtectionAlert in $ProtectionAlerts) {
        $NewName = if ($ProtectionAlert.name.Length -gt 30) { "$($ProtectionAlert.name.substring(0,30)) - $($AlertingEmail)" } else { "$($ProtectionAlert.name) - $($AlertingEmail)" }
        $ExistingRule = Get-ProtectionAlert -id $NewName -ErrorAction "SilentlyContinue"
        if (!$ExistingRule) {
            $splat = @{
                name                = $NewName
                NotifyUser          = $AlertingEmail
                Operation           = $ProtectionAlert.Operation
                NotificationEnabled = $true
                Severity            = $ProtectionAlert.Severity
                Category            = $ProtectionAlert.Category
                Comment             = $ProtectionAlert.Comment
                threattype          = $ProtectionAlert.threattype
                AggregationType     = $ProtectionAlert.AggregationType
                Disabled            = $ProtectionAlert.Disabled
            }
            try {
              $null = New-ProtectionAlert @splat -ErrorAction Stop
            }
            catch {
                write-host "Could not create rule. Most likely no subscription available. Error: $($_.Exception.Message)"
            }
        }
        else {
            write-host "Rule exists, Moving on."
        }
    }

    Remove-PSSession $SccSession
 
}

And that’s it! as always, Happy PowerShelling! 🙂

Monitoring with PowerShell: Monitoring DNS forwarders

One of my friends recently told me that DNS forwarders might be a nice subject to talk about,He had an issue with his forwarders not responding and solved that with PowerShell.

DNS forwarders are often used to decrease the load on a network and not rely on just root hints. Most of the times I suggest to use either the providers DNS servers as the forwarding addresses. There are cases where you’d like to use another server; for example when using DNS filtering.

In any case, you’ll always have to check if the DNS servers that you are forwarding towards are actually resolving. If they won’t you’ll get name resolution issues for your clients and that’s never a good time.

To monitor the DNS forwarders we’ll use two PowerShell commands; One to retrieve the DNS server forwarders and one to test if the DNS servers are actually available and resolving.

The script

So let’s break this script down, because it’s actually quite multi-functional.

$ForwarderAddresses = (get-dnsserverforwarder).ipaddress.ipaddresstostring
$DNSServerTest = foreach ($forwarder in $ForwarderAddresses) {
    [PSCustomObject]@{
        Forwarder = $forwarder
        ForwarderTest = (Test-DNSServer -context forwarder -IPAddress $Forwarder).result
        TCPTest = (Test-NetConnection -ComputerName $forwarder -Port 53).TcpTestSucceeded
        Resolutiontest = if($null -ne (Resolve-DnsName -server $forwarder -name "Google.com" -DnsOnly).ipaddress){ $true } else { $false }
    }
}

By running this script we’re creating an object that tells us which forwarders are currently configured, if the internal Windows DNS server test is able to connect to it, If we are able to connect over TCP, and if DNS resolution worked. We can then use this object to apply different types of monitoring. So let’s combine all of this information into one cool monitoring set.

$ExpectedForwarders = "1.1.1.1", "8.8.8.8", "8.8.4.4","9.9.9.9"

$ForwarderAddresses = (get-dnsserverforwarder).ipaddress.ipaddresstostring
$DNSServerTest = foreach ($forwarder in $ForwarderAddresses) {
    [PSCustomObject]@{
        Forwarder      = $forwarder
        ForwarderTest  = (Test-DNSServer -context forwarder -IPAddress $Forwarder).result
        TCPTest        = (Test-NetConnection -ComputerName $forwarder -Port 53).TcpTestSucceeded
        Resolutiontest = if ($null -ne (Resolve-DnsName -server $forwarder -name "Google.com" -DnsOnly).ipaddress) { $true } else { $false }
    }

}

foreach ($TestedForwarder in $DNSServerTest) {
    if ($TestedForwarder.forwarder -notin $ExpectedForwarders) { write-host "The expected forwarders don't match the configured forwarders: $($TestedForwarder.Forwarder)" }
    if ($TestedForwarder.ForwarderTest -ne "Success") { write-host "$($TestedForwarder.forwarder) has not passed the Forwarder Test" }
    if ($TestedForwarder.TCPTest -ne $true) { write-host "$($TestedForwarder.forwarder) has not passed the TCP Port Test" }
    if ($TestedForwarder.Resolutiontest -ne $true) { write-host "$($TestedForwarder.forwarder) has not passed the Forwarder Test" }
}

And that’s it! as always, Happy PowerShelling!

Automating with PowerShell: Increasing the O365 Secure Score

At the start of this week I’ve blogged about reading the secure score and documenting it. This is of course just one part of the new beta Secure Score module. The next one is actually the more fun part; applying the correct security settings to a tenant.

So first things first; The module is still rough around the edges and in beta. It’s mostly used to demonstrate how you can attack reaching a higher score, and actually helping your clients reach a higher level of security.

The SecureScore is just a baseline of added value items you can apply to each tenant. A description of each item can be found here. Don’t rely on just the secure score for your security needs.

So let’s get to increasing the secure score.

Examples

So imagine you want to apply all ‘low impact’ items, to all tenants. This is stuff like having a breakglass account, not allowing OAuth approvals by users, not having password expire policies and setting up a DLP policy that prevents sending out credit card information. You’ll run:


######### Secrets #########
$ApplicationId         = 'ApplicationID'
$ApplicationSecret     = 'AppSecret'
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "YourUPN"
######### Secrets #########
Install-module SecureScore
set-securescore -AllTenants -ControlName LowImpact -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken -Confirmed

This loops through all tenants, sets up all LowImpact items without confirmation, and poof. 🙂 But now imagine you are using external tooling for something like MFA enrollment; Microsoft doesn’t know and hasn’t given you the points for it, so lets correct that:


######### Secrets #########
$ApplicationId         = 'ApplicationID'
$ApplicationSecret     = 'AppSecret'
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "YourUPN"
######### Secrets #########
Install-Module SecureScore
set-securescore -AllTenants -ControlName MFARegistrationV2 -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken -Confirmed -ExternallyResolved

Using the parameter -ExternallyResolved you won’t apply the actual fix, and instead just tell Microsoft “Hey, we’ve solved this using another product. Please give us the points”. Pretty cool when you are using ADFS with a own MFA product, or just DUO or the likes.

But imagine you’re not sure what an item does, and what effect it has on users. You can run the following command on a single tenant to get a little explanation:

set-securescore -TenantID "Sometenant.onmicrosoft.com" -ControlName AdminMFAV2 -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken

Result:
WARNING: This will enable multi-factor authentication for all admin users, and prompt them at first logon to configure MFA. Do you want to continue?

So, there’s a lot of stuff to play with in this module and I’ll be adding a lot of functionality in the future for other payloads. I hope you guys enjoy it and if you have any issues, let me know! 🙂

And that’s it! as always, Happy PowerShelling!

Documenting with PowerShell: Office 365 Secure Score PowerShell module

A while back I wrote a blog about the Secure Score and how to increase it. After that blog I got a lot more questions about documenting Secure Score with the Secure Application model.

To make handling the Secure Score easier, I’ve decided to make a PowerShell Module for this. The main reason for the module is to ease the complexity of changing the Secure Score settings over a lot of tenants. It’s a lot of small tweaks and settings. The module also tells you exactly what it’s going to do if you do not use the -confirmed switch, so you can check if you really wanna go on or not.

To keep in line with my other blogs, I’ll give some cool examples. First off we’ll document the Secure score both in HTML files and IT-Glue. I’ll blog about the remediation part later in the week, so I don’t overload everyone with information. 🙂

I do have to have a little disclaimer; the module is still a little rough around the edges. It’s a beta and I’m actively working on it. 🙂

To use the module, your Secure Application Model needs some extra permissions:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Security” and click on “SecurityEvents.Read.All”. Click on add permission.
  • Search for “Security” and click on “and SecurityEvents.ReadWrite.All”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

HTML version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret'
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
install-module SecureScore
install-module PsWriteHTML
$Scores = get-securescore -AllTenants -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken

foreach ($Score in $scores) {
    New-HTML {
        New-HTMLSection -HeaderText 'Scores compared'{
            New-HTMLTable -DataTable $Score.scores.averageComparativeScores
        }
        New-HTMLSection -HeaderText "Score: $($score.Scores.currentScore) of $($score.Scores.maxScore)"  {
            New-HTMLTable -DataTable $Score.Scores.controlScores
        }
    } -FilePath "C:\temp\$($Score.TenantName) .html" -Online 
} 

And that’s it for the HTML version, lets move on to IT-Glue next.

IT-Glue version

The IT-Glue version creates the flexible asset for you, uploads the Secure Score for each client. It also looks-up all domains for the client so it can find the right client ID within IT-Glue.

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret'
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########

########################## IT-Glue ############################
$APIKEy = "ITGAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "O365 SecureScore v1"
$Description = "Secure Score v1."
########################## IT-Glue ############################
  
#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
 

install-module SecureScore
install-module PsWriteHTML


write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Default Domain Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "TenantID"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Current Score"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Secure Score Comparetives"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Secure Score Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
  
write-host "Generating unique ID List" -ForegroundColor Green

$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 

$Scores = get-securescore -AllTenants -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken

foreach ($Score in $scores) {
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "default-domain-name"       = $score.TenantName
                "tenantid"                  = $score.TenantID
                "current-score"             = "$($score.Scores.currentScore) of $($score.Scores.maxScore)"
                "secure-score-comparetives" = ($score.Scores.averageComparativeScores | convertto-html -Fragment | out-string) -replace "<th>", "<th style=`"background-color:#4CAF50`">"
                "secure-score-settings"     = ($score.Scores.controlScores | convertto-html -Fragment | out-string) -replace "<th>", "<th style=`"background-color:#4CAF50`">"
            }
        }
    }
      
    write-output "             Finding $($score.TenantName) in IT-Glue"

    $Domains = (Get-MsolDomain -TenantId $Score.TenantID).name
    $ORGId = foreach ($Domain in $Domains) {
        ($domainList | Where-Object { $_.domain -eq $Domain }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Secure Score for $($score.TenantName) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.tenantid -eq $score.TenantID }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new secure score for $($score.TenantName) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
  
        }
        else {
            write-output "                      Updating secure score $($score.TenantName) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
  
    }
} 

And that’s it! Somewhere this week I’ll also be talking about using the SecureScore module to apply the suggested remediation. As I said, those are still a little rough around the edges.

As always, Happy PowerShelling!

Documenting with PowerShell: Documenting Office 365 guest access

So a little while ago we’ve had a client that works a lot with external contractors. These contracts are invited as guests into their Teams. This client came up to us recently and asked “Hey, I wanna know what my contractors are doing”. Normally speaking we’d just point them at IT-Glue and tell them to look there, but we didn’t really have anything for guest access yet.

So we’ve introduced this script. The script connects to Office 365 using the MSOL module, it then gets all guest accounts uses the unified audit log to grab all information for each guest. It then either uploads to IT-Glue or creates a HTML file per tenant depending on which version you’re using.

This way, you can present a readable file to your clients and they can see exactly what files have been edited or what meetings have been attended.

HTML Version

So the HTML version uses PsWriteHTML. This module makes a pretty readable file for us. I’ve included a small screenshot so you can see how it looks;

So, lets get to scripting. As with most of my O365 stuff, you will need to implement the Secure application model.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeRefreshToken'
$UPN = "YourPrettyUpnUsedToGenerateTokens"
######### Secrets #########

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
 
$customers = Get-MsolPartnerContract -All

foreach ($customer in $customers) {
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true&quot; -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
    $GuestUsers = get-msoluser -TenantId $customer.TenantId -all | Where-Object { $_.Usertype -eq "guest" }
    if (!$GuestUsers) { 
        Write-Host "No guests for $($customer.name)" -ForegroundColor Yellow
        continue 
    }
    $startDate = (Get-Date).AddDays(-31)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    New-HTML {
        foreach ($guest in $GuestUsers) {
         
            $Logs = do {
                $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -UserIds $guest.userprincipalname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
                $log
                Write-Host "    Retrieved $($log.count) logs for user $($guest.UserPrincipalName)" -ForegroundColor Green
            }while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
            if ($logs) {
                $AuditData = $Logs.AuditData | ForEach-Object { ConvertFrom-Json $_ }
                New-HTMLTab -Name $guest.UserPrincipalName {
                    New-HTMLSection -Invisible {
                        New-HTMLSection -HeaderText 'Logbook' {
                            New-HTMLTable -DataTable ($AuditData | select-object CreationTime, Operation, ClientIP, UserID, SiteURL, SourceFilename, UserAgent )
                        }
                    }
                }
            }
        } 
    } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
}

IT-Glue version

The IT-glue version works the same as the HTML version, but creates the flexible asset for you.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeRefreshToken'
$UPN = "YourPrettyUpnUsedToGenerateTokens"
######### Secrets #########

######################### IT-Glue ############################
$APIKEy = "ITG-API-KEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "O365 Guest logbook"
$Description = "A logbook of actions a external user has performed"
########################## IT-Glue ############################
 
#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
 
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Guest"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Actions"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
  
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
 

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
 
$customers = Get-MsolPartnerContract -All

foreach ($customer in $customers) {
    $domains = Get-MsolDomain -TenantId $customer.TenantId
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
    $GuestUsers = get-msoluser -TenantId $customer.TenantId -all | Where-Object { $_.Usertype -eq "guest" }
    if (!$GuestUsers) { 
        Write-Host "No guests for $($customer.name)" -ForegroundColor Yellow
        continue 
    }
    $startDate = (Get-Date).AddDays(-31)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    foreach ($guest in $GuestUsers) {
        $Logs = do {
            $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -UserIds $guest.userprincipalname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
            $log
            Write-Host "    Retrieved $($log.count) logs for user $($guest.UserPrincipalName)" -ForegroundColor Green
        }while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
        if ($logs) {
            $AuditData = $logs.AuditData | ForEach-Object { ConvertFrom-Json $_ }
            $FlexAssetBody = 
            @{
                type       = "flexible-assets"
                attributes = @{
                    traits = @{
                        "guest"   = $guest.userprincipalname
                        "actions" = ($AuditData | select-object CreationTime, Operation, ClientIP, UserID, SiteURL, SourceFilename, UserAgent | convertto-html -Fragment | Out-String)
                                                  
                    }
                }
            }
            write-output "             Finding $($customer.name) in IT-Glue"
            $orgid = foreach ($customerDomain in $domains) {
                ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
            }
            write-output "             Uploading O365 guest $($guest.userprincipalname) into IT-Glue"
            foreach ($org in $orgID) {
                $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'guest' -eq $guest.UserPrincipalName }
                #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
                if (!$ExistingFlexAsset) {
                    if ($FlexAssetBody.attributes.'organization-id') {
                        $FlexAssetBody.attributes.'organization-id' = $org
                    }
                    else { 
                        $FlexAssetBody.attributes.add('organization-id', $org)
                        $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
                    }
                    write-output "                      Creating new guest $($guest.userprincipalname) into IT-Glue organisation $org"
                    New-ITGlueFlexibleAssets -data $FlexAssetBody
              
                }
                else {
                    write-output "                      Updating guest $($guest.userprincipalname)  into IT-Glue organisation $org"
                    $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
                    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
                }
              
            }
        }
    } 
} 

And that’s it! as always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring B-Series VM credits

A lot of MSPs use the B-Series VMs for tasks, and why woulnd’t you? It are cheap VMs that allow you to use azure as a cost effective solution for clients. The B-Series VM are “burstable” VMs, meaning they don’t get the full CPU performance constantly.

The description of Microsoft explains it best:

The B-series burstable VMs are ideal for workloads that do not need the full performance of the CPU continuously, like web servers, small databases and development and test environments. These workloads typically have burstable performance requirements. The B-Series provides these customers the ability to purchase a VM size with a price conscience baseline performance that allows the VM instance to build up credits when the VM is utilizing less than its base performance. When the VM has accumulated credit, the VM can burst above the VM’s baseline using up to 100% of the CPU when your application requires the higher CPU performance.

Microsoft – https://azure.microsoft.com/en-us/blog/introducing-b-series-our-new-burstable-vm-size/

This is of course great for smaller servers such as domain controllers, small RemoteApp machines or generic low performance VMs, but you do need to pay attention that you don’t run out of “credits” when performance is required.

So let’s start alerting on B-Series VMs that are running out of steam. Full disclosure and credit where it’s due: A part of this script was shared with me by Andrew Cullen of Lanter Technologies, thanks for that Andrew!

The script

This script checks all the subscriptions for each VM that is in the B-series, from there on we check the current credits remaining and alert on it if those get under 90.

To fix this, you could temporarily upscale the VM to a larger series which gives you new credits, or move tasks to different VM’s if it happens a lot. This will most likely also help in the “why is my VM suddenly so slow” scenarios 😉

We’re using the secure application model and Azure Lighthouse for these tasks, as such you can load these scripts into any RMM system that is able to handle credentials securely.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$azureToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Tenant $TenantId
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantId
 
Connect-Azaccount -AccessToken $azureToken.AccessToken -GraphAccessToken $graphToken.AccessToken -AccountId $upn -TenantId $tenantID
$Subscriptions = Get-AzSubscription  | Where-Object { $_.State -eq 'Enabled' } | Sort-Object -Unique -Property Id
$VMCredits = foreach ($Sub in $Subscriptions) {
    write-host "Processing client $($sub.name)"
    $null = $Sub | Set-AzContext
    get-azvm -status | Where-Object { $_.HardwareProfile.VmSize -like "Standard_B*" -and $_.PowerState -like "*Running*" } | ForEach-Object {
        $Credits = (Get-AzMetric -ResourceId $_.Id -MetricName "CPU Credits Remaining").data.average | Where-Object { $_ -ne "" } | select-object -last 1
        [PSCustomObject]@{
            VMName           = $_.Name
            CreditsRemaining = $Credits
            Subscription     = $sub.name
        }
    }
}

foreach ($VMCredit in $VMCredits | where-object { $_.CreditsRemaining -lt "90" }) {
    write-host "$($VMCredit.VMname) has $($VMCredit.CreditsRemaining) credits remaining"
}

And that’s it! I hope this helps in tackling those pesky performance issues when using the cheaper VMs in Azure. As always, Happy PowerShelling

Documenting with PowerShell: Documenting Azure VMs (And lighthouse setup)

So this blog is actually two blogs all wrapped into one lovely package; I’m going to be showing you how to setup Azure Lighthouse, giving you the ability to manage your clients from your own partner portal, or via PowerShell.

I’m also going to demonstrate how to document VMs in both a local HTML file and IT-Glue. So, lets get started shall we?

Setting up Azure Lighthouse

Azure Lighthouse is a method of getting delegated access to the Azure Subscriptions your client has. If you’re a T2 CSP you’ll need to set this up by hand. You could create a package in the partner portal and have users click on that package from within Azure but that is hardly automated. 😉

To use the script below, you’ll have to log in with the credentials that have access to the clients subscription. The script makes the “AdminAgents” which is the Partner Administrators group “Contributor” in the Azure Portal of the client.

We have a reminder for our billing department that this script needs to run when adding a subscription, that way we never miss getting delegate access to our clients.

$MSPName = "Your Good MSP"
$MSPOffering = "Managed Azure by Good MSP"
$TenantID = "YourTentnantID" #Find this in the Azure Portal -> Azure AD -> Tenant ID
$AdminAgentsID = "YourUserGroupID" #Find this in the Azure Portal -> Azure AD -> Groups -> Look for AdminAgents -> Object ID
$Location = "westeurope" #Enter your Azure Location here.

@"
{
    "`$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "mspName": {
            "type": "string",
            "metadata": {
                "description": "Specify the Managed Service Provider name"
            }
        },
        "mspOfferDescription": {
            "type": "string",
            "metadata": {
                "description": "Name of the Managed Service Provider offering"
            }
        },
        "managedByTenantId": {
            "type": "string",
            "metadata": {
                "description": "Specify the tenant id of the Managed Service Provider"
            }
        },
        "authorizations": {
            "type": "array",
            "metadata": {
                "description": "Specify an array of objects, containing tuples of Azure Active Directory principalId, a Azure roleDefinitionId, and an optional principalIdDisplayName. The roleDefinition specified is granted to the principalId in the provider's Active Directory and the principalIdDisplayName is visible to customers."
            }
        }              
    },
    "variables": {
        "mspRegistrationName": "[guid(parameters('mspName'))]",
        "mspAssignmentName": "[guid(parameters('mspName'))]"
    },
    "resources": [
        {
            "type": "Microsoft.ManagedServices/registrationDefinitions",
            "apiVersion": "2019-06-01",
            "name": "[variables('mspRegistrationName')]",
            "properties": {
                "registrationDefinitionName": "[parameters('mspName')]",
                "description": "[parameters('mspOfferDescription')]",
                "managedByTenantId": "[parameters('managedByTenantId')]",
                "authorizations": "[parameters('authorizations')]"
            }
        },
        {
            "type": "Microsoft.ManagedServices/registrationAssignments",
            "apiVersion": "2019-06-01",
            "name": "[variables('mspAssignmentName')]",
            "dependsOn": [
                "[resourceId('Microsoft.ManagedServices/registrationDefinitions/', variables('mspRegistrationName'))]"
            ],
            "properties": {
                "registrationDefinitionId": "[resourceId('Microsoft.ManagedServices/registrationDefinitions/', variables('mspRegistrationName'))]"
            }
        }
    ],
    "outputs": {
        "mspName": {
            "type": "string",
            "value": "[concat('Managed by', ' ', parameters('mspName'))]"
        },
        "authorizations": {
            "type": "array",
            "value": "[parameters('authorizations')]"
        }
    }
}
"@ | Out-File "rgDelegatedResourceManagement.json"

@"
{
    "`$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "mspName": {
            "value": "$MSPName"
        },
        "mspOfferDescription": {
            "value": "$MSPOffering"
        },
        "managedByTenantId": {
            "value": "$TenantID"
        },
        "authorizations": {
            "value": [
                {
                    "principalId": "$AdminAgentsID",
                    "roleDefinitionId": "b24988ac-6180-42a0-ab88-20f7382dd24c",
                    "principalIdDisplayName": "AdminAgents"
                }
            ]
        }
    }
}
"@ | out-file 'rgDelegatedResourceManagement.parameters.json'

Connect-AzAccount
$Subs = Get-AzSubscription
foreach ($sub in $subs) {
    Set-AzContext -Subscription $sub.id
    New-AzDeployment -Name LightHouse -Location $location -TemplateFile "rgDelegatedResourceManagement.json" -TemplateParameterFile "rgDelegatedResourceManagement.parameters.json" -Verbose
}

And that’s the Azure Lighthouse setup, if you connect using the Secure Application Model you’ll have access to your clients Azure subscriptions. To access them via the portal use the following url: https://portal.azure.com/#blade/Microsoft_Azure_CustomerHub/MyCustomersBladeV2/scopeManagement

Documenting the VMs

As always I’ve prepped two version; one plain HTML, and one for IT-Glue. The HTML versions uses PsWriteHTML to create a nice looking page. The IT-Glue version creates the flexible asset for you.

Before we do that though, execute the following code to allow your Secure Application Model to access Azure:

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Azuretoken = New-PartnerAccessToken -ApplicationId $ApplicationID -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Credential $credential -Tenant $tenantid -UseAuthorizationCode

HTML Version

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "limenetworks@limenetworks.nl"
######### Secrets #########


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$azureToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Tenant $TenantId
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantId

Connect-Azaccount -AccessToken $azureToken.AccessToken -GraphAccessToken $graphToken.AccessToken -AccountId $upn -TenantId $tenantID 
$Subscriptions = Get-AzSubscription  | Where-Object { $_.State -eq 'Enabled' } | Sort-Object -Unique -Property Id
foreach ($Sub in $Subscriptions) {
    write-host "Processing client $($sub.name)"
    $null = $Sub | Set-AzContext
    $VMs = Get-azvm -Status | Select-Object PowerState, Name, ProvisioningState, Location, 
    @{Name = 'OS Type'; Expression = { $_.Storageprofile.osdisk.OSType } }, 
    @{Name = 'VM Size'; Expression = { $_.hardwareprofile.vmsize } },
    @{Name = 'OS Disk Type'; Expression = { $_.StorageProfile.osdisk.manageddisk.storageaccounttype } }
    $networks = get-aznetworkinterface | select-object Primary,
    @{Name = 'NSG'; Expression = { ($_.NetworkSecurityGroup).id -split "/" | select-object -last 1 } }, 
    @{Name = 'DNS Settings'; Expression = { ($_.DNSsettings).dnsservers -join ',' } }, 
    @{Name = 'Connected VM'; Expression = { ($_.VirtualMachine).id -split '/' | select-object -last 1 } },
    @{Name = 'Internal IP'; Expression = { ($_.IPConfigurations).PrivateIpAddress -join "," } },
    @{Name = 'External IP'; Expression = { ($_.IPConfigurations).PublicIpAddress.IpAddress -join "," } }, tags
    $NSGs = get-aznetworksecuritygroup | select-object Name, Location,
    @{Name = 'Allowed Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -eq 'allow'}).DestinationPortRange}  } ,
    @{Name = 'Denied Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -ne 'allow'}).DestinationPortRange} }
    
    New-HTML {
        New-HTMLTab -Name 'Azure VM documentation' {
                New-HTMLSection -HeaderText 'Virtual Machines' {
                    New-HTMLTable -DataTable $VMs
                }
                New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Network Security Groups' {
                    New-HTMLTable -DataTable $NSGs
                }

                New-HTMLSection -HeaderText "Networks" {
                    New-HTMLTable -DataTable $networks
                }
            }
            }
        } -FilePath "C:\temp\$($sub.name) .html" -Online

}

And that’s it for the HTML version, lets move on to IT-Glue next.

IT-Glue version

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "limenetworks@limenetworks.nl"
######### Secrets #########

########################## IT-Glue ############################
$APIKEy = "ITGlueKey"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Azure Virtual Machines"
$Description = "A network one-page document that shows the Azure VM Settings."
########################## IT-Glue ############################

#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy

write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Subscription ID"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "VMs"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "NSGs"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Networks"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
 
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 

$DomainList = $DomainList | sort-object -Property Combined -Unique

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$azureToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Tenant $TenantId
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantId
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
Connect-Azaccount -AccessToken $azureToken.AccessToken -GraphAccessToken $graphToken.AccessToken -AccountId $upn -TenantId $tenantID 
$Subscriptions = Get-AzSubscription  | Where-Object { $_.State -eq 'Enabled' } | Sort-Object -Unique -Property Id
foreach ($Sub in $Subscriptions) {
    $OrgTenant = ((Invoke-AzRestMethod -path "/subscriptions/$($sub.subscriptionid)/?api-version=2020-06-01" -method GET).content | convertfrom-json).tenantid
    write-host "Processing client $($sub.name)"
    $Domains = get-msoldomain -tenant $OrgTenant
    $null = $Sub | Set-AzContext
    $VMs = Get-azvm -Status | Select-Object PowerState, Name, ProvisioningState, Location, 
    @{Name = 'OS Type'; Expression = { $_.Storageprofile.osdisk.OSType } }, 
    @{Name = 'VM Size'; Expression = { $_.hardwareprofile.vmsize } },
    @{Name = 'OS Disk Type'; Expression = { $_.StorageProfile.osdisk.manageddisk.storageaccounttype } }
    $networks = get-aznetworkinterface | select-object Primary,
    @{Name = 'NSG'; Expression = { ($_.NetworkSecurityGroup).id -split "/" | select-object -last 1 } }, 
    @{Name = 'DNS Settings'; Expression = { ($_.DNSsettings).dnsservers -join ',' } }, 
    @{Name = 'Connected VM'; Expression = { ($_.VirtualMachine).id -split '/' | select-object -last 1 } },
    @{Name = 'Internal IP'; Expression = { ($_.IPConfigurations).PrivateIpAddress -join "," } },
    @{Name = 'External IP'; Expression = { ($_.IPConfigurations).PublicIpAddress.IpAddress -join "," } }, tags
    $NSGs = get-aznetworksecuritygroup | select-object Name, Location,
    @{Name = 'Allowed Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -eq 'allow' }).DestinationPortRange } } ,
    @{Name = 'Denied Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -ne 'allow' }).DestinationPortRange } }
    
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "subscription-id" = $sub.SubscriptionId
                "vms"             = ($VMs | convertto-html -Fragment | out-string)
                "nsgs"            = ($NSGs | convertto-html -Fragment | out-string)
                "networks"        = ($networks | convertto-html -Fragment | out-string)
                                     
            }
        }
    }
     



    write-output "             Finding $($sub.name) in IT-Glue"
    $orgid = foreach ($customerDomain in $domains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Azure VMs for $($sub.name) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'subscription-id' -eq $sub.subscriptionid }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org 
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new Azure VMs for $($sub.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
 
        }
        else {
            write-output "                      Updating Azure VMs $($sub.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
 
    }

}

And that’s it, this will document your Azure VMs for you. Next time we’ll focus on other resources and resource groups. As always, Happy PowerShelling.

Special thanks on this blog go to Andrew Cullen whom helped me figure out some Azure Secure App Model issues I was encountering. 🙂

Monitoring with PowerShell: O365 location alerts

A while back someone asked me to convert a script they had to the Secure Application Model. This specific script checked the Office 365 audit log IPs against a online database of locations. I declined at first and suggested it to look into Microsoft 365, a P1 or P2 subscription which allows you to do this native.

The reader came back to me recently asking once more, and giving a explanation on why P1/P2 or M365 was not possible in her case. I understand that sometimes you might have to make due with what you have. I also figured others might be in the same boat.

So this script is made to monitor the O365 unified audit log and to compare the IP addresses to a database online. The database she used previously was very rate-limited and as such not really suitable. Not a lot of people know that there actually is a great 100% free online lookup service for IPs at https://ip2c.org/. The script uses that database as there are no API limitations. 🙂

As always, I’d like to note that this should just be one layer in your entire security model and you should not put all your faith in this. Enable MFA, and keep good security hygiene.

The Script

You’ll need the Secure Application Model for this script. The script currently checks all your tenants except the ones you state in “$Skiplist”.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-User-To-Generate-IDs"
######### Secrets #########

$AllowedCountries = @('Belgium', 'Netherlands', 'Germany', 'United Kingdom')
$Skiplist = @("bla1.onmicrosoft.com", "bla2.onmicrosoft.com", "bla2.onmicrosoft.com")

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken

$customers = Get-MsolPartnerContract -All | Where-Object { $_.DefaultDomainName -notin $SkipList }

$StrangeLocations = foreach ($customer in $customers) {
    Write-Host "Getting logon location details for $($customer.Name)" -ForegroundColor Green
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true&quot; -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -CommandName Search-UnifiedAuditLog -AllowClobber
 

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    $logs = do {
        $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations UserLoggedIn
        Write-Host "Retrieved $($log.count) logs" -ForegroundColor Yellow
        $log
    } while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
 
    $userIds = $logs.userIds | Sort-Object -Unique

    $LocationMonitoring = foreach ($userId in $userIds) {
 
        $searchResult = ($logs | Where-Object { $_.userIds -contains $userId }).auditdata | ConvertFrom-Json -ErrorAction SilentlyContinue
        $ips = $searchResult.clientip | Sort-Object -Unique
        foreach ($ip in $ips) {
            $IsIp = ($ip -as [ipaddress]) -as [bool]
            if ($IsIp) { $ipresult = (Invoke-restmethod -method get -uri "https://ip2c.org/$($ip)") -split ';' }
            [PSCustomObject]@{
                user              = $userId
                IP                = $ip
                Country           = ($ipresult | Select-Object -index 3)
                CountryCode       = ($ipresult | Select-Object -Index 1)
                Company           = $customer.Name
                TenantID          = $customer.tenantID
                DefaultDomainName = $customer.DefaultDomainName
            }
           
        }

    }
    foreach ($Location in $LocationMonitoring) {
        if ($Location.country -notin $AllowedCountries) { $Location }
    }
}
if (!$StrangeLocations) {
    $StrangeLocations = 'Healthy'
}

$StrangeLocations

And that’s it! Now you’re monitoring the locations from where users are logging on. As always, Happy PowerShelling!

Documenting with PowerShell: Documenting the O365 portal

A couple of years ago Eliot at GCITS wrote a great script to update the Office365 portal’s within IT-Glue. This script synced a lot of user information to IT-Glue and kept everything up to date if ran by a function.

I loved this script and have been using it pretty much since it’s inception. Some users recently contacted me and asked how to run this script with the Secure Application Model. Also there were some complaints that the script ran pretty slow. I didn’t really like directly modifying the script so I’ve decided to make a rewrite.

My version uses the Graph API to collect data. We also grab some more information such as mailboxes sizes, and the current multi-factor authentication state for the user.

As always, I’ve made two versions, one for IT-Glue, and one plain HTML version. A small difference in here is that I’ve decided to use the PSWriteHTML module. This is a great PowerShell module by Przemysław Kłys at Evotec that makes generating pretty html files a whole lot easier.

So lets get to scripting! AS always, you’ll need the Secure Application Model to connect to O365.

HTML version

This version connects to all tenants, creates a nice looking HTML page, and writes these to C:\Temp\. The report will look like the screenshot below.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'RefreshToken'
$UPN = "Upn-used-to-generate-tokens"
######### Secrets #########

If (Get-Module -ListAvailable -Name "PsWriteHTML") { Import-module PsWriteHTML } Else { install-module PsWriteHTML -Force; import-module PsWriteHTML }
If (Get-Module -ListAvailable -Name "MsOnline") { Import-module "Msonline" } Else { install-module "MsOnline" -Force; import-module "Msonline" }
If (Get-Module -ListAvailable -Name "PartnerCenter") { Import-module "PartnerCenter" } Else { install-module "PartnerCenter" -Force; import-module "PartnerCenter" }
#Account SKUs to transform to normal name.
$AccountSkuIdDecodeData = @{
    "O365_BUSINESS_ESSENTIALS"           = "Office 365 Business Essentials"
    "O365_BUSINESS_PREMIUM"              = "Office 365 Business Premium"
    "DESKLESSPACK"                       = "Office 365 (Plan K1)"
    "DESKLESSWOFFPACK"                   = "Office 365 (Plan K2)"
    "LITEPACK"                           = "Office 365 (Plan P1)"
    "EXCHANGESTANDARD"                   = "Office 365 Exchange Online"
    "STANDARDPACK"                       = "Enterprise Plan E1"
    "STANDARDWOFFPACK"                   = "Office 365 (Plan E2)"
    "ENTERPRISEPACK"                     = "Enterprise Plan E3"
    "ENTERPRISEPACKLRG"                  = "Enterprise Plan E3"
    "ENTERPRISEWITHSCAL"                 = "Enterprise Plan E4"
    "STANDARDPACK_STUDENT"               = "Office 365 (Plan A1) for Students"
    "STANDARDWOFFPACKPACK_STUDENT"       = "Office 365 (Plan A2) for Students"
    "ENTERPRISEPACK_STUDENT"             = "Office 365 (Plan A3) for Students"
    "ENTERPRISEWITHSCAL_STUDENT"         = "Office 365 (Plan A4) for Students"
    "STANDARDPACK_FACULTY"               = "Office 365 (Plan A1) for Faculty"
    "STANDARDWOFFPACKPACK_FACULTY"       = "Office 365 (Plan A2) for Faculty"
    "ENTERPRISEPACK_FACULTY"             = "Office 365 (Plan A3) for Faculty"
    "ENTERPRISEWITHSCAL_FACULTY"         = "Office 365 (Plan A4) for Faculty"
    "ENTERPRISEPACK_B_PILOT"             = "Office 365 (Enterprise Preview)"
    "STANDARD_B_PILOT"                   = "Office 365 (Small Business Preview)"
    "VISIOCLIENT"                        = "Visio Pro Online"
    "POWER_BI_ADDON"                     = "Office 365 Power BI Addon"
    "POWER_BI_INDIVIDUAL_USE"            = "Power BI Individual User"
    "POWER_BI_STANDALONE"                = "Power BI Stand Alone"
    "POWER_BI_STANDARD"                  = "Power-BI Standard"
    "PROJECTESSENTIALS"                  = "Project Lite"
    "PROJECTCLIENT"                      = "Project Professional"
    "PROJECTONLINE_PLAN_1"               = "Project Online"
    "PROJECTONLINE_PLAN_2"               = "Project Online and PRO"
    "ProjectPremium"                     = "Project Online Premium"
    "ECAL_SERVICES"                      = "ECAL"
    "EMS"                                = "Enterprise Mobility Suite"
    "RIGHTSMANAGEMENT_ADHOC"             = "Windows Azure Rights Management"
    "MCOMEETADV"                         = "PSTN conferencing"
    "SHAREPOINTSTORAGE"                  = "SharePoint storage"
    "PLANNERSTANDALONE"                  = "Planner Standalone"
    "CRMIUR"                             = "CMRIUR"
    "BI_AZURE_P1"                        = "Power BI Reporting and Analytics"
    "INTUNE_A"                           = "Windows Intune Plan A"
    "PROJECTWORKMANAGEMENT"              = "Office 365 Planner Preview"
    "ATP_ENTERPRISE"                     = "Exchange Online Advanced Threat Protection"
    "EQUIVIO_ANALYTICS"                  = "Office 365 Advanced eDiscovery"
    "AAD_BASIC"                          = "Azure Active Directory Basic"
    "RMS_S_ENTERPRISE"                   = "Azure Active Directory Rights Management"
    "AAD_PREMIUM"                        = "Azure Active Directory Premium"
    "MFA_PREMIUM"                        = "Azure Multi-Factor Authentication"
    "STANDARDPACK_GOV"                   = "Microsoft Office 365 (Plan G1) for Government"
    "STANDARDWOFFPACK_GOV"               = "Microsoft Office 365 (Plan G2) for Government"
    "ENTERPRISEPACK_GOV"                 = "Microsoft Office 365 (Plan G3) for Government"
    "ENTERPRISEWITHSCAL_GOV"             = "Microsoft Office 365 (Plan G4) for Government"
    "DESKLESSPACK_GOV"                   = "Microsoft Office 365 (Plan K1) for Government"
    "ESKLESSWOFFPACK_GOV"                = "Microsoft Office 365 (Plan K2) for Government"
    "EXCHANGESTANDARD_GOV"               = "Microsoft Office 365 Exchange Online (Plan 1) only for Government"
    "EXCHANGEENTERPRISE_GOV"             = "Microsoft Office 365 Exchange Online (Plan 2) only for Government"
    "SHAREPOINTDESKLESS_GOV"             = "SharePoint Online Kiosk"
    "EXCHANGE_S_DESKLESS_GOV"            = "Exchange Kiosk"
    "RMS_S_ENTERPRISE_GOV"               = "Windows Azure Active Directory Rights Management"
    "OFFICESUBSCRIPTION_GOV"             = "Office ProPlus"
    "MCOSTANDARD_GOV"                    = "Lync Plan 2G"
    "SHAREPOINTWAC_GOV"                  = "Office Online for Government"
    "SHAREPOINTENTERPRISE_GOV"           = "SharePoint Plan 2G"
    "EXCHANGE_S_ENTERPRISE_GOV"          = "Exchange Plan 2G"
    "EXCHANGE_S_ARCHIVE_ADDON_GOV"       = "Exchange Online Archiving"
    "EXCHANGE_S_DESKLESS"                = "Exchange Online Kiosk"
    "SHAREPOINTDESKLESS"                 = "SharePoint Online Kiosk"
    "SHAREPOINTWAC"                      = "Office Online"
    "YAMMER_ENTERPRISE"                  = "Yammer for the Starship Enterprise"
    "EXCHANGE_L_STANDARD"                = "Exchange Online (Plan 1)"
    "MCOLITE"                            = "Lync Online (Plan 1)"
    "SHAREPOINTLITE"                     = "SharePoint Online (Plan 1)"
    "OFFICE_PRO_PLUS_SUBSCRIPTION_SMBIZ" = "Office ProPlus"
    "EXCHANGE_S_STANDARD_MIDMARKET"      = "Exchange Online (Plan 1)"
    "MCOSTANDARD_MIDMARKET"              = "Lync Online (Plan 1)"
    "SHAREPOINTENTERPRISE_MIDMARKET"     = "SharePoint Online (Plan 1)"
    "OFFICESUBSCRIPTION"                 = "Office ProPlus"
    "YAMMER_MIDSIZE"                     = "Yammer"
    "DYN365_ENTERPRISE_PLAN1"            = "Dynamics 365 Customer Engagement Plan Enterprise Edition"
    "ENTERPRISEPREMIUM_NOPSTNCONF"       = "Enterprise E5 (without Audio Conferencing)"
    "ENTERPRISEPREMIUM"                  = "Enterprise E5 (with Audio Conferencing)"
    "MCOSTANDARD"                        = "Skype for Business Online Standalone Plan 2"
    "PROJECT_MADEIRA_PREVIEW_IW_SKU"     = "Dynamics 365 for Financials for IWs"
    "STANDARDWOFFPACK_IW_STUDENT"        = "Office 365 Education for Students"
    "STANDARDWOFFPACK_IW_FACULTY"        = "Office 365 Education for Faculty"
    "EOP_ENTERPRISE_FACULTY"             = "Exchange Online Protection for Faculty"
    "EXCHANGESTANDARD_STUDENT"           = "Exchange Online (Plan 1) for Students"
    "OFFICESUBSCRIPTION_STUDENT"         = "Office ProPlus Student Benefit"
    "STANDARDWOFFPACK_FACULTY"           = "Office 365 Education E1 for Faculty"
    "STANDARDWOFFPACK_STUDENT"           = "Microsoft Office 365 (Plan A2) for Students"
    "DYN365_FINANCIALS_BUSINESS_SKU"     = "Dynamics 365 for Financials Business Edition"
    "DYN365_FINANCIALS_TEAM_MEMBERS_SKU" = "Dynamics 365 for Team Members Business Edition"
    "FLOW_FREE"                          = "Microsoft Flow Free"
    "POWER_BI_PRO"                       = "Power BI Pro"
    "O365_BUSINESS"                      = "Office 365 Business"
    "DYN365_ENTERPRISE_SALES"            = "Dynamics Office 365 Enterprise Sales"
    "RIGHTSMANAGEMENT"                   = "Rights Management"
    "PROJECTPROFESSIONAL"                = "Project Professional"
    "VISIOONLINE_PLAN1"                  = "Visio Online Plan 1"
    "EXCHANGEENTERPRISE"                 = "Exchange Online Plan 2"
    "DYN365_ENTERPRISE_P1_IW"            = "Dynamics 365 P1 Trial for Information Workers"
    "DYN365_ENTERPRISE_TEAM_MEMBERS"     = "Dynamics 365 For Team Members Enterprise Edition"
    "CRMSTANDARD"                        = "Microsoft Dynamics CRM Online Professional"
    "EXCHANGEARCHIVE_ADDON"              = "Exchange Online Archiving For Exchange Online"
    "EXCHANGEDESKLESS"                   = "Exchange Online Kiosk"
    "SPZA_IW"                            = "App Connect"
    "WINDOWS_STORE"                      = "Windows Store for Business"
    "MCOEV"                              = "Microsoft Phone System"
    "VIDEO_INTEROP"                      = "Polycom Skype Meeting Video Interop for Skype for Business"
    "SPE_E5"                             = "Microsoft 365 E5"
    "SPE_E3"                             = "Microsoft 365 E3"
    "ATA"                                = "Advanced Threat Analytics"
    "MCOPSTN2"                           = "Domestic and International Calling Plan"
    "FLOW_P1"                            = "Microsoft Flow Plan 1"
    "FLOW_P2"                            = "Microsoft Flow Plan 2"
    "CRMSTORAGE"                         = "Microsoft Dynamics CRM Online Additional Storage"
    "SMB_APPS"                           = "Microsoft Business Apps"
    "MICROSOFT_BUSINESS_CENTER"          = "Microsoft Business Center"
    "DYN365_TEAM_MEMBERS"                = "Dynamics 365 Team Members"
    "STREAM"                             = "Microsoft Stream Trial"
    "EMSPREMIUM"                         = "ENTERPRISE MOBILITY + SECURITY E5"

}


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host "Creating body to request Graph access for each client." -ForegroundColor Green
    $CustomerToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -Tenant $customer.TenantID

    $headers = @{ "Authorization" = "Bearer $($CustomerToken.AccessToken)" }
    write-host "Collecting data for $($Customer.defaultdomainname)" -ForegroundColor Green
    $domains = Get-MsolDomain
    $Licenselist = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/subscribedSkus" -Headers $Headers -Method Get -ContentType "application/json").value
    $Licenselist | ForEach-Object { $_.skupartnumber = "$($AccountSkuIdDecodeData.$($_.skupartnumber))" }
    $AdminRole = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/DirectoryRoles' -Headers $Headers -Method Get -ContentType "application/json").value | Where-Object { $_.displayname -eq 'Company Administrator' }
    $Admins = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/DirectoryRoles/$($AdminRole.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, userprincipalname
    $Users = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/users?$top=999' -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, proxyaddresses, AssignedLicenses, userprincipalname
    $MFAStatus = Get-MsolUser -all -TenantId $customer.TenantId | Select-Object DisplayName,UserPrincipalName,@{N="MFA Status"; E={if( $_.StrongAuthenticationRequirements.State -ne $null) {$_.StrongAuthenticationRequirements.State} else { "Disabled"}}}


    $UserObj = foreach ($user in $users) {
        $Addresses = ($user.proxyaddresses | Sort-Object -Descending) -join "`n" -creplace "SMTP:", "Primary:" -creplace "smtp:", "Alias:"
        [PSCustomObject]@{
            'Display name'      = $user.displayname
            'Addresses'         = [System.Web.HttpUtility]::HtmlDecode($Addresses)
            "Licenses Assigned" = ($Licenselist | Where-Object { $_.skuid -in $User.assignedLicenses.skuid }).skupartnumber -join "`n"
            "MFA Enabled"       = ($MFAStatus | Where-Object { $_.UserPrincipalName -eq $user.userPrincipalName}).'MFA Status'
        }
    
    }

    $licenseObj = foreach ($License in $Licenselist) {
      [PSCustomObject]@{
          'License Name' = $license.skupartnumber
          'Active Licenses' = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended
          'Consumed Licenses' = $license.consumedunits
          'unused licenses' =  $license.prepaidUnits.enabled - $license.prepaidUnits.suspended - $license.consumedunits
      }  
    }
    
    New-HTML {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Administrators' {
                    New-HTMLTable -DataTable $Admins
                }
                New-HTMLSection -HeaderText 'Licenses' {
                    New-HTMLTable -DataTable  $licenseObj
                }
    
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Licensed Users" {
                    New-HTMLTable -DataTable ($UserObj | Where-Object { $_.'Licenses Assigned' -ne ""})
                }
                New-HTMLSection -HeaderText "Unlicensed Users" {
                    New-HTMLTable -DataTable ($UserObj | Where-Object { $_.'Licenses Assigned' -eq ""})
                }
            }
    } -FilePath "C:\temp\$($Customer.DefaultDomainName).html" -Online
    


}

IT-Glue version

So the IT-Glue version creates the flexible asset for you, and then starts filling it with the data from O365. I’ve compared this version to the original one just to see if Graph was so much faster – and it is. The original version takes about 87 seconds per client. This version takes 17 seconds per client.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'RefreshToken'
$UPN = "Upn-used-to-generate-tokens"
######### Secrets #########


################# IT-Glue Information ######################################
$ITGkey = "YOUR-ITG-APIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Office365 - Portals"
$Description = "Office365 portal documentation."
################# /IT-Glue Information #####################################
  
  

write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
   
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 

write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
   
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Tenant name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Tenant ID"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Verified Domains"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Admin Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Licenses"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Licensed Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Unlicensed Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } 
   
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
#Account SKUs to transform to normal name.
$AccountSkuIdDecodeData = @{
    "O365_BUSINESS_ESSENTIALS"           = "Office 365 Business Essentials"
    "O365_BUSINESS_PREMIUM"              = "Office 365 Business Premium"
    "DESKLESSPACK"                       = "Office 365 (Plan K1)"
    "DESKLESSWOFFPACK"                   = "Office 365 (Plan K2)"
    "LITEPACK"                           = "Office 365 (Plan P1)"
    "EXCHANGESTANDARD"                   = "Office 365 Exchange Online"
    "STANDARDPACK"                       = "Enterprise Plan E1"
    "STANDARDWOFFPACK"                   = "Office 365 (Plan E2)"
    "ENTERPRISEPACK"                     = "Enterprise Plan E3"
    "ENTERPRISEPACKLRG"                  = "Enterprise Plan E3"
    "ENTERPRISEWITHSCAL"                 = "Enterprise Plan E4"
    "STANDARDPACK_STUDENT"               = "Office 365 (Plan A1) for Students"
    "STANDARDWOFFPACKPACK_STUDENT"       = "Office 365 (Plan A2) for Students"
    "ENTERPRISEPACK_STUDENT"             = "Office 365 (Plan A3) for Students"
    "ENTERPRISEWITHSCAL_STUDENT"         = "Office 365 (Plan A4) for Students"
    "STANDARDPACK_FACULTY"               = "Office 365 (Plan A1) for Faculty"
    "STANDARDWOFFPACKPACK_FACULTY"       = "Office 365 (Plan A2) for Faculty"
    "ENTERPRISEPACK_FACULTY"             = "Office 365 (Plan A3) for Faculty"
    "ENTERPRISEWITHSCAL_FACULTY"         = "Office 365 (Plan A4) for Faculty"
    "ENTERPRISEPACK_B_PILOT"             = "Office 365 (Enterprise Preview)"
    "STANDARD_B_PILOT"                   = "Office 365 (Small Business Preview)"
    "VISIOCLIENT"                        = "Visio Pro Online"
    "POWER_BI_ADDON"                     = "Office 365 Power BI Addon"
    "POWER_BI_INDIVIDUAL_USE"            = "Power BI Individual User"
    "POWER_BI_STANDALONE"                = "Power BI Stand Alone"
    "POWER_BI_STANDARD"                  = "Power-BI Standard"
    "PROJECTESSENTIALS"                  = "Project Lite"
    "PROJECTCLIENT"                      = "Project Professional"
    "PROJECTONLINE_PLAN_1"               = "Project Online"
    "PROJECTONLINE_PLAN_2"               = "Project Online and PRO"
    "ProjectPremium"                     = "Project Online Premium"
    "ECAL_SERVICES"                      = "ECAL"
    "EMS"                                = "Enterprise Mobility Suite"
    "RIGHTSMANAGEMENT_ADHOC"             = "Windows Azure Rights Management"
    "MCOMEETADV"                         = "PSTN conferencing"
    "SHAREPOINTSTORAGE"                  = "SharePoint storage"
    "PLANNERSTANDALONE"                  = "Planner Standalone"
    "CRMIUR"                             = "CMRIUR"
    "BI_AZURE_P1"                        = "Power BI Reporting and Analytics"
    "INTUNE_A"                           = "Windows Intune Plan A"
    "PROJECTWORKMANAGEMENT"              = "Office 365 Planner Preview"
    "ATP_ENTERPRISE"                     = "Exchange Online Advanced Threat Protection"
    "EQUIVIO_ANALYTICS"                  = "Office 365 Advanced eDiscovery"
    "AAD_BASIC"                          = "Azure Active Directory Basic"
    "RMS_S_ENTERPRISE"                   = "Azure Active Directory Rights Management"
    "AAD_PREMIUM"                        = "Azure Active Directory Premium"
    "MFA_PREMIUM"                        = "Azure Multi-Factor Authentication"
    "STANDARDPACK_GOV"                   = "Microsoft Office 365 (Plan G1) for Government"
    "STANDARDWOFFPACK_GOV"               = "Microsoft Office 365 (Plan G2) for Government"
    "ENTERPRISEPACK_GOV"                 = "Microsoft Office 365 (Plan G3) for Government"
    "ENTERPRISEWITHSCAL_GOV"             = "Microsoft Office 365 (Plan G4) for Government"
    "DESKLESSPACK_GOV"                   = "Microsoft Office 365 (Plan K1) for Government"
    "ESKLESSWOFFPACK_GOV"                = "Microsoft Office 365 (Plan K2) for Government"
    "EXCHANGESTANDARD_GOV"               = "Microsoft Office 365 Exchange Online (Plan 1) only for Government"
    "EXCHANGEENTERPRISE_GOV"             = "Microsoft Office 365 Exchange Online (Plan 2) only for Government"
    "SHAREPOINTDESKLESS_GOV"             = "SharePoint Online Kiosk"
    "EXCHANGE_S_DESKLESS_GOV"            = "Exchange Kiosk"
    "RMS_S_ENTERPRISE_GOV"               = "Windows Azure Active Directory Rights Management"
    "OFFICESUBSCRIPTION_GOV"             = "Office ProPlus"
    "MCOSTANDARD_GOV"                    = "Lync Plan 2G"
    "SHAREPOINTWAC_GOV"                  = "Office Online for Government"
    "SHAREPOINTENTERPRISE_GOV"           = "SharePoint Plan 2G"
    "EXCHANGE_S_ENTERPRISE_GOV"          = "Exchange Plan 2G"
    "EXCHANGE_S_ARCHIVE_ADDON_GOV"       = "Exchange Online Archiving"
    "EXCHANGE_S_DESKLESS"                = "Exchange Online Kiosk"
    "SHAREPOINTDESKLESS"                 = "SharePoint Online Kiosk"
    "SHAREPOINTWAC"                      = "Office Online"
    "YAMMER_ENTERPRISE"                  = "Yammer for the Starship Enterprise"
    "EXCHANGE_L_STANDARD"                = "Exchange Online (Plan 1)"
    "MCOLITE"                            = "Lync Online (Plan 1)"
    "SHAREPOINTLITE"                     = "SharePoint Online (Plan 1)"
    "OFFICE_PRO_PLUS_SUBSCRIPTION_SMBIZ" = "Office ProPlus"
    "EXCHANGE_S_STANDARD_MIDMARKET"      = "Exchange Online (Plan 1)"
    "MCOSTANDARD_MIDMARKET"              = "Lync Online (Plan 1)"
    "SHAREPOINTENTERPRISE_MIDMARKET"     = "SharePoint Online (Plan 1)"
    "OFFICESUBSCRIPTION"                 = "Office ProPlus"
    "YAMMER_MIDSIZE"                     = "Yammer"
    "DYN365_ENTERPRISE_PLAN1"            = "Dynamics 365 Customer Engagement Plan Enterprise Edition"
    "ENTERPRISEPREMIUM_NOPSTNCONF"       = "Enterprise E5 (without Audio Conferencing)"
    "ENTERPRISEPREMIUM"                  = "Enterprise E5 (with Audio Conferencing)"
    "MCOSTANDARD"                        = "Skype for Business Online Standalone Plan 2"
    "PROJECT_MADEIRA_PREVIEW_IW_SKU"     = "Dynamics 365 for Financials for IWs"
    "STANDARDWOFFPACK_IW_STUDENT"        = "Office 365 Education for Students"
    "STANDARDWOFFPACK_IW_FACULTY"        = "Office 365 Education for Faculty"
    "EOP_ENTERPRISE_FACULTY"             = "Exchange Online Protection for Faculty"
    "EXCHANGESTANDARD_STUDENT"           = "Exchange Online (Plan 1) for Students"
    "OFFICESUBSCRIPTION_STUDENT"         = "Office ProPlus Student Benefit"
    "STANDARDWOFFPACK_FACULTY"           = "Office 365 Education E1 for Faculty"
    "STANDARDWOFFPACK_STUDENT"           = "Microsoft Office 365 (Plan A2) for Students"
    "DYN365_FINANCIALS_BUSINESS_SKU"     = "Dynamics 365 for Financials Business Edition"
    "DYN365_FINANCIALS_TEAM_MEMBERS_SKU" = "Dynamics 365 for Team Members Business Edition"
    "FLOW_FREE"                          = "Microsoft Flow Free"
    "POWER_BI_PRO"                       = "Power BI Pro"
    "O365_BUSINESS"                      = "Office 365 Business"
    "DYN365_ENTERPRISE_SALES"            = "Dynamics Office 365 Enterprise Sales"
    "RIGHTSMANAGEMENT"                   = "Rights Management"
    "PROJECTPROFESSIONAL"                = "Project Professional"
    "VISIOONLINE_PLAN1"                  = "Visio Online Plan 1"
    "EXCHANGEENTERPRISE"                 = "Exchange Online Plan 2"
    "DYN365_ENTERPRISE_P1_IW"            = "Dynamics 365 P1 Trial for Information Workers"
    "DYN365_ENTERPRISE_TEAM_MEMBERS"     = "Dynamics 365 For Team Members Enterprise Edition"
    "CRMSTANDARD"                        = "Microsoft Dynamics CRM Online Professional"
    "EXCHANGEARCHIVE_ADDON"              = "Exchange Online Archiving For Exchange Online"
    "EXCHANGEDESKLESS"                   = "Exchange Online Kiosk"
    "SPZA_IW"                            = "App Connect"
    "WINDOWS_STORE"                      = "Windows Store for Business"
    "MCOEV"                              = "Microsoft Phone System"
    "VIDEO_INTEROP"                      = "Polycom Skype Meeting Video Interop for Skype for Business"
    "SPE_E5"                             = "Microsoft 365 E5"
    "SPE_E3"                             = "Microsoft 365 E3"
    "ATA"                                = "Advanced Threat Analytics"
    "MCOPSTN2"                           = "Domestic and International Calling Plan"
    "FLOW_P1"                            = "Microsoft Flow Plan 1"
    "FLOW_P2"                            = "Microsoft Flow Plan 2"
    "CRMSTORAGE"                         = "Microsoft Dynamics CRM Online Additional Storage"
    "SMB_APPS"                           = "Microsoft Business Apps"
    "MICROSOFT_BUSINESS_CENTER"          = "Microsoft Business Center"
    "DYN365_TEAM_MEMBERS"                = "Dynamics 365 Team Members"
    "STREAM"                             = "Microsoft Stream Trial"
    "EMSPREMIUM"                         = "ENTERPRISE MOBILITY + SECURITY E5"

}


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host "Creating body to request Graph access for each client." -ForegroundColor Green
    $CustomerToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -Tenant $customer.TenantID

    $headers = @{ "Authorization" = "Bearer $($CustomerToken.AccessToken)" }
    write-host "Collecting data for $($Customer.defaultdomainname)" -ForegroundColor Green
    $domains = Get-MsolDomain -TenantId $customer.TenantId | Where-Object { $_.status -contains "Verified" }
    $Licenselist = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/subscribedSkus" -Headers $Headers -Method Get -ContentType "application/json").value
    $Licenselist | ForEach-Object { $_.skupartnumber = "$($AccountSkuIdDecodeData.$($_.skupartnumber))" }
    $AdminRole = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/DirectoryRoles' -Headers $Headers -Method Get -ContentType "application/json").value | Where-Object { $_.displayname -eq 'Company Administrator' }
    $Admins = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/DirectoryRoles/$($AdminRole.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, userprincipalname
    $Users = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/users?$top=999' -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, proxyaddresses, AssignedLicenses, userprincipalname
    $MFAStatus = Get-MsolUser -all -TenantId $customer.TenantId | Select-Object DisplayName, UserPrincipalName, @{N = "MFA Status"; E = { if ( $_.StrongAuthenticationRequirements.State -ne $null) { $_.StrongAuthenticationRequirements.State } else { "Disabled" } } }


    $UserObj = foreach ($user in $users) {
        $Addresses = ($user.proxyaddresses | Sort-Object -Descending) -join "`n" -creplace "SMTP:", "Primary:" -creplace "smtp:", "Alias:"
        [PSCustomObject]@{
            'Display name'      = $user.displayname
            'Addresses'         = [System.Web.HttpUtility]::HtmlDecode($Addresses)
            "Licenses Assigned" = ($Licenselist | Where-Object { $_.skuid -in $User.assignedLicenses.skuid }).skupartnumber -join "`n"
            "MFA Enabled"       = ($MFAStatus | Where-Object { $_.UserPrincipalName -eq $user.userPrincipalName }).'MFA Status'
        }
    
    }

    $licenseObj = foreach ($License in $Licenselist) {
        [PSCustomObject]@{
            'License Name'      = $license.skupartnumber
            'Active Licenses'   = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended
            'Consumed Licenses' = $license.consumedunits
            'unused licenses'   = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended - $license.consumedunits
        }  
    }
    
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "tenant-name"      = $customer.Name
                "tenant-id"        = $customer.TenantId
                "verified-domains" = ($domains | select-object Name, Status, IsDefault | convertto-html -Fragment | out-string)
                "admin-users"      = ($admins  | select-object DisplayName, UserPrincipalName | convertto-html -Fragment  | out-string)
                "licenses"         = ($licenseObj | select-object 'License Name', 'active licenses', 'consumed licenses', 'unused licenses' | convertto-html -Fragment  | out-string)
                "licensed-users"   = (($UserObj | Where-Object { $_.'Licenses Assigned' -ne "" }) | convertto-html -Fragment  | out-string)
                "unlicensed-users" = (($UserObj | Where-Object { $_.'Licenses Assigned' -eq "" }) | convertto-html -Fragment  | out-string)
                                    
            }
        }
    }
    
    write-output "             Finding $($customer.name) in IT-Glue"
    $DomainList = foreach ($Contact in $AllITGlueContacts) {
        $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
        [PSCustomObject]@{
            Domain   = $ITGDomain
            OrgID    = $Contact.'organization-id'
            Combined = "$($ITGDomain)$($Contact.'organization-id')"
        }
    }
    $domainList = $DomainList | sort-object -Property Combined -Unique

    $orgid = foreach ($customerDomain in $domains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Office Portal $($customer.name) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'tenant-id' -eq $customer.tenantid.guid }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org 
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new Office Portal $($customer.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody

        }
        else {
            write-output "                      Updating Office Portal $($customer.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }

    }

}