Skip to content
Merged
5 changes: 3 additions & 2 deletions docs-mslearn/toolkit/hubs/configure-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Configure scopes for FinOps hubs
description: Connect FinOps hubs to billing accounts and subscriptions by configuring Cost Management exports manually or give FinOps hubs access to manage exports for you.
author: flanakin
ms.author: micflan
ms.date: 02/24/2026
ms.date: 03/01/2026
ms.topic: how-to
ms.service: finops
ms.subservice: finops-toolkit
Expand Down Expand Up @@ -224,7 +224,8 @@ Managed exports use a managed identity (MI) to configure the exports automatical
- Use the following guides to assign access to each scope you want to monitor:
- EA enrollments – [Assign enrollment reader role permission](/azure/cost-management-billing/manage/assign-roles-azure-service-principals#assign-enrollment-account-role-permission-to-the-spn).
- EA departments – [Assign department reader role permission](/azure/cost-management-billing/manage/assign-roles-azure-service-principals#assign-enrollment-account-role-permission-to-the-spn).
- Subscriptions and resource groups – [Assign Azure roles using the Azure portal](/azure/role-based-access-control/role-assignments-portal).
- Subscriptions and resource groups – [Assign Azure roles using the Azure portal](/azure/role-based-access-control/role-assignments-portal). Assign the following roles to the hub managed identity on each scope:
Comment thread
flanakin marked this conversation as resolved.
Outdated
- **Cost Management Contributor** – create and manage exports.

<!--
### Enterprise agreement billing accounts and departments
Expand Down
108 changes: 107 additions & 1 deletion src/scripts/Deploy-Hub.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@
.PARAMETER Location
Optional. Azure location. Default: westus.

.PARAMETER PR
Optional. PR number for CI deployments. Resources are named "pr-{number}" or "pr-{number}-{name}" when -Name is also specified.

.PARAMETER Scope
Optional. Azure scope ID for cost data exports (e.g., "/subscriptions/{id}"). When specified with -ManagedExports, enables managed exports and grants the hub identity access. When specified without -ManagedExports, creates exports manually via New-FinOpsCostExport after deployment.

.PARAMETER ManagedExports
Optional. Use managed exports instead of manual exports. Requires -Scope. Grants the hub managed identity the required roles on the scope and passes scopesToMonitor to the template.

.PARAMETER Build
Optional. Build the template before deploying.

Expand All @@ -94,6 +103,9 @@ param(
[string]$Fabric,
[switch]$StorageOnly,
[switch]$Remove,
[int]$PR,
[string]$Scope,
[switch]$ManagedExports,
[string]$Location,
[switch]$Build,
[switch]$WhatIf
Expand Down Expand Up @@ -123,7 +135,14 @@ function Get-Initials()
return $u.Substring(0, [Math]::Min(2, $u.Length))
}

$initials = Get-Initials
if ($PR)
{
$initials = "pr-$PR"
}
else
{
$initials = Get-Initials
}

# Default name to "adx" when not specified
if (-not $Name)
Expand Down Expand Up @@ -188,6 +207,12 @@ if ($StorageOnly -and $Fabric)
return
}

if ($ManagedExports -and -not $Scope)
{
Write-Error "-ManagedExports requires -Scope. Provide an Azure scope ID (e.g., '/subscriptions/{id}')."
return
}

# Build parameters
$params = @{}

Expand Down Expand Up @@ -215,6 +240,19 @@ else

Write-Host " Hub: $($params.hubName)"

# Managed exports via template parameters
if ($ManagedExports -and $Scope)
{
$params.enableManagedExports = $true
$params.scopesToMonitor = @($Scope)
Write-Host " Managed exports: $Scope"
}
elseif ($Scope)
{
Comment thread
flanakin marked this conversation as resolved.
$params.enableManagedExports = $false
Write-Host " Manual exports: $Scope"
}

# Resource group
if (-not $ResourceGroup)
{
Expand All @@ -232,3 +270,71 @@ $deployArgs.Build = $Build
$deployArgs.WhatIf = $WhatIf

& "$PSScriptRoot/Deploy-Toolkit" @deployArgs

#------------------------------------------------------------------------------
# Post-deployment: configure exports
#------------------------------------------------------------------------------

if ($Scope -and -not $WhatIf -and $global:ftkDeployment)
{
$outputs = $global:ftkDeployment.Outputs

if ($ManagedExports)
{
# Grant hub managed identity the required roles on the scope
$managedIdentityId = $outputs["managedIdentityId"].Value
if ($managedIdentityId)
{
Write-Host "Granting hub identity access to $Scope..."
$roles = @("Cost Management Contributor")
foreach ($role in $roles)
{
$existing = Get-AzRoleAssignment -ObjectId $managedIdentityId -RoleDefinitionName $role -Scope $Scope -ErrorAction SilentlyContinue
if (-not $existing)
{
$result = New-AzRoleAssignment -ObjectId $managedIdentityId -RoleDefinitionName $role -Scope $Scope -ErrorAction SilentlyContinue
if ($result)
{
Write-Host " Granted: $role"
}
else
{
Write-Warning "Failed to grant $role. You may need to assign it manually."
}
}
}
}
else
{
Write-Warning "Could not retrieve managedIdentityId from deployment outputs. Grant access manually."
}
}
else
{
# Create exports manually via New-FinOpsCostExport
$storageAccountId = $outputs["storageAccountId"].Value
if ($storageAccountId)
{
Write-Host "Creating manual exports for $Scope..."

# Import the FinOps toolkit PowerShell module if not already loaded
if (-not (Get-Command New-FinOpsCostExport -ErrorAction SilentlyContinue))
{
Import-Module "$PSScriptRoot/../powershell/FinOpsToolkit.psm1" -Force
}

New-FinOpsCostExport -Name "ftk-focuscost" `
-Scope $Scope `
-Dataset "FocusCost" `
-StorageAccountId $storageAccountId `
-StorageContainer "msexports" `
-DoNotOverwrite `
-Execute
Write-Host " Created FocusCost export and triggered initial run."
}
else
{
Write-Warning "Could not retrieve storageAccountId from deployment outputs. Create exports manually."
}
}
}
45 changes: 33 additions & 12 deletions src/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,21 @@ By default, deploys with Azure Data Explorer (dev SKU). Use `-StorageOnly` for s

All resources use an `{initials}-{name}` naming convention where initials are pulled from `git config user.name` and name defaults to `adx`. Pass a name as the first positional parameter to use a custom value (e.g., `216` for Feb 16).

| Parameter | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `‑Name` | Optional. First positional parameter. Suffix for `{initials}-{name}` convention. Default: `adx`. |
| `‑HubName` | Optional. Name of the hub instance. Default: `hub`. |
| `‑ADX` | Optional. Name of the Azure Data Explorer cluster. Overrides the `{initials}-{name}` convention. |
| `‑ResourceGroup` | Optional. Name of the resource group. Overrides the `{initials}-{name}` convention. |
| `‑Fabric` | Optional. Deploy with Microsoft Fabric. Provide the eventhouse query URI. |
| `‑StorageOnly` | Optional. Deploy a storage-only hub (no Azure Data Explorer or Fabric). |
| `‑Remove` | Optional. Remove test environments. With a name, deletes the target RG. Alone, lists all `{initials}-*`. |
| `‑Location` | Optional. Azure location. Default: `westus`. |
| `‑Build` | Optional. Build the template before deploying. |
| `‑WhatIf` | Optional. Validate the deployment without making changes. |
| Parameter | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `‑Name` | Optional. First positional parameter. Suffix for `{initials}-{name}` convention. Default: `adx`. |
| `‑HubName` | Optional. Name of the hub instance. Default: `hub`. |
| `‑ADX` | Optional. Name of the Azure Data Explorer cluster. Overrides the `{initials}-{name}` convention. |
| `‑ResourceGroup` | Optional. Name of the resource group. Overrides the `{initials}-{name}` convention. |
| `‑Fabric` | Optional. Deploy with Microsoft Fabric. Provide the eventhouse query URI. |
| `‑StorageOnly` | Optional. Deploy a storage-only hub (no Azure Data Explorer or Fabric). |
| `‑Remove` | Optional. Remove test environments. With a name, deletes the target RG. Alone, lists all `{initials}-*`. |
| `‑PR` | Optional. PR number for CI deployments. Resources are named `pr-{number}` or `pr-{number}-{name}` when `-Name` is also specified. |
| `‑Scope` | Optional. Azure scope ID for cost data exports (e.g., `/subscriptions/{id}`). With `-ManagedExports`, enables managed exports. Without it, creates exports manually. |
| `‑ManagedExports` | Optional. Use managed exports instead of manual exports. Requires `-Scope`. Passes `scopesToMonitor` to the template and grants the hub identity required roles. |
| `‑Location` | Optional. Azure location. Default: `westus`. |
| `‑Build` | Optional. Build the template before deploying. |
| `‑WhatIf` | Optional. Validate the deployment without making changes. |

Examples:

Expand Down Expand Up @@ -205,6 +208,24 @@ Examples:
./Deploy-Hub -Remove
```

- Deploy with PR naming convention (e.g., RG `pr-123-adx`, ADX `pr-123-adx`):

```powershell
./Deploy-Hub -PR 123 -Name adx
```
Comment thread
flanakin marked this conversation as resolved.

- Deploy with managed exports:

```powershell
./Deploy-Hub -PR 123 -Name adx -Scope "/subscriptions/{id}" -ManagedExports -Build
```

- Deploy storage-only with manual exports:

```powershell
./Deploy-Hub -StorageOnly -Scope "/subscriptions/{id}" -Build
```

<br>

## 🚀 Deploy-Toolkit
Expand Down