diff --git a/README.md b/README.md
index 3823e7be..ba1b57f5 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,13 @@ Listed as [security monitoring tool](https://docs.microsoft.com/en-us/azure/arch
## Release history
+__Changes__ (2021-Nov-01 / Major)
+
+* New output - Feature request to create __Scope Insights__ output per Subscription has been implement. With this new feature you can share Subscription __Scope Insights__ with Subscription responsible staff. Use parameter `-NoSingleSubscriptionOutput` to disable the feature
+* Update [Required permissions in Azure Active Directory](#required-permissions-in-azure-active-directory) for the scenario of a Guest User executing the script
+* Add 'daily summary' output (CSV) to easily track your Tenant´s Governance evolution over time - Tim will hopefully create a PR for how he leverages AzGovViz historical data for Azure Log Analytics based dashboards
+* Improved permission related error handling
+
__Changes__ (2021-Oct-25 / Major)
* AzAPICall enhanced error handling (general error 'An error has occurred.' ; roleAssignment schedules)
@@ -274,7 +281,8 @@ markdown in Azure DevOps Wiki as Code
## AzGovViz Setup Guide
-💡 Although 30 minutes of troubleshooting can save you 5 minutes reading the documentation :) .. check the detailed __[Setup Guide](setup.md)__
+💡 Although 30 minutes of troubleshooting can save you 5 minutes reading the documentation :) ..
+Check the detailed __[Setup Guide](setup.md)__
## Technical documentation
@@ -314,37 +322,31 @@ This permission is mandatory in each and every scenario!
- Option 1 (simple setup but more read permissions than required)
- Add assignment for the Service Principal to AAD Role Directory readers 💡 Directory readers
- Option 2 (explicit permission model)
Feature
Permissions
-
Parameter
-
Get AAD Guest Users
+
Get AAD Users
Service Principal's App registration grant with Microsoft Graph permissions: Application permissions / User / User.Read.All 💡 Get user
-
n/a
Get AAD Groups
Service Principal's App registration grant with Microsoft Graph permissions: Application permissions / Group / Group.Read.All 💡 Get group
-
NoAADGroupsResolveMembers
Get AAD SP/App
Service Principal's App registration grant with Microsoft Graph permissions: Application permissions / Application / Application.Read.All 💡 Get servicePrincipal, Get application
-
n/a
@@ -353,30 +355,23 @@ This permission is mandatory in each and every scenario!
D Azure DevOps Pipeline | ServicePrincipal (Service Connection)
- Option 1 (simple setup but more read permissions than required)
- Add assignment for the Azure DevOps Service Connection's Service Principal to AAD Role Directory readers 💡 Directory readers
- Option 2 (explicit permission model)
Feature
Permissions
-
Parameter
-
Get AAD Guest Users
+
Get AAD Users
Azure DevOps Service Connection's App registration grant with Microsoft Graph permissions: Application permissions / User / User.Read.All 💡 Get user
-
n/a
Get AAD Groups
Azure DevOps Service Connection's App registration grant with Microsoft Graph permissions: Application permissions / Group / Group.Read.All 💡 Get group
-
NoAADGroupsResolveMembers
Get AAD SP/App
Azure DevOps Service Connection's App registration grant with Microsoft Graph permissions: Application permissions / Application / Application.Read.All 💡 Get servicePrincipal, Get application
-
n/a
@@ -441,6 +436,7 @@ Screenshot Azure Portal
* `-AADGroupMembersLimit` - Defines the limit (default=500) of AAD Group members; For AAD Groups that have more members than the defined limit Group members will not be resolved
* `-NoResources` - Will speed up the processing time but information like Resource diagnostics capability and resource type statistic (featured for large tenants)
* `-StatsOptOut` - Opt out sending [stats](#stats)
+ * `-NoSingleSubscriptionOutput` - Single __Scope Insights__ output per Subscription should not be created
## Integrate with AzOps
diff --git a/history.md b/history.md
index 50a5f6ae..fdb66476 100644
--- a/history.md
+++ b/history.md
@@ -4,6 +4,13 @@
### AzGovViz version 6
+__Changes__ (2021-Nov-01 / Major)
+
+* New output - Feature request to create __Scope Insights__ output per Subscription has been implement. With this new feature you can share Subscription __Scope Insights__ with Subscription responsible staff. Use parameter `-NoSingleSubscriptionOutput` to disable the feature
+* Update [Required permissions in Azure Active Directory](#required-permissions-in-azure-active-directory) for the scenario of a Guest User executing the script
+* Add 'daily summary' output (CSV) to easily track your Tenant´s Governance evolution over time - Tim will hopefully create a PR for how he leverages AzGovViz historical data for Azure Log Analytics based dashboards
+* Improved permission related error handling
+
__Changes__ (2021-Oct-25 / Major)
* AzAPICall enhanced error handling (general error 'An error has occurred.' ; roleAssignment schedules)
diff --git a/pipeline/AzGovViz.yml b/pipeline/AzGovViz.yml
index 3e442db0..e7189f06 100644
--- a/pipeline/AzGovViz.yml
+++ b/pipeline/AzGovViz.yml
@@ -1,11 +1,11 @@
-# AzGovViz v6_major_20211018_1
+# AzGovViz v6_major_20211101_1
# First things first:
-# 1. edit line 59 and line 60
-# 2. check line 74 and 85 if branch 'master' is applicable
+# 1. edit line 60 and line 61
+# 2. check line 75 and 86 if branch 'master' is applicable
# Documentation: https://github.com/JulianHayward/Azure-MG-Sub-Governance-Reporting
# Also check https://www.azadvertizer.net - AzAdvertizer helps you to keep up with the pace by providing overview and insights on new releases and changes/updates for Azure Governance capabilities such as Azure Policy's policy definitions, initiatives (set definitions), aliases and Azure RBAC's role definitions and resource provider operations.
#
-# Parameters reference (use in line 108)
+# Parameters reference (use in line 109)
# LimitCriticalPercentage | default is '80' | example: -LimitCriticalPercentage 90 | WhatDoesItDo? marks capabilities that approch limits e.g. limit 100, usage 80 will mark with warning
# SubscriptionQuotaIdWhitelist | default is 'undefined' | example: -SubscriptionQuotaIdWhitelist MSDN_, EnterpriseAgreement_ | WhatDoesItDo? processes only Subscriptions that startWith the given QuotaIds
# HierarchyMapOnly | switch | example: -HierarchyMapOnly | WhatDoesItDo? only creates the Hierarchy Tree
@@ -40,6 +40,7 @@
# AADGroupMembersLimit | example: -AADGroupMembersLimit 333 | WhatDoesItDo? Defines the limit (default=500) of AAD Group members; For AAD Groups that have more members than the defined limit Group members will not be resolved
# NoResources | example: -NoResources | WhatDoesItDo? Will speed up the processing time but information like Resource diagnostics capability and resource type stats (featured for large tenants)
# StatsOptOut | example: -StatsOptOut | WhatDoesItDo? Will opt-out sending stats
+# NoSingleSubscriptionOutput | example: -NoSingleSubscriptionOutput | WhatDoesItDo? Single Scope Insights output per Subscription should not be created
trigger: none
diff --git a/pwsh/AzGovVizParallel.ps1 b/pwsh/AzGovVizParallel.ps1
index 32a7bbc8..ca537149 100644
--- a/pwsh/AzGovVizParallel.ps1
+++ b/pwsh/AzGovVizParallel.ps1
@@ -129,6 +129,9 @@
.PARAMETER StatsOptOut
Will opt-out sending stats
+.PARAMETER NoSingleSubscriptionOutput
+ Single Scope Insights output per Subscription should not be created
+
.EXAMPLE
Define the ManagementGroup ID
PS C:\> .\AzGovVizParallel.ps1 -ManagementGroupId
@@ -244,7 +247,10 @@
Will opt-out sending stats
PS C:\>.\AzGovVizParallel.ps1 -ManagementGroupId -StatsOptOut
-.NOTES
+ Will not create a single Scope Insights output per Subscription
+ PS C:\>.\AzGovVizParallel.ps1 -ManagementGroupId -NoSingleSubscriptionOutput
+
+ .NOTES
AUTHOR: Julian Hayward - Customer Engineer - Customer Success Unit | Azure Infrastucture/Automation/Devops/Governance | Microsoft
.LINK
@@ -257,7 +263,7 @@
Param
(
[string]$Product = "AzGovViz",
- [string]$ProductVersion = "v6_major_20211025_1",
+ [string]$ProductVersion = "v6_major_20211101_1",
[string]$GithubRepository = "aka.ms/AzGovViz",
[string]$ManagementGroupId,
[switch]$AzureDevOpsWikiAsCode, #Use this parameter only when running AzGovViz in a Azure DevOps Pipeline!
@@ -299,6 +305,7 @@ Param
[switch]$RBACAtScopeOnly,
[switch]$NoResources,
[switch]$StatsOptOut,
+ [switch]$NoSingleSubscriptionOutput,
#https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#role-based-access-control-limits
[int]$LimitRBACCustomRoleDefinitionsTenant = 5000,
@@ -992,7 +999,7 @@ $funcResolveObjectIds = $function:ResolveObjectIds.ToString()
#API
#region azapicall
-function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumption, $getGroup, $getGroupMembersCount, $getApp, $getSP, $getGuests, $caller, $consistencyLevel, $getCount, $getPolicyCompliance, $getMgAscSecureScore, $getRoleAssignmentSchedules, $getDiagnosticSettingsMg, $validateAccess) {
+function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumption, $getGroup, $getGroupMembersCount, $getApp, $caller, $consistencyLevel, $getCount, $getPolicyCompliance, $getMgAscSecureScore, $getRoleAssignmentSchedules, $getDiagnosticSettingsMg, $validateAccess) {
$tryCounter = 0
$tryCounterUnexpectedError = 0
$retryAuthorizationFailed = 5
@@ -1136,13 +1143,10 @@ function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumptio
($getConsumption -and $catchResult.error.code -eq "IndirectCostDisabled")
) -or
$catchResult.error.message -like "*The offer MS-AZR-0110P is not supported*" -or
- ($getSP -and $catchResult.error.code -like "*Request_ResourceNotFound*") -or
- ($getSP -and $catchResult.error.code -like "*Authorization_RequestDenied*") -or
($getApp -and $catchResult.error.code -like "*Request_ResourceNotFound*") -or
($getApp -and $catchResult.error.code -like "*Authorization_RequestDenied*") -or
($getGroup -and $catchResult.error.code -like "*Request_ResourceNotFound*") -or
($getGroupMembersCount -and $catchResult.error.code -like "*Request_ResourceNotFound*") -or
- ($getGuests -and $catchResult.error.code -like "*Authorization_RequestDenied*") -or
$catchResult.error.code -like "*UnknownError*" -or
$catchResult.error.code -like "*BlueprintNotFound*" -or
$catchResult.error.code -eq "500" -or
@@ -1269,7 +1273,7 @@ function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumptio
Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') <.code: '$($catchResult.code)'> <.error.code: '$($catchResult.error.code)'> | <.message: '$($catchResult.message)'> <.error.message: '$($catchResult.error.message)'> - (plain : $catchResult) uncertain Group status - skipping for now :)"
return "Request_ResourceNotFound"
}
- if (($getApp -or $getSP) -and $catchResult.error.code -like "*Request_ResourceNotFound*") {
+ if ($getApp -and $catchResult.error.code -like "*Request_ResourceNotFound*") {
Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') <.code: '$($catchResult.code)'> <.error.code: '$($catchResult.error.code)'> | <.message: '$($catchResult.message)'> <.error.message: '$($catchResult.error.message)'> - (plain : $catchResult) uncertain ServicePrincipal status - skipping for now :)"
return "Request_ResourceNotFound"
}
@@ -1277,21 +1281,10 @@ function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumptio
Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') <.code: '$($catchResult.code)'> <.error.code: '$($catchResult.error.code)'> | <.message: '$($catchResult.message)'> <.error.message: '$($catchResult.error.message)'> - (plain : $catchResult) cannot get the executing user´s userType information (member/guest) - proceeding as 'unknown'"
return "unknown"
}
- if ((($getApp -or $getSP) -and $catchResult.error.code -like "*Authorization_RequestDenied*") -or ($getGuests -and $catchResult.error.code -like "*Authorization_RequestDenied*")) {
- if ($userType -eq "Guest" -or $userType -eq "unknown") {
- Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') <.code: '$($catchResult.code)'> <.error.code: '$($catchResult.error.code)'> | <.message: '$($catchResult.message)'> <.error.message: '$($catchResult.error.message)'> - (plain : $catchResult)"
- if ($userType -eq "Guest") {
- Write-Host " AzGovViz says: Your UserType is 'Guest' (member/guest/unknown) in the tenant therefore not enough permissions. You have the following options: [1. request membership to AAD Role 'Directory readers'.] Grant explicit Microsoft Graph API permission." -ForegroundColor Yellow
- }
- if ($userType -eq "unknown") {
- Write-Host " AzGovViz says: Your UserType is 'unknown' (member/guest/unknown) in the tenant. Seems you do not have enough permissions geeting AAD related data. You have the following options: [1. request membership to AAD Role 'Directory readers'.]" -ForegroundColor Yellow
- }
- if ($htParameters.AzureDevOpsWikiAsCode -eq $true) {
- Write-Error "Error"
- }
- else {
- Throw "Authorization_RequestDenied"
- }
+ if ($getApp -and $catchResult.error.code -like "*Authorization_RequestDenied*") {
+ if ($htParameters.userType -eq "Guest") {
+ Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') <.code: '$($catchResult.code)'> <.error.code: '$($catchResult.error.code)'> | <.message: '$($catchResult.message)'> <.error.message: '$($catchResult.error.message)'> - (plain : $catchResult) - skip Application (Secrets & Certificates)"
+ return "skipApplications"
}
else {
Write-Host "- - - - - - - - - - - - - - - - - - - - "
@@ -1378,6 +1371,14 @@ function AzAPICall($uri, $method, $currentTask, $body, $listenOn, $getConsumptio
return "failed"
}
+ if ($htParameters.userType -eq "Guest" -and $catchResult.error.code -eq "Authorization_RequestDenied") {
+ #https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/users-restrict-guest-permissions
+ Write-Host " $currentTask - try #$tryCounter; returned: (StatusCode: '$($azAPIRequest.StatusCode)') '$($catchResult.error.code)' | '$($catchResult.error.message)' - exit"
+ Write-Host "Tenant seems hardened (AAD External Identities / Guest user access = most restrictive) -> https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/users-restrict-guest-permissions"
+ Write-Host "AAD Role 'Directory readers' is required for your Guest User Account!"
+ Throw "Error - AzGovViz: check the last console output for details"
+ }
+
}
else {
if (-not $catchResult.code -and -not $catchResult.error.code -and -not $catchResult.message -and -not $catchResult.error.message -and -not $catchResult -and $tryCounter -lt 6) {
@@ -1946,9 +1947,6 @@ function dataCollection($mgId) {
$allManagementGroupsFromEntitiesChildOfRequestedMg = $arrayEntitiesFromAPI.where( { $_.type -eq "Microsoft.Management/managementGroups" -and ($_.Name -eq $mgId -or $_.properties.parentNameChain -contains $mgId) })
$allManagementGroupsFromEntitiesChildOfRequestedMgCount = ($allManagementGroupsFromEntitiesChildOfRequestedMg | Measure-Object).Count
- #test
- Write-Host " BuiltIn PolicyDefinitions: $($($htCacheDefinitionsPolicy).Values.where({$_.Type -eq "BuiltIn"}).Count) | $((($htCacheDefinitionsPolicy).Values | Where-Object {$_.Type -eq "BuiltIn"}).Count)"
-
$allManagementGroupsFromEntitiesChildOfRequestedMg | ForEach-Object -Parallel {
$mgdetail = $_
#region UsingVARs
@@ -2007,7 +2005,6 @@ function dataCollection($mgId) {
$function:namingValidation = $using:funcNamingValidation
$function:ResolveObjectIds = $using:funcResolveObjectIds
#endregion usingVARS
- #test
$builtInPolicyDefinitionsCount = $using:builtInPolicyDefinitionsCount
$addRowToTableDone = $false
@@ -2249,7 +2246,7 @@ function dataCollection($mgId) {
foreach ($mgPolicyDefinition in $mgPolicyDefinitions) {
$hlpMgPolicyDefinitionId = ($mgPolicyDefinition.id).ToLower()
- if ($hlpMgPolicyDefinitionId -notlike "/providers/Microsoft.Management/managementGroups/*"){
+ if ($hlpMgPolicyDefinitionId -notlike "/providers/Microsoft.Management/managementGroups/*") {
Write-Host "!!!** showed up in the foreach loop for custom policy definitions: '$hlpMgPolicyDefinitionId'"
}
@@ -3139,10 +3136,7 @@ function dataCollection($mgId) {
Write-Host " CustomDataCollection ManagementGroups processing duration: $((NEW-TIMESPAN -Start $startMgLoop -End $endMgLoop).TotalMinutes) minutes ($((NEW-TIMESPAN -Start $startMgLoop -End $endMgLoop).TotalSeconds) seconds)"
#test
- Write-Host " BuiltIn PolicyDefinitions: $($($htCacheDefinitionsPolicy).Values.where({$_.Type -eq "BuiltIn"}).Count) | $((($htCacheDefinitionsPolicy).Values | Where-Object {$_.Type -eq "BuiltIn"}).Count)"
- Write-Host " Custom PolicyDefinitions: $($($htCacheDefinitionsPolicy).Values.where({$_.Type -eq "Custom"}).Count)"
- Write-Host " All PolicyDefinitions: $($($htCacheDefinitionsPolicy).Values.Count)"
- if ($builtInPolicyDefinitionsCount -ne ($($htCacheDefinitionsPolicy).Values.where({$_.Type -eq "BuiltIn"}).Count) -or $builtInPolicyDefinitionsCount -ne ((($htCacheDefinitionsPolicy).Values | Where-Object {$_.Type -eq "BuiltIn"}).Count)){
+ if ($builtInPolicyDefinitionsCount -ne ($($htCacheDefinitionsPolicy).Values.where({ $_.Type -eq "BuiltIn" }).Count) -or $builtInPolicyDefinitionsCount -ne ((($htCacheDefinitionsPolicy).Values | Where-Object { $_.Type -eq "BuiltIn" }).Count)) {
Write-Host "$builtInPolicyDefinitionsCount -ne $($($htCacheDefinitionsPolicy).Values.where({$_.Type -eq "BuiltIn"}).Count) OR $builtInPolicyDefinitionsCount -ne $((($htCacheDefinitionsPolicy).Values | Where-Object {$_.Type -eq "BuiltIn"}).Count)"
Write-Host "Listing all PolicyDefinitions:"
foreach ($tmpPolicyDefinitionId in ($($htCacheDefinitionsPolicy).Keys | Sort-Object)) {
@@ -5046,63 +5040,65 @@ function tableMgHTML($mgChild, $mgChildOf) {
$mgLevel = $mgDetails.Level
$mgId = $mgDetails.MgId
- if ($mgId -eq $defaultManagementGroupId) {
- $classDefaultMG = "defaultMG"
- }
- else {
- $classDefaultMG = ""
- }
+ if (-not $NoScopeInsights) {
+ if ($mgId -eq $defaultManagementGroupId) {
+ $classDefaultMG = "defaultMG"
+ }
+ else {
+ $classDefaultMG = ""
+ }
- switch ($mgLevel) {
- "0" { $levelSpacing = "| " }
- "1" { $levelSpacing = "| - " }
- "2" { $levelSpacing = "| - - " }
- "3" { $levelSpacing = "| - - - " }
- "4" { $levelSpacing = "| - - - - " }
- "5" { $levelSpacing = "|- - - - - " }
- "6" { $levelSpacing = "|- - - - - - " }
- }
+ switch ($mgLevel) {
+ "0" { $levelSpacing = "| " }
+ "1" { $levelSpacing = "| - " }
+ "2" { $levelSpacing = "| - - " }
+ "3" { $levelSpacing = "| - - - " }
+ "4" { $levelSpacing = "| - - - - " }
+ "5" { $levelSpacing = "|- - - - - " }
+ "6" { $levelSpacing = "|- - - - - - " }
+ }
- $mgPath = $htManagementGroupsMgPath.($mgChild).pathDelimited
+ $mgPath = $htManagementGroupsMgPath.($mgChild).pathDelimited
- $mgLinkedSubsCount = ((($optimizedTableForPathQuery | Where-Object { $_.MgId -eq $mgChild -and -not [String]::IsNullOrEmpty($_.SubscriptionId) }).SubscriptionId | Get-Unique) | measure-object).count
- $subscriptionsOutOfScopelinkedCount = ($outOfScopeSubscriptions | Where-Object { $_.ManagementGroupId -eq $mgChild } | Measure-Object).count
- if ($mgLinkedSubsCount -gt 0 -and $subscriptionsOutOfScopelinkedCount -eq 0) {
- $subInfo = "$mgLinkedSubsCount"
- }
- if ($mgLinkedSubsCount -gt 0 -and $subscriptionsOutOfScopelinkedCount -gt 0) {
- $subInfo = "$mgLinkedSubsCount $subscriptionsOutOfScopelinkedCount"
- }
- if ($mgLinkedSubsCount -eq 0 -and $subscriptionsOutOfScopelinkedCount -gt 0) {
- $subInfo = "$subscriptionsOutOfScopelinkedCount"
- }
- if ($mgLinkedSubsCount -eq 0 -and $subscriptionsOutOfScopelinkedCount -eq 0) {
- $subInfo = ""
- }
+ $mgLinkedSubsCount = ((($optimizedTableForPathQuery | Where-Object { $_.MgId -eq $mgChild -and -not [String]::IsNullOrEmpty($_.SubscriptionId) }).SubscriptionId | Get-Unique) | measure-object).count
+ $subscriptionsOutOfScopelinkedCount = ($outOfScopeSubscriptions | Where-Object { $_.ManagementGroupId -eq $mgChild } | Measure-Object).count
+ if ($mgLinkedSubsCount -gt 0 -and $subscriptionsOutOfScopelinkedCount -eq 0) {
+ $subInfo = "$mgLinkedSubsCount"
+ }
+ if ($mgLinkedSubsCount -gt 0 -and $subscriptionsOutOfScopelinkedCount -gt 0) {
+ $subInfo = "$mgLinkedSubsCount $subscriptionsOutOfScopelinkedCount"
+ }
+ if ($mgLinkedSubsCount -eq 0 -and $subscriptionsOutOfScopelinkedCount -gt 0) {
+ $subInfo = "$subscriptionsOutOfScopelinkedCount"
+ }
+ if ($mgLinkedSubsCount -eq 0 -and $subscriptionsOutOfScopelinkedCount -eq 0) {
+ $subInfo = ""
+ }
- if ($mgName -eq $mgId) {
- $mgNameAndOrId = "$($mgName -replace "<", "<" -replace ">", ">")"
- }
- else {
- $mgNameAndOrId = "$($mgName -replace "<", "<" -replace ">", ">") ($mgId)"
- }
+ if ($mgName -eq $mgId) {
+ $mgNameAndOrId = "$($mgName -replace "<", "<" -replace ">", ">")"
+ }
+ else {
+ $mgNameAndOrId = "$($mgName -replace "<", "<" -replace ">", ">") ($mgId)"
+ }
- $script:html += @"
+ $script:html += @"