Category Archives: Series: PowerShell documenting

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. 🙂

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
        }

    }

}

Documenting with PowerShell: Breaches using the HIBP API

So I was thinking of this idea for a bit. My sales team got approached by a product that gives you information about what breaches you are in. There were a couple of issues we had with this product. The first part is that 90% of its data comes from the public “Have I been Pwned” database, while they claimed it was their own. The second was that the tool did not integrate with our documentation system directly. There were some weird limits like a maximum of one domain per client, so I figured I’d try to build something myself instead.

After some testing I’ve decided to make 3 versions of this script. One that directly uploads the breach information to IT-Glue. Another to create generic HTML files which you could easily send to clients. Lastly I also wanted to help our sales team out a little with a dynamic version. With this one you could enter emails and IP and get a nice looking report back.

So let’s get started! For all three scripts you’ll need 2 API keys. One for Have I been Pwned which will cost you €3,50 a month. You’ll need another for Shodan which can be free, premium, or bought in discount for 1 dollar once in a while.

IT-Glue version

The IT-Glue version creates a flexible asset for you, uploads the data per client. It looks up each e-mail address in the O365 tenant, and does a Shodan search for the registered domain names.

################### Secure Application Model Information ###################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$RefreshToken = 'ExtremelyLongRefreshToken'
################# /Secure Application Model Information ####################

################# API Keys #################################################
$ShodanAPIKey = 'YourShodanAPIKEy'
$HaveIBeenPwnedKey = 'HIBPAPIKey'
################# /API Keys ################################################

################# IT-Glue Information ######################################
$ITGkey = "ITGluekey"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Breach v1 - Autodoc"
$Description = "Automatic Documentation for known breaches."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /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
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.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            = "Breaches"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Shodan Info"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } 
  
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


write-host "Creating credentials and tokens." -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
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
$HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {

    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs for $($customer.name)" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "  Retrieving Breach Info for $($customer.name)" -ForegroundColor Green
    $UserList = get-msoluser -all -TenantId $Customer.TenantId
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user.UserPrincipalName)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user.UserPrincipalName
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "Getting Shodan information for $($Customer.name)'s domains."
    $SHodanInfo = foreach ($Domain in $CustomerDomains.Name) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo -or $SHodanInfo) { $ShodanInfo = @{'Detection' = "No information found for domains on Shodan"} }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'tenant-name' = $customer.DefaultDomainName
                'breaches'    = [System.Web.HttpUtility]::HtmlDecode($BreachListHTML -replace $TableStyling)
                'shodan-info' = ($ShodanHTML -replace $TableStyling)
            }
        }
    }

    write-host "   Uploading Breach Info $($customer.name) into IT-Glue" -foregroundColor green
    foreach ($org in $orgID | Select-Object -Unique) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'tenant-name' -eq $($Customer.DefaultDomainName) } | 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) {
            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 Breach Info for $($Customer.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
 
        }
        else {
            write-output "                      Updating Breach Info for $($Customer.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
 
    }
}

Generic version

So the generic version works the same as the IT-Glue version. The only difference is that it will create a file per tenant in C:\Temp. You can distribute this file or add it to your own documentation system manually or via an API I don’t know about. 🙂 I’ve added a screenshot for how this would look as requested.

################### Secure Application Model Information ###################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$RefreshToken = 'ExtremelyLongRefreshToken'
################# /Secure Application Model Information ####################

################# API Keys #################################################
$ShodanAPIKey = 'YourShodanAPIKEy'
$HaveIBeenPwnedKey = 'HIBPAPIKey'
################# /API Keys ################################################


$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>LNPP - Lime Networks Partner Portal</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
   
$PreContent = @"
<H1> Breach logbook</H1> <br>
   
This log contains all breaches found for the e-mail addresses in your Microsoft tenant. You can use the search to find specific e-mail addresses.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
   


write-host "Creating credentials and tokens." -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
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
$HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
  $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "  Retrieving Breach Info for $($customer.name)" -ForegroundColor Green
    $UserList = get-msoluser -all -TenantId $Customer.TenantId
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user.UserPrincipalName)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user.UserPrincipalName
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "Getting Shodan information for $($Customer.name)'s domains."
    $SHodanInfo = foreach ($Domain in $CustomerDomains.Name) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo) { $ShodanInfo = @{ 'Detection' = "No information found for domains on Shodan" } }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
$head,$PreContent,[System.Web.HttpUtility]::HtmlDecode($BreachListHTML),$ShodanHTML | Out-File "C:\temp\$($customer.name).html"
   
}

On-Demand version

So the on-demand version is transformed into a function. This allows you to enter the e-mail addresses as a list, and any IP addresses you also want to add to the report. An example query could be:

Get-BreachInfo -EmailAddress 'Person2@google.com','Person1@google.com' -IPs '1.1.1.1','cyberdrain.com' -ShodanAPIKey 'YourShodanKey' -HaveIBeenPwnedKey 'YourShodanKey'

We’re hosting this version on a Azure Function that our sales engineers can query whenever they need to. It makes it easy for them to create a report for a client on-demand.

function Get-BreachInfo {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$EmailAddress,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$IPs,
        [Parameter(Mandatory = $true)]$ShodanAPIKey,
        [Parameter(Mandatory = $true)]$HaveIBeenPwnedKey,
        [Parameter(Mandatory = $true)]$Outputfile
    )
    $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>LNPP - Lime Networks Partner Portal</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
   
    $PreContent = @"
<H1> Breach logbook</H1> <br>
   
This log contains all breaches found for the e-mail addresses in your Microsoft tenant. You can use the search to find specific e-mail addresses.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
    write-host "  Retrieving Breach Info" -ForegroundColor Green
    $UserList = $EmailAddress
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "      Getting Shodan information." -ForegroundColor Green
    $SHodanInfo = foreach ($Domain in $IPs) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo) { $ShodanInfo = @{ 'Detection' = "No information found for domains on Shodan" } }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
    $head, $PreContent, [System.Web.HttpUtility]::HtmlDecode($BreachListHTML), $ShodanHTML | Out-File $Outputfile
   
}

So that’s it! With this, I hope you can document your breaches a little a better and help clients understand the risks involved. As always, Happy PowerShelling!

Documenting with PowerShell: Documenting DHCP server settings

This script was requested by a friend of mine. She had trouble keeping her IP address management under control. A lot of changes on super and subscopes within networks caused her to lose oversight and she was wondering if there wasn’t a clean and automated way of generating documentation for this.

But before we dive into that script, I want to let you all know that the AzGlue function to protect, and help circumvent rate limitations for IT-Glue has been updated. Angus Warren made an amazing update. He added loads of functionality and security measures. If you are using AzGlue right now, I’d suggest to update to the latest version.

Angus his changes have updated the function entirely, and it’s practically a new product all together. I’ll be updating the documentation for this in the coming weeks.

The scripts

So, now let’s get to the scripts, I am sharing two versions again. One for IT-Glue, and one generic HTML version. The IT-Glue version creates the flexible asset for you at first run, and then documents the DHCP settings.

IT-Glue version

You can run this version by hand, using your RMM, or from a scheduled task.

###############
$ITGkey = "YOURIGLUEKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "DHCP Server - Autodoc"
$ITGlueOrgID = "ITGLUEORGID"
$Description = "A logbook for DHCP server witha ll information about scopes, superscopes, etc.."
##############
#Settings IT-Glue logon information
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 $ITGbaseURI
Add-ITGlueAPIKey $ITGkey

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            = "DHCP Server Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "DHCP Server Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "DHCP Server Database Information"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "DHCP Domain Authorisation"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "DHCP Scopes"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "DHCP Scope Information"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "DHCP Statistics"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

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


$DCHPServerSettings = Get-DhcpServerSetting | select-object ActivatePolicies, ConflictDetectionAttempts, DynamicBootp, IsAuthorized, IsDomainJoined, NapEnabled, NpsUnreachableAction, RestoreStatus | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server Settings</h1>" | Out-String
$databaseinfo = Get-DhcpServerDatabase | Select-Object BackupInterval, BackupPath, CleanupInterval, FileName, LoggingEnabled, RestoreFromBackup | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Database information</h1>" | Out-String
$DHCPDCAuth = Get-DhcpServerInDC | select-object IPAddress, DnsName  | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Domain Controller Authorisations</h1>" | Out-String
$Scopes = Get-DhcpServerv4Scope
$ScopesAvailable = $Scopes | Select-Object ScopeId, SubnetMask, StartRange, EndRange, ActivatePolicies, Delay, Description, LeaseDuration, MaxBootpClients, Name, NapEnable, NapProfile, State, SuperscopeName, Type | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server scopes</h1>" | Out-String
$ScopeInfo = foreach ($Scope in $scopes) {
    $scope | Get-DhcpServerv4Lease | select-object ScopeId, IPAddress, AddressState, ClientId, ClientType, Description, DnsRegistration, DnsRR, HostName, LeaseExpiryTime |  ConvertTo-Html -Fragment -PreContent "<h1>Scope Information: $($Scope.name) - $($scope.ScopeID) </h1>" | Out-String
}

$DHCPServerStats = Get-DhcpServerv4Statistics | Select-Object InUse, Available, Acks, AddressesAvailable, AddressesInUse, Declines, DelayedOffers, Discovers, Naks, Offers, PendingOffers, PercentageAvailable, PercentageInUse, PercentagePendingOffers, Releases, Requests, ScopesWithDelayConfigured, ServerStartTime, TotalAddresses, TotalScope | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server statistics</h1>" -As List | Out-String


write-host "Uploading to IT-Glue." -foregroundColor green
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'dhcp-server-name'                 = $env:computername
            'dhcp-server-settings'             = $DCHPServerSettings
            'dhcp-server-database-information' = $databaseinfo
            'dhcp-domain-authorisation'        = $DHCPDCAuth
            'dhcp-scopes'                      = $ScopesAvailable
            'dhcp-scope-information'           = $ScopeInfo
            'dhcp-statistics'                  = $DHCPServerStats
        }
    }
}
write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'dhcp-server-name' -eq $env:computername }

#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', $ITGlueOrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating DHCP Server Log into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing DHCP Server Log into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

Generic version

As requested for the generic version I’ve included a screenshot of the end results:

###############
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############


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

$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>DHCP Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@

$DCHPServerSettings = Get-DhcpServerSetting | select-object ActivatePolicies,ConflictDetectionAttempts,DynamicBootp,IsAuthorized,IsDomainJoined,NapEnabled,NpsUnreachableAction,RestoreStatus | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server Settings</h1>" | Out-String
$databaseinfo = Get-DhcpServerDatabase | Select-Object BackupInterval,BackupPath,CleanupInterval,FileName,LoggingEnabled,RestoreFromBackup | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Database information</h1>" | Out-String
$DHCPDCAuth = Get-DhcpServerInDC | select-object IPAddress,DnsName  |ConvertTo-Html -Fragment -PreContent "<h1>DHCP Domain Controller Authorisations</h1>" | Out-String
$Scopes = Get-DhcpServerv4Scope
$ScopesAvailable = $Scopes | Select-Object ScopeId,SubnetMask,StartRange,EndRange,ActivatePolicies,Delay,Description,LeaseDuration,MaxBootpClients,Name,NapEnable,NapProfile,State,SuperscopeName,Type | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server scopes</h1>" | Out-String
$ScopeInfo = foreach ($Scope in $scopes) {
    $scope | Get-DhcpServerv4Lease | select-object ScopeId, IPAddress, AddressState, ClientId, ClientType, Description, DnsRegistration, DnsRR, HostName, LeaseExpiryTime |  ConvertTo-Html -Fragment -PreContent "<h1>Scope Information: $($Scope.name) - $($scope.ScopeID) </h1>" | Out-String
}

$DHCPServerStats = Get-DhcpServerv4Statistics | Select-Object InUse,Available,Acks,AddressesAvailable,AddressesInUse,Declines,DelayedOffers,Discovers,Naks,Offers,PendingOffers,PercentageAvailable,PercentageInUse,PercentagePendingOffers,Releases,Requests,ScopesWithDelayConfigured,ServerStartTime,TotalAddresses,TotalScope | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server statistics</h1>" -As List | Out-String


$head, $DCHPServerSettings, $databaseinfo, $DHCPDCAuth, $ScopesAvailable,$ScopeInfo,$DHCPServerStats | out-file "C:\Temp\Auditoutput.html"

And that’s it! Nicole is now using this script to document her DHCP servers, and so can you. As always, Happy PowerShelling.

Documenting with PowerShell: Documenting Microsoft Teams

I was thinking of creating an automated teams mapping tool, which runs when a user logs on to a new machine to automatically sync all the Teams sites required and joined. I mostly wanted to do this because the current implementation of the registry/GPO method can take up to 8 hours.

During the creation of my tool I noticed I didn’t have an up to date document for all teams. So I figured I would create one. To do this, I’m using the Secure Application Model as always. The only difference is the method we are using to log onto the Graph API.

Logging onto the Graph API requires a token, normally we generate this token using the PartnerCenter module. This works fine for most tasks, but for using Teams with the Graph API we need a different claim, namely the “Client_Credentials” claim.

So instead of using the partner center module, we will create our own request for a token. I’ll show you how in the script.

I’ve created two versions again, one generic, and one for IT-Glue.

Generic version

The generic version creates 1 html file per team in C:\Temp. Feel free to modify this for your own documentation system. The result will look something like this:

################### Secure Application Model Information ###################
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' 
$TenantID = 'YourTenantID'
$RefreshToken = 'VeryLongRefreshToken'
################# /Secure Application Model Information ####################
write-host "Creating credentials and tokens." -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$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' -ServicePrincipal

write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
write-host "Setting HTML Headers" -ForegroundColor Green
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@



write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {

    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.accesstoken)" }
    
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    $headers = @{ "Authorization" = "Bearer $($CustgraphToken.AccessToken)" }
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    $TeamSettings = foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value

        [PSCustomObject]@{
            'Team Name'          = $settings.displayname
            'Team ID'            = $settings.id
            'Description'        = $Settings.description
            'Teams URL'          = $settings.webUrl
            'Messaging Settings' = $settings.messagingSettings
            'Member Settings'    = $Settings.memberSettings
            'Guest Settings'     = $Settings.guestSettings
            'Fun Settings'       = $settings.funSettings
            'Discovery Settings' = $settings.discoverySettings
            'Is Archived'        = $Settings.isArchived
            'Team Owners'        = $Owners | Select-Object Displayname, UserPrincipalname
            'Team Members'       = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname
            'Team Guests'        = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname
        }
        $SettingsHTML = $Settings | ConvertTo-Html -as List - -Fragment -PreContent "<h1>Settings<h2>" | Out-String
        $OwnersHTML = $Owners | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Owners<h2>" | Out-String
        $MembersHTML = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html  -Fragment -PreContent "<h1>Members<h2>" | Out-String
        $GuestsHTML = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Guests<h2>" | Out-String
    
        $head, $SettingsHTML, $OwnersHTML, $MembersHTML, $GuestsHTML -replace "<th>", "<th style=`"background-color:#4CAF50`">" | Out-File "C:\Temp\$($Customer.name) - $($Settings.displayname).html"

    }


}

IT-Glue version

So the IT-Glue version is quite a bit longer; I’ve also improved the previous domain matching logic we used to make the script run much faster. This script creates the Flexible Asset for you when running it the first time. We match the teams based on the Team Name. This script can also run from an Azure Function.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
 
################# IT-Glue Information ######################################
$ITGkey = "YourITGAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Teams - Autodoc"
$Description = "Teams information automatically retrieved."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /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
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.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            = "Team Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Team URL"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Team Message settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Team Member settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Team Guest settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Team Fun Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Team Owners"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Team Members"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "Team Guests"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                      
  
  
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
  
 
 
write-host "Creating credentials and tokens." -ForegroundColor Green
 
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$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' -ServicePrincipal
 
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
 
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    write-host "Grabbing domains for client $($Customer.name)." -ForegroundColor Green
    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "Documenting in the following organizations." -ForegroundColor Green
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
 
        $FlexAssetBody =
        @{
            type       = 'flexible-assets'
            attributes = @{
                traits = @{
                    'team-name'             = $settings.displayname
                    'team-url'              = $settings.webUrl
                    'team-message-settings' = ($settings.messagingSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-member-settings'  = ($Settings.memberSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-guest-settings"   = ($Settings.guestSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-fun-settings"     = ($settings.funSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-owners'           = ($Owners | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-members'          = ($Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-guests'           = ($Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                }
            }
        }
 
        write-host "   Uploading $($Settings.displayName) into IT-Glue" -foregroundColor green
        foreach ($org in $orgID | Select-Object -Unique) {
            $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'team-name' -eq $Settings.displayName }
            #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 Team: $($Settings.displayName) into IT-Glue organisation $org"
                New-ITGlueFlexibleAssets -data $FlexAssetBody
     
            }
            else {
                write-output "                      Updating Team: $($Settings.displayName)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!

Automating with PowerShell: Using the Secure Application model updates.

I have a feeling I might be giving people a blogging overdose, but I’ve been playing with so much cool stuff the last couple of days. So lets get the ball rolling; I’ve finally found a method to connect to the SCC succesfully using the Secure Application model.

I’ve also found that non-partners can use the Secure Application Model too, for Exchange and the SCC. This is thanks to some people in the PowerShell Discord that inspired me to get to coding. 🙂

First off, partners can still use the older blog here to use the secure application model. This script is targeted to Microsoft Partners.

So, what exactly is the Secure Application Model?

The Secure application model is a method of connecting to Office365 services by using oauth instead of a regular username/password combination. By using oauth you use tokens instead. These tokens have a specific life-time and are revoked if they are not used.

The great benefit it gives is that you can run headless scripts, while still having MFA enabled. You won’t need to authenticate with MFA each time the script runs.

Non-Microsoft partners

To get started, you’ll need to give consent to the Exchange Application for your tenant. Microsoft uses the well-known-application-id “a0c73c16-a7e3-4564-9a95-2bdf47383716” for this. You can give consent by executing the following code:

$Exchangetoken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -Scopes 'https://outlook.office365.com/.default' -Tenant $TenantID -UseDeviceAuthentication
write-host "Exchange Token: $($ExchangeToken.RefreshToken)"

After this, you should store this token somewhere safe like an Azure Keyvault or password manager. With this token we can start connecting to resources.

Connecting to the Security center can be done with the following code. If you want to connect to both Exchange, and the Security center you will have to use a different token for each, as the token gets invalidated after use by one of the applications.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Permissions"

$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
import-session $SccSession -disablenamechecking -allowclobber

To connect to Exchange, you can use this code.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $Exchangetoken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
Import-PSSession $session -AllowClobber -DisableNameChecking

And that’s it for non partners. With this you can run unattended, headless scripts for O365 as a non-microsoft partner. You will need to install the PartnerCenter module to create the authentication tokens.

Partner Methods

After you collect your tokens, you can use this method to connect to the Security Center using the Secure Application Model for partners. This allows your to connect to each tenant under your administration.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$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
$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
import-pssession $SccSession -disablenamechecking -allowclobber
#YourCommands here

#/End of Commands

}

The rest of the partner methods remain the same, unless you want to connect to both the Security center and Exchange at the same time, then use the following code.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$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 "Connecting to the Security Center for client $($customer.name)"
    $SCCToken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
    $SCCTokenValue = ConvertTo-SecureString "Bearer $($SCCToken.AccessToken)" -AsPlainText -Force
    $SCCcredential = New-Object System.Management.Automation.PSCredential($upn, $SCCTokenValue)
    $SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $SCCcredential -AllowRedirection -Authentication Basic
    import-session $SccSession -disablenamechecking -allowclobber
    #YourCommands here

    #/End of Commands

    Remove-session $SccSession
    write-host "Connecting to the Exchange managed console for client $($customer.name)"

    Write-host "Enabling all settings 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
    $credentialExchange = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

    $ExchangeOnlineSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credentialExchange -Authentication Basic -AllowRedirection -erroraction Stop
    Import-PSSession -Session $ExchangeOnlineSession -AllowClobber -DisableNameChecking
    #YourCommands here

    #/End of Commands
    Remove-PSSession $ExchangeOnlineSession
}

And that’s it! I hope that helps partners and non-partners alike. As always, Happy PowerShelling. 🙂

Documenting with PowerShell: creating a device audit log

My engineers sometimes have questions about past events on a specific machine, to which I often point them towards our RMM system. The RMM system contains a bulk of data that can be used to make analysis.

The problem is just that – It’s unedited bulk data, often with different locations for different pieces of information.

So to assist them in gathering this info quicker I’ve decided to create an IT-Glue Automatic documentation for a device audit log of sorts. This audit log can be reviewed to check information easier and quicker than in the RMM system. It also contains more legacy information. e.g. “Who logged onto this computer last year”.

As always, I’ve created two scripts. One for IT-Glue users, the other for generic documentation systems. Both scripts collect the following info:

  • All user profiles on the computer
  • Installed updates
  • Installed software
  • Events such as the first time a user logs on, software installations/removal, kb installations and if the WAN IP has changed and to what. You can easily add new events if you’d like.

IT-Glue version

The IT-Glue version grabs the current asset from IT-Glue and tags this. That way you can find the log attached to the correct device. Cool huh!? It also creates the flexible asset for you, if it does not exist.

###############
$ITGkey = "ITGLUEAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Device logbook - Autodoc"
$ITGlueOrgID = "ORGID"
$Description = "A logbook for each device that contains information about the last logged on users, dates of software installation."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

#Settings IT-Glue logon information
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 $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
 
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            = "Device Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Events"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "User Profiles"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Installed Updates"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Installed Software"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Device"
                            kind           = "Tag"
                            "tag-type"     = 'Configurations'
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


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

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$events = $events | Sort-Object -Property date -Descending
$TaggedResource = (Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number (get-ciminstance win32_bios).serialnumber).data
$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling

write-host "Uploading to IT-Glue." -foregroundColor green
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'device-name'        = $env:computername
            'events'             = $eventshtml
            'user-profiles'      = $ProfilesHTML
            'installed-updates'  = $UpdatesHTML
            'installed-software' = $SoftwareHTML
            'device'             = $TaggedResource.id
        }
    }
}
write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'device-name' -eq $env:computername }

#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', $ITGlueOrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Device Asset Log into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Device Asset Log into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
    write-host "  Adding previous events from asset to current one."  -ForegroundColor Green
    $CombinedList =  "$($Eventshtml)`n$($ExistingFlexAsset.attributes.traits.events)" -split "`n" | Select-Object -Unique | Out-String
    $FlexAssetBody.attributes.traits.events = $CombinedList
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}
 

Generic version

The generic version collects the same info, but outputs this to C:\Temp\Auditoutput.html.

###############
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

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

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@


$events = $events | Sort-Object -Property date -Descending

$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling


$head,$eventshtml,$ProfilesHTML,$updatesHTML,$SoftwareHTML | out-file "C:\Temp\Auditoutput.html"

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

Documenting with PowerShell: Syncing Unifi devices to IT-Glue

This blog should be used in together with my previous blog about Unifi documentation. This script syncs all devices to IT-Glue and makes sure the configurations are in sync with eachother.

It will overwrite any changes you’ve made to the configurations, so treat with care.

###############
$ITGkey = "ITGlueKey"
$ITGbaseURI = "https://api.eu.itglue.com"
$UnifiBaseUri = "https://Controller.yourdomain.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIPASSWORD"
##############

#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
write-host "Checking if products exist in IT-Glue, and if not creating them."
$unifiAllModels = @"
[{"c":"BZ2","t":"uap","n":"UniFi AP"},{"c":"BZ2LR","t":"uap","n":"UniFi AP-LR"},{"c":"U2HSR","t":"uap","n":"UniFi AP-Outdoor+"},
{"c":"U2IW","t":"uap","n":"UniFi AP-In Wall"},{"c":"U2L48","t":"uap","n":"UniFi AP-LR"},{"c":"U2Lv2","t":"uap","n":"UniFi AP-LR v2"},
{"c":"U2M","t":"uap","n":"UniFi AP-Mini"},{"c":"U2O","t":"uap","n":"UniFi AP-Outdoor"},{"c":"U2S48","t":"uap","n":"UniFi AP"},
{"c":"U2Sv2","t":"uap","n":"UniFi AP v2"},{"c":"U5O","t":"uap","n":"UniFi AP-Outdoor 5G"},{"c":"U7E","t":"uap","n":"UniFi AP-AC"},
{"c":"U7EDU","t":"uap","n":"UniFi AP-AC-EDU"},{"c":"U7Ev2","t":"uap","n":"UniFi AP-AC v2"},{"c":"U7HD","t":"uap","n":"UniFi AP-HD"},
{"c":"U7SHD","t":"uap","n":"UniFi AP-SHD"},{"c":"U7NHD","t":"uap","n":"UniFi AP-nanoHD"},{"c":"UCXG","t":"uap","n":"UniFi AP-XG"},
{"c":"UXSDM","t":"uap","n":"UniFi AP-BaseStationXG"},{"c":"UCMSH","t":"uap","n":"UniFi AP-MeshXG"},{"c":"U7IW","t":"uap","n":"UniFi AP-AC-In Wall"},
{"c":"U7IWP","t":"uap","n":"UniFi AP-AC-In Wall Pro"},{"c":"U7MP","t":"uap","n":"UniFi AP-AC-Mesh-Pro"},{"c":"U7LR","t":"uap","n":"UniFi AP-AC-LR"},
{"c":"U7LT","t":"uap","n":"UniFi AP-AC-Lite"},{"c":"U7O","t":"uap","n":"UniFi AP-AC Outdoor"},{"c":"U7P","t":"uap","n":"UniFi AP-Pro"},
{"c":"U7MSH","t":"uap","n":"UniFi AP-AC-Mesh"},{"c":"U7PG2","t":"uap","n":"UniFi AP-AC-Pro"},{"c":"p2N","t":"uap","n":"PicoStation M2"},
{"c":"US8","t":"usw","n":"UniFi Switch 8"},{"c":"US8P60","t":"usw","n":"UniFi Switch 8 POE-60W"},{"c":"US8P150","t":"usw","n":"UniFi Switch 8 POE-150W"},
{"c":"S28150","t":"usw","n":"UniFi Switch 8 AT-150W"},{"c":"USC8","t":"usw","n":"UniFi Switch 8"},{"c":"US16P150","t":"usw","n":"UniFi Switch 16 POE-150W"},
{"c":"S216150","t":"usw","n":"UniFi Switch 16 AT-150W"},{"c":"US24","t":"usw","n":"UniFi Switch 24"},{"c":"US24P250","t":"usw","n":"UniFi Switch 24 POE-250W"},
{"c":"US24PL2","t":"usw","n":"UniFi Switch 24 L2 POE"},{"c":"US24P500","t":"usw","n":"UniFi Switch 24 POE-500W"},{"c":"S224250","t":"usw","n":"UniFi Switch 24 AT-250W"},
{"c":"S224500","t":"usw","n":"UniFi Switch 24 AT-500W"},{"c":"US48","t":"usw","n":"UniFi Switch 48"},{"c":"US48P500","t":"usw","n":"UniFi Switch 48 POE-500W"},
{"c":"US48PL2","t":"usw","n":"UniFi Switch 48 L2 POE"},{"c":"US48P750","t":"usw","n":"UniFi Switch 48 POE-750W"},{"c":"S248500","t":"usw","n":"UniFi Switch 48 AT-500W"},
{"c":"S248750","t":"usw","n":"UniFi Switch 48 AT-750W"},{"c":"US6XG150","t":"usw","n":"UniFi Switch 6XG POE-150W"},{"c":"USXG","t":"usw","n":"UniFi Switch 16XG"},
{"c":"UGW3","t":"ugw","n":"UniFi Security Gateway 3P"},{"c":"UGW4","t":"ugw","n":"UniFi Security Gateway 4P"},{"c":"UGWHD4","t":"ugw","n":"UniFi Security Gateway HD"},
{"c":"UGWXG","t":"ugw","n":"UniFi Security Gateway XG-8"},{"c":"UP4","t":"uph","n":"UniFi Phone-X"},{"c":"UP5","t":"uph","n":"UniFi Phone"},
{"c":"UP5t","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7","t":"uph","n":"UniFi Phone-Executive"},{"c":"UP5c","t":"uph","n":"UniFi Phone"},
{"c":"UP5tc","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7c","t":"uph","n":"UniFi Phone-Executive"}]
"@
 
$configTypes = @"
[{"t":"uap","n":"Unifi AP"},{"t":"usw","n":"Unifi Switch"},{"t":"ugw","n":"Unifi Gateway"},{"t":"uph","n":"Unifi VOIP"}]
"@ | ConvertFrom-Json

$unifiAllModels = $unifiAllModels | ConvertFrom-Json
$unifiModels = $unifiAllModels | Sort-Object n -Unique
$ITGConfigTypes = (Get-ITGlueConfigurationTypes).data
write-host "Check Config Types and creating if required" -ForegroundColor Green
foreach ($ConfType in $configTypes) {
    if ($ConfType.n -notin $ITGConfigTypes.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueConfigurationTypes -data @{
            type       = 'configuration-types'
            attributes = @{
                name = $ConfType.n
            }
        }

    }
}
$ExistingModels = (Get-ITGlueModels -page_size 1000).data
write-host "Checkings manufacture and creating if required" -ForegroundColor Green
$Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
if (!$Manafacture) {
    New-ITGlueManufacturers -data @{
        type       = 'manufacturers'
        attributes = @{
            name = 'uniFi-2'
        }
    }
    $Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
}
write-host "Grabbing active status"
$ConfigurationStatusId = (Get-ITGlueConfigurationStatuses -filter_name 'Active').data.ID | Select-Object -Last 1
write-host "Checkings models and creating if required" -ForegroundColor Green
foreach ($Model in $unifiAllModels) {
    if ($model.n -notin $ExistingModels.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueModels -data @{
            type       = 'models'
            attributes = @{
                'manufacturer-id' = $Manafacture.id
                name              = $model.n
            }
        }

    }
}

write-host "Start configuration syncing process." -foregroundColor green


$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
}

foreach ($site in $sites) {
    $ITGlueOrgID = $site.desc.split('()')[1]
    if (!$ITGlueOrgID) {
        write-host "Could not get IT-Glue OrgID for site $($site.desc). Moving on to next site." -ForegroundColor Yellow
        continue
    }
    else {
        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green
    }

    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    foreach ($device in $unifiDevices.data) {
 ($ExistingConfiguration = Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number $device.serial).data.id | select-object -last 1
        $DeviceName = if (!$device.name) { "Unifi Device $($device.serial)" } else { $device.name }
        $ModelName = ($unifiAllModels | Where-Object { $_.c -eq $device.model }).n
        $modelid = $ExistingModels | Where-Object { $_.attributes.name -eq $ModelName } | Select-Object -last 1
        $ConfigName = ($configtypes | Where-Object { $_.t -eq $device.type }).n
        $Configurationtypeid = ($ITGConfigTypes | Where-Object { $_.attributes.name -eq $Configname }).id
        $ConfigurationBody = @{
            type       = "configurations"
            attributes = @{
                "organization-id"         = $ITGlueOrgID
                "name"                    = $DeviceName
                "configuration-type-id"   = $Configurationtypeid
                "configuration-status-id" = $ConfigurationStatusId
                "manufacturer-id"         = $Manafacture.id
                "model-id"                = $ModelID.id
                "primary-ip"              = $device.ip
                "serial-number"           = $device.serial
                "mac-address"             = $device.mac
            }
        }
        if (!$ExistingConfiguration) { 
            write-host "Creating new device" -ForegroundColor Green
            New-ITGlueConfigurations -organization_id $ITGlueOrgID -data $ConfigurationBody
        } 
        else {
            write-host "Editing previous existing device" -ForegroundColor Green
            Set-ITGlueConfigurations -id ($ExistingConfiguration.data.id | Select-Object -last 1) -data $ConfigurationBody

        }
    }


}

And that’s it! this blog will be followed up by another documentation blog pretty quick, so its shorter than normal. As always, Happy PowerShelling.

Documenting and monitoring blogs updates

No new blog today, its officially a bank holiday and I’m enjoying the sun 🙂 I did make sure not to leave my readers empty handed. A bunch of my blogs got a little bit outdated, so I decided to update them.

The following blogs have been updated:

O365 blogs

I’ve updated the Secure Application Model blog with a method to retrieve new tokens, I’ve also fixed small logic bugs 🙂

Also I’ve solved some html encoding issues with the Faster Partner Portal blog. I added the read-only MFA portal so its easier to find out which user has enabled and which not

The easier to read and automatic downloading of the audit logs has also been updated.

Monitoring Blogs

Since Microsoft decided to change the names for Office, I’ve updated the blog to monitor and install office C2R updates. My friend Stan was kind of enough to share all the possible URL’s and names with me.

I’ve also updated the Dell DCU blog so it downloads the latest version from Dell itself, instead of having to create a ZIP file, for some extra ease of use.

The script about monitoring and deploying the client based VPNs has also been updated to allow more types of VPN to be deployed.

After some troubeshooting my friend Isaac found that somehow I copy and pasted some blogs into each other for the WOL enablement and monitoring scripts. We’ve fixed that and the current version also improved on the detection.

Documentation blogs

So a bunch of the documentation blogs have had minor updates. I think its easiest if you use the documentation scripts to grab the latest update by browsing the category. Even the most recent unifi infrastructure had a small logic mistake for the generic version.

Automation blogs

I also updated the warranty lookup script, the GPO Deployment alternative and the IT-Glue backup script. The last one now also generates an HTML file with the passwords per client, instead of just one general password file. I’d still advise you to be really careful with that one of course.

And last but not least; Github

I’ve finally created some public github repos as per popular request. The github repos can be found here. I’ll try to keep it up to date as much as possible and will also included some of my bigger projects in there; right now I am working on the new Autotask REST API that is going to be released in version 2020.2.

So that’s all updates, I hope you enjoy and as always, happy PowerShelling!

Documenting with PowerShell: Documenting Unifi infrastructure

This blog is based on an earlier blog by Eliot Munro; Syncing Unifi Sites with IT-Glue by Eliot Munro. I loved the script, but wanted a little bit of extra information, I also didn’t really like the syncing with a Sharepoint list, so I modified the script to use the site name instead.

So, to use this version you’ll have to make some modifications to your Unifi site names. Rename your sites to follow this template: Name (ITGlueID). An example world be “SuperClient (1234)”. I also added a bit more IPSEC VPN information.

This script also has an added feature of documenting the switches for the site, where it lists which ports are in use and which ports are using PoE. A small example screenshot:

As with all my documentation scripts; it creates the flexible asset in IT-Glue for you. You can run this from an Azure runbook, your RMM system, or your personal workstation.

IT-Glue version

###############
$ITGkey = "ITGAPIKey"
$ITGbaseURI = "https://api.eu.itglue.com"
$UnifiBaseUri = "https://YourController.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIPAssword"
$FlexAssetName = "Unifi Controller Autodoc"
$Description = "A network one-page document that displays the unifi status."
##############

$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
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            = "Site Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "WAN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "LAN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "VPN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Wi-Fi"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },  
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Port Forwards"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }, @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Switches"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

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


$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
}

foreach ($site in $sites) {
    $ITGlueOrgID = $site.desc.split('()')[1]
    if (!$ITGlueOrgID) {
        write-host "Could not get IT-Glue OrgID for site $($site.desc). Moving on to next site." -ForegroundColor Yellow
        continue
    }
    else {
        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green
    }

    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    $UnifiSwitches = $unifiDevices.data | Where-Object { $_.type -contains "usw" }
    $SwitchPorts = foreach ($unifiswitch in $UnifiSwitches) {
        "<h2>$($unifiswitch.name) - $($unifiswitch.mac)</h2> <table><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            "<th>$($port.port_idx)</th>"
        }
        "</tr><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            $colour = if ($port.up -eq $true) { '02ab26' } else { 'ad2323' }
            $speed = switch ($port.speed) {
                10000 { "10Gb" }
                1000 { "1Gb" }
                100 { "100Mb" }
                10 { "10Mb" }
                0 { "Port off" }
            }
            "<td style='background-color:#$($colour)'>$speed</td>"
        }
        '</tr><tr>'
        foreach ($Port in $unifiswitch.port_table) {
            $poestate = if ($port.poe_enable -eq $true) { 'PoE on'; $colour = '02ab26' } elseif ($port.port_poe -eq $false) { 'No PoE'; $colour = '#696363' } else { "PoE Off"; $colour = 'ad2323' }
            "<td style='background-color:#$($colour)'>$Poestate</td >"
        }
        '</tr></table>'
    }

    $uaps = $unifiDevices.data | Where-Object { $_.type -contains "uap" }

    $Wifinetworks = $uaps.vap_table | Group-Object Essid
    $wifi = foreach ($Wifinetwork in $Wifinetworks) {
        $Wifinetwork | Select-object @{n = "SSID"; e = { $_.Name } }, @{n = "Access Points"; e = { $uaps.name -join "`n" } }, @{n = "Channel"; e = { $_.group.channel -join ", " } }, @{n = "Usage"; e = { $_.group.usage | Sort-Object -Unique } }, @{n = "Enabled"; e = { $_.group.up | sort-object -Unique } }
    } 
     
    $alarms = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/alarm" -WebSession $websession).data
    $alarms = $alarms | Select-Object @{n = "Universal Time"; e = { [datetime]$_.datetime } }, @{n = "Device Name"; e = { $_.$(($_ | Get-Member | Where-Object { $_.Name -match "_name" }).name) } }, @{n = "Message"; e = { $_.msg } } -First 10

    $portforward = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/portforward" -WebSession $websession).data
    $portForward = $portforward | Select-Object Name, @{n = "Source"; e = { "$($_.src):$($_.dst_port)" } }, @{n = "Destination"; e = { "$($_.fwd):$($_.fwd_port)" } }, @{n = "Protocol"; e = { $_.proto } }

 
    $networkConf = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/networkconf" -WebSession $websession).data
 
    $NetworkInfo = foreach ($network in $networkConf) {
        [pscustomobject] @{
            'Purpose'                 = $network.purpose
            'Name'                    = $network.name
            'vlan'                    = "$($network.vlan_enabled) $($network.vlan)" 
            "LAN IP Subnet"           =	$network.ip_subnet                 
            "LAN DHCP Relay Enabled"  =	$network.dhcp_relay_enabled        
            "LAN DHCP Enabled"        =	$network.dhcpd_enabled
            "LAN Network Group"       =	$network.networkgroup              
            "LAN Domain Name"         =	$network.domain_name               
            "LAN DHCP Lease Time"     =	$network.dhcpd_leasetime           
            "LAN DNS 1"               =	$network.dhcpd_dns_1               
            "LAN DNS 2"               =	$network.dhcpd_dns_2               
            "LAN DNS 3"               =	$network.dhcpd_dns_3               
            "LAN DNS 4"               =	$network.dhcpd_dns_4                           
            'DHCP Range'              = "$($network.dhcpd_start) - $($network.dhcpd_stop)"
            "WAN IP Type"             = $network.wan_type 
            'WAN IP'                  = $network.wan_ip 
            "WAN Subnet"              = $network.wan_netmask
            'WAN Gateway'             = $network.wan_gateway 
            "WAN DNS 1"               = $network.wan_dns1 
            "WAN DNS 2"               = $network.wan_dns2 
            "WAN Failover Type"       = $network.wan_load_balance_type
            'VPN Ike Version'         = $network.ipsec_key_exchange
            'VPN Encryption protocol' = $network.ipsec_encryption
            'VPN Hashing protocol'    = $network.ipsec_hash
            'VPN DH Group'            = $network.ipsec_dh_group
            'VPN PFS Enabled'         = $network.ipsec_pfs
            'VPN Dynamic Routing'     = $network.ipsec_dynamic_routing
            'VPN Local IP'            = $network.ipsec_local_ip
            'VPN Peer IP'             = $network.ipsec_peer_ip
            'VPN IPSEC Key'           = $network.x_ipsec_pre_shared_key
        }

    }

    $WANs = ($networkinfo | where-object { $_.Purpose -eq "wan" } | select-object Name, *WAN* | convertto-html -frag | out-string) -replace $tablestyling
    $LANS = ($networkinfo | where-object { $_.Purpose -eq "corporate" } | select-object Name, *LAN* | convertto-html -frag | out-string) -replace $tablestyling
    $VPNs = ($networkinfo | where-object { $_.Purpose -eq "site-vpn" } | select-object Name, *VPN* | convertto-html -frag | out-string) -replace $tablestyling
    $Wifi = ($wifi | convertto-html -frag | out-string) -replace $tablestyling
    $PortForwards = ($Portforward | convertto-html -frag | out-string) -replace $tablestyling

    $FlexAssetBody = @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'site-name'     = $site.name
                'wan'           = $WANs
                'lan'           = $LANS
                'vpn'           = $VPNs
                'wi-fi'          = $wifi
                'port-forwards' = $PortForwards
                'switches'      = ($SwitchPorts | out-string)
            }
        }
    }
    write-host "Documenting to IT-Glue"  -ForegroundColor Green
    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'site-name' -eq $site.name }
    #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', $ITGlueOrgID)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
        write-host "  Creating Unifi into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
        New-ITGlueFlexibleAssets -data $FlexAssetBody
    }
    else {
        write-host "  Editing Unifi into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
        $ExistingFlexAsset = $ExistingFlexAsset[-1]
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
    }

}


Generic HTML Version

So as always, I’ve also included a generic version. You can use this generic version with your own documentation system. Per popular request here’s a screenshot on how that looks.

###############
$UnifiBaseUri = "https://Controller.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIUSER"
##############

$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"

$UniFiCredentials = @{
    username = $UnifiUser
    password = $UnifiPassword
    remember = $true
} | ConvertTo-Json
 
$UnifiCredentials
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
}

foreach ($site in $sites) {

        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green


    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    $UnifiSwitches = $unifiDevices.data | Where-Object { $_.type -contains "usw" }
    $SwitchPorts = foreach ($unifiswitch in $UnifiSwitches) {
        "<h2>$($unifiswitch.name) - $($unifiswitch.mac)</h2> <table><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            "<th>$($port.port_idx)</th>"
        }
        "</tr><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            $colour = if ($port.up -eq $true) { '02ab26' } else { 'ad2323' }
            $speed = switch ($port.speed) {
                10000 { "10Gb" }
                1000 { "1Gb" }
                100 { "100Mb" }
                10 { "10Mb" }
                0 { "Port off" }
            }
            "<td style='background-color:#$($colour)'>$speed</td>"
        }
        '</tr><tr>'
        foreach ($Port in $unifiswitch.port_table) {
            $poestate = if ($port.poe_enable -eq $true) { 'PoE on'; $colour = '02ab26' } elseif ($port.port_poe -eq $false) { 'No PoE'; $colour = '#696363' } else { "PoE Off"; $colour = 'ad2323' }
            "<td style='background-color:#$($colour)'>$Poestate</td >"
        }
        '</tr></table>'
    }

    $uaps = $unifiDevices.data | Where-Object { $_.type -contains "uap" }

    $Wifinetworks = $uaps.vap_table | Group-Object Essid
    $wifi = foreach ($Wifinetwork in $Wifinetworks) {
        $Wifinetwork | Select-object @{n = "SSID"; e = { $_.Name } }, @{n = "Access Points"; e = { $uaps.name -join "`n" } }, @{n = "Channel"; e = { $_.group.channel -join ", " } }, @{n = "Usage"; e = { $_.group.usage | Sort-Object -Unique } }, @{n = "Enabled"; e = { $_.group.up | sort-object -Unique } }
    } 
     
    $alarms = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/alarm" -WebSession $websession).data
    $alarms = $alarms | Select-Object @{n = "Universal Time"; e = { [datetime]$_.datetime } }, @{n = "Device Name"; e = { $_.$(($_ | Get-Member | Where-Object { $_.Name -match "_name" }).name) } }, @{n = "Message"; e = { $_.msg } } -First 10

    $portforward = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/portforward" -WebSession $websession).data
    $portForward = $portforward | Select-Object Name, @{n = "Source"; e = { "$($_.src):$($_.dst_port)" } }, @{n = "Destination"; e = { "$($_.fwd):$($_.fwd_port)" } }, @{n = "Protocol"; e = { $_.proto } }

 
    $networkConf = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/networkconf" -WebSession $websession).data
 
    $NetworkInfo = foreach ($network in $networkConf) {
        [pscustomobject] @{
            'Purpose'                 = $network.purpose
            'Name'                    = $network.name
            'vlan'                    = "$($network.vlan_enabled) $($network.vlan)" 
            "LAN IP Subnet"           =	$network.ip_subnet                 
            "LAN DHCP Relay Enabled"  =	$network.dhcp_relay_enabled        
            "LAN DHCP Enabled"        =	$network.dhcpd_enabled
            "LAN Network Group"       =	$network.networkgroup              
            "LAN Domain Name"         =	$network.domain_name               
            "LAN DHCP Lease Time"     =	$network.dhcpd_leasetime           
            "LAN DNS 1"               =	$network.dhcpd_dns_1               
            "LAN DNS 2"               =	$network.dhcpd_dns_2               
            "LAN DNS 3"               =	$network.dhcpd_dns_3               
            "LAN DNS 4"               =	$network.dhcpd_dns_4                           
            'DHCP Range'              = "$($network.dhcpd_start) - $($network.dhcpd_stop)"
            "WAN IP Type"             = $network.wan_type 
            'WAN IP'                  = $network.wan_ip 
            "WAN Subnet"              = $network.wan_netmask
            'WAN Gateway'             = $network.wan_gateway 
            "WAN DNS 1"               = $network.wan_dns1 
            "WAN DNS 2"               = $network.wan_dns2 
            "WAN Failover Type"       = $network.wan_load_balance_type
            'VPN Ike Version'         = $network.ipsec_key_exchange
            'VPN Encryption protocol' = $network.ipsec_encryption
            'VPN Hashing protocol'    = $network.ipsec_hash
            'VPN DH Group'            = $network.ipsec_dh_group
            'VPN PFS Enabled'         = $network.ipsec_pfs
            'VPN Dynamic Routing'     = $network.ipsec_dynamic_routing
            'VPN Local IP'            = $network.ipsec_local_ip
            'VPN Peer IP'             = $network.ipsec_peer_ip
            'VPN IPSEC Key'           = $network.x_ipsec_pre_shared_key
        }

    }
    $head = @"
    <script>
    function myFunction() {
        const filter = document.querySelector('#myInput').value.toUpperCase();
        const trs = document.querySelectorAll('table tr:not(.header)');
        trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
      }</script>
    <Title>Audit Log Report</Title>
    <style>
    body { background-color:#E5E4E2;
          font-family:Monospace;
          font-size:10pt; }
    td, th { border:0px solid black; 
            border-collapse:collapse;
            white-space:pre; }
    th { color:white;
        background-color:black; }
    table, tr, td, th {
         padding: 2px; 
         margin: 0px;
         white-space:pre; }
    tr:nth-child(odd) {background-color: lightgray}
    table { width:95%;margin-left:5px; margin-bottom:20px; }
    h2 {
    font-family:Tahoma;
    color:#6D7B8D;
    }
    .footer 
    { color:green; 
     margin-left:10px; 
     font-family:Tahoma;
     font-size:8pt;
     font-style:italic;
    }
    #myInput {
      background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
      background-position: 10px 12px; /* Position the search icon */
      background-repeat: no-repeat; /* Do not repeat the icon image */
      width: 50%; /* Full-width */
      font-size: 16px; /* Increase font-size */
      padding: 12px 20px 12px 40px; /* Add some padding */
      border: 1px solid #ddd; /* Add a grey border */
      margin-bottom: 12px; /* Add some space below the input */
    }
    </style>
"@
    $WANs = ($networkinfo | where-object { $_.Purpose -eq "wan" } | select-object Name, *WAN* | convertto-html -frag -PreContent "<h1>WANS</h2>" | out-string) -replace $tablestyling
    $LANS = ($networkinfo | where-object { $_.Purpose -eq "corporate" } | select-object Name, *LAN* | convertto-html -frag -PreContent "<h1>LANs</h2>" | out-string) -replace $tablestyling
    $VPNs = ($networkinfo | where-object { $_.Purpose -eq "site-vpn" } | select-object Name, *VPN* | convertto-html -frag -PreContent "<h1>VPNs</h2>" | out-string) -replace $tablestyling
    $Wifi = ($wifi | convertto-html -frag -PreContent "<h1>Wi-Fi</h2>" | out-string) -replace $tablestyling
    $PortForwards = ($Portforward | convertto-html -frag -PreContent "<h1>Port Forwards</h2>" | out-string) -replace $tablestyling
    
$head,$WANs,$LANS,$VPNs,$wifi,$PortForwards,$SwitchPorts | out-file "C:\Temp\$($site.desc).html"
}


So thats it! as always, Happy PowerShelling!

Documenting with PowerShell: Using PowerShell to create faster partner portal

I love having the ability to manage all clients from a single portal. My only issue is that the partner portal is quite error prone and sluggish, and it seems to get worse with each added client.

There are some more problems like only being able to find clients based on their name. So I’ve decided to make a quicker and a mostly simpler partner portal page. The page is a single HTML file with a search option. It allows you to access each of your clients portals using your credentials. The HTML page also allows search on all properties – I’ve added all domains in a hidden column so you can find clients based on the domain name too. Here’s how it looks:

There are two scripts; one that can run headless and uses the Secure App Model. Another that just uses your credentials.

Secure App Model Version

The reason I’ve made two versions is because we’re running a function page on azure that runs this script on a schedule.

Every day this page is updated for us and all my employees can access the page easily by browsing to the hosted page. I’ve of course made this page SSO with the Office365 credentials as described here. This way the page is always secured. Remember to change the last line to your own output folder.

#Related blog: https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/
########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
########################## Secure App Model Settings ############################
   
$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
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>LNPP - Lime Networks Partner Portal</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
  
$PreContent = @"
<H1> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

Non-Secure App Model version

This is a version that you can run on-demand, simply to create the HTML file and host it internally or if you don’t have rapid client expansion just run it whenever a client is added.

Connect-MsolService
$customers = Get-MsolPartnerContract -All
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>LNPP - Lime Networks Partner Portal</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
  
$PreContent = @"
<H1> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

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

Documenting with PowerShell: Documenting intune applications

So I’ve been writing about intune for a couple of times now, and figured I’d make another documentation blog. We’re going to document the applications. As always I will show you both IT-Glue, and a generic HTML version.

so the use case for this is so you can easily show your clients which applications you deploy. Its also easy to log into the documentation system and check exactly who has access to each application so you won’t have to directly grab the intune portal.

To get started you’ll need the secure application model for this script. If you use the IT-Glue version you will also need the API key.

IT-Glue version

########################## IT-Glue ############################
$APIKEy = "YourITGlueAPIKey"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "intune - Application documentation v1"
$Description = "Documentation for all registered intune applications"
########################## IT-Glue ############################

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'yourApplicationsecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourtenantID'
$RefreshToken = 'verylongrefreshtoken'
$upn = 'UPN-Used-To-Generate-Tokens'
########################## Secure App Model Settings ############################

write-host "Grabbing IT-Glue module" -ForegroundColor Green

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            = "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       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Application info"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                    


                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

#Grab all IT-Glue contacts to match the domain name.
write-host "Getting IT-Glue contact list" -foregroundColor green
$i = 0
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.count -ne 0) 


write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Baseuri = "https://graph.microsoft.com/beta"
$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
foreach ($Tenant in $Tenants) {
    $CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.windows.net/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $tenant.CustomerContextId | out-null
    write-host "Starting documentation process for $($Tenant.Displayname)" -ForegroundColor Green
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }

    write-host "Grabbing all applications for $($Tenant.Displayname)." -ForegroundColor Green
    try {
        $ApplicationList = (Invoke-RestMethod -Uri "$baseuri/deviceAppManagement/mobileApps/?`$expand=categories,assignments" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.'@odata.type' -eq "#microsoft.graph.win32LobApp" }
    }
    catch {
        write-host "     Could not grab application list for $($Tenant.Displayname). Is intune configured? Error was: $($_.Exception.Message)" -ForegroundColor Yellow
        continue
    }
    $Applications = foreach ($Application in $ApplicationList) {
        write-host "              grabbing Application Assignment for $($Application.displayname)" -ForegroundColor Green
        $GroupsRequired = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Required" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        $GroupsAvailable = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Available" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        [pscustomobject]@{
            Displayname               = $Application.Displayname
            description               = $Application.description
            Publisher                 = $application.Publisher
            "Featured Application"    = $application.IsFeatured
            Notes                     = $Application.notes
            "Application is assigned" = $application.isassigned
            "Install Command"         = $Application.InstallCommandLine
            "Uninstall Command"       = $Application.Uninstallcommandline
            "Architectures"           = $Application.applicableArchitectures
            "Created on"              = $Application.createdDateTime
            "Last Modified"           = $Application.LastModifieddatetime
            "Privacy Information URL" = $Application.PrivacyInformationURL
            "Information URL"         = $Application.PrivacyInformationURL
            "Required for group"      = $GroupsRequired -join "`n'"
            "Available to group"      = $GroupsAvailable -join "`n"
        } 

    }
    $TableStyling = "<th>", "<th> <style=`"background-color:#4CAF50`">"
    $AppHTML = ($applications | convertto-html -Fragment | out-string) -replace $TableStyling

    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'tenant-name'      = $tenant.DisplayName
                'tenant-id'        = $tenant.CustomerContextId
                'application-info' = $AppHTML
            }
        }
    }
    $customerdomains = get-azureaddomain
    $PrimaryDomain = ($customerdomains | Where-Object { $_.IsDefault -eq $true }).name
    Write-Host "          Finding $($customer.name) in IT-Glue" -ForegroundColor Green
    $orgID = @()
    foreach ($customerDomain in $customerdomains) {
        $orgID += ($AllITGlueContacts | Where-Object { $_.'contact-emails'.value -match $customerDomain.name }).'organization-id' | Select-Object -Unique
    }
    write-host "             Uploading Application list $($customer.name) into IT-Glue"  -ForegroundColor Green
    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 $tenant.CustomerContextId }
        #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', $org)
            $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
            write-host "                      Creating new Application list $($customer.name) into IT-Glue organisation $org" -ForegroundColor Green
            New-ITGlueFlexibleAssets -data $FlexAssetBody
        }
        else {
            write-host "                      Updating Application list$($customer.name) into IT-Glue organisation $org"  -ForegroundColor Green
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
        }
        Disconnect-AzureAD
    }


}

So, the script runs for all your tenants, this creates the flexible asset for you, grabs all applications and uploads them to IT-Glue.

Generic version

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'yourApplicationsecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourtenantID'
$RefreshToken = 'verylongrefreshtoken'
$upn = 'yourupn'
########################## Secure App Model Settings ############################
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Baseuri = "https://graph.microsoft.com/beta"
$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
$PreContent = @"
<H1> Graph Application Documentation</H1><br>

<br>Please note that this documentation only includes windows line-of-business applications and excludes the default applications such as ios and android applications.
<br/>
<br/>

<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@ 
  $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
$tenants = Get-AzureAdContract -All:$true



foreach ($Tenant in $Tenants) {
    write-host "Starting documentation process for $($Tenant.Displayname)" -ForegroundColor Green
    $CustomergraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $Tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustomergraphToken.AccessToken)"
    }

    write-host "Grabbing all applications for $($Tenant.Displayname)." -ForegroundColor Green
    try {
        $ApplicationList = (Invoke-RestMethod -Uri "$baseuri/deviceAppManagement/mobileApps/?`$expand=categories,assignments" -Headers $Header -Method get -ContentType "application/json").value | Where-Object {$_.'@odata.type' -eq "#microsoft.graph.win32LobApp"}
    }
    catch {
        write-host "     Could not grab application list for $($Tenant.Displayname). Is intune configured? Error was: $($_.Exception.Message)" -ForegroundColor Yellow
        continue
    }
    $Applications = foreach ($Application in $ApplicationList) {
        write-host "              grabbing Application Assignment for $($Application.displayname)" -ForegroundColor Green
        $GroupsRequired = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Required" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        $GroupsAvailable = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Available" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        [pscustomobject]@{
            Displayname               = $Application.Displayname
            description               = $Application.description
            Publisher                 = $application.Publisher
            "Featured Application"    = $application.IsFeatured
            Notes                     = $Application.notes
            "Application is assigned" = $application.isassigned
            "Install Command"         = $Application.InstallCommandLine
            "Uninstall Command"       = $Application.Uninstallcommandline
            "Architectures"           = $Application.applicableArchitectures
            "Created on"              = $Application.createdDateTime
            "Last Modified"           = $Application.LastModifieddatetime
            "Privacy Information URL" = $Application.PrivacyInformationURL
            "Information URL"         = $Application.PrivacyInformationURL
            "Required for group"      = $GroupsRequired -join "`n'"
            "Available to group"      = $GroupsAvailable -join "`n"
        } 

    }
    $applications | ConvertTo-Html -head $head -PreContent $PreContent | out-file "C:\temp\$($Tenant.Displayname).html"

}

You could use this generic HTML version for your own documentation system. I’ve included a screenshot of how it looks because this was asked for in the past 🙂

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

Documenting with PowerShell: Documenting Office 365 usage reports

I like knowing what specific parts of Office365 my clients use most, so I can customize their experience to the way they work. This means I can send them manuals for mobile usage when they are only using mobile phones, or I can help them in using Teams, Onedrive, or other stuff like Planner or ToDo to the fullest.

I also like using the usage report as an early alerting measure – but that’ll be a different blog this week. To get the reports, we’ll be using the Secure Application Model. We will need to add a single permission first. Do the following to create this permission:

  • 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 “Reports.Read.All”. Click on add permission
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

After giving these permissions, you can start running either of the scripts below. These gather all the usage reports that are available via the Azure AD Graph API for all your clients. These reports contain information like how many files are stored in Onedrive, what applications the client uses, and how many office activations the client has. I’ve listed all the ones that I like – Feel free to strip out the ones you do not use.

Generic Version

I’ve been asked a couple of times to include a screenshot of how the report could look. This is a template of one of my testing tenants, please note that with production tenants this will most likely be a much longer list. 🙂

Example Report – Generic HTML version

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating access tokens" -ForegroundColor Green
$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 -Tenant $tenantID 

write-host "Connecting to MSOLService" -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
write-host "Grabbing client list" -ForegroundColor Green
$customers = Get-MsolPartnerContract -All
write-host "Connecting to clients" -ForegroundColor Green

foreach ($customer in $customers) {
    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    write-host "Gathering Reports for $($Customer.name)" -ForegroundColor Green
    #Gathers which devices currently use Teams, and the details for these devices.
    $TeamsDeviceReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsDeviceUsageUserDetail(period='D7')"
    $TeamsDeviceReports = (Invoke-RestMethod -Uri $TeamsDeviceReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams device report</h1>" | Out-String
    #Gathers which Users currently use Teams, and the details for these Users.
    $TeamsUserReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')"
    $TeamsUserReports = (Invoke-RestMethod -Uri $TeamsUserReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams user report</h1>"| Out-String
    #Gathers which users currently use email and the details for these Users
    $EmailReportsURI = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D7')"
    $EmailReports = (Invoke-RestMethod -Uri $EmailReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email users Report</h1>"| Out-String
    #Gathers the storage used for each e-mail user.
    $MailboxUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period='D7')"
    $MailboxUsage = (Invoke-RestMethod -Uri $MailboxUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email storage report</h1>"| Out-String
    #Gathers the activations for each user of office.
    $O365ActivationsReportsURI = "https://graph.microsoft.com/v1.0/reports/getOffice365ActivationsUserDetail"
    $O365ActivationsReports = (Invoke-RestMethod -Uri $O365ActivationsReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>O365 Activation report</h1>"| Out-String
    #Gathers the Onedrive activity for each user.
    $OneDriveActivityURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveActivityUserDetail(period='D7')"
    $OneDriveActivityReports = (Invoke-RestMethod -Uri $OneDriveActivityURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Onedrive Activity report</h1>"| Out-String
    #Gathers the Onedrive usage for each user.
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>OneDrive usage report</h1>"| Out-String
    #Gathers the Sharepoint usage for each user.
    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Sharepoint usage report</h1>"| Out-String

$head = 
@"
      <Title>O365 Reports</Title>
    <style>
    body { background-color:#E5E4E2;
          font-family:Monospace;
          font-size:10pt; }
    td, th { border:0px solid black; 
            border-collapse:collapse;
            white-space:pre; }
    th { color:white;
        background-color:black; }
    table, tr, td, th {
         padding: 2px; 
         margin: 0px;
         white-space:pre; }
    tr:nth-child(odd) {background-color: lightgray}
    table { width:95%;margin-left:5px; margin-bottom:20px; }
    h2 {
    font-family:Tahoma;
    color:#6D7B8D;
    }
    .footer 
    { color:green; 
     margin-left:10px; 
     font-family:Tahoma;
     font-size:8pt;
     font-style:italic;
    }
    </style>
"@

$head,$TeamsDeviceReports,$TeamsUserReports,$EmailReports,$MailboxUsage,$O365ActivationsReports,$OneDriveActivityReports,$OneDriveUsageReports,$SharepointUsageReports | out-file "C:\Temp\$($Customer.name).html"


}

IT-Glue version

########################## Office 365 ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
########################## IT-Glue ############################
$APIKEy = "ITGLUEAPIEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Office365 Reports - AutoDoc v1"
$Description = "Office365 Reporting."
#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%`">"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
###########################
#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            = "Teams Device Reports"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Teams User Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Email Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Mailbox Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "O365 Activations Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "OneDrive Activity Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "OneDrive Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Sharepoint Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "TenantID"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
$AllITGlueContacts = @()
#Grab all IT-Glue contacts to match the domain name.
write-host "Getting IT-Glue contact list" -foregroundColor green
$i = 0
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.count -ne 0) 


write-host "Start documentation process." -foregroundColor green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating access tokens" -ForegroundColor Green
$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 
write-host "Connecting to MSOLService" -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
write-host "Grabbing client list" -ForegroundColor Green
$customers = Get-MsolPartnerContract -All
write-host "Connecting to clients" -ForegroundColor Green

foreach ($customer in $customers) {
    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    write-host "Gathering Reports for $($Customer.name)" -ForegroundColor Green
    #Gathers which devices currently use Teams, and the details for these devices.
    $TeamsDeviceReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsDeviceUsageUserDetail(period='D7')"
    $TeamsDeviceReports = (Invoke-RestMethod -Uri $TeamsDeviceReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams device report</h1>" | Out-String
    #Gathers which Users currently use Teams, and the details for these Users.
    $TeamsUserReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')"
    $TeamsUserReports = (Invoke-RestMethod -Uri $TeamsUserReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams user report</h1>" | Out-String
    #Gathers which users currently use email and the details for these Users
    $EmailReportsURI = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D7')"
    $EmailReports = (Invoke-RestMethod -Uri $EmailReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email users Report</h1>" | Out-String
    #Gathers the storage used for each e-mail user.
    $MailboxUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period='D7')"
    $MailboxUsage = (Invoke-RestMethod -Uri $MailboxUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email storage report</h1>" | Out-String
    #Gathers the activations for each user of office.
    $O365ActivationsReportsURI = "https://graph.microsoft.com/v1.0/reports/getOffice365ActivationsUserDetail"
    $O365ActivationsReports = (Invoke-RestMethod -Uri $O365ActivationsReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>O365 Activation report</h1>" | Out-String
    #Gathers the Onedrive activity for each user.
    $OneDriveActivityURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveActivityUserDetail(period='D7')"
    $OneDriveActivityReports = (Invoke-RestMethod -Uri $OneDriveActivityURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Onedrive Activity report</h1>" | Out-String
    #Gathers the Onedrive usage for each user.
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>OneDrive usage report</h1>" | Out-String
    #Gathers the Sharepoint usage for each user.
    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Sharepoint usage report</h1>" | Out-String
    
    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'teams-device-reports'      = ($TableHeader + $TeamsDeviceReports) -replace $TableStyling
                'teams-user-reports'        = ($TableHeader + $TeamsUserReports ) -replace $TableStyling
                'email-reports'             = ($TableHeader + $EmailReports) -replace $TableStyling
                'mailbox-usage-reports'     = ($TableHeader + $MailboxUsage) -replace $TableStyling
                'o365-activations-reports'  = ($TableHeader + $O365ActivationsReports) -replace $TableStyling
                'onedrive-activity-reports' = ($TableHeader + $OneDriveActivityReports) -replace $TableStyling
                'onedrive-usage-reports'    = ($TableHeader + $OneDriveUsageReports) -replace $TableStyling
                'sharepoint-usage-reports'  = ($TableHeader + $SharepointUsageReports) -replace $TableStyling
                'tenantid'                  = $customer.TenantId
            }
        }
    }
     
    Write-Host "          Finding $($customer.name) in IT-Glue" -ForegroundColor Green
    $orgID = @()
    $customerdomains = Get-MsolDomain -TenantId $customer.tenantid
    foreach ($customerDomain in $customerdomains) {
        $orgID += ($AllITGlueContacts | Where-Object { $_.'contact-emails'.value -match $customerDomain.name }).'organization-id' | Select-Object -Unique
    }
    write-host "             Uploading Reports $($customer.name) into IT-Glue"  -ForegroundColor Green
    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 $customer.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) {
            $FlexAssetBody.attributes.add('organization-id', $org)
            $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
            write-host "                      Creating Reports $($customer.name) into IT-Glue organisation $org" -ForegroundColor Green
            New-ITGlueFlexibleAssets -data $FlexAssetBody
            start-sleep 2
        }
        else {
            write-host "                      Updating Reports $($customer.name) into IT-Glue organisation $org"  -ForegroundColor Green
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
            start-sleep 2
        }

    }
    


}

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

Documenting with PowerShell: Passportal API Examples

UPDATE: The blog below is based on a private alpha/beta, and as such complete documentation is not yet available. Solarwinds is working on making the API available to everyone. 🙂

So recently I’ve gotten access to the alpha Solarwinds Passportal API, Passportal is a relatively young documentation platform that has the ability to store documents as plain html files but make a relational database out of it. The API is brand new so it’s a cool chance to make a small post about how people can approach the API and start using it for automatic documentation.

Currently the API is still in alpha/beta, so all of this blog can change. One thing to note is that currently the API endpoints cannot store passwords. That means that password-based documentation such as my Bitlocker blog is not yet available. 

I’ll consider making an unofficial Solarwinds Passportal PowerShell Module when the final version arrives, even if I’m not a regular PassPortal user I like having the same tools available. Anyway, let’s get started!

First, we’ll have to get our API key. You can get the API key by following these instructions:

Now that we have our key, we can get started with actual code. The passportal API uses access tokens to make sure that you are allowed to do anything on the API so our first job is generating an access token. Enter the required information for your environment.

$URL = "https://de-clover.passportalmsp.com/api"
$XAPIKey = "YOURAPIKEY"
$XAPISecret = "APISECRET1000"
#Next we will hash our secret to HMAC 256 using the secret "aUa&&XUQBJXz2x&". This is a preset hashing secret.
$secrethash = "aUa&&XUQBJXz2x&amp;" #Do not change this.
$hmacsha = New-Object System.Security.Cryptography.HMACSHA256
$hmacsha.key = [Text.Encoding]::ASCII.GetBytes($XAPISecret)
$signature = $hmacsha.ComputeHash([Text.Encoding]::ASCII.GetBytes($secrethash))
$XHash = [System.BitConverter]::ToString($signature).Replace('-', '').ToLower()
#After we have an encrypted method of sending the key we’re going to create the correct headers. 
$headers = @{
    'X-KEY'  = $XAPIKey
    'X-HASH' = $XHash
}
$Content = @{
    'content' = $secrethash
    'scope'   = 'docs_api'
}
#And now we can make a request to get our access key. This access key will be our actual login for the API this session.
$Tokens = Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/auth/client_token" -Method POST -Body $Content -ContentType "application/x-www-form-urlencoded" 

Now that we have an access token, we’ll remove our hashed API key from the headers, add our access token and try to get a list of all our clients.

#we'll remove our x-key and x-hash from the headers, and add the API access token instead. 
$headers.Remove('x-key')
$headers.Remove('x-hash')
$headers.Add('x-access-token', $Tokens.access_token)
$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results

So with this list of information, we’re able to grab all documents for all client by adding this part:

foreach($Client in $Clients){
    (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents?clientId=$($Client.id)" -Method Get -Verbose).results
}

To create a document for a specific client, we modify our script just a little bit and add the following code, in this example we’re filling in the default template supplied by Solarwinds within Passportal, for an Application called “Autodoc” – This application will be added to all clients.

$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results
$TemplateID = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/templates?resultsPerPage=1000" -Method Get -Verbose).results | Where-Object { $_.type -eq "application" }

foreach ($Client in $Clients) {
    $body = ConvertTo-Json @(@{
        templateUid      = $TemplateID.id
        clientId         = $client.id
        title            = "Autodoc CyberDrain.com API Test"
        application_name = "AutoDoc CyberDrain.com"
        version          = "1.0"
        notes            = "This was created with an API test."
    })

    Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents" -Method POST -Body $body -Verbose
}

Full script

$URL = "https://de-clover.passportalmsp.com/api"
$XAPIKey = "YOURAPIKEY"
$XAPISecret = "APISECRET1000"
#Next we will hash our secret to HMAC 256 using the secret "aUa&&amp;XUQBJXz2x&". This is a preset hashing secret.
$secrethash = "aUa&&XUQBJXz2x&"
$hmacsha = New-Object System.Security.Cryptography.HMACSHA256
$hmacsha.key = [Text.Encoding]::ASCII.GetBytes($XAPISecret)
$signature = $hmacsha.ComputeHash([Text.Encoding]::ASCII.GetBytes($secrethash))
$XHash = [System.BitConverter]::ToString($signature).Replace('-', '').ToLower()
#After we have an encrypted method of sending the key we’re going to create the correct headers. 
$headers = @{
    'X-KEY'  = $XAPIKey
    'X-HASH' = $XHash
}
$Content = @{
    'content' = $secrethash
    'scope'   = 'docs_api'
}
#And now we can make a request to get our access key. This access key will be our actual login for the API this session.
$Tokens = Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/auth/client_token" -Method POST -Body $Content -ContentType "application/x-www-form-urlencoded" 

#we'll remove our x-key and x-hash from the headers, and add the API access token instead. 
$headers.Remove('x-key')
$headers.Remove('x-hash')
$headers.Add('x-access-token', $Tokens.access_token)
#With the access key, we can make actual API requests. We'll try creating a document!

$TemplateID = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/templates?resultsPerPage=1000" -Method Get -Verbose).results | Where-Object { $_.type -eq "application" }
$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results

foreach ($Client in $Clients) {
    $body = ConvertTo-Json @(@{
        templateUid      = $TemplateID.id
        clientId         = $client.id
        title            = "Autodoc CyberDrain.com API Test"
        application_name = "AutoDoc CyberDrain.com"
        version          = "1.0"
        notes            = "This was created with an API test."
    })

    Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents" -Method POST -Body $body -Verbose
}

And that’s it! This API will be opening entire new avenues to documentation. I am loving the method of using plain HTML as a source for documents as most of my “Documenting with PowerShell” series has an HTML based solution too, it’s still going to take some time to evolve but with this tutorial it should be easy enough to add Passportal to my “Documenting with…” series.

Documenting with Powershell: Documenting Hyper-V settings

It’s been a couple of weeks since I’ve touched my Documenting with PowerShell series. I figured to get it started again we get going with Hyper-v. I use Hyper-v for nearly all our virtualized deployments. This script documents the following items:

  • The current Virtual Machines
  • The Virtual Machine network settings
  • The host network settings
  • The host settings
  • and the Virtual Replication settings

So this script has both been tested on a larger hyper-v cluster and a local hyper-v machine. We use this information if we ever need to do a rebuild or just check how the system is setup.

As always I’ve made two versions. One for IT-Glue, and one that generates a HTML file.

IT-Glue version

The IT-Glue version of the script uploads a new Flexible asset if it does not exist, and fills the data for you. If you don’t feel confident with leaving your API key in a script because your RMM cannot handle credentials that well, please check out this blog I wrote about the IT-Glue API.

########################## IT-Glue ############################
$APIKEy = "ITGLUEAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Hyper-v AutoDoc v2"
$OrgID = "YOURORGID"
$Description = "A network one-page document that displays the current Hyper-V Settings and virtual machines"
#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           = "Virtual Machines"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Network Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Replication Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Host Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

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

$FlexAssetBody =
@{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'host-name'            = $env:COMPUTERNAME
            'virtual-machines'     = $VirtualMachines
            'network-settings'     = $NetworkSettings
            'replication-settings' = $ReplicationSettings
            'host-settings'        = $HostSettings
        }
    }
}

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 }
#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
    $ExistingFlexAsset = $ExistingFlexAsset[-1]
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

Generic HTML version

########################## IT-Glue ############################
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################

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 -PreContent "<h2>Virtual Machines</h2>" | Out-String
$VirtualMachines = $TableHeader + ($VirtualMachines -replace $TableStyling) + $Whitespace
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS | convertto-html -Fragment -PreContent "<h2>Network Switches</h2>" | Out-String
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } } | ConvertTo-Html -Fragment -PreContent "<br><h2>VM Network Settings</h2>" | 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 "<h2>Replication Settings</h2>"  | 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 -PreContent "<h2>Host Settings</h2>"  | Out-String

$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
$head,$VirtualMachines,$NetworkSettings,$ReplicationSettings,$HostSettings |  Out-File "C:\temp\Hyper-v.html"

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