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/12/2026
ms.date: 08/26/2026
ms.topic: reference
ms.service: finops
ms.subservice: finops-toolkit
Expand All @@ -29,6 +29,7 @@ The following section lists features and enhancements that are currently in deve

- **Added**
- Added VNet and private network modes, including opt-in NAT Gateway support for private mode; NAT Gateway incurs additional cost when enabled ([#2163](https://github.com/microsoft/finops-toolkit/pull/2163)).
- Added an opt-in `enableKeyVaultRbacAuthorization` parameter to switch the remote hub Key Vault from access policies to Azure RBAC, satisfying Cloud Adoption Framework / Enterprise-Scale landing zone guardrails that require RBAC-authorized key vaults; the Data Factory managed identity is granted an equivalent Key Vault Secrets User role assignment so secret access keeps working when enabled ([#1067](https://github.com/microsoft/finops-toolkit/issues/1067)).
- **Changed**
- 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)).
Expand Down
10 changes: 9 additions & 1 deletion src/templates/finops-hub/createUiDefinition.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,14 @@
"name": "enablePurgeProtection",
"type": "Microsoft.Common.CheckBox",
"label": "Enable Key Vault purge protection",
"toolTip": "Enables purge protection for the Key Vault used to store the remote hub storage key. Purge protection prevents permanent deletion of the Key Vault for 90 days after deletion. Note: If the key is lost, you can regenerate it from the remote hub's storage account.",
"toolTip": "Enables purge protection for the Key Vault used to store the remote hub storage key. Purge protection prevents permanent deletion of the Key Vault for 90 days after deletion. Note: If the key is lost, you can regenerate it from the remote hub's storage account. This cannot be disabled once enabled.",
"visible": "[equals(basics('analyticsBackend').analyticsEngine, 'remote')]"
},
{
"name": "enableKeyVaultRbacAuthorization",
"type": "Microsoft.Common.CheckBox",
"label": "Enable Key Vault RBAC authorization",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens on existing deployments? Will it end up with both, rbac and access policies, as we are not removing access policies via bicep I guess.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [AI][Claude] 💬 Needs discussion

Traced this precisely: no, it will not end up with both. accessPolicies is set as a direct properties.accessPolicies array on the Microsoft.KeyVault/vaults resource itself (hub-app.bicep), not via the separate additive Microsoft.KeyVault/vaults/accessPolicies child resource type. Since the keyVault resource is redeclared on every deployment where the RemoteHub/KeyVault feature is enabled (not skipped on redeploy), Incremental mode's "don't touch what's absent from the template" guarantee doesn't apply here — it only protects other resources not mentioned in the template, not properties on a resource that is declared. Whatever array the template sends fully replaces the live value.

The keyVaultAccessPolicies variable already reflects this: it evaluates to [] whenever keyVaultEnableRbacAuthorization is true (see the comment already there: "RBAC-authorized vaults must have an empty accessPolicies array; Azure rejects a non-empty array otherwise"). So flipping the flag to true on a redeploy of an existing hub cleanly replaces the old access policy with an empty array — the vault ends up RBAC-only, not dual-auth. The new keyVaultRoleAssignment resource (also gated on the same flag) grants the equivalent Key Vault Secrets User role to Data Factory's identity in the same deployment, so access isn't dropped in the process — it's a clean cutover in one apply.

No code change needed; the existing-deployment behavior is already correct and non-destructive by construction (Azure itself enforces the empty-array requirement, which is what forces the replace). The real risk here isn't dual-auth, it's the one already called out in the PR description and param docs: if any identity other than Data Factory's happened to rely on an access-policy grant on this vault, it would lose access with no automatic RBAC equivalent — but the PR's own investigation confirmed Data Factory's managed identity is the only data-plane secret reader, so that gap doesn't apply today.

"toolTip": "Enables Azure RBAC instead of access policies to authorize access to the Key Vault used to store the remote hub storage key. Required by some organizations for policy compliance (e.g., Cloud Adoption Framework guardrails). Enable this if you are deploying to a subscription that enforces this requirement.",
"visible": "[equals(basics('analyticsBackend').analyticsEngine, 'remote')]"
}
],
Expand Down Expand Up @@ -1013,6 +1020,7 @@
"remoteHubStorageUri": "[if(equals(basics('analyticsBackend').analyticsEngine, 'remote'), basics('analyticsBackend').remoteHubStorageUri, '')]",
"remoteHubStorageKey": "[if(equals(basics('analyticsBackend').analyticsEngine, 'remote'), basics('analyticsBackend').remoteHubStorageKey, '')]",
"enablePurgeProtection": "[if(equals(basics('analyticsBackend').analyticsEngine, 'remote'), coalesce(basics('analyticsBackend').enablePurgeProtection, false), false)]",
"enableKeyVaultRbacAuthorization": "[if(equals(basics('analyticsBackend').analyticsEngine, 'remote'), coalesce(basics('analyticsBackend').enableKeyVaultRbacAuthorization, false), false)]",
"tagsByResource": "[steps('tags').tagsByResource]"
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/templates/finops-hub/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ param storageSku string = 'Premium_LRS'
@description('Optional. Enable infrastructure encryption on the storage account. Default = false.')
param enableInfrastructureEncryption bool = false

@description('Optional. Enable purge protection for the Key Vault. Default: false.')
@description('Optional. Enable purge protection for the Key Vault. Once enabled on a vault, purge protection cannot be disabled. Default: false.')
param enablePurgeProtection bool = false

@description('Optional. Enable Azure RBAC for authorizing access to the Key Vault instead of access policies. Required by some organizations for policy compliance (e.g., Cloud Adoption Framework guardrails). Switching an existing vault from access policies to RBAC has migration implications, so this defaults to false for backward compatibility with existing deployments; review before enabling on an upgrade. Default: false.')
param enableKeyVaultRbacAuthorization bool = false

@description('Optional. Storage account to push data to for ingestion into a remote hub.')
param remoteHubStorageUri string = ''

Expand Down Expand Up @@ -179,6 +182,7 @@ module hub 'modules/hub.bicep' = {
storageSku: storageSku
enableInfrastructureEncryption: enableInfrastructureEncryption
enablePurgeProtection: enablePurgeProtection
enableKeyVaultRbacAuthorization: enableKeyVaultRbacAuthorization
enableManagedExports: enableManagedExports
enableRecommendations: enableRecommendations
enableAHBRecommendations: enableAHBRecommendations
Expand Down
23 changes: 20 additions & 3 deletions src/templates/finops-hub/modules/fx/hub-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ var storageInfrastructureEncryptionProperties = !app.hub.options.storageInfrastr
}
}

// KeyVault access policies
var keyVaultAccessPolicies = [
// KeyVault access policies -- only used when the vault uses the legacy access-policy auth model
// (RBAC-authorized vaults must have an empty accessPolicies array; Azure rejects a non-empty array otherwise)
var keyVaultAccessPolicies = app.hub.options.keyVaultEnableRbacAuthorization ? [] : [
{
#disable-next-line BCP318 // Null safety warning for conditional resource access // Null safety warning for conditional resource access
objectId: dataFactory.identity.principalId
Expand All @@ -93,6 +94,10 @@ var keyVaultAccessPolicies = [
}
]

// Built-in role definition IDs used for Key Vault RBAC role assignments
// Key Vault Secrets User -- https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-secrets-user
var keyVaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'


//==============================================================================
// Resources
Expand Down Expand Up @@ -484,7 +489,7 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = if (usesKeyVault) {
softDeleteRetentionInDays: 90
// Use null instead of false when purge protection is disabled - Azure requires null to indicate the property should not be set
enablePurgeProtection: app.hub.options.keyVaultEnablePurgeProtection ? true : null
enableRbacAuthorization: false
enableRbacAuthorization: app.hub.options.keyVaultEnableRbacAuthorization
createMode: 'default'
tenantId: subscription().tenantId
accessPolicies: keyVaultAccessPolicies
Expand All @@ -495,6 +500,18 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = if (usesKeyVault) {
}
}

// Grant ADF identity RBAC access to read secrets when the vault uses RBAC instead of access policies
resource keyVaultRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (usesKeyVault && usesDataFactory && app.hub.options.keyVaultEnableRbacAuthorization) {
name: guid(keyVault.id, keyVaultSecretsUserRoleId, dataFactory.id)
scope: keyVault
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleId)
#disable-next-line BCP318 // Null safety warning for conditional resource access
principalId: dataFactory.identity.principalId
principalType: 'ServicePrincipal'
}
}

resource keyVaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = if (usesKeyVault && app.hub.options.privateRouting) {
name: 'privatelink${replace(environment().suffixes.keyvaultDns, 'vault', 'vaultcore')}' // cSpell:ignore privatelink, vaultcore
location: 'global'
Expand Down
6 changes: 6 additions & 0 deletions src/templates/finops-hub/modules/fx/hub-types.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type HubRoutingProperties = {
enableTelemetry: 'Indicates whether telemetry should be enabled for deployments.'
keyVaultSku: 'KeyVault SKU. Allowed values: "standard", "premium".'
keyVaultEnablePurgeProtection: 'Indicates whether purge protection is enabled for the Key Vault. When enabled, deleted Key Vault and its secrets cannot be permanently deleted until the retention period expires, which is required for compliance in some environments.'
keyVaultEnableRbacAuthorization: 'Indicates whether the Key Vault uses Azure RBAC instead of access policies to authorize access to secrets. When enabled, access policies are ignored and callers need an RBAC role assignment (e.g., Key Vault Secrets User) on the vault, which is required for compliance in some environments.'
networkAddressPrefix: 'Address prefix for the FinOps hub isolated virtual network, if private network routing is enabled.'
natGateway: 'Indicates whether a NAT Gateway should be deployed for controlled outbound internet access. When enabled, subnets disable Azure default outbound access and route through the NAT Gateway.'
privateRouting: 'Indicates whether private network routing is enabled.'
Expand All @@ -96,6 +97,7 @@ type HubProperties = {
enableTelemetry: bool
keyVaultSku: string
keyVaultEnablePurgeProtection: bool
keyVaultEnableRbacAuthorization: bool
networkAddressPrefix: string
natGateway: bool
privateRouting: bool
Expand Down Expand Up @@ -192,6 +194,7 @@ func newHubInternal(
storageSku string,
keyVaultSku string,
keyVaultEnablePurgeProtection bool,
keyVaultEnableRbacAuthorization bool,
enableInfrastructureEncryption bool,
enablePublicAccess bool,
enableNatGateway bool,
Expand All @@ -213,6 +216,7 @@ func newHubInternal(
enableTelemetry: isTelemetryEnabled ?? true
keyVaultSku: keyVaultSku
keyVaultEnablePurgeProtection: keyVaultEnablePurgeProtection
keyVaultEnableRbacAuthorization: keyVaultEnableRbacAuthorization
networkAddressPrefix: networkAddressPrefix
natGateway: !enablePublicAccess && enableNatGateway
privateRouting: !enablePublicAccess
Expand Down Expand Up @@ -253,6 +257,7 @@ func newHub(
storageSku string,
keyVaultSku string,
keyVaultEnablePurgeProtection bool,
keyVaultEnableRbacAuthorization bool,
enableInfrastructureEncryption bool,
enablePublicAccess bool,
enableNatGateway bool,
Expand All @@ -268,6 +273,7 @@ func newHub(
storageSku,
keyVaultSku,
keyVaultEnablePurgeProtection,
keyVaultEnableRbacAuthorization,
enableInfrastructureEncryption,
enablePublicAccess,
enableNatGateway,
Expand Down
6 changes: 5 additions & 1 deletion src/templates/finops-hub/modules/hub.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ param enableInfrastructureEncryption bool = false
])
param keyVaultSku string = 'premium'

@description('Optional. Enable purge protection for the Key Vault. Default: false.')
@description('Optional. Enable purge protection for the Key Vault. Once enabled on a vault, purge protection cannot be disabled. Default: false.')
param enablePurgeProtection bool = false

@description('Optional. Enable Azure RBAC for authorizing access to the Key Vault instead of access policies. Required by some organizations for policy compliance (e.g., Cloud Adoption Framework guardrails). Switching an existing vault from access policies to RBAC has migration implications, so this defaults to false for backward compatibility with existing deployments; review before enabling on an upgrade. Default: false.')
param enableKeyVaultRbacAuthorization bool = false

@description('Optional. Remote storage account for ingestion dataset.')
param remoteHubStorageUri string = ''

Expand Down Expand Up @@ -195,6 +198,7 @@ var hub = newHub(
storageSku,
keyVaultSku,
enablePurgeProtection,
enableKeyVaultRbacAuthorization,
enableInfrastructureEncryption,
enablePublicAccess,
enableNatGateway,
Expand Down