diff --git a/.azuredevops/pipelines/AzGovViz.variables.yml b/.azuredevops/pipelines/AzGovViz.variables.yml index 9ceb4547..46d9147c 100644 --- a/.azuredevops/pipelines/AzGovViz.variables.yml +++ b/.azuredevops/pipelines/AzGovViz.variables.yml @@ -1,4 +1,4 @@ -# AzGovViz v6_major_20220521_1 +# AzGovViz v6_major_20220531_1 # First things first: # 1. Replace with the name of your service connection # 2. Replace with the your ManagementGroupId @@ -76,11 +76,6 @@ variables: # Integer | default = 14 | example: value: 21 value: - # Define for which time period Azure Consumption data should be gathered - - name: AzureConsumptionPeriod - # Integer | default = 1 | example: value: 7 - value: - # Define the direction the Hierarchy should be built in Azure DevOps WikiAsCode (Markdown) TD = TopDown (Horizontal), LR = LeftRight (Vertical) - name: AzureDevOpsWikiHierarchyDirection # String | default = 'TD' | example: value: 'LR' @@ -96,6 +91,11 @@ variables: # Switch | example: value: true value: + # If DoAzureConsumption == true then you may define for which time period (days) Azure Consumption data should be gathered + - name: AzureConsumptionPeriod + # Integer | default = 1 | example: value: 7 + value: + # Do not include Role assignments on ResourceGroups and Resources - name: DoNotIncludeResourceGroupsAndResourcesOnRBAC # Switch | example: value: true diff --git a/README.md b/README.md index 48175c36..6164445c 100644 --- a/README.md +++ b/README.md @@ -59,20 +59,11 @@ Listed as [security monitoring tool](https://docs.microsoft.com/en-us/azure/arch ## Release history -__Changes__ (2022-May-21 / Major) - -> Note: Azure DevOps and GitHub users must update the YAML file(s) and PowerShell files (`AzGovVizParallel.ps1` and `prerequisites.ps1`) - -* Integration of [PSRule for Azure](#integrate-psrule-for-azure). This feature is optional, use new parameter `-DoPSRule` - * Provides a [Azure Well-Architected Framework](https://docs.microsoft.com/en-gb/azure/architecture/framework/) aligned suite of rules for validating Azure resources - * Provides meaningful information to allow remediation - * New parameter `-PSRuleVersion` - Define the PSRule..Rules.Azure PowerShell module version, if undefined then 'latest' will be used -* Optional feature: publish HTML to Azure Web App (check the __[Setup Guide](setup.md)__) in Azure DevOps or GitHub Actions - thanks Wayne Meyer -* New feature / report on [enabled Subscription Features](https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/preview-features) TenantSummary, ScopeInsights and CSV export -* Decomissioned Azure DevOps `.pipelines` - use the new YAML files `.azuredevops/pipelines/*` -* Fix [#issue92](https://github.com/JulianHayward/Azure-MG-Sub-Governance-Reporting/issues/92) -> pipeline .azuredevops/pipelines/AzGovViz.pipeline.yml -* Update Azure DevOps pipelines / use AzurePowershell@5 -* Update prerequisites.ps1 +__Changes__ (2022-May-31 / Major) + +* New feature - Report on 'Classic Administrators' for Subscriptions -> TenantSummary, ScopeInsights and CSV export +* Fix consumption reporting (issue #101 - handle error: 'Management group `` does not have any valid subscriptions') +* PSRule for Azure / Azure DevOps dependencies (Az.Resources) workaround -> use PSRule for Azure version 1.14.3 (else latest) Passed tests: Powershell Core 7.2.3 on Windows Passed tests: Powershell Core 7.2.3 Azure DevOps hosted agent ubuntu-20.04 diff --git a/history.md b/history.md index 142c68e3..c08af09b 100644 --- a/history.md +++ b/history.md @@ -4,6 +4,12 @@ ### AzGovViz version 6 +__Changes__ (2022-May-31 / Major) + +* New feature - Report on 'Classic Administrators' for Subscriptions -> TenantSummary, ScopeInsights and CSV export +* Fix consumption reporting (issue #101 - handle error: 'Management group `` does not have any valid subscriptions') +* PSRule for Azure / Azure DevOps dependencies (Az.Resources) workaround -> use PSRule for Azure version 1.14.3 (else latest) + __Changes__ (2022-May-21 / Major) > Note: Azure DevOps and GitHub users must update the YAML file(s) and PowerShell files (`AzGovVizParallel.ps1` and `prerequisites.ps1`) diff --git a/pwsh/AzGovVizParallel.ps1 b/pwsh/AzGovVizParallel.ps1 index 684c552f..c7a90c28 100644 --- a/pwsh/AzGovVizParallel.ps1 +++ b/pwsh/AzGovVizParallel.ps1 @@ -280,7 +280,7 @@ Param $AzAPICallVersion = '1.1.12', [string] - $ProductVersion = 'v6_major_20220521_1', + $ProductVersion = 'v6_major_20220531_1', [string] $GithubRepository = 'aka.ms/AzGovViz', @@ -2090,7 +2090,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -2120,7 +2120,7 @@ function getConsumption { } #> - if ($mgConsumptionData -eq 'Unauthorized' -or $mgConsumptionData -eq 'OfferNotSupported') { + if ($mgConsumptionData -eq 'Unauthorized' -or $mgConsumptionData -eq 'OfferNotSupported' -or $mgConsumptionData -eq 'NoValidSubscriptions') { if (-not $script:htConsumptionExceptionLog.Mg.($ManagementGroupId)) { $script:htConsumptionExceptionLog.Mg.($ManagementGroupId) = @{} } @@ -2151,7 +2151,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -2281,7 +2281,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -2307,17 +2307,16 @@ function getConsumption { #test #$allConsumptionData = "OfferNotSupported" - if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled' -or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { - $generalShowStopperResult = $true + if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled' <#-or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions'#>) { if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled') { $detailShowStopperResult = $allConsumptionDataAPIResult } - if ($allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { + <#if ($allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { $detailShowStopperResult = $allConsumptionDataAPIResult - } + }#> } else { - if ($allConsumptionDataAPIResult -eq 'Unauthorized' -or $allConsumptionDataAPIResult -eq 'OfferNotSupported') { + if ($allConsumptionDataAPIResult -eq 'Unauthorized' -or $allConsumptionDataAPIResult -eq 'OfferNotSupported' -or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { $script:htConsumptionExceptionLog.Mg.($ManagementGroupId) = @{} $script:htConsumptionExceptionLog.Mg.($ManagementGroupId).Exception = $allConsumptionDataAPIResult Write-Host " Switching to 'foreach Subscription' mode. Getting Consumption data using Management Group scope failed." @@ -2344,7 +2343,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -2477,7 +2476,7 @@ function getConsumption { $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).ConsumptionData = $subscriptionId.group $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).TotalCost = $subTotalCost $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).Currency = $currency.Name - $resourceTypes = $subscriptionId.Group.ConsumedService | Sort-Object -Unique + $resourceTypes = $subscriptionId.Group.ResourceType | Sort-Object -Unique foreach ($parentMg in $htSubscriptionsMgPath.($subscriptionId.Name).ParentNameChain) { @@ -2544,9 +2543,9 @@ function getConsumption { } $totalCost = 0 - $script:tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ConsumedService, ChargeType, MeterCategory + $script:tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ResourceType, ChargeType, MeterCategory $subsCount = ($tenantSummaryConsumptionDataGrouped.group.subscriptionId | Sort-Object -Unique | Measure-Object).Count - $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.consumedService | Sort-Object -Unique | Measure-Object).Count + $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceType | Sort-Object -Unique | Measure-Object).Count $resourceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceId | Sort-Object -Unique | Measure-Object).Count foreach ($consumptionline in $tenantSummaryConsumptionDataGrouped) { @@ -2560,7 +2559,7 @@ function getConsumption { } $null = $script:arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count @@ -3722,9 +3721,9 @@ function processDataCollection { $arrayDefenderPlansSubscriptionNotRegistered = $using:arrayDefenderPlansSubscriptionNotRegistered $arrayUserAssignedIdentities4Resources = $using:arrayUserAssignedIdentities4Resources $htSubscriptionsRoleAssignmentLimit = $using:htSubscriptionsRoleAssignmentLimit - $PSRuleVersion = $using:PSRuleVersion $arrayPsRule = $using:arrayPsRule $arrayPSRuleTracking = $using:arrayPSRuleTracking + $htClassicAdministrators = $using:htClassicAdministrators #other $function:addRowToTable = $using:funcAddRowToTable $function:namingValidation = $using:funcNamingValidation @@ -3748,6 +3747,7 @@ function processDataCollection { $function:dataCollectionPolicyAssignmentsSub = $using:funcDataCollectionPolicyAssignmentsSub $function:dataCollectionRoleDefinitions = $using:funcDataCollectionRoleDefinitions $function:dataCollectionRoleAssignmentsSub = $using:funcDataCollectionRoleAssignmentsSub + $function:dataCollectionClassicAdministratorsSub = $using:funcDataCollectionClassicAdministratorsSub #endregion UsingVARs $addRowToTableDone = $false @@ -3895,6 +3895,9 @@ function processDataCollection { if ($functionReturn.'addRowToTableDone') { $addRowToTableDone = $true } + + #SubscriptionClassicAdministrators + dataCollectionClassicAdministratorsSub @baseParameters -SubscriptionMgPath $childMgMgPath } if ($addRowToTableDone -ne $true) { @@ -6200,9 +6203,9 @@ extensions: [{ name: 'sort' }] $totalCost = 0 $currency = $htAzureConsumptionSubscriptions.($subscriptionId).Currency - $consumedServiceCount = ($consumptionData.consumedService | Sort-Object -Unique | Measure-Object).Count + $consumedServiceCount = ($consumptionData.ResourceType | Sort-Object -Unique | Measure-Object).Count $resourceCount = ($consumptionData.ResourceId | Sort-Object -Unique | Measure-Object).Count - $subConsumptionDataGrouped = $consumptionData | Group-Object -property ConsumedService, ChargeType, MeterCategory + $subConsumptionDataGrouped = $consumptionData | Group-Object -property ResourceType, ChargeType, MeterCategory foreach ($consumptionline in $subConsumptionDataGrouped) { @@ -6215,7 +6218,7 @@ extensions: [{ name: 'sort' }] } $null = $arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count @@ -6259,7 +6262,7 @@ extensions: [{ name: 'sort' }] @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) @@ -6796,9 +6799,9 @@ extensions: [{ name: 'sort' }] $consumptionDataGroupedByCurrency = $consumptionData | Group-Object -property Currency foreach ($currency in $consumptionDataGroupedByCurrency) { $totalCost = 0 - $tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ConsumedService, ChargeType, MeterCategory + $tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ResourceType, ChargeType, MeterCategory $subsCount = ($tenantSummaryConsumptionDataGrouped.group.subscriptionId | Sort-Object -Unique).Count - $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.consumedService | Sort-Object -Unique).Count + $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceType | Sort-Object -Unique).Count $resourceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceId | Sort-Object -Unique).Count foreach ($consumptionline in $tenantSummaryConsumptionDataGrouped) { @@ -6811,7 +6814,7 @@ extensions: [{ name: 'sort' }] } $null = $arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count @@ -6859,7 +6862,7 @@ extensions: [{ name: 'sort' }] @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) @@ -8806,6 +8809,94 @@ extensions: [{ name: 'sort' }] '@) #endregion ScopeInsightsBlueprintsScoped + if ($mgOrSub -eq 'sub') { + #region ScopeInsightsClassicAdministrators + if ($htClassicAdministrators.($subscriptionId).ClassicAdministrators.Count -gt 0) { + $tfCount = $htClassicAdministrators.($subscriptionId).ClassicAdministrators.Count + $htmlTableId = "ScopeInsights_ClassicAdministrators_$($subscriptionId -replace '\(','_' -replace '\)','_' -replace '-','_' -replace '\.','_')" + $randomFunctionName = "func_$htmlTableId" + [void]$htmlScopeInsights.AppendLine(@" + +
+   Download CSV semicolon | comma + + + + + + + + +"@) + $htmlScopeInsightsClassicAdministrators = $null + $htmlScopeInsightsClassicAdministrators = foreach ($classicAdministrator in $htClassicAdministrators.($subscriptionId).ClassicAdministrators | Sort-Object -Property Role, Identity) { + @" + + + + +"@ + } + [void]$htmlScopeInsights.AppendLine($htmlScopeInsightsClassicAdministrators) + [void]$htmlScopeInsights.AppendLine(@" + +
RoleIdentity
$($classicAdministrator.Role)$($classicAdministrator.Identity)
+
+ +"@) + } + else { + [void]$htmlScopeInsights.AppendLine(@" +

No Classic Administrators

+"@) + } + [void]$htmlScopeInsights.AppendLine(@' + + +'@) + #endregion ScopeInsightsClassicAdministrators + } + #RoleAssignments #region ScopeInsightsRoleAssignments if ($mgOrSub -eq 'mg') { @@ -13213,6 +13304,106 @@ extensions: [{ name: 'sort' }] } #endregion SUMMARYOrphanedRoleAssignments + #region SUMMARYClassicAdministrators + Write-Host ' processing TenantSummary ClassicAdministrators' + + if ($htClassicAdministrators.Keys.Count -gt 0) { + $tfCount = $htClassicAdministrators.Values.ClassicAdministrators.Count + $htmlTableId = 'TenantSummary_ClassicAdministrators' + [void]$htmlTenantSummary.AppendLine(@" + +
+ Download CSV semicolon | comma + + + + + + + + + + + +"@) + $htmlSUMMARYClassicAdministrators = $null + $classicAdministrators = $htClassicAdministrators.Values.ClassicAdministrators | Sort-Object -Property Subscription, Role, Identity + if (-not $NoCsvExport) { + $csvFilename = "$($filename)_ClassicAdministrators" + Write-Host " Exporting ClassicAdministrators CSV '$($outputPath)$($DirectorySeparatorChar)$($csvFilename).csv'" + $classicAdministrators | Select-Object -ExcludeProperty Id | Export-Csv -Path "$($outputPath)$($DirectorySeparatorChar)$($csvFilename).csv" -Delimiter $csvDelimiter -Encoding utf8 -NoTypeInformation + } + $htmlSUMMARYClassicAdministrators = foreach ($classicAdministrator in $classicAdministrators) { + @" + + + + + + + +"@ + } + [void]$htmlTenantSummary.AppendLine($htmlSUMMARYClassicAdministrators) + [void]$htmlTenantSummary.AppendLine(@" + +
SubscriptionSubscriptionIdMgPathRoleIdentity
$($classicAdministrator.Subscription)$($classicAdministrator.SubscriptionId)$($classicAdministrator.SubscriptionMgPath)$($classicAdministrator.Role)$($classicAdministrator.Identity)
+
+ +"@) + } + else { + [void]$htmlTenantSummary.AppendLine(@" +

No ClassicAdministrators

+"@) + } + #endregion SUMMARYClassicAdministrators + #region SUMMARYRoleAssignmentsAll $startRoleAssignmentsAll = Get-Date Write-Host ' processing TenantSummary RoleAssignments' @@ -19273,7 +19464,7 @@ tf.init();}} @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) @@ -22307,9 +22498,6 @@ function verifyModules3rd { try { $moduleVersion = (Find-Module -name $($module.ModuleName)).Version Write-Host " Latest module version: $moduleVersion" - if ($module.ModuleName -eq 'PSRule.Rules.Azure') { - $PSRuleVersion = $moduleVersion - } } catch { Write-Host ' Check latest module version failed' @@ -22338,6 +22526,16 @@ function verifyModules3rd { RequiredVersion = $moduleVersion } Install-Module @params + <# + if ($module.ModuleName -eq 'PSRule.Rules.Azure') { + if (($env:SYSTEM_TEAMPROJECTID -and $env:BUILD_REPOSITORY_ID)) { + #Azure DevOps /noDeps + $path = (Get-Module PSRule.Rules.Azure -ListAvailable | Sort-Object Version -Descending -Top 1).ModuleBase + Write-Host "Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1')" + Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1') + } + } + #> } catch { throw " Installing '$($module.ModuleName)' module ($($moduleVersion)) failed" @@ -22644,7 +22842,18 @@ function dataCollectionResources { if ($azAPICallConf['htParameters'].DoPSRule -eq $true) { if ($resourcesSubscriptionResult.Count -gt 0) { $startPSRule = Get-Date - $psruleResults = $resourcesSubscriptionResult | Invoke-PSRule -Module psrule.rules.azure -As Detail -Culture en-us -WarningAction Ignore -ErrorAction SilentlyContinue + try { + <# + $path = (Get-Module PSRule.Rules.Azure -ListAvailable | Sort-Object Version -Descending -Top 1).ModuleBase + Write-Host "Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1')" + Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1') + #> + $psruleResults = $resourcesSubscriptionResult | Invoke-PSRule -Module psrule.rules.Azure -As Detail -Culture en-us -WarningAction Ignore -ErrorAction SilentlyContinue + } + catch { + Write-Host " Please report 'PSRule for Azure' error '$($scopeDisplayName)' ('$scopeId'): $_" + } + $endPSRule = Get-Date $durationPSRule = $((NEW-TIMESPAN -Start $startPSRule -End $endPSRule).TotalSeconds) @@ -25276,6 +25485,44 @@ function dataCollectionRoleAssignmentsSub { } $funcDataCollectionRoleAssignmentsSub = $function:dataCollectionRoleAssignmentsSub.ToString() +function dataCollectionClassicAdministratorsSub { + [CmdletBinding()]Param( + [string]$scopeId, + [string]$scopeDisplayName, + [string]$subscriptionMgPath + ) + + $apiEndPoint = $azAPICallConf['azAPIEndpointUrls'].ARM + $api = "/subscriptions/$($scopeId)/providers/Microsoft.Authorization/classicAdministrators" + $apiVersion = '?api-version=2015-07-01' + $uri = $apiEndPoint + $api + $apiVersion + $azAPICallPayload = @{ + uri = $uri + method = 'GET' + currentTask = "classicAdministrators '$($scopeDisplayName)' ('$scopeId')" + AzAPICallConfiguration = $azAPICallConf + } + + $AzApiCallResult = AzAPICall @azAPICallPayload + $arrayClassicAdministrators = [System.Collections.ArrayList]@() + foreach ($roleAll in $AzApiCallResult) { + $splitPropertiesRole = $roleAll.properties.role.Split(';') + foreach ($role in $splitPropertiesRole) { + $null = $arrayClassicAdministrators.Add([PSCustomObject]@{ + Subscription = $scopeDisplayName + SubscriptionId = $scopeId + SubscriptionMgPath = $subscriptionMgPath + Identity = $roleAll.properties.emailAddress + Role = $role + Id = $roleAll.id + }) + } + } + $script:htClassicAdministrators.($scopeId) = @{} + $script:htClassicAdministrators.($scopeId).ClassicAdministrators = $arrayClassicAdministrators +} +$funcDataCollectionClassicAdministratorsSub = $function:dataCollectionClassicAdministratorsSub.ToString() + #endregion functions4DataCollection #region HTML function HierarchyMgHTML($mgChild) { @@ -25651,6 +25898,13 @@ $null = $modules.Add([PSCustomObject]@{ }) if ($DoPSRule) { + + #temporary workaround / PSRule/Azure DevOps Az.Resources module requirements + if ($env:SYSTEM_TEAMPROJECTID -and $env:BUILD_REPOSITORY_ID) { + $PSRuleVersion = '1.14.3' + Write-Host "Running in Azure DevOps; enforce PSRule version '$PSRuleVersion' (Az.Resources dependency on latest PSRule)" + } + $null = $modules.Add([PSCustomObject]@{ ModuleName = 'PSRule.Rules.Azure' ModuleVersion = $PSRuleVersion @@ -25807,7 +26061,6 @@ if ($azAPICallConf['htParameters'].HierarchyMapOnly -eq $false) { $htPolicyAssignmentManagedIdentity = @{} $htManagedIdentityDisplayName = @{} $htAppDetails = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} - if (-not $NoAADGroupsResolveMembers) { $htAADGroupsDetails = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} @@ -25816,13 +26069,12 @@ if ($azAPICallConf['htParameters'].HierarchyMapOnly -eq $false) { $arrayGroupRequestResourceNotFound = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) $arrayProgressedAADGroups = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) } - if ($DoAzureConsumption) { $allConsumptionData = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) } - $arrayPsRule = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) $arrayPSRuleTracking = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) + $htClassicAdministrators = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} } getEntities diff --git a/pwsh/dev/devAzGovVizParallel.ps1 b/pwsh/dev/devAzGovVizParallel.ps1 index 715b2a5a..d1a43b19 100644 --- a/pwsh/dev/devAzGovVizParallel.ps1 +++ b/pwsh/dev/devAzGovVizParallel.ps1 @@ -280,7 +280,7 @@ Param $AzAPICallVersion = '1.1.12', [string] - $ProductVersion = 'v6_major_20220521_1', + $ProductVersion = 'v6_major_20220531_1', [string] $GithubRepository = 'aka.ms/AzGovViz', @@ -561,6 +561,13 @@ $null = $modules.Add([PSCustomObject]@{ }) if ($DoPSRule) { + + #temporary workaround / PSRule/Azure DevOps Az.Resources module requirements + if ($env:SYSTEM_TEAMPROJECTID -and $env:BUILD_REPOSITORY_ID) { + $PSRuleVersion = '1.14.3' + Write-Host "Running in Azure DevOps; enforce PSRule version '$PSRuleVersion' (Az.Resources dependency on latest PSRule)" + } + $null = $modules.Add([PSCustomObject]@{ ModuleName = 'PSRule.Rules.Azure' ModuleVersion = $PSRuleVersion @@ -717,7 +724,6 @@ if ($azAPICallConf['htParameters'].HierarchyMapOnly -eq $false) { $htPolicyAssignmentManagedIdentity = @{} $htManagedIdentityDisplayName = @{} $htAppDetails = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} - if (-not $NoAADGroupsResolveMembers) { $htAADGroupsDetails = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} @@ -726,13 +732,12 @@ if ($azAPICallConf['htParameters'].HierarchyMapOnly -eq $false) { $arrayGroupRequestResourceNotFound = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) $arrayProgressedAADGroups = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) } - if ($DoAzureConsumption) { $allConsumptionData = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) } - $arrayPsRule = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) $arrayPSRuleTracking = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) + $htClassicAdministrators = [System.Collections.Hashtable]::Synchronized((New-Object System.Collections.Hashtable)) #@{} } getEntities diff --git a/pwsh/dev/functions/dataCollection/dataCollectionFunctions.ps1 b/pwsh/dev/functions/dataCollection/dataCollectionFunctions.ps1 index dc9007fc..bca450c7 100644 --- a/pwsh/dev/functions/dataCollection/dataCollectionFunctions.ps1 +++ b/pwsh/dev/functions/dataCollection/dataCollectionFunctions.ps1 @@ -272,7 +272,18 @@ function dataCollectionResources { if ($azAPICallConf['htParameters'].DoPSRule -eq $true) { if ($resourcesSubscriptionResult.Count -gt 0) { $startPSRule = Get-Date - $psruleResults = $resourcesSubscriptionResult | Invoke-PSRule -Module psrule.rules.azure -As Detail -Culture en-us -WarningAction Ignore -ErrorAction SilentlyContinue + try { + <# + $path = (Get-Module PSRule.Rules.Azure -ListAvailable | Sort-Object Version -Descending -Top 1).ModuleBase + Write-Host "Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1')" + Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1') + #> + $psruleResults = $resourcesSubscriptionResult | Invoke-PSRule -Module psrule.rules.Azure -As Detail -Culture en-us -WarningAction Ignore -ErrorAction SilentlyContinue + } + catch { + Write-Host " Please report 'PSRule for Azure' error '$($scopeDisplayName)' ('$scopeId'): $_" + } + $endPSRule = Get-Date $durationPSRule = $((NEW-TIMESPAN -Start $startPSRule -End $endPSRule).TotalSeconds) @@ -2904,4 +2915,42 @@ function dataCollectionRoleAssignmentsSub { } $funcDataCollectionRoleAssignmentsSub = $function:dataCollectionRoleAssignmentsSub.ToString() +function dataCollectionClassicAdministratorsSub { + [CmdletBinding()]Param( + [string]$scopeId, + [string]$scopeDisplayName, + [string]$subscriptionMgPath + ) + + $apiEndPoint = $azAPICallConf['azAPIEndpointUrls'].ARM + $api = "/subscriptions/$($scopeId)/providers/Microsoft.Authorization/classicAdministrators" + $apiVersion = '?api-version=2015-07-01' + $uri = $apiEndPoint + $api + $apiVersion + $azAPICallPayload = @{ + uri = $uri + method = 'GET' + currentTask = "classicAdministrators '$($scopeDisplayName)' ('$scopeId')" + AzAPICallConfiguration = $azAPICallConf + } + + $AzApiCallResult = AzAPICall @azAPICallPayload + $arrayClassicAdministrators = [System.Collections.ArrayList]@() + foreach ($roleAll in $AzApiCallResult) { + $splitPropertiesRole = $roleAll.properties.role.Split(';') + foreach ($role in $splitPropertiesRole) { + $null = $arrayClassicAdministrators.Add([PSCustomObject]@{ + Subscription = $scopeDisplayName + SubscriptionId = $scopeId + SubscriptionMgPath = $subscriptionMgPath + Identity = $roleAll.properties.emailAddress + Role = $role + Id = $roleAll.id + }) + } + } + $script:htClassicAdministrators.($scopeId) = @{} + $script:htClassicAdministrators.($scopeId).ClassicAdministrators = $arrayClassicAdministrators +} +$funcDataCollectionClassicAdministratorsSub = $function:dataCollectionClassicAdministratorsSub.ToString() + #endregion functions4DataCollection \ No newline at end of file diff --git a/pwsh/dev/functions/getConsumption.ps1 b/pwsh/dev/functions/getConsumption.ps1 index 7bd8657c..fa482b7f 100644 --- a/pwsh/dev/functions/getConsumption.ps1 +++ b/pwsh/dev/functions/getConsumption.ps1 @@ -78,7 +78,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -108,7 +108,7 @@ function getConsumption { } #> - if ($mgConsumptionData -eq 'Unauthorized' -or $mgConsumptionData -eq 'OfferNotSupported') { + if ($mgConsumptionData -eq 'Unauthorized' -or $mgConsumptionData -eq 'OfferNotSupported' -or $mgConsumptionData -eq 'NoValidSubscriptions') { if (-not $script:htConsumptionExceptionLog.Mg.($ManagementGroupId)) { $script:htConsumptionExceptionLog.Mg.($ManagementGroupId) = @{} } @@ -139,7 +139,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -269,7 +269,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -295,17 +295,16 @@ function getConsumption { #test #$allConsumptionData = "OfferNotSupported" - if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled' -or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { - $generalShowStopperResult = $true + if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled' <#-or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions'#>) { if ($allConsumptionDataAPIResult -eq 'AccountCostDisabled') { $detailShowStopperResult = $allConsumptionDataAPIResult } - if ($allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { + <#if ($allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { $detailShowStopperResult = $allConsumptionDataAPIResult - } + }#> } else { - if ($allConsumptionDataAPIResult -eq 'Unauthorized' -or $allConsumptionDataAPIResult -eq 'OfferNotSupported') { + if ($allConsumptionDataAPIResult -eq 'Unauthorized' -or $allConsumptionDataAPIResult -eq 'OfferNotSupported' -or $allConsumptionDataAPIResult -eq 'NoValidSubscriptions') { $script:htConsumptionExceptionLog.Mg.($ManagementGroupId) = @{} $script:htConsumptionExceptionLog.Mg.($ManagementGroupId).Exception = $allConsumptionDataAPIResult Write-Host " Switching to 'foreach Subscription' mode. Getting Consumption data using Management Group scope failed." @@ -332,7 +331,7 @@ function getConsumption { }, { "type": "Dimension", - "name": "ConsumedService" + "name": "ResourceType" }, { "type": "Dimension", @@ -465,7 +464,7 @@ function getConsumption { $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).ConsumptionData = $subscriptionId.group $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).TotalCost = $subTotalCost $script:htAzureConsumptionSubscriptions.($subscriptionId.Name).Currency = $currency.Name - $resourceTypes = $subscriptionId.Group.ConsumedService | Sort-Object -Unique + $resourceTypes = $subscriptionId.Group.ResourceType | Sort-Object -Unique foreach ($parentMg in $htSubscriptionsMgPath.($subscriptionId.Name).ParentNameChain) { @@ -532,9 +531,9 @@ function getConsumption { } $totalCost = 0 - $script:tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ConsumedService, ChargeType, MeterCategory + $script:tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ResourceType, ChargeType, MeterCategory $subsCount = ($tenantSummaryConsumptionDataGrouped.group.subscriptionId | Sort-Object -Unique | Measure-Object).Count - $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.consumedService | Sort-Object -Unique | Measure-Object).Count + $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceType | Sort-Object -Unique | Measure-Object).Count $resourceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceId | Sort-Object -Unique | Measure-Object).Count foreach ($consumptionline in $tenantSummaryConsumptionDataGrouped) { @@ -548,7 +547,7 @@ function getConsumption { } $null = $script:arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count diff --git a/pwsh/dev/functions/processDataCollection.ps1 b/pwsh/dev/functions/processDataCollection.ps1 index 9e7ac76e..cd65447d 100644 --- a/pwsh/dev/functions/processDataCollection.ps1 +++ b/pwsh/dev/functions/processDataCollection.ps1 @@ -323,9 +323,9 @@ function processDataCollection { $arrayDefenderPlansSubscriptionNotRegistered = $using:arrayDefenderPlansSubscriptionNotRegistered $arrayUserAssignedIdentities4Resources = $using:arrayUserAssignedIdentities4Resources $htSubscriptionsRoleAssignmentLimit = $using:htSubscriptionsRoleAssignmentLimit - $PSRuleVersion = $using:PSRuleVersion $arrayPsRule = $using:arrayPsRule $arrayPSRuleTracking = $using:arrayPSRuleTracking + $htClassicAdministrators = $using:htClassicAdministrators #other $function:addRowToTable = $using:funcAddRowToTable $function:namingValidation = $using:funcNamingValidation @@ -349,6 +349,7 @@ function processDataCollection { $function:dataCollectionPolicyAssignmentsSub = $using:funcDataCollectionPolicyAssignmentsSub $function:dataCollectionRoleDefinitions = $using:funcDataCollectionRoleDefinitions $function:dataCollectionRoleAssignmentsSub = $using:funcDataCollectionRoleAssignmentsSub + $function:dataCollectionClassicAdministratorsSub = $using:funcDataCollectionClassicAdministratorsSub #endregion UsingVARs $addRowToTableDone = $false @@ -496,6 +497,9 @@ function processDataCollection { if ($functionReturn.'addRowToTableDone') { $addRowToTableDone = $true } + + #SubscriptionClassicAdministrators + dataCollectionClassicAdministratorsSub @baseParameters -SubscriptionMgPath $childMgMgPath } if ($addRowToTableDone -ne $true) { diff --git a/pwsh/dev/functions/processScopeInsightsMgOrSub.ps1 b/pwsh/dev/functions/processScopeInsightsMgOrSub.ps1 index c4082ce1..eeb1b107 100644 --- a/pwsh/dev/functions/processScopeInsightsMgOrSub.ps1 +++ b/pwsh/dev/functions/processScopeInsightsMgOrSub.ps1 @@ -632,9 +632,9 @@ extensions: [{ name: 'sort' }] $totalCost = 0 $currency = $htAzureConsumptionSubscriptions.($subscriptionId).Currency - $consumedServiceCount = ($consumptionData.consumedService | Sort-Object -Unique | Measure-Object).Count + $consumedServiceCount = ($consumptionData.ResourceType | Sort-Object -Unique | Measure-Object).Count $resourceCount = ($consumptionData.ResourceId | Sort-Object -Unique | Measure-Object).Count - $subConsumptionDataGrouped = $consumptionData | Group-Object -property ConsumedService, ChargeType, MeterCategory + $subConsumptionDataGrouped = $consumptionData | Group-Object -property ResourceType, ChargeType, MeterCategory foreach ($consumptionline in $subConsumptionDataGrouped) { @@ -647,7 +647,7 @@ extensions: [{ name: 'sort' }] } $null = $arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count @@ -691,7 +691,7 @@ extensions: [{ name: 'sort' }] @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) @@ -1228,9 +1228,9 @@ extensions: [{ name: 'sort' }] $consumptionDataGroupedByCurrency = $consumptionData | Group-Object -property Currency foreach ($currency in $consumptionDataGroupedByCurrency) { $totalCost = 0 - $tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ConsumedService, ChargeType, MeterCategory + $tenantSummaryConsumptionDataGrouped = $currency.group | Group-Object -property ResourceType, ChargeType, MeterCategory $subsCount = ($tenantSummaryConsumptionDataGrouped.group.subscriptionId | Sort-Object -Unique).Count - $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.consumedService | Sort-Object -Unique).Count + $consumedServiceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceType | Sort-Object -Unique).Count $resourceCount = ($tenantSummaryConsumptionDataGrouped.group.ResourceId | Sort-Object -Unique).Count foreach ($consumptionline in $tenantSummaryConsumptionDataGrouped) { @@ -1243,7 +1243,7 @@ extensions: [{ name: 'sort' }] } $null = $arrayConsumptionData.Add([PSCustomObject]@{ - ConsumedService = ($consumptionline.name).split(', ')[0] + ResourceType = ($consumptionline.name).split(', ')[0] ConsumedServiceChargeType = ($consumptionline.name).split(', ')[1] ConsumedServiceCategory = ($consumptionline.name).split(', ')[2] ConsumedServiceInstanceCount = $consumptionline.Count @@ -1291,7 +1291,7 @@ extensions: [{ name: 'sort' }] @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) @@ -3238,6 +3238,94 @@ extensions: [{ name: 'sort' }] '@) #endregion ScopeInsightsBlueprintsScoped + if ($mgOrSub -eq 'sub') { + #region ScopeInsightsClassicAdministrators + if ($htClassicAdministrators.($subscriptionId).ClassicAdministrators.Count -gt 0) { + $tfCount = $htClassicAdministrators.($subscriptionId).ClassicAdministrators.Count + $htmlTableId = "ScopeInsights_ClassicAdministrators_$($subscriptionId -replace '\(','_' -replace '\)','_' -replace '-','_' -replace '\.','_')" + $randomFunctionName = "func_$htmlTableId" + [void]$htmlScopeInsights.AppendLine(@" + +
+   Download CSV semicolon | comma + + + + + + + + +"@) + $htmlScopeInsightsClassicAdministrators = $null + $htmlScopeInsightsClassicAdministrators = foreach ($classicAdministrator in $htClassicAdministrators.($subscriptionId).ClassicAdministrators | Sort-Object -Property Role, Identity) { + @" + + + + +"@ + } + [void]$htmlScopeInsights.AppendLine($htmlScopeInsightsClassicAdministrators) + [void]$htmlScopeInsights.AppendLine(@" + +
RoleIdentity
$($classicAdministrator.Role)$($classicAdministrator.Identity)
+
+ +"@) + } + else { + [void]$htmlScopeInsights.AppendLine(@" +

No Classic Administrators

+"@) + } + [void]$htmlScopeInsights.AppendLine(@' + + +'@) + #endregion ScopeInsightsClassicAdministrators + } + #RoleAssignments #region ScopeInsightsRoleAssignments if ($mgOrSub -eq 'mg') { diff --git a/pwsh/dev/functions/processTenantSummary.ps1 b/pwsh/dev/functions/processTenantSummary.ps1 index 7621b448..31125d1c 100644 --- a/pwsh/dev/functions/processTenantSummary.ps1 +++ b/pwsh/dev/functions/processTenantSummary.ps1 @@ -4141,6 +4141,106 @@ extensions: [{ name: 'sort' }] } #endregion SUMMARYOrphanedRoleAssignments + #region SUMMARYClassicAdministrators + Write-Host ' processing TenantSummary ClassicAdministrators' + + if ($htClassicAdministrators.Keys.Count -gt 0) { + $tfCount = $htClassicAdministrators.Values.ClassicAdministrators.Count + $htmlTableId = 'TenantSummary_ClassicAdministrators' + [void]$htmlTenantSummary.AppendLine(@" + +
+ Download CSV semicolon | comma + + + + + + + + + + + +"@) + $htmlSUMMARYClassicAdministrators = $null + $classicAdministrators = $htClassicAdministrators.Values.ClassicAdministrators | Sort-Object -Property Subscription, Role, Identity + if (-not $NoCsvExport) { + $csvFilename = "$($filename)_ClassicAdministrators" + Write-Host " Exporting ClassicAdministrators CSV '$($outputPath)$($DirectorySeparatorChar)$($csvFilename).csv'" + $classicAdministrators | Select-Object -ExcludeProperty Id | Export-Csv -Path "$($outputPath)$($DirectorySeparatorChar)$($csvFilename).csv" -Delimiter $csvDelimiter -Encoding utf8 -NoTypeInformation + } + $htmlSUMMARYClassicAdministrators = foreach ($classicAdministrator in $classicAdministrators) { + @" + + + + + + + +"@ + } + [void]$htmlTenantSummary.AppendLine($htmlSUMMARYClassicAdministrators) + [void]$htmlTenantSummary.AppendLine(@" + +
SubscriptionSubscriptionIdMgPathRoleIdentity
$($classicAdministrator.Subscription)$($classicAdministrator.SubscriptionId)$($classicAdministrator.SubscriptionMgPath)$($classicAdministrator.Role)$($classicAdministrator.Identity)
+
+ +"@) + } + else { + [void]$htmlTenantSummary.AppendLine(@" +

No ClassicAdministrators

+"@) + } + #endregion SUMMARYClassicAdministrators + #region SUMMARYRoleAssignmentsAll $startRoleAssignmentsAll = Get-Date Write-Host ' processing TenantSummary RoleAssignments' @@ -10201,7 +10301,7 @@ tf.init();}} @" $($consumptionLine.ConsumedServiceChargeType) -$($consumptionLine.ConsumedService) +$($consumptionLine.ResourceType) $($consumptionLine.ConsumedServiceCategory) $($consumptionLine.ConsumedServiceInstanceCount) $($consumptionLine.ConsumedServiceCost) diff --git a/pwsh/dev/functions/verifyModules3rd.ps1 b/pwsh/dev/functions/verifyModules3rd.ps1 index b2c2443a..f9a1beab 100644 --- a/pwsh/dev/functions/verifyModules3rd.ps1 +++ b/pwsh/dev/functions/verifyModules3rd.ps1 @@ -30,9 +30,6 @@ function verifyModules3rd { try { $moduleVersion = (Find-Module -name $($module.ModuleName)).Version Write-Host " Latest module version: $moduleVersion" - if ($module.ModuleName -eq 'PSRule.Rules.Azure') { - $PSRuleVersion = $moduleVersion - } } catch { Write-Host ' Check latest module version failed' @@ -61,6 +58,16 @@ function verifyModules3rd { RequiredVersion = $moduleVersion } Install-Module @params + <# + if ($module.ModuleName -eq 'PSRule.Rules.Azure') { + if (($env:SYSTEM_TEAMPROJECTID -and $env:BUILD_REPOSITORY_ID)) { + #Azure DevOps /noDeps + $path = (Get-Module PSRule.Rules.Azure -ListAvailable | Sort-Object Version -Descending -Top 1).ModuleBase + Write-Host "Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1')" + Import-Module (Join-Path $path -ChildPath 'PSRule.Rules.Azure-nodeps.psd1') + } + } + #> } catch { throw " Installing '$($module.ModuleName)' module ($($moduleVersion)) failed" diff --git a/version.txt b/version.txt index 04551938..2c12c19d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v6_major_20220521_1 \ No newline at end of file +v6_major_20220531_1 \ No newline at end of file