Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs-mslearn/toolkit/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: FinOps toolkit changelog
description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more.
author: MSBrett
ms.author: brettwil
ms.date: 08/13/2026
ms.date: 08/17/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -33,6 +33,7 @@ The following section lists features and enhancements that are currently in deve
- Replaced redundant `tolower()` comparisons in hub KQL with case-insensitive operators (`has`, `=~`, `!~`) so the engine can use the term index instead of scanning every row ([#2213](https://github.com/microsoft/finops-toolkit/issues/2213)).
- Replaced whole-term `contains` matches with `has` across hub KQL and the query catalog (resource ID paths, licensing phrases, SKU description terms) and added a per-row operator-equivalence regression harness with unit test coverage ([#2220](https://github.com/microsoft/finops-toolkit/pull/2220)).
- **Fixed**
- Fixed managed exports failing with an `Unauthorized` error because the Role Based Access Control Administrator role was never assigned to the Data Factory identity. Roles requested by a hub app are now assigned on the publisher storage account even when the app doesn't create the storage account itself ([#2253](https://github.com/microsoft/finops-toolkit/issues/2253)).
- Fixed the `ContractedCost` recompute guard to compare with a null-safe tolerance instead of exact float equality, eliminating millions of no-op rewrites that polluted the `x_SourceValues` audit trail while preserving the null-cost backfill and no longer overwriting an existing cost when the unit price is missing ([#2216](https://github.com/microsoft/finops-toolkit/issues/2216)).
- Fixed the SQL VMs without Azure Hybrid Benefit recommendation query to join on the SQL VM `virtualMachineResourceId` instead of a case-sensitive VM name match that skipped VMs with uppercase names and dropped duplicate names, and made all Azure Resource Graph join kinds explicit so no query relies on the `innerunique` default ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
- Switched dimension enrichment in the v1_0/v1_2 ingestion transforms (`PricingUnits`, `Regions`, `ResourceTypes`, `Services`) from `join` to the broadcast-optimized `lookup` operator and deduplicated the `Services` mapping per resource type to prevent cost row fan-out ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)).
Expand Down
6 changes: 3 additions & 3 deletions docs-mslearn/toolkit/hubs/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: FinOps hub template
description: Learn about what's included in the FinOps hub template including parameters, resources, and outputs.
author: flanakin
ms.author: micflan
ms.date: 06/03/2026
ms.date: 08/17/2026
ms.topic: concept-article
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -65,7 +65,7 @@ Ensure the following prerequisites are met before you deploy the template:
| [Storage Account Contributor](/azure/role-based-access-control/built-in-roles#storage-account-contributor) | Assigned to Data Factory to manage data in storage. |
| [Storage Blob Data Contributor](/azure/role-based-access-control/built-in-roles#storage-blob-data-contributor) | Assigned to Data Factory and Data Explorer to manage data in storage. |
| [Storage File Data Privileged Contributor](/azure/role-based-access-control/built-in-roles/storage#storage-file-data-privileged-contributor) | Assigned to the deployment file upload identity that uploads files to the config container. |
| [User Access Administrator](/azure/role-based-access-control/built-in-roles#user-access-administrator) | Assigned to Data Factory to manage data in storage. Not applied when **enableManagedExports** is disabled. |
| [Role Based Access Control Administrator](/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator) | Assigned to Data Factory on the storage account so it can create Cost Management exports, which grant their own identity access to the export destination. Not applied when **enableManagedExports** is disabled. |

- The Microsoft.EventGrid resource provider must be registered in your subscription. For more information, see [Register a resource provider](/azure/azure-resource-manager/management/resource-providers-and-types#register-resource-provider).

Expand Down Expand Up @@ -97,7 +97,7 @@ Here are the parameters you can use to customize the deployment:
| **dataExplorerFinalRetentionInMonths** | Int | Optional. Number of months of data to retain in the Data Explorer \*_final_v\* tables. | 13 |
| **remoteHubStorageUri** | String | Optional. Data Lake storage endpoint from the remote (primary) hub storage account. Used for cross-tenant cost data collection where this hub sends processed data to a central hub. Example: `https://primaryhub.dfs.core.windows.net/` | |
| **remoteHubStorageKey** | String | Optional. Storage account access key for the remote (primary) hub. Used with remoteHubStorageUri for cross-tenant scenarios. Must be kept secure as it provides full storage access. | |
| **enableManagedExports** | Bool | Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant User Access Administrator role to FinOps hubs, which is required to create Cost Management exports. | True |
| **enableManagedExports** | Bool | Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant the Role Based Access Control Administrator role to FinOps hubs, which is required to create Cost Management exports. | True |
| **enableRecommendations** | Bool | Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. | False |
| **enableAHBRecommendations** | Bool | Optional. Enable Azure Hybrid Benefit recommendations that flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses. Requires enableRecommendations. | False |
| **enableSpotRecommendations** | Bool | Optional. Enable non-Spot AKS cluster recommendations that flag AKS clusters with autoscaling but not using Spot VMs. May generate noise since Spot VMs are only appropriate for interruptible workloads. Requires enableRecommendations. | False |
Expand Down
116 changes: 116 additions & 0 deletions src/powershell/Tests/Unit/HubsAppStorageRoles.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
Regression coverage for hub app storage role assignments (issue #2253):

Hub apps declare the roles their Data Factory identity needs on the publisher storage account
via the storageRoles parameter of fx/hub-app.bicep. Managed exports requests Role Based Access
Control Administrator there, because Cost Management exports are created with a system-assigned
identity that Cost Management must grant access to the export destination. Without it, every
export creation fails with:
{"error":{"code":"Unauthorized","message":"The user does not have authorization to perform
'Microsoft.Authorization/roleAssignments/write' action on specified storage account, ..."}}

The role assignment loop used to be gated on the "Storage" feature, which apps only declare when
they create the publisher storage account. Managed exports uses the storage account created by
Microsoft.CostManagement.Exports, so it doesn't declare the feature, and every role it requested
was silently dropped at build time.

These tests parse the app registrations and fx/hub-app.bicep and verify:
1. Roles requested via storageRoles always flow into the assignment loop.
2. The assignment loop isn't gated on the "Storage" feature.
3. Every app that requests storage roles declares the "DataFactory" feature, since the roles are
assigned to the Data Factory identity and are otherwise dropped.
4. Managed exports still requests Role Based Access Control Administrator.
#>

Describe 'HubsAppStorageRoles' {

BeforeDiscovery {
$repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
$modulesPath = Join-Path $repoRoot 'src/templates/finops-hub/modules'

# Extract the features and storageRoles passed to fx/hub-app.bicep from every app registration.
$appRegistrations = @(Get-ChildItem -Path $modulesPath -Recurse -Filter 'app.bicep' -File | ForEach-Object {
$content = Get-Content -Path $_.FullName -Raw
if ($content -notmatch 'hub-app\.bicep') { return }

$getValues = {
param([string]$Name)
$match = [regex]::Match($content, "(?ms)^\s*$Name\s*:\s*\[(.*?)^\s*\]")
if (-not $match.Success) { return @() }
return @([regex]::Matches($match.Groups[1].Value, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value })
}

@{
Name = (Split-Path -Path $_.DirectoryName -Leaf)
Path = $_.FullName
Features = @(& $getValues 'features')
StorageRoles = @(& $getValues 'storageRoles')
}
})

$appsWithStorageRoles = @($appRegistrations | Where-Object { $_.StorageRoles.Count -gt 0 })
}

BeforeAll {
$repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
$hubAppPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/fx/hub-app.bicep'
$hubAppContent = Get-Content -Path $hubAppPath -Raw

# Role definition ID for Role Based Access Control Administrator
# https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged#role-based-access-control-administrator
$rbacAdministratorRoleId = 'f58310d9-a9f6-439a-9e8d-f62e7b41a168'

$managedExportsPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.CostManagement/ManagedExports/app.bicep'
$managedExportsContent = Get-Content -Path $managedExportsPath -Raw

# The variable the role assignment loop iterates over
$factoryStorageRoles = [regex]::Match($hubAppContent, '(?ms)^var factoryStorageRoles\s*=.*?^\]\)').Value

# The condition on the storage role assignment loop
$storageRoleAssignmentCondition = [regex]::Match($hubAppContent, "(?ms)resource storageRoleAssignments\s+'Microsoft\.Authorization/roleAssignments@[^']+'\s*=\s*\[\s*for\s+\w+\s+in\s+(?<roles>\w+)\s*:\s*if\s*\((?<condition>[^)]*(?:\([^)]*\)[^)]*)*)\)")
}

Context 'fx/hub-app.bicep' {

It 'Should iterate over a role list that always includes the requested storageRoles' {
$factoryStorageRoles | Should -Match 'union\(\s*storageRoles\s*,'
}

It 'Should assign storage roles without requiring the Storage feature' {
$storageRoleAssignmentCondition.Success | Should -BeTrue -Because 'the storage role assignment loop should be parseable'
$storageRoleAssignmentCondition.Groups['roles'].Value | Should -Be 'factoryStorageRoles'
$storageRoleAssignmentCondition.Groups['condition'].Value | Should -Not -Match 'usesStorage' -Because 'apps that request storage roles do not necessarily create the publisher storage account (issue #2253)'
}

It 'Should still require the Data Factory feature to assign storage roles' {
$storageRoleAssignmentCondition.Groups['condition'].Value | Should -Match 'usesDataFactory' -Because 'the roles are assigned to the Data Factory identity'
}
}

Context 'App registrations' {

It 'Should find at least one app that requests storage roles' -ForEach @{ Count = 0 } {
# Recomputed here so the assertion runs even if discovery found nothing
$repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
$modulesPath = Join-Path $repoRoot 'src/templates/finops-hub/modules'
$requesting = @(Get-ChildItem -Path $modulesPath -Recurse -Filter 'app.bicep' -File | Where-Object {
(Get-Content -Path $_.FullName -Raw) -match '(?ms)hub-app\.bicep.*^\s*storageRoles\s*:\s*\[\s*\r?\n\s*[^\]]'
})
$requesting.Count | Should -BeGreaterThan 0
}

It 'Should declare the DataFactory feature when requesting storage roles: <Name>' -ForEach $appsWithStorageRoles {
$Features | Should -Contain 'DataFactory' -Because 'storage roles are assigned to the Data Factory identity and are dropped when the app has no Data Factory'
}
}

Context 'Managed exports' {

It 'Should request the Role Based Access Control Administrator role' {
$managedExportsContent | Should -Match $rbacAdministratorRoleId -Because 'Cost Management requires the caller to grant the export identity access to the destination storage account'
}
}
}
2 changes: 1 addition & 1 deletion src/templates/finops-hub/createUiDefinition.json
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@
"name": "enableManagedExports",
"type": "Microsoft.Common.CheckBox",
"label": "Enable managed exports",
"toolTip": "Creating exports in Cost Management requires the User Access Administrator role. You must have access to grant User Access Administrator to your FinOps hub to enable managed exports."
"toolTip": "Creating exports in Cost Management requires the ability to grant access to the export destination. You must have access to grant the Role Based Access Control Administrator role to your FinOps hub to enable managed exports."
}
],
"visible": true
Expand Down
2 changes: 1 addition & 1 deletion src/templates/finops-hub/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ param remoteHubStorageUri string = ''
@secure()
param remoteHubStorageKey string = ''

@description('Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant User Access Administrator role to FinOps hubs, which is required to create Cost Management exports. Default: true.')
@description('Optional. Enable managed exports where your FinOps hub instance will create and run Cost Management exports on your behalf. Not supported for Microsoft Customer Agreement (MCA) billing profiles. Requires the ability to grant the Role Based Access Control Administrator role to FinOps hubs, which is required to create Cost Management exports. Default: true.')
param enableManagedExports bool = true

@description('Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. Default: false.')
Expand Down
8 changes: 5 additions & 3 deletions src/templates/finops-hub/modules/fx/hub-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ param version string
@description('Optional. Indicate which features the app requires. Allowed values: "DataFactory", "KeyVault", "Storage". Default: [] (none).')
param features HubAppFeature[] = []

@description('Optional. Indicate which RBAC roles the Data Factory identity needs on the storage account, if created. This is in addition to Storage Blob Data Contributor for reading and managing content. Default: [] (none).')
@description('Optional. Indicate which RBAC roles the Data Factory identity needs on the publisher storage account. This is in addition to Storage Blob Data Contributor for reading and managing content, which is granted to apps that use the "Storage" feature. Roles are assigned whether or not the app uses the "Storage" feature since the storage account is shared across all apps from the same publisher. Default: [] (none).')
param storageRoles string[] = []

@description('Optional. Custom string with additional metadata to log. Must an alphanumeric string without spaces or special characters except for underscores and dashes. Namespace + appName + telemetryString must be 50 characters or less - additional characters will be trimmed.')
Expand Down Expand Up @@ -65,7 +65,8 @@ var factoryManagementRoles = [

// Roles for ADF to manage data in storage
// Does not include roles assignments needed against the export scope
var factoryStorageRoles = union(storageRoles, [
// The data management roles are only granted to apps that use the "Storage" feature; roles requested via storageRoles are always granted
var factoryStorageRoles = union(storageRoles, !usesStorage ? [] : [
// Storage Account Contributor -- https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#storage-account-contributor
// Used to move files from the msexports to ingestion container
'17d1049b-9a84-46fb-8f53-869881c3d3ab'
Expand Down Expand Up @@ -284,8 +285,9 @@ module approveStoragePrivateEndpointConnections 'storageEndpoints.bicep' = if (u
//------------------------------------------------------------------------------

// Grant ADF identity access to storage
// The storage account is shared across all apps from the same publisher, so apps that request roles via storageRoles are granted them even when they do not create the storage account themselves
resource storageRoleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
for role in factoryStorageRoles: if (usesDataFactory && usesStorage) {
for role in factoryStorageRoles: if (usesDataFactory) {
name: guid(storageAccount.id, role, dataFactory.id)
scope: storageAccount
properties: {
Expand Down
Loading
Loading