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

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

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

Setting up Azure Lighthouse

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

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

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

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

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

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

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

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

Documenting the VMs

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

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

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

HTML Version

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


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

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

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

}

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

IT-Glue version

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

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

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

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

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

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

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

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



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

}

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

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

1 thought on “Documenting with PowerShell: Documenting Azure VMs (And lighthouse setup)

  1. Pingback: ICYMI: PowerShell Week of 31-July-2020 | PowerShell.org

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.