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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
|
$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:
1
2
3
4
5
6
7
8
9
|
######### 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
######### 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
######### 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. 🙂