Category Archives: Series: PowerShell documenting

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.

Documenting with PowerShell: Documenting Azure AD Settings

Almost all of my clients currently are running Office365 and AzureAD in some shape or form. I like having the ability to look at what exactly is going on in their Azure AD environment. Previously we’ve talked about documenting the office365 side. Today we’re going to be using the Azure AD module to create documentation for all of our clients.

The script currently documents the following:

  • The normal users in the Azure AD
  • All guest users in the Azure AD
  • All domain admins in the Azure AD
  • The Applications registered in the AzureAD. (This also helps in preventing OAuth2 fraud.)
  • The devices registered in the AzureAD.
  • all domains attached to the AzureAD.

As always I’ve created two scripts for this. One is for use with IT-Glue, the other your own documentation system. Both of them use the Secure App Model to connect to all your partner tenants and download the information. If you have issues with rate limiting, look at my earlier blog here.

IT-Glue version

This version of the script uses the primary domain to match to IT-Glue contacts, and then uploads it to the correct client. If the flexible asset does not exist, it creates it for you. ūüôā

########################## IT-Glue ############################
$APIKEy = "ITGlueKey"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Azure AD - AutoDoc v2"
$Description = "A network one-page document that shows the Azure AD settings."
########################## IT-Glue ############################

########################## Azure AD ###########################
$ApplicationId         = 'xxxx-xxxx-xxx-xxxx-xxxx'
$ApplicationSecret     = 'TheSecretTheSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken  = 'ExchangeRefreshToken'
$upn                   = 'UPN-Used-To-Generate-Tokens'
########################## Azure AD ###########################
#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
 

#Connect to your Azure AD Account.
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $UPN -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$Customers = Get-AzureADContract -All:$true
Disconnect-AzureAD
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            = "Primary Domain Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Guest Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Domain admins"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Applications"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Devices"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Domains"
                            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 "Start documentation process." -foregroundColor green

foreach ($Customer in $Customers) {
    $CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.windows.net/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
    write-host "Connecting to $($customer.Displayname)" -foregroundColor green
    Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $customer.CustomerContextId | out-null
    write-host "       Documenting Users for $($customer.Displayname)" -foregroundColor green
    $Users = Get-AzureADUser -All:$true
    write-host "       Documenting Applications for $($customer.Displayname)" -foregroundColor green
    $Applications = Get-AzureADApplication -All:$true
    write-host "       Documenting Devices for $($customer.Displayname)" -foregroundColor green
    $Devices = Get-AzureADDevice -all:$true
    write-host "       Documenting AzureAD Domains for $($customer.Displayname)" -foregroundColor green
    $customerdomains = get-azureaddomain
    $AdminUsers = Get-AzureADDirectoryRole | Where-Object { $_.Displayname -eq "Company Administrator" } | Get-AzureADDirectoryRoleMember
    $PrimaryDomain = ($customerdomains | Where-Object { $_.IsDefault -eq $true }).name
    Disconnect-AzureAD
    $TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
    $Whitespace = "<br/>"
    $TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"

    $NormalUsers = $users | Where-Object { $_.UserType -eq "Member" } | Select-Object DisplayName, mail,ProxyAddresses | ConvertTo-Html -Fragment | Out-String
    $NormalUsers = $TableHeader + ($NormalUsers -replace $TableStyling) + $Whitespace
    $GuestUsers = $users | Where-Object { $_.UserType -ne "Member" } | Select-Object DisplayName, mail | ConvertTo-Html -Fragment | Out-String
    $GuestUsers =  $TableHeader + ($GuestUsers -replace $TableStyling) + $Whitespace
    $AdminUsers = $AdminUsers | Select-Object Displayname, mail | ConvertTo-Html -Fragment | Out-String
    $AdminUsers = $TableHeader + ($AdminUsers  -replace $TableStyling) + $Whitespace
    $Devices = $Devices | select-object DisplayName, DeviceOSType, DEviceOSversion, ApproximateLastLogonTimeStamp | ConvertTo-Html -Fragment | Out-String
    $Devices =  $TableHeader + ($Devices -replace $TableStyling) + $Whitespace
    $HTMLDomains = $customerdomains | Select-Object Name, IsDefault, IsInitial, Isverified | ConvertTo-Html -Fragment | Out-String
    $HTMLDomains = $TableHeader + ($HTMLDomains -replace $TableStyling) + $Whitespace
    $Applications = $Applications | Select-Object Displayname, AvailableToOtherTenants,PublisherDomain | ConvertTo-Html -Fragment | Out-String
    $Applications = $TableHeader + ($Applications -replace $TableStyling) + $Whitespace
    


    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'primary-domain-name' = $PrimaryDomain
                'users'               = $NormalUsers
                'guest-users'         = $GuestUsers
                'domain-admins'       = $AdminUsers
                'applications'        = $Applications
                'devices'             = $Devices
                'domains'             = $HTMLDomains
            }
        }
    }

    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 Azure AD $($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.'primary-domain-name' -eq $PrimaryDomain }
        #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 Azure AD $($customer.name) into IT-Glue organisation $org" -ForegroundColor Green
            New-ITGlueFlexibleAssets -data $FlexAssetBody
        }
        else {
            write-host "                      Updating Azure AD $($customer.name) into IT-Glue organisation $org"  -ForegroundColor Green
            $ExistingFlexAsset = $ExistingFlexAsset[-1]
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
        }

    }

}

HTML version

The HTML version creates a file in C:\Temp for each of your Azure AD environments.

########################## Azure AD ###########################
$ApplicationId         = 'xxxx-xxxx-xxx-xxxx-xxxx'
$ApplicationSecret     = 'TheSecretTheSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken  = 'ExchangeRefreshToken'
$upn                   = 'UPN-Used-To-Generate-Tokens'
########################## Azure AD ###########################
#Connect to your Azure AD Account.
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $UPN -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$Customers = Get-AzureADContract -All:$true
Disconnect-AzureAD
write-host "Start documentation process." -foregroundColor green

foreach ($Customer in $Customers) {
    $CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.windows.net/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $customer.CustomerContextId
    write-host "Connecting to $($customer.Displayname)" -foregroundColor green
    Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $customer.CustomerContextId | out-null
    write-host "       Documenting Users for $($customer.Displayname)" -foregroundColor green
    $Users = Get-AzureADUser -All:$true
    write-host "       Documenting Applications for $($customer.Displayname)" -foregroundColor green
    $Applications = Get-AzureADApplication -All:$true
    write-host "       Documenting Devices for $($customer.Displayname)" -foregroundColor green
    $Devices = Get-AzureADDevice -all:$true
    write-host "       Documenting AzureAD Domains for $($customer.Displayname)" -foregroundColor green
    $customerdomains = get-azureaddomain
    $AdminUsers = Get-AzureADDirectoryRole | Where-Object { $_.Displayname -eq "Company Administrator" } | Get-AzureADDirectoryRoleMember
    $PrimaryDomain = ($customerdomains | Where-Object { $_.IsDefault -eq $true }).name
    Disconnect-AzureAD
    $TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
    $Whitespace = "<br/>"
    $TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"

    $NormalUsers = $users | Where-Object { $_.UserType -eq "Member" } | Select-Object DisplayName, mail,ProxyAddresses | ConvertTo-Html -PreContent "<h2>Users</h2>" -Fragment | Out-String
    $NormalUsers = $TableHeader + ($NormalUsers -replace $TableStyling) + $Whitespace
    $GuestUsers = $users | Where-Object { $_.UserType -ne "Member" } | Select-Object DisplayName, mail | ConvertTo-Html -PreContent "<h2>Guests</h2>" -Fragment | Out-String
    $GuestUsers =  $TableHeader + ($GuestUsers -replace $TableStyling) + $Whitespace
    $AdminUsers = $AdminUsers | Select-Object Displayname, mail | ConvertTo-Html -PreContent "<h2>Admins</h2>" -Fragment | Out-String
    $AdminUsers = $TableHeader + ($AdminUsers  -replace $TableStyling) + $Whitespace
    $Devices = $Devices | select-object DisplayName, DeviceOSType, DEviceOSversion, ApproximateLastLogonTimeStamp | ConvertTo-Html -PreContent "<h2>Devices</h2>" -Fragment | Out-String
    $Devices =  $TableHeader + ($Devices -replace $TableStyling) + $Whitespace
    $HTMLDomains = $customerdomains | Select-Object Name, IsDefault, IsInitial, Isverified | ConvertTo-Html -PreContent "<h2>Domains</h2>" -Fragment | Out-String
    $HTMLDomains = $TableHeader + ($HTMLDomains -replace $TableStyling) + $Whitespace
    $Applications = $Applications | Select-Object Displayname, AvailableToOtherTenants,PublisherDomain | ConvertTo-Html -PreContent "<h2>Applications</h2>" -Fragment | Out-String
    $Applications = $TableHeader + ($Applications -replace $TableStyling) + $Whitespace
    $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 "      Done - Creating HTML file for $($customer.Displayname)" -foregroundColor green
    $head, $NormalUsers,$GuestUsers,$AdminUsers,$Applications, $Devices,$HTMLDomains | Out-File "C:\temp\$($customer.displayname).html"
}

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

Documenting with PowerShell: Increasing the Office365 Secure Score.

So previously we’ve spoken about documenting the Office 365 Secure Score. For a great resource on this I’d suggest you check out Eliot’s blog on documenting the Secure Score here. Its a fantastic resource.

This time I’m not going to focus on documenting the Secure Score directly – But increasing it. we want to make sure that the Secure Score is as high as possible with as little user impact as possible. To do this, I’ve selected some items that increase your secure score but have next to no impact on normal usage. Of course you’ll have to check if this is true for your environment too.

The Script

The script is set up to enable the following features for all tenants in your partner portal.

  • Move mail with a high confidence spam rating to the Junk Folder (Does not increase SecureScore, but was requested to add on Slack. You can remove this item if you only want the Secure Score increase)
  • Mailbox Auditing for all users
  • Mailbox Litigation hold where possible.
  • DelegateSentitemsStyle for mailboxes
  • NDR report for journaling.
  • Set the outbound spam filter reporting e-mail address
  • Set “Do not allow users to grant consent to unmanaged applications”
  • Disable password expire on user accounts
  • Enable the self-service password reset(I’d strongly recommend to first enable multi factor authentication for all your users.

I believe I could’ve added more features but I chose to only enable the ones with no to very little user impact. Using this script you can adapt it to all your wishes. It’s also very easy to disable one of the features – Just remove the the entire block of code that you do not want to enable.

#Set the recipient for outbound spam reports and Journaling NDRs.
$SpamAndEmailRecipient = "Helpdesk@limenetworks.nl"
#######################################################################
###################  CREDENTIALS     ##################################
$ApplicationId         = 'xxxx-xxxx-xxx-xxxx-xxxx'
$ApplicationSecret     = 'TheSecretTheSecrey' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken  = 'ExchangeRefreshToken'
$upn                   = 'UPN-Used-To-Generate-Tokens' 
###################  END CREDENTIALS ##################################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $Applicatio nId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&amp;BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session -AllowClobber -DisableNameChecking
    Write-Host "Starting process for client $($customer.name)" -ForegroundColor Green
    #Move mail with a high confidence spam to the Junk folder. 
    try {
        Get-HostedContentFilterPolicy -ErrorAction Stop | Set-HostedContentFilterPolicy -HighConfidenceSpamAction MoveToJmf -ErrorAction Stop 
    }
    catch {
        Write-Output "Failed to change the spam policy. $($_.Exception.Message)"
    }
    #Enable mailbox auditing for each user.
    try {
        Get-Mailbox -ResultSize Unlimited -ErrorAction Stop | Set-Mailbox -ErrorAction Stop -AuditEnabled $true -AuditOwner MailboxLogin, HardDelete, SoftDelete, Update, Move -AuditDelegate SendOnBehalf, MoveToDeletedItems, Move -AuditAdmin Copy, MessageBind 
    }
    catch {
        Write-Output "Failed to enable Mailbox auditing. $($_.Exception.Message)"
    }

    #Enable mailbox litigation hold
    try {
        Get-Mailbox -ResultSize Unlimited -ErrorAction Stop | Set-Mailbox -ErrorAction Stop -LitigationHoldEnabled $true -LitigationHoldDuration 2555 
    }
    catch {
        Write-Output "Failed to enable Mailbox Litigation hold. $($_.Exception.Message)"
    }
    #Enable DelegateSentItems.
    try {
        Get-Mailbox -ResultSize Unlimited -ErrorAction Stop | set-mailbox -ErrorAction Stop -MessageCopyForSentAsEnabled $true -MessageCopyForSendOnBehalfEnabled $true 
    }
    catch {
        Write-Output "Failed to enable DelegateSentItems style. $($_.Exception.Message)"
    }
    #Set Journaling NDR
    try {
        set-transportconfig -JournalingReportNdrTo "$SpamAndEmailRecipient" -ErrorAction Stop 
    }
    catch {
        Write-Output "Failed to set Transport Config Journaling NDR $($_.Exception.Message)"
    }
    #Set outbound spamfilter reporting
    try {
        Set-HostedOutboundSpamFilterPolicy "Default" -NotifyOutboundSpam $true -NotifyOutboundSpamRecipients $SpamAndEmailRecipient -ErrorAction Stop 
    }
    catch {
        Write-Output "Failed to set outbound spam settings $($_.Exception.Message)"
    }
    
    #Set "Do not allow users to grant consent to unmanaged applications"
    try {
        Set-MsolCompanySettings -tenantID $customer.TenantId -UsersPermissionToUserConsentToAppEnabled:$false -ErrorAction Stop 
    }
    catch {
        Write-Output "Failed to set Permissions to allow user to grant consent to unmanaged applications $($_.Exception.Message)"
    }
    #Disable password expire on accounts.
    try {
¬†¬†¬†¬†¬†¬†¬†¬†Get-MsolUser¬†-TenantId¬†$customer.TenantId¬†-ErrorAction¬†Stop¬†|¬†Set-MsolUser¬†‚ÄďPasswordNeverExpires¬†$true¬†-ErrorAction¬†Stop¬†
    }
    catch {
        Write-Output "Disable password expire failed. $($_.Exception.Message)"
    }

    #Enable Self Service Password Reset
    try {
        Set-MsolCompanySettings -TenantId $customer.TenantId -SelfServePasswordResetEnabled:$true -erroraction Stop
    }
    catch {
        Write-Output "Enabling Self Service Password Reset Failed. $($_.Exception.Message)"
    }


    Write-Host "Finished process for client $($customer.name)" -ForegroundColor Green
    Remove-PSSession $session
}

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

Documenting with PowerShell: Downloading and storing the Office 365 Audit logs (With search!)

As we’re continuing the documenting with PowerShell series I’d like to take a step away from our regular IT-Glue and Documentation scripts and look at something that is related to documentation but also the monitoring side of the house. We’re going to be checking out the Office 365 Unified Audit log.

The unified audit log is the log where all actions that you take in the O365 environment are logged too, which is a great solution for compliance, security, and finding those pesky hackers that are trying to break into our cool Office365 environment. There’s a couple of downsides to the Unified Audit log;

  • Audit logs are only retained for 30 days if you have a business subscription, or 90 days when you have an enterprise subscription. Sometimes you need to go back to what happened more than 3 months ago.
  • Searching the audit log online via the Security and Compliance center is slow and does not show all results.
  • When exporting the results via the webinterface a maximum of 5000 audit log records is exported, meaning you will have to create 10 exports if you have 50,000 items logged.
Introducing the CyberDrain.com Auditlog HTML generator.

These issues are the reason I’ve made the following script – I wanted a way to search the Audit Log easily and have all records included. My script will download all of the audit logs of the previous day and save them as a CSV file. It also creates a completely searchable HTML file for ease of use. I’ve set this up to automatically download all the audit files each day to a secure location. That way whenever I have to start digging into logs I can easily find what I’m looking for. I’ve been requested to put more screenshots of the finished results, so this is how the HTML file will look;

The script

The script uses the secure application model to connect to office365. You can find instructions for the secure application model in this blog.

##########################################
$ApplicationId         = 'xxxx-xxxx-xxx-xxxx-xxxx'
$ApplicationSecret     = 'TheSecretTheSecrey' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken  = 'ExchangeRefreshToken'
$upn                   = 'UPN-Used-To-Generate-Tokens'
##########################################
$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
#Logged in. Moving on to creating folders and getting data.
$folderName = (Get-Date).tostring("dd-MM-yyyy")
$outputfolder = "C:\ScriptOutput"
new-item -Path $outputfolder -ItemType Directory -Name $folderName -Force
foreach ($customer in $customers) {
  $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
  $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
  $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
  $customerId = $customer.DefaultDomainName
  $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
  Import-PSSession $session -allowclobber -DisableNameChecking

  $startDate = (Get-Date).AddDays(-1)
  $endDate = (Get-Date)
  $Logs = @()
  Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
  do {
    $logs += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate
    Write-Host "Retrieved $($logs.count) logs" -ForegroundColor Yellow
  }while ($Logs.count % 5000 -eq 0 -and $logs.count -ne 0)
  Write-Host "Finished Retrieving logs" -ForegroundColor Green
  $ObjLogs = foreach ($Log in $Logs) {
    $log.auditdata | convertfrom-json
  }
  $PreContent = @"
<H1> $($Customer.Name) - Audit Log from $StartDate until $EndDate </H1><br>

<br> Please note that this log is not complete - It is a representation where fields have been selected that are most commonly filtered on. .<br>
To analyze the complete log for this day, please click here for the complete CSV file log: <a href="$($Customer.Name).csv"/>CSV Logbook</a>
<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>
"@
  #$ObjLogs
  $Logs | export-csv "$($outputfolder)\$($FolderName)\$($Customer.Name).csv" -NoTypeInformation
  $ObjLogs | Select-object CreationTime, UserID, Operation, ResultStatus, ClientIP, Workload, ClientInfoString, * -ErrorAction SilentlyContinue | Convertto-html -head $head -PreContent $PreContent | out-file "$($outputfolder)\$($FolderName)\$($customer.Name).html"
}

Documenting with PowerShell: Handling IT-Glue API security and rate limiting.

I’ve been blogging a whole lot about documentation lately; I truly believe all automated documentation is better than just having people enter data manually. My company uses IT-Glue as a documentation system. IT-Glue is a very cool system but has some huge API limitations. For example; You’re allowed to make 10 requests per second and 10,000 requests per day. These limitations can get pretty bad if you manage a lot of workstations or servers that upload data at the same time.

After my previous blogs the comment I’ve received most was worries about the API key. If they key gets stolen you’re giving away the keys to the castle. The API has no limitations and with a leaked key all your documentation could be download. I’ve been discussing this issue with IT-Glue for some time but haven’t gotten a real solution yet. This has forced me to look for a solution myself. I gave myself some requirements for the solution.

  • The solution needed to be simple and accessible for everyone.
  • The solution needed to have multiple levels of authentication; an API key, IP whitelisting, and organization whitelisting.
  • The solution needed to block requests for all passwords/files/etc for all organisations.
  • The solution needed to allow some form of handling of the API rate limiting, e.g. repeating a request if it was rate limited.
  • The solution needed to be able to used, without adapting any scripts (except URLs and API codes.)

So after some research I decided to use an Azure Function for this. I’ve blogged about Azure Functions before, but the main reason is that running this function in the consumption model will cost us nothing (or next to nothing if you are an extremely heavy user.)

Setup

This time we will not use the Azure Function to only run a script but act as a “middleware” for the IT-Glue API. Follow this guide to set up your Azure function App. The only difference is that we select “PowerShell” as our runtime language. Do not continue at “Create an HTTP triggered function” as we’re going to be inserting our own function.

When the Function App has been deployed click on your Function’s name and then on “platform features”. You should be presented with the following screen

In this screen click on “Configuration” – We’re going to be adding some configuration options here that are used in our scripts. Add the three following items:

  • AzAPIKey: This will be the new API key you will enter on all your scripts that will upload data to IT-Glue. Generate a password for this or enter one of choice.
  • ITGlueURI: This is the current IT-Glue API url you use, most likely https://api.itglue.com or https://api.eu.itglue.com
  • ITGlueAPIKey: Your current API key. This is the only location that this API key will be used from now on.

After this you can return to the overview page and click the + symbol next to the “Functions”, Choose the “HTTP trigger” option. Name the HTTP trigger “AzGlueForwarder” and choose the Anonymous Authorisation level. This is because we are going to take care of authentication on the script level and not at the Azure Function level. After creating the function you’ll be presented with a script page. Paste the following script:

AZGlueForwarder
using namespace System.Net
param($Request, $TriggerMetadata)
#Check if AZapiKey is correct
if ($request.Headers.'x-api-key' -eq $ENV:AzAPIKey) {
    #Comparing the client IP to the Organization list, and checking if it exists.
    $ClientIP = ($request.headers.'X-Forwarded-For' -split ':')[0]
    $CompareList = import-csv "AzGlueForwarder\OrgList.csv" -delimiter ","
    $AllowedOrgs = $comparelist | where-object { $_.ip -eq $ClientIP }
    if (!$AllowedOrgs) { 
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                headers    = @{'content-type' = 'application\json' }
                StatusCode = [httpstatuscode]::OK
                Body       = @{"Error" = "401 - No match found in allowed list" } | convertto-json
            })
        exit 1
    }
 
    #Sending request to ITGlue
  
    $resource = $request.url -replace "https://$($ENV:WEBSITE_HOSTNAME)/API/", ""
    #Replace x-api-key with actual key
    $ITGHeaders = @{
        "x-api-key" = $ENV:ITGlueAPIKey
    } 
    $Method = $($Request.method)
    $ITGBody = $($Request.body)
    #write-host ($AllowedOrgs | out-string)
    $SuccessfullQuery = $false
    $attempt = 3
    while ($attempt -gt 0 -and -not $SuccessfullQuery) {
        try {
            $ITGlueRequest = Invoke-RestMethod -Method $Method -ContentType "application/vnd.api+json" -Uri "$($ENV:ITGlueURI)/$resource" -Body $ITGBody -Headers $ITGHeaders
            $SuccessfullQuery = $true
        }
        catch {
            $ITGlueRequest = @{'Errorcode' = $_.Exception.Response.StatusCode.value__ }
            $rand = get-random -Minimum 0 -Maximum 10
            start-sleep $rand
            $attempt--
            if ($attempt -eq 0) { $ITGlueRequest = @{'Errorcode' = "Error code $($_.Exception.Response.StatusCode.value__) - Made 3 attempts and upload failed. $($_.Exception.Message) / Resource was $($ENV:ITGlueURI)/$resource" } }
        }
    }
 
    #Checking if we can strip the data that does not belong to this client. 
    #Important so passwords/items can only be retrieved belonging to this organisation.
    #Can't do it for all requests, such as get-organisation, but for senstive data it works perfectly. :)
 
    if ($($ITGlueRequest.data.attributes.'organization-id')) {
        write-host ($AllowedOrgs.ITGlueOrgID)
        $ITGlueRequest.data = $ITGlueRequest.data | where-object { $_.attributes.'organization-id' -in $($AllowedOrgs.ITGlueOrgID) }    
    }
 
    #Sending the final object back to the client.
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            headers    = @{'content-type' = 'application\json' }
            StatusCode = [httpstatuscode]::OK
            Body       = $ITGlueRequest
        })
 
 
}
else {
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            headers    = @{'content-type' = 'application\json' }
            StatusCode = [httpstatuscode]::OK
            Body       = @{"Error" = "401 - No API Key entered or API key incorrect." } | convertto-json
        })
     
}

Save the script and use the right-hand menu to add a file to the function. Call this file “OrgList.csv”. This is the database that will be used to check which IP’s are allowed to upload data, and for which organisations they can retrieve data.

IP,ITGlueOrgID
1.1.1.1,123456
2.2.2.2,123457

Next click on “Integrate” and select the allowed methods, in our case we want all methods selected for the IT-Glue API. Replace the “Route template” with “{*path}”.

Click on AzGlueForwarder once more and press “Get Function URL” and copy this URL up to the {PATH} part. This will be the URL you will put in place of the API endpoint variable in your scripts. e.g. “https://AzureFunctionITGlue.azurewebsites.net/api/”.

And that’s it! A small recap:

  • Create the Azure Function
  • Add the environment variables AzAPIKey ITGlueBaseURI,ITGlueAPIKey.
  • The function URL will be your new IT-Glue API url to put in your scripts
  • The AzAPIKey is the key to put in your script.
  • The IT-Glue API key will only remain at the Azure Function side.
  • The OrgList.CSV file should contain your client’s their IP’s and allowed organisation.
  • your API requests can only be used for the organisations defined in OrgList.CSV.
  • When an API call fails, the script will try again 3 times, each with a random wait between 1 and 10 seconds to prevent rate limiting from getting in the way.

It’s a fairly simple but clean solution while I try to work with our friends at IT-Glue to increase the API limitations. It also helps on the security side as no one will be able to just download your entire database.

That’s it for today. As always, Happy PowerShelling.

Documenting with PowerShell: Active Directory domain and settings

Clients that still have a server on-site are become rare these days – Most of our client base is either completely public cloud using AAD or they have hosted servers in our private cloud. For these clients I’ve made the following script to document their Active Directory server settings. I always I want to be in complete control of my clients environment. That means having up to date documentation at the ready.

Of course there are a stack of other reasons to document the Active Directory environment, think of disaster recovery/runbook scenarios, troubleshooting, possible mergers, or even simply getting a correct overview of the sites, servers, and roles. So, let’s get started! I’ll be posting 2 versions of the script. One for IT-Glue and another for generic use.

IT-Glue version

Warning: Currently my wordpress installation is still replacing < with the HTML equivalent. I’m looking into better code plugins but please check the HTML code parts if you are seeing strangle results.

#####################################################################
$APIKEy =  "APIKEY"
$orgID = "ORGID"
$APIEndpoint = "https://api.itglue.com"
$FlexAssetName = "Active Directory - AutoDoc"
$Description = "A network one-page document that shows the current configuration for Active Directory."
#####################################################################
#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
 
function Get-WinADForestInformation {
    $Data = @{ }
    $ForestInformation = $(Get-ADForest)
    $Data.Forest = $ForestInformation
    $Data.RootDSE = $(Get-ADRootDSE -Properties *)
    $Data.ForestName = $ForestInformation.Name
    $Data.ForestNameDN = $Data.RootDSE.defaultNamingContext
    $Data.Domains = $ForestInformation.Domains
    $Data.ForestInformation = @{
        'Name'                    = $ForestInformation.Name
        'Root Domain'             = $ForestInformation.RootDomain
        'Forest Functional Level' = $ForestInformation.ForestMode
        'Domains Count'           = ($ForestInformation.Domains).Count
        'Sites Count'             = ($ForestInformation.Sites).Count
        'Domains'                 = ($ForestInformation.Domains) -join ", "
        'Sites'                   = ($ForestInformation.Sites) -join ", "
    }
     
    $Data.UPNSuffixes = Invoke-Command -ScriptBlock {
        $UPNSuffixList  =  [PSCustomObject] @{ 
                "Primary UPN" = $ForestInformation.RootDomain
                "UPN Suffixes"   = $ForestInformation.UPNSuffixes -join ","
            }  
        return $UPNSuffixList
    }
     
    $Data.GlobalCatalogs = $ForestInformation.GlobalCatalogs
    $Data.SPNSuffixes = $ForestInformation.SPNSuffixes
     
    $Data.Sites = Invoke-Command -ScriptBlock {
      $Sites = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest().Sites            
        $SiteData = foreach ($Site in $Sites) {          
          [PSCustomObject] @{ 
                "Site Name" = $site.Name
                "Subnets"   = ($site.Subnets) -join ", "
                "Servers" = ($Site.Servers) -join ", " 
            }  
        }
        Return $SiteData
    }
     
       
    $Data.FSMO = Invoke-Command -ScriptBlock {
        [PSCustomObject] @{ 
            "Domain" = $ForestInformation.RootDomain
            "Role"   = 'Domain Naming Master'
            "Holder" = $ForestInformation.DomainNamingMaster
        }

        [PSCustomObject] @{ 
            "Domain" = $ForestInformation.RootDomain
            "Role"   = 'Schema Master'
            "Holder" = $ForestInformation.SchemaMaster
        }
         
        foreach ($Domain in $ForestInformation.Domains) {
            $DomainFSMO = Get-ADDomain $Domain | Select-Object PDCEmulator, RIDMaster, InfrastructureMaster

            [PSCustomObject] @{ 
                "Domain" = $Domain
                "Role"   = 'PDC Emulator'
                "Holder" = $DomainFSMO.PDCEmulator
            } 

            
            [PSCustomObject] @{ 
                "Domain" = $Domain
                "Role"   = 'Infrastructure Master'
                "Holder" = $DomainFSMO.InfrastructureMaster
            } 

            [PSCustomObject] @{ 
                "Domain" = $Domain
                "Role"   = 'RID Master'
                "Holder" = $DomainFSMO.RIDMaster
            } 

        }
         
        Return $FSMO
    }
     
    $Data.OptionalFeatures = Invoke-Command -ScriptBlock {
        $OptionalFeatures = $(Get-ADOptionalFeature -Filter * )
        $Optional = @{
            'Recycle Bin Enabled'                          = ''
            'Privileged Access Management Feature Enabled' = ''
        }
        ### Fix Optional Features
        foreach ($Feature in $OptionalFeatures) {
            if ($Feature.Name -eq 'Recycle Bin Feature') {
                if ("$($Feature.EnabledScopes)" -eq '') {
                    $Optional.'Recycle Bin Enabled' = $False
                }
                else {
                    $Optional.'Recycle Bin Enabled' = $True
                }
            }
            if ($Feature.Name -eq 'Privileged Access Management Feature') {
                if ("$($Feature.EnabledScopes)" -eq '') {
                    $Optional.'Privileged Access Management Feature Enabled' = $False
                }
                else {
                    $Optional.'Privileged Access Management Feature Enabled' = $True
                }
            }
        }
        return $Optional
        ### Fix optional features
    }
    return $Data
}
 
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
 
$RawAD = Get-WinADForestInformation
 
$ForestRawInfo = new-object PSCustomObject -property $RawAD.ForestInformation | convertto-html -Fragment | Select-Object -Skip 1
$ForestNice = $TableHeader + ($ForestRawInfo -replace $TableStyling) + $Whitespace
 
$SiteRawInfo = $RawAD.Sites | Select-Object 'Site Name', Servers, Subnets | ConvertTo-Html -Fragment | Select-Object -Skip 1
$SiteNice = $TableHeader + ($SiteRawInfo -replace $TableStyling) + $Whitespace
 
$OptionalRawFeatures = new-object PSCustomObject -property $RawAD.OptionalFeatures | convertto-html -Fragment | Select-Object -Skip 1
$OptionalNice = $TableHeader + ($OptionalRawFeatures -replace $TableStyling) + $Whitespace
 
$UPNRawFeatures = $RawAD.UPNSuffixes |  convertto-html -Fragment -as list| Select-Object -Skip 1
$UPNNice = $TableHeader + ($UPNRawFeatures -replace $TableStyling) + $Whitespace
 
$DCRawFeatures = $RawAD.GlobalCatalogs | ForEach-Object { Add-Member -InputObject $_ -Type NoteProperty -Name "Domain Controller" -Value $_; $_ } | convertto-html -Fragment | Select-Object -Skip 1
$DCNice = $TableHeader + ($DCRawFeatures -replace $TableStyling) + $Whitespace
 
$FSMORawFeatures = $RawAD.FSMO | convertto-html -Fragment | Select-Object -Skip 1
$FSMONice = $TableHeader + ($FSMORawFeatures -replace $TableStyling) + $Whitespace
 
$ForestFunctionalLevel = $RawAD.RootDSE.forestFunctionality
$DomainFunctionalLevel = $RawAD.RootDSE.domainFunctionality
$domaincontrollerMaxLevel = $RawAD.RootDSE.domainControllerFunctionality
 
$passwordpolicyraw = Get-ADDefaultDomainPasswordPolicy | Select-Object ComplexityEnabled, PasswordHistoryCount, LockoutDuration, LockoutThreshold, MaxPasswordAge, MinPasswordAge | convertto-html -Fragment -As List | Select-Object -skip 1
$passwordpolicyheader = "<tr><th><b>Policy</b></th><th><b>Setting</b></th></tr>"
$passwordpolicyNice = $TableHeader + ($passwordpolicyheader -replace $TableStyling) + ($passwordpolicyraw -replace $TableStyling) + $Whitespace
 
$adminsraw = Get-ADGroupMember "Domain Admins" | Select-Object SamAccountName, Name | convertto-html -Fragment | Select-Object -Skip 1
$adminsnice = $TableHeader + ($adminsraw -replace $TableStyling) + $Whitespace
 
$EnabledUsers = (Get-AdUser -filter * | Where-Object { $_.enabled -eq $true }).count
$DisabledUSers = (Get-AdUser -filter * | Where-Object { $_.enabled -eq $false }).count
$AdminUsers = (Get-ADGroupMember -Identity "Domain Admins").count
$Users = @"
There are <b> $EnabledUsers </b> users Enabled<br>
There are <b> $DisabledUSers </b> users Disabled<br>
There are <b> $AdminUsers </b> Domain Administrator users<br>
"@
 
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'domain-name'               = $RawAD.ForestName
            'forest-summary'            = $ForestNice
            'site-summary'              = $SiteNice
            'domain-controllers'        = $DCNice
            'fsmo-roles'                = $FSMONice
            'optional-features'         = $OptionalNice
            'upn-suffixes'              = $UPNNice
            'default-password-policies' = $passwordpolicyNice
            'domain-admins'             = $adminsnice
            'user-count'                = $Users
        }
    }
}
 
#Checking if the FlexibleAsset exists. If not, create a new one.
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    $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            = "Domain Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Forest Summary"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Site Summary"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Domain Controllers"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "FSMO Roles"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Optional Features"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "UPN Suffixes"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Default Password Policies"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "Domain Admins"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 10
                            name           = "User Count"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
#Upload data to IT-Glue. We try to match the Server name to current computer name.
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $Filterid.id -filter_organization_id $orgID).data | Where-Object { $_.attributes.traits.'domain-name' -eq $RawAD.ForestName }
 
#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 new flexible asset"
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    Write-Host "Updating Flexible Asset"
    $ExistingFlexAsset = $ExistingFlexAsset[-1]
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
} 

this version of the script does the following:

  • It creates a Flexible Asset configuration in IT-Glue called ” Active Directory – AutoDoc”
  • It creates a Flexible Asset file in the supplied organisation($orgID).
  • The flexible asset file will be filled with the domain name, the forest summary, the site summary, domain controller, fsmo roles, optional features, upn suffixes, default password policies, the domain admins, and a user account.

Special thanks in this blog go out to Przemyslaw Klys for his Get-WinADForestInformation function, and to Jon Czerwinski at Chon Consulting Corp for assisting in some layout and ordering.

Generic version
 #Head for HTML file
 $head = @"
 <Title>Server AD 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;
 }
 </style>
 "@
 
 function Get-WinADForestInformation {
     $Data = @{ }
     $ForestInformation = $(Get-ADForest)
     $Data.Forest = $ForestInformation
     $Data.RootDSE = $(Get-ADRootDSE -Properties *)
     $Data.ForestName = $ForestInformation.Name
     $Data.ForestNameDN = $Data.RootDSE.defaultNamingContext
     $Data.Domains = $ForestInformation.Domains
     $Data.ForestInformation = @{
         'Name'                    = $ForestInformation.Name
         'Root Domain'             = $ForestInformation.RootDomain
         'Forest Functional Level' = $ForestInformation.ForestMode
         'Domains Count'           = ($ForestInformation.Domains).Count
         'Sites Count'             = ($ForestInformation.Sites).Count
         'Domains'                 = ($ForestInformation.Domains) -join ", "
         'Sites'                   = ($ForestInformation.Sites) -join ", "
     }
      
     $Data.UPNSuffixes = Invoke-Command -ScriptBlock {
         $UPNSuffixList  =  [PSCustomObject] @{ 
                 "Primary UPN" = $ForestInformation.RootDomain
                 "UPN Suffixes"   = $ForestInformation.UPNSuffixes -join ","
             }  
         return $UPNSuffixList
     }
      
     $Data.GlobalCatalogs = $ForestInformation.GlobalCatalogs
     $Data.SPNSuffixes = $ForestInformation.SPNSuffixes
      
     $Data.Sites = Invoke-Command -ScriptBlock {
         $Sites = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest().Sites            
           $SiteData = foreach ($Site in $Sites) {          
             [PSCustomObject] @{ 
                   "Site Name" = $site.Name
                   "Subnets"   = ($site.Subnets) -join ", "
                   "Servers" = ($Site.Servers) -join ", " 
               }  
           }
           Return $SiteData
       }
      
     $Data.FSMO = Invoke-Command -ScriptBlock {
         [PSCustomObject] @{ 
             "Domain" = $ForestInformation.RootDomain
             "Role"   = 'Domain Naming Master'
             "Holder" = $ForestInformation.DomainNamingMaster
         }
 
         [PSCustomObject] @{ 
             "Domain" = $ForestInformation.RootDomain
             "Role"   = 'Schema Master'
             "Holder" = $ForestInformation.SchemaMaster
         }
          
         foreach ($Domain in $ForestInformation.Domains) {
             $DomainFSMO = Get-ADDomain $Domain | Select-Object PDCEmulator, RIDMaster, InfrastructureMaster
             [PSCustomObject] @{ 
                 "Domain" = $Domain
                 "Role"   = 'PDC Emulator'
                 "Holder" = $DomainFSMO.PDCEmulator
             } 
 
             [PSCustomObject] @{ 
                 "Domain" = $Domain
                 "Role"   = 'PDC Emulator'
                 "Holder" = $DomainFSMO.PDCEmulator
             } 
 
             
             [PSCustomObject] @{ 
                 "Domain" = $Domain
                 "Role"   = 'Infrastructure Master'
                 "Holder" = $DomainFSMO.InfrastructureMaster
             } 
 
             [PSCustomObject] @{ 
                 "Domain" = $Domain
                 "Role"   = 'RID Master'
                 "Holder" = $DomainFSMO.RIDMaster
             } 
 
         }
          
         Return $FSMO
     }
      
     $Data.OptionalFeatures = Invoke-Command -ScriptBlock {
         $OptionalFeatures = $(Get-ADOptionalFeature -Filter * )
         $Optional = @{
             'Recycle Bin Enabled'                          = ''
             'Privileged Access Management Feature Enabled' = ''
         }
         ### Fix Optional Features
         foreach ($Feature in $OptionalFeatures) {
             if ($Feature.Name -eq 'Recycle Bin Feature') {
                 if ("$($Feature.EnabledScopes)" -eq '') {
                     $Optional.'Recycle Bin Enabled' = $False
                 }
                 else {
                     $Optional.'Recycle Bin Enabled' = $True
                 }
             }
             if ($Feature.Name -eq 'Privileged Access Management Feature') {
                 if ("$($Feature.EnabledScopes)" -eq '') {
                     $Optional.'Privileged Access Management Feature Enabled' = $False
                 }
                 else {
                     $Optional.'Privileged Access Management Feature Enabled' = $True
                 }
             }
         }
         return $Optional
         ### Fix optional features
     }
     return $Data
 }
  
 $TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
 $Whitespace = "<br/>"
 $TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
  
 $RawAD = Get-WinADForestInformation
  
 $ForestRawInfo = new-object PSCustomObject -property $RawAD.ForestInformation | convertto-html -Fragment | Select-Object -Skip 1
 $ForestNice = $TableHeader + ($ForestRawInfo -replace $TableStyling) + $Whitespace
  
 $SiteRawInfo = $RawAD.Sites | Select-Object 'Site Name', Servers, Subnets | ConvertTo-Html -Fragment | Select-Object -Skip 1
 $SiteNice = $TableHeader + ($SiteRawInfo -replace $TableStyling) + $Whitespace
  
 $OptionalRawFeatures = new-object PSCustomObject -property $RawAD.OptionalFeatures | convertto-html -Fragment | Select-Object -Skip 1
 $OptionalNice = $TableHeader + ($OptionalRawFeatures -replace $TableStyling) + $Whitespace
  
 $UPNRawFeatures = $RawAD.UPNSuffixes | convertto-html -Fragment | Select-Object -Skip 1
 $UPNNice = $TableHeader + ($UPNRawFeatures -replace $TableStyling) + $Whitespace
  
 $DCRawFeatures = $RawAD.GlobalCatalogs | ForEach-Object { Add-Member -InputObject $_ -Type NoteProperty -Name "Domain Controller" -Value $_; $_ } | convertto-html -Fragment | Select-Object -Skip 1
 $DCNice = $TableHeader + ($DCRawFeatures -replace $TableStyling) + $Whitespace
  
 $FSMORawFeatures = $RawAD.FSMO | convertto-html -Fragment | Select-Object -Skip 1
 $FSMONice = $TableHeader + ($FSMORawFeatures -replace $TableStyling) + $Whitespace
  
 $ForestFunctionalLevel = $RawAD.RootDSE.forestFunctionality
 $DomainFunctionalLevel = $RawAD.RootDSE.domainFunctionality
 $domaincontrollerMaxLevel = $RawAD.RootDSE.domainControllerFunctionality
  
 $passwordpolicyraw = Get-ADDefaultDomainPasswordPolicy | Select-Object ComplexityEnabled, PasswordHistoryCount, LockoutDuration, LockoutThreshold, MaxPasswordAge, MinPasswordAge | convertto-html -Fragment -As List | Select-Object -skip 1
 $passwordpolicyheader = "<tr><th><b>Policy</b></th><th><b>Setting</b></th></tr>"
 $passwordpolicyNice = $TableHeader + ($passwordpolicyheader -replace $TableStyling) + ($passwordpolicyraw -replace $TableStyling) + $Whitespace
  
 $adminsraw = Get-ADGroupMember "Domain Admins" | Select-Object SamAccountName, Name | convertto-html -Fragment | Select-Object -Skip 1
 $adminsnice = $TableHeader + ($adminsraw -replace $TableStyling) + $Whitespace
  
 $EnabledUsers = (Get-AdUser -filter * | Where-Object { $_.enabled -eq $true }).count
 $DisabledUSers = (Get-AdUser -filter * | Where-Object { $_.enabled -eq $false }).count
 $AdminUsers = (Get-ADGroupMember -Identity "Domain Admins").count
 $Users = @"
 There are <b> $EnabledUsers </b> users Enabled<br>
 There are <b> $DisabledUSers </b> users Disabled<br>
 There are <b> $AdminUsers </b> Domain Administrator users<br>
 "@
 
 $HTMLFile = @"
 $head
 <b>Domain Name</b>: $($RawAD.ForestName) <br>
 <br>
 <h1>Forest Configuration</h1> <br>
 $ForestNice
 <br>
 <h1>Site Summary</h1> <br>
 $SiteNice
 <br>
 <h1>Domain Controllers</h1> <br>
 $DCNice
 <br>
 <h1>FSMO Roles</h1>
 $FSMONice
 <h1>Optional Features</h1>
 $OptionalNice
 <br>
 <h1>UPN Suffixes</h1>
 $UPNNice
 <br>
 <h1>Password Policies</h1>
 $passwordpolicyNice
 <br>
 <h1>Domain Admins</h1>
 $adminsnice
 <br>
 <h1>Domain Admins</h1>
 $Users
 <br>
 "@
 $HTMLFile | out-file C:\Temp\ServerDoc.html 

And that’s it. If you want more local documentation without the IT-Glue component you should check out this blog too. as always, Happy PowerShelling.

Update: After some comments from PrzemysŇāaw KŇāys I’ve updated the scripts to be a little more efficient and mostly prettier ūüôā

Documenting with PowerShell: Documenting SQL settings and databases

Most of our clients have some form of line of business application that requires a database engine. in 99% of the cases this ends up being a SQL server. I always enjoy being in complete control of an environment so whenever we deploy SQL servers we automatically run this documentation script. This is especially good if you ever need to recreate databases, or need to check what the state of a SQL server was a couple of weeks ago.

So, for this script we use the SQLPS module which is included on any server with SQL Server 2012+ installed. The SQLPS module gives us a PSDrive with the SQLSERVER:\ path. This allows us to grab all information we need.

The script documents the existing databases, their settings, file locations, but also generic server settings. It automatically finds all instances on the server so in the case of multiple SQL instances you’re also covered by this script ūüôā

IT-Glue script
#####################################################################
$APIKEy = "ITGLUEAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$orgID = "ORGIDHERE"
$FlexAssetName = "ITGLue AutoDoc - SQL Server"
$Description = "SQL Server settings and configuration, Including databases."
#####################################################################
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
#Collect Data
import-module SQLPS
$Instances = Get-ChildItem "SQLSERVER:\SQL\$($ENV:COMPUTERNAME)"
foreach ($Instance in $Instances) {
    $databaseList = get-childitem "SQLSERVER:\SQL\$($ENV:COMPUTERNAME)\$($Instance.Displayname)\Databases"
    $Databases = @()
    foreach ($Database in $databaselist) {
        $Databaseobj = New-Object -TypeName PSObject
        $Databaseobj | Add-Member -MemberType NoteProperty -Name "Name" -value $Database.Name
        $Databaseobj | Add-Member -MemberType NoteProperty -Name "Status" -value $Database.status
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "RecoveryModel" -value $Database.RecoveryModel
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "LastBackupDate" -value $Database.LastBackupDate
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "DatabaseFiles" -value $database.filegroups.files.filename
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "Logfiles"      -value $database.LogFiles.filename
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "MaxSize" -value $database.filegroups.files.MaxSize
        $Databases += $Databaseobj
    }
    $InstanceInfo = $Instance | Select-Object DisplayName, Collation, AuditLevel, BackupDirectory, DefaultFile, DefaultLog, Edition, ErrorLogPath | convertto-html -PreContent "&lt;h1>Settings&lt;/h1>" -Fragment | Out-String
    $Instanceinfo = $instanceinfo -replace "&lt;th>", "&lt;th style=`"background-color:#4CAF50`">"
    $InstanceInfo = $InstanceInfo -replace "&lt;table>", "&lt;table class=`"table table-bordered table-hover`" style=`"width:80%`">"
    $DatabasesHTML = $Databases | ConvertTo-Html -fragment -PreContent "&lt;h3>Database Settings&lt;/h3>" | Out-String
    $DatabasesHTML = $DatabasesHTML -replace "&lt;th>", "&lt;th style=`"background-color:#4CAF50`">"
    $DatabasesHTML = $DatabasesHTML -replace "&lt;table>", "&lt;table class=`"table table-bordered table-hover`" style=`"width:80%`">"



    #Tagging devices
    $DeviceAsset = @()
    If ($TagRelatedDevices -eq $true) {
        Write-Host "Finding all related resources - Based on computername: $ENV:COMPUTERNAME"
        foreach ($hostfound in $networkscan | Where-Object { $_.Ping -ne $false }) {
            $DeviceAsset += (Get-ITGlueConfigurations -page_size "1000" -filter_name $ENV:COMPUTERNAME -organization_id $orgID).data 
        }
    }     
    $FlexAssetBody = 
    @{
        type       = 'flexible-assets'
        attributes = @{
            name   = $FlexAssetName
            traits = @{
                "instance-name"     = "$($ENV:COMPUTERNAME)\$($Instance.displayname)"
                "instance-settings" = $InstanceInfo
                "databases"         = $DatabasesHTML
                "tagged-devices"    = $DeviceAsset.ID
                    
            }
        }
    }
    #Checking if the FlexibleAsset exists. If not, create a new one.
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
    if (!$FilterID) { 
        $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            = "Instance Name"
                                kind            = "Text"
                                required        = $true
                                "show-in-list"  = $true
                                "use-for-title" = $true
                            }
                        },
                        @{
                            type       = "flexible_asset_fields"
                            attributes = @{
                                order          = 2
                                name           = "Instance Settings"
                                kind           = "Textbox"
                                required       = $false
                                "show-in-list" = $true
                            }
                        },
                        @{
                            type       = "flexible_asset_fields"
                            attributes = @{
                                order          = 3
                                name           = "Databases"
                                kind           = "Textbox"
                                required       = $false
                                "show-in-list" = $false
                            }
                        },
                        @{
                            type       = "flexible_asset_fields"
                            attributes = @{
                                order          = 8
                                name           = "Tagged Devices"
                                kind           = "Tag"
                                "tag-type"     = "Configurations"
                                required       = $false
                                "show-in-list" = $false
                            }
                        }
                    )
                }
            }
                  
        }
        New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
        $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
    } 
    #Upload data to IT-Glue. We try to match the Server name to current computer name.
    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $Filterid.id -filter_organization_id $orgID).data | Where-Object { $_.attributes.traits.'instance-name' -eq "$($ENV:COMPUTERNAME)\$($Instance.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) {
        $FlexAssetBody.attributes.add('organization-id', $orgID)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
        Write-Host "Creating new flexible asset"
        New-ITGlueFlexibleAssets -data $FlexAssetBody
    }
    else {
        Write-Host "Updating Flexible Asset"
        $ExistingFlexAsset = $ExistingFlexAsset[-1]
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
    }
}
Generic version

As always I’ve included a generic version. You can use this with any other system.

import-module SQLPS
$Instances = Get-ChildItem "SQLSERVER:\SQL\$($ENV:COMPUTERNAME)"
foreach ($Instance in $Instances) {
    $InstanceInfo = $Instance | Select-Object DisplayName, Collation, AuditLevel, BackupDirectory, DefaultFile, DefaultLog, Edition, ErrorLogPath
    $databaseList = get-childitem "SQLSERVER:\SQL\$($ENV:COMPUTERNAME)\$($Instance.Displayname)\Databases"
    $Databases = @()
    foreach($Database in $databaselist){
        $Databaseobj = New-Object -TypeName PSObject
        $Databaseobj | Add-Member -MemberType NoteProperty -Name "Name" -value $Database.Name
        $Databaseobj | Add-Member -MemberType NoteProperty -Name "Status" -value $Database.status
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "RecoveryModel" -value $Database.RecoveryModel
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "LastBackupDate" -value $Database.LastBackupDate
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "DatabaseFiles" -value $database.filegroups.files.filename
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "Logfiles"      -value $database.LogFiles.filename
        $Databaseobj | Add-Member -MemberType NoteProperty -Name  "MaxSize" -value $database.filegroups.files.MaxSize
        $Databases += $Databaseobj
    }
    $InstanceInfo = $Instance | Select-Object DisplayName, Collation, AuditLevel, BackupDirectory, DefaultFile, DefaultLog, Edition, ErrorLogPath | convertto-html -PreContent "&lt;h1>Settings&lt;/h1>" -Fragment | Out-String
    $Instanceinfo = $instanceinfo -replace "&lt;th>", "&lt;th style=`"background-color:#4CAF50`">"
    $InstanceInfo = $InstanceInfo -replace "&lt;table>", "&lt;table class=`"table table-bordered table-hover`" style=`"width:80%`">"
    $DatabasesHTML = $Databases | ConvertTo-Html -fragment -PreContent "&lt;h3>Database Settings&lt;/h3>" | Out-String
    $DatabasesHTML = $DatabasesHTML -replace "&lt;th>", "&lt;th style=`"background-color:#4CAF50`">"
    $DatabasesHTML = $DatabasesHTML -replace "&lt;table>", "&lt;table class=`"table table-bordered table-hover`" style=`"width:80%`">"

    $output = $InstanceInfo,$DatabasesHTML | out-file "C:\Temp\Output.html"

}

a small warning: it seems that the latest wordpress updates makes the < symbol appear as its html encoded version in the code. Visual Studio Code automatically converts this. if you are using any other IDE replace this yourself.

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

Monitoring and Documenting with PowerShell: End of year review

Hi! So this is the final post of this year. I’m going to be enjoying some well deserved holidays and spend Christmas with my family. The past year has been pretty cool. I’ve been doing so many cool projects.

I figured I also would list the top blogs of this year by views, and just generally some stuff I’m proud of, so lets get started:

Top blogs

The most viewed blog this year is my Functional PowerShell for MSPs webinar, which is pretty amazing because it was only posted 3 months ago. I still see the views racking up on the Teams Live Event recording and I am going to be giving another (albeit slightly shorter one) the 16th of december.

The runner up in this is the start of the Documenting with PowerShell series. That entire series seems to have been a favorite for most people. The third place is going to the unofficial IT-Glue backup script.

My personal favorite has to be a recent blog; either the Secure Application Model blog or the OneDrive monitoring script which uses user impersonation.

Documenting with PowerShell series

The documenting with PowerShell series has been a hit. I’ve taken a small break from it to reorganise and make it a little more “Eye-candy” focussed as this was the primary question I’ve been getting. I love how some of you have adapted the scripts. Most of them were made as an example so it’s cool to see all different variations of it. I will be continuing this series at the start of next year. If you have any wishes, let me know!

Monitoring with PowerShell series

The monitoring with PowerShell series is still my baby, I love doing it and showing all the different methods of using PowerShell over SNMP, or PowerShell over generic WMI monitoring. Next year I hope to still be posting at least 1 blog a week. I’ve recently been mailed some question that I will be picking up next year too.

Special thanks

This year was great! I especially want to thank my peers in MSP’r’Us that always help me find new ideas. I also want to thank Datto for the way we have been collaborating and adding my blogs and script to their product. It’s been a great adventure.

And that’s it! I wish all my readers an amazing Christmas, and of course a happy new year. As always, Happy PowerShelling!