#requires -Modules Az.Accounts, Az.Resources <# .SYNOPSIS Costory one-liner post-install — auto-discovers your Costory deployment across all your Azure subscriptions and grants the Function App's User-Assigned Managed Identity (UAMI) the sub-scope roles it needs to read Azure cost data. .DESCRIPTION Wrapper around Set-CostorySubscriptionRoles.ps1. Designed to be run from Azure Cloud Shell (PowerShell mode) by anyone with `Owner` (or `User Access Administrator`) on the deployment subscription. Zero parameters in the nominal case. Discovery strategy (in order): 1. Azure Resource Graph cross-subscription query (fast, recommended) 2. Fallback: per-subscription Get-AzResource iteration Matches Costory deployments by ANY of: - Marketplace plan product = '3sr-costory' - applicationDefinitionId contains 'costory' (case-insensitive) - Tag `devOpsRepo: Costory` on the application resource If multiple candidates are found, presents a numbered menu for you to pick. Run with: iex (irm https://marketplace.3sr.fr/costory/scripts/Install-Costory.ps1) Or download then run (e.g. for Premium plan with -EnableDefender): irm https://marketplace.3sr.fr/costory/scripts/Install-Costory.ps1 -OutFile Install-Costory.ps1 ./Install-Costory.ps1 -EnableDefender .PARAMETER ManagedAppName Optional — skip the interactive picker by specifying the application's exact name. .PARAMETER SubscriptionId Optional — restrict the search to a single subscription. .PARAMETER AdditionalSubscriptionIds Extra subscription IDs to grant the roles on, on top of those listed in the Managed App's `subscriptionsToScan` parameter. Useful if you want to scan more subs than you declared at deployment time. .PARAMETER EnableDefender Switch — also activate Defender for Cloud Standard tier on the targeted subscriptions. Required for Costory Premium plan's security perimeter scan. .PARAMETER DryRun Show the planned actions without applying any changes. .EXAMPLE # Nominal case iex (irm https://marketplace.3sr.fr/costory/scripts/Install-Costory.ps1) .EXAMPLE # Premium with Defender + scan an additional subscription ./Install-Costory.ps1 -EnableDefender -AdditionalSubscriptionIds '11111111-1111-1111-1111-111111111111' .EXAMPLE # Skip discovery if you know the exact app name ./Install-Costory.ps1 -ManagedAppName 'costoryclaude123' .NOTES The deployed Managed Application must expose 'functionAppPrincipalId' in its outputs — Costory v0.0.9 or later. Earlier versions: use Set-CostorySubscriptionRoles.ps1 directly with -FunctionAppMsiPrincipalId from the Function App Identity blade. #> [CmdletBinding()] param( [string]$ManagedAppName, [string]$SubscriptionId, [string[]]$AdditionalSubscriptionIds, [switch]$EnableDefender, [switch]$DryRun ) $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Banner + connection # --------------------------------------------------------------------------- Write-Host '' Write-Host '====================================================================' -ForegroundColor Cyan Write-Host ' Costory — Post-install role assignment (Install-Costory.ps1) ' -ForegroundColor Cyan Write-Host '====================================================================' -ForegroundColor Cyan Write-Host '' if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { Write-Host 'Not signed in to Azure. Launching Connect-AzAccount...' -ForegroundColor Yellow Connect-AzAccount | Out-Null } $ctx = Get-AzContext Write-Host "Signed in as: $($ctx.Account.Id)" -ForegroundColor Green Write-Host '' # --------------------------------------------------------------------------- # Discovery — cross-subscription if Az.ResourceGraph available # --------------------------------------------------------------------------- Write-Host 'Searching for Costory deployment(s)...' -ForegroundColor Cyan $candidates = @() $useResourceGraph = $null -ne (Get-Module -ListAvailable -Name Az.ResourceGraph) if ($useResourceGraph) { Import-Module Az.ResourceGraph -ErrorAction SilentlyContinue $useResourceGraph = $null -ne (Get-Command -Name Search-AzGraph -ErrorAction SilentlyContinue) } if ($useResourceGraph) { Write-Host ' Using Azure Resource Graph (cross-subscription, fast)...' $subsFilter = '' if ($SubscriptionId) { $subsFilter = "| where subscriptionId == '$SubscriptionId'" } $query = @" Resources | where type =~ 'Microsoft.Solutions/applications' $subsFilter | where (plan.product == '3sr-costory') or (tostring(properties.applicationDefinitionId) contains 'costory') or (tags['devOpsRepo'] == 'Costory') | project name, resourceGroup, subscriptionId, planName = tostring(plan.name), planProduct = tostring(plan.product), applicationDefinitionId = tostring(properties.applicationDefinitionId), provisioningState = tostring(properties.provisioningState), createdTime = tostring(properties.createdTime), id | order by createdTime desc "@ try { $rows = Search-AzGraph -Query $query -First 100 -ErrorAction Stop foreach ($r in $rows) { $kind = if ($r.planProduct -eq '3sr-costory') { 'Marketplace' } else { 'ServiceCatalog' } $subName = (Get-AzSubscription -SubscriptionId $r.subscriptionId -ErrorAction SilentlyContinue).Name if (-not $subName) { $subName = $r.subscriptionId } $candidates += [PSCustomObject]@{ Name = $r.name ResourceGroup = $r.resourceGroup SubscriptionId = $r.subscriptionId SubscriptionName = $subName Kind = $kind Plan = if ($r.planName) { $r.planName } else { '(catalog)' } ProvisioningState = $r.provisioningState AppResourceId = $r.id } } } catch { Write-Host " Resource Graph query failed: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host ' Falling back to per-subscription scan...' $useResourceGraph = $false } } if (-not $useResourceGraph) { Write-Host ' Using per-subscription scan (slower)...' $subsToScan = if ($SubscriptionId) { @($SubscriptionId) } else { (Get-AzSubscription -ErrorAction SilentlyContinue).Id } foreach ($subId in $subsToScan) { try { Set-AzContext -SubscriptionId $subId -ErrorAction Stop | Out-Null $sub = Get-AzSubscription -SubscriptionId $subId -ErrorAction SilentlyContinue Write-Host " Scanning sub: $($sub.Name) ($subId)..." $apps = Get-AzResource -ResourceType 'Microsoft.Solutions/applications' -ExpandProperties -ErrorAction SilentlyContinue foreach ($app in $apps) { $isMarketplace = $app.Plan -and $app.Plan.Product -eq '3sr-costory' $isServiceCatalog = $app.Properties.applicationDefinitionId -and ($app.Properties.applicationDefinitionId -match '(?i)costory') $isTagged = $app.Tags -and $app.Tags['devOpsRepo'] -eq 'Costory' if ($isMarketplace -or $isServiceCatalog -or $isTagged) { $kind = if ($isMarketplace) { 'Marketplace' } else { 'ServiceCatalog' } $candidates += [PSCustomObject]@{ Name = $app.Name ResourceGroup = $app.ResourceGroupName SubscriptionId = $subId SubscriptionName = $sub.Name Kind = $kind Plan = if ($isMarketplace) { $app.Plan.Name } else { '(catalog)' } ProvisioningState = $app.Properties.provisioningState AppResourceId = $app.ResourceId } } } } catch { Write-Host " Skipped sub $subId — $($_.Exception.Message)" -ForegroundColor DarkYellow } } } # --------------------------------------------------------------------------- # Filter & select # --------------------------------------------------------------------------- if ($ManagedAppName) { $candidates = @($candidates | Where-Object { $_.Name -eq $ManagedAppName }) } if (-not $candidates -or $candidates.Count -eq 0) { Write-Host '' Write-Host 'No Costory deployment found.' -ForegroundColor Red Write-Host '' Write-Host 'Possible reasons:' Write-Host ' - You are signed in with a different account than the one used to deploy Costory.' Write-Host ' - Your account does not have access to the subscription where Costory was deployed.' Write-Host ' - The deployment is named differently. Try -ManagedAppName .' Write-Host '' Write-Host 'You can also run the low-level script directly:' Write-Host ' irm https://marketplace.3sr.fr/costory/scripts/Set-CostorySubscriptionRoles.ps1 -OutFile Set-CostorySubscriptionRoles.ps1' Write-Host " ./Set-CostorySubscriptionRoles.ps1 -FunctionAppMsiPrincipalId '' -SubscriptionIds ''" Write-Host '' Write-Host ' (Find the MSI Object ID in the portal: Function App -> Identity -> Object (principal) ID)' exit 1 } if ($candidates.Count -gt 1) { Write-Host '' Write-Host "Multiple Costory deployment(s) found: $($candidates.Count) candidate(s)." -ForegroundColor Yellow Write-Host '' for ($i = 0; $i -lt $candidates.Count; $i++) { $c = $candidates[$i] $stateColor = if ($c.ProvisioningState -eq 'Succeeded') { 'Green' } else { 'Yellow' } Write-Host (" [{0}] " -f $i) -NoNewline -ForegroundColor Cyan Write-Host ("{0,-32}" -f $c.Name) -NoNewline Write-Host (" kind={0,-15}" -f $c.Kind) -NoNewline Write-Host ("plan={0,-10}" -f $c.Plan) -NoNewline Write-Host ("state=") -NoNewline Write-Host $c.ProvisioningState -NoNewline -ForegroundColor $stateColor Write-Host '' Write-Host (" sub: {0} ({1})" -f $c.SubscriptionName, $c.SubscriptionId) -ForegroundColor DarkGray Write-Host (" rg : {0}" -f $c.ResourceGroup) -ForegroundColor DarkGray } Write-Host '' $choice = Read-Host "Pick a deployment [0-$($candidates.Count - 1)], or 'q' to quit" if ($choice -eq 'q' -or $choice -eq 'Q') { Write-Host 'Aborted.' -ForegroundColor Yellow exit 0 } $idx = 0 if (-not [int]::TryParse($choice, [ref]$idx) -or $idx -lt 0 -or $idx -ge $candidates.Count) { Write-Host "Invalid choice '$choice'." -ForegroundColor Red exit 1 } $selected = $candidates[$idx] } else { $selected = $candidates[0] Write-Host "Found 1 Costory deployment: $($selected.Name)" -ForegroundColor Green } Write-Host '' Write-Host '=== Selected deployment ===' -ForegroundColor Cyan Write-Host " Name: $($selected.Name)" Write-Host " Kind: $($selected.Kind) ($($selected.Plan))" Write-Host " Subscription: $($selected.SubscriptionName) ($($selected.SubscriptionId))" Write-Host " Resource Group: $($selected.ResourceGroup)" Write-Host " Provisioning state: $($selected.ProvisioningState)" if ($selected.ProvisioningState -ne 'Succeeded') { Write-Host '' Write-Host "WARNING: provisioningState is '$($selected.ProvisioningState)' — not 'Succeeded'." -ForegroundColor Yellow Write-Host ' The Costory Function App may not be reachable. Proceed with caution.' -ForegroundColor Yellow $confirm = Read-Host "Continue anyway? [y/N]" if ($confirm -notmatch '^[yY]') { exit 0 } } # --------------------------------------------------------------------------- # Extract MSI principal ID and subscriptionsToScan from the Managed App # --------------------------------------------------------------------------- Write-Host '' Write-Host 'Reading the Managed App outputs and parameters...' Set-AzContext -SubscriptionId $selected.SubscriptionId | Out-Null $appResource = Get-AzResource -ResourceId $selected.AppResourceId -ExpandProperties -ErrorAction Stop $outputs = $appResource.Properties.outputs $params = $appResource.Properties.parameters if (-not $outputs -or -not $outputs.functionAppPrincipalId) { Write-Host '' Write-Host "ERROR: The selected Managed App's outputs do not include 'functionAppPrincipalId'." -ForegroundColor Red Write-Host ' You are probably on Costory < v0.0.9. Update via Partner Center, or use the' -ForegroundColor Red Write-Host ' low-level script with the MSI Object ID copied from the Function App Identity blade.' -ForegroundColor Red exit 1 } $msiPrincipalId = $outputs.functionAppPrincipalId.value $runtimePlan = if ($outputs.costoryPlan) { $outputs.costoryPlan.value } else { 'Free' } $funcAppName = if ($outputs.functionAppName) { $outputs.functionAppName.value } else { '(unknown)' } Write-Host " UAMI Principal ID: $msiPrincipalId" Write-Host " Function App: $funcAppName" Write-Host " Runtime plan: $runtimePlan" $subsParam = if ($params.subscriptionsToScan) { $params.subscriptionsToScan.value } else { '' } $subs = @($subsParam -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) if ($AdditionalSubscriptionIds) { $subs = @($subs + $AdditionalSubscriptionIds) | Select-Object -Unique } # Always include the deployment subscription in the role grants (Costory needs # Cost Management Reader on the sub where it lives, even if not listed in # subscriptionsToScan — many things go wrong otherwise). if ($subs -notcontains $selected.SubscriptionId) { $subs = @($selected.SubscriptionId) + $subs } if (-not $subs) { Write-Host '' Write-Host "ERROR: No subscription to grant roles on." -ForegroundColor Red Write-Host ' Pass -AdditionalSubscriptionIds to specify subscriptions to scan.' exit 1 } if ($runtimePlan -eq 'Premium' -and -not $EnableDefender) { Write-Host '' Write-Host 'NOTE: Premium plan detected, but -EnableDefender is NOT set.' -ForegroundColor Yellow Write-Host ' Defender for Cloud Standard tier will NOT be activated this run.' Write-Host ' Re-run with -EnableDefender to activate it (additional Microsoft billing applies).' } # --------------------------------------------------------------------------- # Planned actions # --------------------------------------------------------------------------- Write-Host '' Write-Host '=== Planned actions ===' -ForegroundColor Cyan Write-Host "Grant to MSI $msiPrincipalId, on each subscription below:" Write-Host ' - Cost Management Reader' Write-Host ' - Reader' foreach ($s in $subs) { $sName = (Get-AzSubscription -SubscriptionId $s -ErrorAction SilentlyContinue).Name if ($sName) { Write-Host " on $s ($sName)" } else { Write-Host " on $s" } } if ($EnableDefender) { Write-Host 'Activate Defender for Cloud Standard tier (VirtualMachines + StorageAccounts) on the same subscriptions.' -ForegroundColor Yellow } if ($DryRun) { Write-Host '' Write-Host 'DryRun mode — no changes applied. Remove -DryRun to proceed.' -ForegroundColor Yellow exit 0 } # --------------------------------------------------------------------------- # Permission pre-check # --------------------------------------------------------------------------- Write-Host '' Write-Host 'Pre-check: verifying you have the right to assign roles on the target subscription(s)...' $signedInUserId = (Get-AzContext).Account.Id foreach ($s in $subs) { Set-AzContext -SubscriptionId $s -ErrorAction SilentlyContinue | Out-Null $myRoles = Get-AzRoleAssignment -SignInName $signedInUserId -Scope "/subscriptions/$s" -ErrorAction SilentlyContinue $isOwner = $myRoles | Where-Object { $_.RoleDefinitionName -eq 'Owner' } $isUAA = $myRoles | Where-Object { $_.RoleDefinitionName -eq 'User Access Administrator' } if (-not ($isOwner -or $isUAA)) { Write-Host " WARNING: you don't appear to be Owner or User Access Administrator on $s." -ForegroundColor Yellow Write-Host ' Role assignments may fail (DenyAssignment of Lighthouse delegation can hide rights).' Write-Host " If grants fail below : connect with a DIRECT Owner of the subscription's tenant" Write-Host ' (not via Lighthouse delegation). Run `az login --tenant ` then retry.' } } # --------------------------------------------------------------------------- # Download and run the low-level script # --------------------------------------------------------------------------- Write-Host '' $scriptUrl = 'https://marketplace.3sr.fr/costory/scripts/Set-CostorySubscriptionRoles.ps1' Write-Host "Downloading $scriptUrl..." $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "Set-CostorySubscriptionRoles-$([Guid]::NewGuid()).ps1" try { Invoke-WebRequest -Uri $scriptUrl -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download $scriptUrl — $($_.Exception.Message)" -ForegroundColor Red exit 1 } try { Write-Host 'Running role assignments...' & $scriptPath -FunctionAppMsiPrincipalId $msiPrincipalId -SubscriptionIds $subs -EnableDefender:$EnableDefender } finally { Remove-Item $scriptPath -Force -ErrorAction SilentlyContinue } Write-Host '' Write-Host '=== Costory post-install complete ===' -ForegroundColor Green Write-Host " UAMI has Cost Management Reader + Reader on $($subs.Count) subscription(s)." # v0.0.54 (fix Gregory WI 727 Cause A belt-and-suspenders) : reveiller explicitement # le worker Linux Y1 Consumption pour que les timers `runOnStartup: true` (InitialBackfill # + RbacChecker) tirent immediatement. Sans ce wake-up HTTP, le worker peut rester en # veille des heures et aucun mail ne part avant la prochaine activation (webtest 10min ou # trigger externe). Le call /api/version est anonymous et ultra-leger (~5s cold start). $funcUrl = "https://$funcAppName.azurewebsites.net/api/version" Write-Host '' Write-Host 'Wake-up Function App pour declencher l initialisation immediate...' try { $r = Invoke-RestMethod -Uri $funcUrl -TimeoutSec 30 -ErrorAction Stop Write-Host " OK ($($r.version)) - timer RbacChecker + InitialBackfill vont tirer dans les prochaines secondes." -ForegroundColor Green } catch { Write-Host " Wake-up echec ($($_.Exception.Message)) - le worker tirera de toute facon au prochain webtest (~10min)." -ForegroundColor Yellow } Write-Host '' Write-Host ' Vous recevrez 2 mails dans les prochaines minutes : "Donnees collectees" puis "Rapport FinOps".' Write-Host '' Write-Host ' Diagnostic etat en temps reel (apres collecte) :' Write-Host ' Function App > Configuration > app settings > COSTORY_ADMIN_SECRET' Write-Host ' curl -s -H "x-admin-secret: " https://.azurewebsites.net/api/diagnostics' Write-Host '' Write-Host ' Need help? https://marketplace.3sr.fr/costory/support.html - support@3sr.fr'