CostManagementView.psm1
|
function ConvertFrom-JwtPayload { param ( [Parameter(Mandatory = $true)] [string]$Token ) $payload = $Token.Split('.')[1].Replace('-', '+').Replace('_', '/') $payload = $payload.PadRight($payload.Length + (4 - $payload.Length % 4) % 4, '=') [System.Text.Encoding]::UTF8.GetString( [Convert]::FromBase64String($payload) ) | ConvertFrom-Json } function Get-CurrentUserIdentity { param ( [string]$TenantId ) try { if ($TenantId) { $tokenObj = Get-AzAccessToken ` -ResourceUrl "https://management.azure.com" ` -TenantId $TenantId ` -ErrorAction Stop } else { $tokenObj = Get-AzAccessToken ` -ResourceUrl "https://management.azure.com" ` -ErrorAction Stop } if ($tokenObj.Token -is [System.Security.SecureString]) { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tokenObj.Token) try { $token = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } else { $token = $tokenObj.Token } $decoded = ConvertFrom-JwtPayload -Token $token $email = if ($decoded.unique_name) { $decoded.unique_name } elseif ($decoded.email) { $decoded.email } elseif ($decoded.upn) { $decoded.upn } else { (Get-AzContext).Account.Id } [PSCustomObject]@{ Email = $email ObjectId = $decoded.oid TenantId = $decoded.tid } } catch { Write-Host "Warning: Failed to decode access token. Falling back to Az context." -ForegroundColor Yellow $context = Get-AzContext [PSCustomObject]@{ Email = if ($context) { $context.Account.Id } else { $null } ObjectId = $null TenantId = if ($context) { $context.Tenant.Id } else { $null } } } } function Ensure-AzureSignIn { param ( [string]$TenantId ) $context = Get-AzContext if ($context -and (-not $TenantId -or $context.Tenant.Id -eq $TenantId)) { return $true } Write-Host "Launching Azure sign-in..." -ForegroundColor Yellow try { if ($TenantId) { Connect-AzAccount -TenantId $TenantId -ErrorAction Stop | Out-Null } else { Connect-AzAccount -ErrorAction Stop | Out-Null } $context = Get-AzContext if (-not $context) { Write-Host "Sign-in was not completed." -ForegroundColor Red return $false } Write-Host "Signed in successfully as $($context.Account.Id)" -ForegroundColor Green return $true } catch { Write-Host "Error during sign-in: $($_.Exception.Message)" -ForegroundColor Red return $false } } function Invoke-AzureManagementGet { param ( [Parameter(Mandatory = $true)] [string]$Path ) $response = Invoke-AzRestMethod ` -Method GET ` -Path $Path ` -ErrorAction Stop if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { throw "Azure REST API failed. StatusCode: $($response.StatusCode). Content: $($response.Content)" } return ($response.Content | ConvertFrom-Json) } function Invoke-AzureManagementPut { param ( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [object]$Body ) $jsonBody = $Body | ConvertTo-Json -Depth 30 $response = Invoke-AzRestMethod ` -Method PUT ` -Path $Path ` -Payload $jsonBody ` -ErrorAction Stop if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { throw "Azure REST API failed. StatusCode: $($response.StatusCode). Content: $($response.Content)" } return ($response.Content | ConvertFrom-Json) } function Get-BillingAccounts { $path = '/providers/Microsoft.Billing/billingAccounts' + '?$expand=billingProfiles/invoiceSections,billingProfiles,billingProfiles/customers,subscriptions,soldTo' + '&includeCAID=true' + '&includeAllOrgs=false' + '&api-version=2022-10-01-privatepreview' $result = Invoke-AzureManagementGet -Path $path return @($result.value) } function Get-McaBillingProfiles { param ( [Parameter(Mandatory = $true)] [string]$BillingAccountName ) $encodedBillingAccountName = [System.Uri]::EscapeDataString($BillingAccountName) $path = "/providers/Microsoft.Billing/billingAccounts/$encodedBillingAccountName/billingProfiles" + '?api-version=2020-11-01-privatepreview' + '&%24top=20' + '&%24orderBy=properties%2FdisplayName%20asc' $result = Invoke-AzureManagementGet -Path $path return @($result.value) } function Select-ItemFromList { param ( [Parameter(Mandatory = $true)] [array]$Items, [Parameter(Mandatory = $true)] [string]$Prompt, [Parameter(Mandatory = $true)] [scriptblock]$Display, [bool]$AllowBack = $false ) if ($Items.Count -eq 0) { return $null } for ($i = 0; $i -lt $Items.Count; $i++) { $displayText = & $Display $Items[$i] Write-Host " $($i + 1). $displayText" } $backSelectionNumber = $null if ($AllowBack) { $backSelectionNumber = $Items.Count + 1 Write-Host " $backSelectionNumber. Back" } $maxSelection = if ($AllowBack) { $Items.Count + 1 } else { $Items.Count } do { $selection = Read-Host $Prompt } while ( $selection -notmatch '^\d+$' -or [int]$selection -lt 1 -or [int]$selection -gt $maxSelection ) if ($AllowBack -and [int]$selection -eq $backSelectionNumber) { return "__BACK__" } return $Items[[int]$selection - 1] } function Normalize-AzureScope { param ( [string]$Scope ) if ([string]::IsNullOrWhiteSpace($Scope)) { return $null } $normalized = $Scope.Trim() if ($normalized -eq "/") { return "/" } return $normalized.TrimEnd("/") } function Test-RoleAssignmentAppliesToScope { param ( [Parameter(Mandatory = $true)] [string]$AssignmentScope, [Parameter(Mandatory = $true)] [string]$TargetScope ) $assignment = Normalize-AzureScope -Scope $AssignmentScope $target = Normalize-AzureScope -Scope $TargetScope if (-not $assignment -or -not $target) { return $false } if ($assignment -eq "/") { return $true } return ( $target -ieq $assignment -or $target.StartsWith("$assignment/", [System.StringComparison]::OrdinalIgnoreCase) ) } function Test-ActionPatternCovers { param ( [Parameter(Mandatory = $true)] [string]$AllowedAction, [Parameter(Mandatory = $true)] [string]$RequiredAction ) return ( $AllowedAction -eq "*" -or $AllowedAction -ieq $RequiredAction -or $RequiredAction -like $AllowedAction ) } function Test-ActionPatternDenied { param ( [Parameter(Mandatory = $true)] [string]$NotAction, [Parameter(Mandatory = $true)] [string]$RequiredAction ) return ( $NotAction -eq "*" -or $RequiredAction -like $NotAction -or $NotAction -like $RequiredAction ) } function Get-RolePermissionActions { param ( [Parameter(Mandatory = $true)] $RoleDefinition ) $actions = @() $notActions = @() if ($RoleDefinition.Permissions) { foreach ($permission in $RoleDefinition.Permissions) { $actions += @($permission.Actions) $notActions += @($permission.NotActions) } } else { $actions += @($RoleDefinition.Actions) $notActions += @($RoleDefinition.NotActions) } [PSCustomObject]@{ Actions = @($actions | Where-Object { $_ }) NotActions = @($notActions | Where-Object { $_ }) } } function Test-CostManagementContributorPermission { param ( [Parameter(Mandatory = $true)] [string]$Scope, [Parameter(Mandatory = $true)] [string]$TenantId ) $requiredActions = @( "Microsoft.Consumption/*", "Microsoft.CostManagement/*", "Microsoft.Billing/billingPeriods/read", "Microsoft.Resources/subscriptions/read", "Microsoft.Resources/subscriptions/resourceGroups/read", "Microsoft.Support/*", "Microsoft.Advisor/configurations/read", "Microsoft.Advisor/recommendations/read", "Microsoft.Management/managementGroups/read", "Microsoft.Billing/billingProperty/read" ) $context = Get-AzContext if (-not $context) { Write-Host "Access denied. No Azure context found." -ForegroundColor Red return $false } $identity = Get-CurrentUserIdentity -TenantId $TenantId if (-not $identity.ObjectId) { Write-Host "Access denied. Could not determine signed-in user ObjectId." -ForegroundColor Red return $false } $roleAssignments = @( Get-AzRoleAssignment ` -ObjectId $identity.ObjectId ` -ExpandPrincipalGroups ` -ErrorAction SilentlyContinue ) if ($roleAssignments.Count -eq 0) { Write-Host "Access denied. No role assignments found for $($identity.Email)." -ForegroundColor Red return $false } $applicableAssignments = @( $roleAssignments | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Scope) -and (Test-RoleAssignmentAppliesToScope -AssignmentScope $_.Scope -TargetScope $Scope) } ) if ($applicableAssignments.Count -eq 0) { Write-Host "Access denied. No applicable role assignments found for scope $Scope." -ForegroundColor Red return $false } $bypassRoles = @( "Owner", "Contributor", "Cost Management Contributor" ) $matchingBypassRole = $applicableAssignments | Where-Object { $_.RoleDefinitionName -in $bypassRoles } | Select-Object -First 1 if ($matchingBypassRole) { Write-Host "Access confirmed. User has '$($matchingBypassRole.RoleDefinitionName)' on the selected subscription scope." -ForegroundColor Green return $true } foreach ($requiredAction in $requiredActions) { $hasPermission = $false foreach ($assignment in $applicableAssignments) { $roleDefinition = Get-AzRoleDefinition ` -Id $assignment.RoleDefinitionId ` -ErrorAction SilentlyContinue ` -WarningAction SilentlyContinue if (-not $roleDefinition) { continue } $permissions = Get-RolePermissionActions -RoleDefinition $roleDefinition $isDenied = $false foreach ($notAction in $permissions.NotActions) { if (Test-ActionPatternDenied -NotAction $notAction -RequiredAction $requiredAction) { $isDenied = $true break } } if ($isDenied) { continue } foreach ($allowedAction in $permissions.Actions) { if (Test-ActionPatternCovers -AllowedAction $allowedAction -RequiredAction $requiredAction) { $hasPermission = $true break } } if ($hasPermission) { break } } if (-not $hasPermission) { Write-Host "Access denied. Missing required action: $requiredAction" -ForegroundColor Red return $false } } Write-Host "Access confirmed. User has equivalent or higher permissions than Cost Management Contributor." -ForegroundColor Green return $true } function Select-CostManagementScope { while ($true) { Write-Host "" Write-Host "=== Select Cost Management Scope ===" -ForegroundColor Cyan Write-Host "" Write-Host " 1. Subscription" Write-Host " 2. Enterprise Agreement (EA)" Write-Host " 3. Microsoft Customer Agreement (MCA)" Write-Host " 4. MCA Billing Profile" Write-Host "" do { $scopeSelection = Read-Host "Select scope type" } while ($scopeSelection -notin @("1", "2", "3", "4")) switch ($scopeSelection) { "1" { do { $subscriptionId = Read-Host "Enter Subscription GUID" } while ( [string]::IsNullOrWhiteSpace($subscriptionId) -or $subscriptionId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' ) try { Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop | Out-Null } catch { Write-Host "Could not set Azure context to subscription $subscriptionId. Error: $($_.Exception.Message)" -ForegroundColor Red return $null } return [PSCustomObject]@{ ScopeType = "Subscription" SubscriptionId = $subscriptionId BillingAccountName = $null BillingAccountId = $null BillingProfileName = $null BillingProfileId = $null Scope = "/subscriptions/$subscriptionId" DisplayName = $subscriptionId } } "2" { Write-Host "" Write-Host "Fetching billing accounts..." -ForegroundColor Yellow $billingAccounts = Get-BillingAccounts $eaAccounts = @( $billingAccounts | Where-Object { $_.properties.agreementType -eq "EnterpriseAgreement" } ) if ($eaAccounts.Count -eq 0) { Write-Host "No Enterprise Agreement billing accounts found." -ForegroundColor Red continue } Write-Host "" Write-Host "Enterprise Agreement billing accounts:" -ForegroundColor Cyan $selectedAccount = Select-ItemFromList ` -Items $eaAccounts ` -Prompt "Select an EA billing account" ` -AllowBack $true ` -Display { param ($account) $displayName = $account.properties.displayName if ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = $account.name } "$displayName | Billing Account: $($account.name) | AgreementType: $($account.properties.agreementType)" } if ($selectedAccount -is [string] -and $selectedAccount -eq "__BACK__") { continue } return [PSCustomObject]@{ ScopeType = "EnterpriseAgreement" SubscriptionId = $null BillingAccountName = $selectedAccount.name BillingAccountId = $selectedAccount.id BillingProfileName = $null BillingProfileId = $null Scope = $selectedAccount.id DisplayName = $selectedAccount.properties.displayName } } "3" { Write-Host "" Write-Host "Fetching billing accounts..." -ForegroundColor Yellow $billingAccounts = Get-BillingAccounts $mcaAccounts = @( $billingAccounts | Where-Object { $_.properties.agreementType -eq "MicrosoftCustomerAgreement" } ) if ($mcaAccounts.Count -eq 0) { Write-Host "No Microsoft Customer Agreement billing accounts found." -ForegroundColor Red continue } Write-Host "" Write-Host "Microsoft Customer Agreement billing accounts:" -ForegroundColor Cyan $selectedAccount = Select-ItemFromList ` -Items $mcaAccounts ` -Prompt "Select an MCA billing account" ` -AllowBack $true ` -Display { param ($account) $displayName = $account.properties.displayName if ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = $account.name } "$displayName | Billing Account: $($account.name) | AgreementType: $($account.properties.agreementType)" } if ($selectedAccount -is [string] -and $selectedAccount -eq "__BACK__") { continue } return [PSCustomObject]@{ ScopeType = "MicrosoftCustomerAgreement" SubscriptionId = $null BillingAccountName = $selectedAccount.name BillingAccountId = $selectedAccount.id BillingProfileName = $null BillingProfileId = $null Scope = $selectedAccount.id DisplayName = $selectedAccount.properties.displayName } } "4" { Write-Host "" Write-Host "Fetching billing accounts..." -ForegroundColor Yellow $billingAccounts = Get-BillingAccounts $mcaAccounts = @( $billingAccounts | Where-Object { $_.properties.agreementType -eq "MicrosoftCustomerAgreement" } ) if ($mcaAccounts.Count -eq 0) { Write-Host "No Microsoft Customer Agreement billing accounts found." -ForegroundColor Red continue } while ($true) { Write-Host "" Write-Host "Microsoft Customer Agreement billing accounts:" -ForegroundColor Cyan $selectedAccount = Select-ItemFromList ` -Items $mcaAccounts ` -Prompt "Select an MCA billing account" ` -AllowBack $true ` -Display { param ($account) $displayName = $account.properties.displayName if ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = $account.name } "$displayName | Billing Account: $($account.name) | AgreementType: $($account.properties.agreementType)" } if ($selectedAccount -is [string] -and $selectedAccount -eq "__BACK__") { break } Write-Host "" Write-Host "Fetching billing profiles for $($selectedAccount.properties.displayName)..." -ForegroundColor Yellow $billingProfiles = Get-McaBillingProfiles -BillingAccountName $selectedAccount.name if ($billingProfiles.Count -eq 0) { Write-Host "No billing profiles found for this MCA billing account." -ForegroundColor Red continue } Write-Host "" Write-Host "MCA billing profiles:" -ForegroundColor Cyan $selectedProfile = Select-ItemFromList ` -Items $billingProfiles ` -Prompt "Select a billing profile" ` -AllowBack $true ` -Display { param ($profile) $displayName = $profile.properties.displayName if ([string]::IsNullOrWhiteSpace($displayName)) { $displayName = $profile.name } "$displayName | Billing Profile: $($profile.name)" } if ($selectedProfile -is [string] -and $selectedProfile -eq "__BACK__") { continue } $billingProfileSystemId = $selectedProfile.properties.systemId if ([string]::IsNullOrWhiteSpace($billingProfileSystemId)) { Write-Host "Selected billing profile does not include a systemId. Please select a different billing profile." -ForegroundColor Red continue } $billingProfileScope = "$($selectedAccount.id)/billingProfiles/$billingProfileSystemId" return [PSCustomObject]@{ ScopeType = "McaBillingProfile" SubscriptionId = $null BillingAccountName = $selectedAccount.name BillingAccountId = $selectedAccount.id BillingProfileName = $selectedProfile.name BillingProfileId = $selectedProfile.id BillingProfileSystemId = $billingProfileSystemId Scope = $billingProfileScope DisplayName = $selectedProfile.properties.displayName } } } } } } function Read-CostManagementViewInput { while ($true) { Write-Host "" Write-Host "=== Create Cost Management View ===" -ForegroundColor Cyan Write-Host "" Write-Host " 1. Continue" Write-Host " 2. Back to scope selection" Write-Host "" do { $selection = Read-Host "Select an option" } while ($selection -notin @("1", "2")) if ($selection -eq "2") { return "__BACK__" } do { $viewName = Read-Host "Enter the view name" } while ([string]::IsNullOrWhiteSpace($viewName)) while ($true) { $startDateInput = (Read-Host "Enter the start date (yyyy-MM-dd)").Trim() $endDateInput = (Read-Host "Enter the end date (yyyy-MM-dd)").Trim() try { $startDate = [DateTime]::ParseExact( $startDateInput, "yyyy-MM-dd", [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None ) $endDate = [DateTime]::ParseExact( $endDateInput, "yyyy-MM-dd", [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None ) } catch { Write-Host "Invalid date format. Please enter dates in yyyy-MM-dd format, for example 2026-01-01." -ForegroundColor Red continue } if ($endDate -lt $startDate) { Write-Host "End date cannot be earlier than start date. Please enter a valid date range." -ForegroundColor Red continue } if ($endDate -gt $startDate.AddMonths(12)) { Write-Host "The selected time period is outside the allowed 12-month window. Please enter a start date and end date within 12 months." -ForegroundColor Red continue } return [PSCustomObject]@{ ViewName = $viewName.Trim() StartDate = $startDate.ToString("yyyy-MM-dd") EndDate = $endDate.ToString("yyyy-MM-dd") } } } } function New-CostManagementViewRequestBody { param ( [Parameter(Mandatory = $true)] [string]$Scope, [Parameter(Mandatory = $true)] [string]$ViewName, [Parameter(Mandatory = $true)] [string]$StartDate, [Parameter(Mandatory = $true)] [string]$EndDate ) [PSCustomObject]@{ properties = [PSCustomObject]@{ currency = "USD" dateRange = "Custom" query = [PSCustomObject]@{ type = "ActualCost" dataSet = [PSCustomObject]@{ granularity = "Daily" aggregation = [PSCustomObject]@{ totalCost = [PSCustomObject]@{ name = "Cost" function = "Sum" } totalCostUSD = [PSCustomObject]@{ name = "CostUSD" function = "Sum" } } sorting = @( [PSCustomObject]@{ direction = "ascending" name = "UsageDate" } ) } timeframe = "Custom" timePeriod = [PSCustomObject]@{ from = $StartDate to = $EndDate } } chart = "Area" accumulated = "true" pivots = @( [PSCustomObject]@{ type = "Dimension" name = "ServiceName" }, [PSCustomObject]@{ type = "Dimension" name = "ResourceLocation" }, [PSCustomObject]@{ type = "Dimension" name = "MeterCategory" } ) scope = $Scope kpis = @( [PSCustomObject]@{ type = "Budget" id = "COST_NAVIGATOR.BUDGET_OPTIONS.NONE" enabled = $true extendedProperties = [PSCustomObject]@{ name = "COST_NAVIGATOR.BUDGET_OPTIONS.NONE" } }, [PSCustomObject]@{ type = "Forecast" enabled = $true } ) displayName = $ViewName } } } function New-CostManagementView { param ( [Parameter(Mandatory = $true)] [string]$Scope, [Parameter(Mandatory = $true)] [string]$ViewName, [Parameter(Mandatory = $true)] [string]$StartDate, [Parameter(Mandatory = $true)] [string]$EndDate ) $encodedViewName = [System.Uri]::EscapeDataString($ViewName) $normalizedScope = Normalize-AzureScope -Scope $Scope $path = "$normalizedScope/providers/Microsoft.CostManagement/views/$encodedViewName" + "?api-version=2026-06-01" $body = New-CostManagementViewRequestBody ` -Scope $normalizedScope ` -ViewName $ViewName ` -StartDate $StartDate ` -EndDate $EndDate Invoke-AzureManagementPut -Path $path -Body $body | Out-Null } function Invoke-CostManagementViewWizard { [CmdletBinding()] param ( [string]$TenantId ) $tenantIdInput = $TenantId if ([string]::IsNullOrWhiteSpace($tenantIdInput)) { $tenantIdInput = Read-Host "Enter Tenant ID, or press Enter to use current/default tenant" } if ([string]::IsNullOrWhiteSpace($tenantIdInput)) { $tenantIdInput = $null } if (-not (Ensure-AzureSignIn -TenantId $tenantIdInput)) { return } $context = Get-AzContext if (-not $tenantIdInput) { $tenantIdInput = $context.Tenant.Id } $identity = Get-CurrentUserIdentity -TenantId $tenantIdInput Write-Host "" Write-Host "Signed-in user: $($identity.Email)" -ForegroundColor Cyan Write-Host "Tenant ID: $tenantIdInput" -ForegroundColor Cyan while ($true) { $targetScope = Select-CostManagementScope if (-not $targetScope) { Write-Host "No scope selected. Exiting." -ForegroundColor Red return } Write-Host "" Write-Host "Selected scope:" -ForegroundColor Green Write-Host "Scope Type: $($targetScope.ScopeType)" Write-Host "Name: $($targetScope.DisplayName)" Write-Host "Scope: $($targetScope.Scope)" if ($targetScope.BillingAccountName) { Write-Host "Billing Account Name: $($targetScope.BillingAccountName)" } if ($targetScope.BillingProfileName) { Write-Host "Billing Profile Name: $($targetScope.BillingProfileName)" } if ($targetScope.ScopeType -eq "Subscription") { if (-not (Test-CostManagementContributorPermission -Scope $targetScope.Scope -TenantId $tenantIdInput)) { Write-Host "You do not have the required permissions to create the Cost Management view." -ForegroundColor Red return } } $viewInput = Read-CostManagementViewInput if ($viewInput -is [string] -and $viewInput -eq "__BACK__") { continue } try { New-CostManagementView ` -Scope $targetScope.Scope ` -ViewName $viewInput.ViewName ` -StartDate $viewInput.StartDate ` -EndDate $viewInput.EndDate Write-Host "" Write-Host "Cost Management view '$($viewInput.ViewName)' was successfully created." -ForegroundColor Green Write-Host "Scope: $($targetScope.Scope)" return } catch { Write-Host "" Write-Host "Failed to create Cost Management view '$($viewInput.ViewName)'." -ForegroundColor Red Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red return } } } Export-ModuleMember -Function Invoke-CostManagementViewWizard |