diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index d1f201360..9c4d8fba0 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -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: 10/28/2026 +ms.date: 10/29/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -62,6 +62,7 @@ _Released October 2026_ - Generated at build time from the open data CSV files, so the local hub stays in sync with published open data. - Added VNet and private network modes, including opt-in NAT Gateway support for private mode ([#2161](https://github.com/microsoft/finops-toolkit/issues/2161)). - NAT Gateway adds cost when enabled. + - 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** - Updated the hub database guide to explain how columns are organized and corrected the `Costs()` column reference to match the deployed schema ([#2325](https://github.com/microsoft/finops-toolkit/issues/2325), [#2329](https://github.com/microsoft/finops-toolkit/issues/2329)). - Documents the organization chain and the two parallel service chains. diff --git a/src/powershell/Public/Deploy-FinOpsHub.ps1 b/src/powershell/Public/Deploy-FinOpsHub.ps1 index e48f29555..d7e86f1fb 100644 --- a/src/powershell/Public/Deploy-FinOpsHub.ps1 +++ b/src/powershell/Public/Deploy-FinOpsHub.ps1 @@ -34,6 +34,9 @@ .PARAMETER EnablePurgeProtection Optional. Enable purge protection for the Key Vault. Default = false. + .PARAMETER EnableKeyVaultRbacAuthorization + Optional. Switch the Key Vault from access policies to Azure RBAC authorization. Required for CAF/Enterprise-Scale landing zone compliance. Switching an existing vault from access policies to RBAC removes all access policies immediately; any access granted outside the template must be recreated as role assignments. Default = false. + .PARAMETER RemoteHubStorageUri Optional. Storage account to push data to for ingestion into a remote hub. @@ -145,6 +148,10 @@ function Deploy-FinOpsHub [switch] $EnablePurgeProtection, + [Parameter()] + [switch] + $EnableKeyVaultRbacAuthorization, + [Parameter()] [string] $RemoteHubStorageUri, @@ -274,6 +281,12 @@ function Deploy-FinOpsHub throw "NAT Gateway / private network mode requires hub template version 15.0 or later. Current version: $Version" } + # Key Vault RBAC authorization requires hub template version 15.0 or later + if ($EnableKeyVaultRbacAuthorization -and $Version -ne 'latest' -and $templateVersion -lt '15.0') + { + throw "-EnableKeyVaultRbacAuthorization requires hub template version 15.0 or later. Current version: $Version" + } + # Download template if (Test-ShouldProcess $PSCmdlet $Version 'DownloadTemplate') { @@ -343,6 +356,11 @@ function Deploy-FinOpsHub $parameterSplat.TemplateParameterObject.Add('enablePurgeProtection', $EnablePurgeProtection.IsPresent) } + if ($EnableKeyVaultRbacAuthorization -and ($Version -eq 'latest' -or $templateVersion -ge '15.0')) + { + $parameterSplat.TemplateParameterObject.Add('enableKeyVaultRbacAuthorization', $true) + } + # Only pass enableNatGateway when private mode is requested. This keeps public/vnet # deployments compatible with template versions that predate the parameter. if ($effectiveNetworkMode -eq 'private' -and ($Version -eq 'latest' -or $templateVersion -ge '15.0')) diff --git a/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 b/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 index f73214a40..3255327e4 100644 --- a/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 +++ b/src/powershell/Tests/Unit/Deploy-FinOpsHub.Tests.ps1 @@ -302,6 +302,34 @@ InModuleScope 'FinOpsToolkit' { $TemplateParameterObject.enableNatGateway -eq $true } -Times 1 } + + It 'Should pass enableKeyVaultRbacAuthorization when the switch is set on version ' -TestCases @( + @{ Version = '15.0' } + @{ Version = '15' } + @{ Version = 'v15' } + ) { + param($Version) + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -EnableKeyVaultRbacAuthorization -Version $Version } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + $TemplateParameterObject.enableKeyVaultRbacAuthorization -eq $true + } -Times 1 + } + + It 'Should throw a friendly error when -EnableKeyVaultRbacAuthorization targets version ' -TestCases @( + @{ Version = '14.0' } + @{ Version = '14' } + ) { + param($Version) + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -EnableKeyVaultRbacAuthorization -Version $Version } | Should -Throw -ExpectedMessage '*15*' + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -Times 0 + } + + It 'Should not pass enableKeyVaultRbacAuthorization when the switch is not set' { + { Deploy-FinOpsHub -Name $hubName -ResourceGroup $rgName -Location $location -Version 'latest' } | Should -Not -Throw + Should -Invoke -CommandName 'New-AzResourceGroupDeployment' -ParameterFilter { + -not $TemplateParameterObject.ContainsKey('enableKeyVaultRbacAuthorization') + } -Times 1 + } } Context 'Version parsing' { diff --git a/src/templates/finops-hub/createUiDefinition.json b/src/templates/finops-hub/createUiDefinition.json index 77ba01053..aef6ce119 100644 --- a/src/templates/finops-hub/createUiDefinition.json +++ b/src/templates/finops-hub/createUiDefinition.json @@ -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", + "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. Switching an existing vault to RBAC removes all access policies immediately; any access granted outside this template must be recreated as role assignments.", "visible": "[equals(basics('analyticsBackend').analyticsEngine, 'remote')]" } ], @@ -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]" } } diff --git a/src/templates/finops-hub/main.bicep b/src/templates/finops-hub/main.bicep index 03be52372..fbadf84f9 100644 --- a/src/templates/finops-hub/main.bicep +++ b/src/templates/finops-hub/main.bicep @@ -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 = '' @@ -179,6 +182,7 @@ module hub 'modules/hub.bicep' = { storageSku: storageSku enableInfrastructureEncryption: enableInfrastructureEncryption enablePurgeProtection: enablePurgeProtection + enableKeyVaultRbacAuthorization: enableKeyVaultRbacAuthorization enableManagedExports: enableManagedExports enableRecommendations: enableRecommendations enableAHBRecommendations: enableAHBRecommendations diff --git a/src/templates/finops-hub/modules/fx/hub-app.bicep b/src/templates/finops-hub/modules/fx/hub-app.bicep index b176c05a5..3c8928844 100644 --- a/src/templates/finops-hub/modules/fx/hub-app.bicep +++ b/src/templates/finops-hub/modules/fx/hub-app.bicep @@ -84,8 +84,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 @@ -94,6 +95,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 @@ -486,7 +491,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 @@ -497,6 +502,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' diff --git a/src/templates/finops-hub/modules/fx/hub-types.bicep b/src/templates/finops-hub/modules/fx/hub-types.bicep index f3727bf0b..ba7580cef 100644 --- a/src/templates/finops-hub/modules/fx/hub-types.bicep +++ b/src/templates/finops-hub/modules/fx/hub-types.bicep @@ -73,6 +73,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, the script and Data Explorer subnets route outbound traffic through the NAT Gateway.' privateRouting: 'Indicates whether private network routing is enabled.' @@ -98,6 +99,7 @@ type HubProperties = { enableTelemetry: bool keyVaultSku: string keyVaultEnablePurgeProtection: bool + keyVaultEnableRbacAuthorization: bool networkAddressPrefix: string natGateway: bool privateRouting: bool @@ -194,6 +196,7 @@ func newHubInternal( storageSku string, keyVaultSku string, keyVaultEnablePurgeProtection bool, + keyVaultEnableRbacAuthorization bool, enableInfrastructureEncryption bool, enablePublicAccess bool, enableNatGateway bool, @@ -215,6 +218,7 @@ func newHubInternal( enableTelemetry: isTelemetryEnabled ?? true keyVaultSku: keyVaultSku keyVaultEnablePurgeProtection: keyVaultEnablePurgeProtection + keyVaultEnableRbacAuthorization: keyVaultEnableRbacAuthorization networkAddressPrefix: networkAddressPrefix natGateway: !enablePublicAccess && enableNatGateway privateRouting: !enablePublicAccess @@ -256,6 +260,7 @@ func newHub( storageSku string, keyVaultSku string, keyVaultEnablePurgeProtection bool, + keyVaultEnableRbacAuthorization bool, enableInfrastructureEncryption bool, enablePublicAccess bool, enableNatGateway bool, @@ -271,6 +276,7 @@ func newHub( storageSku, keyVaultSku, keyVaultEnablePurgeProtection, + keyVaultEnableRbacAuthorization, enableInfrastructureEncryption, enablePublicAccess, enableNatGateway, diff --git a/src/templates/finops-hub/modules/hub.bicep b/src/templates/finops-hub/modules/hub.bicep index d829a0bed..c0167efa4 100644 --- a/src/templates/finops-hub/modules/hub.bicep +++ b/src/templates/finops-hub/modules/hub.bicep @@ -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 = '' @@ -195,6 +198,7 @@ var hub = newHub( storageSku, keyVaultSku, enablePurgeProtection, + enableKeyVaultRbacAuthorization, enableInfrastructureEncryption, enablePublicAccess, enableNatGateway,