#Requires -Version 5.1 <# .SYNOPSIS Prepares your Microsoft 365 tenant for SlashZero FinOps. Run it ONCE, as a Global Administrator of your own directory. Safe to re-run: it creates nothing twice. .DESCRIPTION Creates, in YOUR directory and nowhere else: 1. "SlashZero FinOps Dashboard" - the application your people sign in through. Three app roles (Viewer / Approver / Admin), assignment required, so somebody in none of the groups below receives no token at all. 2. SG-FinOps-Viewer / -Approver / -Admin - the groups that decide who may sign in and what they may do. The account running this script is made OWNER and MEMBER of all three, so whoever installs FinOps can use it immediately and manage access afterwards. 3. "SlashZero FinOps Collector" - READ-ONLY. Six application permissions, no write scope of any kind. This is the identity that reads usage, so FinOps can tell a used licence from an unused one. 4. "SlashZero FinOps Licence Executor" - the only application that could ever change a licence, and it is created INERT. It asks for LicenseAssignment.ReadWrite.All and nobody consents to it, so its API permissions blade here shows a "Grant admin consent" button nobody has pressed. Until somebody does, it can do nothing whatsoever. See below. 5. "SlashZero FinOps Power BI Reader" + SG-FinOps-PBI-Reader - optional, and holds NO API permissions at all: the read-only Power BI admin APIs reject a service principal that has any. Authorisation comes from membership of that group instead. Each application gets its own certificate. THE PRIVATE KEYS NEVER LEAVE THIS MACHINE except as the .b64 files this script writes, which you upload yourself. WHAT IT DELIBERATELY DOES NOT DO - No User.ReadWrite.All, no Directory.Read.All, no Directory.ReadWrite.All. The one application that COULD change a licence - the executor - is created holding LicenseAssignment.ReadWrite.All as REQUESTED AND NOT GRANTED, so it can do nothing at all until an administrator here presses "Grant admin consent" on it. That decision is made in your own FinOps settings, and this script cannot make it for you. - Mail.Send is never granted UNTIL IT IS BOUNDED. As an application permission it means "send as anybody in this tenant", so it is not created at all unless you ask for it with -IncludeMailSender, and even then it is inert - ZERO permissions - until you also give -AlertsMailbox. With that, this script creates a mail-enabled security group containing only that mailbox, applies an Exchange access policy restricting the application to it, PROVES the restriction (the mailbox Granted, a control mailbox Denied), and grants Mail.Send only after that proof passes. Policy, then proof, then grant - never the other way round. - You do NOT need a mail sender to receive FinOps's operational alerts. Those come from its own platform address. The mail sender exists for the warning sent to a LICENCE HOLDER before their licence is reclaimed, which comes from a mailbox you own - so you need it only if you intend to let FinOps remove licences. - No Install-Module. It refuses and tells you what to install: a script somebody was emailed should not install code. - Nothing is written outside -OutDir, and no secret is ever printed or transcribed. .PARAMETER TenantId Your Microsoft Entra directory (tenant) ID - a GUID. Pre-filled when this file was downloaded from your FinOps environment; you are prompted if it was not. .PARAMETER DryRun Survey everything, print exactly what would be created, change nothing. Run this first. .PARAMETER SkipCertificates Fix the applications, groups and permissions but touch no key material. This is the re-run switch - see CERTIFICATES below for why a second run otherwise refuses. .PARAMETER EnableFabricAdminApi Turns on the Fabric tenant setting that lets the Power BI reader work, for the reader's group only. WITHOUT it that setting is one manual click and this script tells you where; with it, the script does it - and asks you to sign in a SECOND time, because the Fabric API is a different resource from Microsoft Graph and no Graph token can reach it. It needs Az.Accounts or MicrosoftPowerBIMgmt, whichever you have, and the account must be a Fabric administrator. A Global Administrator is one. OFF by default because of the second sign-in, not because the setting is risky. It is the same trade -IncludeMailSender makes: an extra interactive sign-in is the single thing that has stopped these installations most often, so nothing standard requires one. What it changes is narrow and is stated back to you afterwards, read from the tenant rather than assumed: the setting is enabled for SG-FinOps-PBI-Reader and no other group. Allow about 15 minutes for Fabric to propagate it. .PARAMETER AlsoAddApprover A second person's sign-in address, added to SG-FinOps-Approver. FinOps refuses to let one human both request and approve a licence removal, and there is no override, so a second approver is required before anything can be approved at all. .PARAMETER IncludeMailSender Creates the mail sender. OFF by default: it is needed only if you intend to let FinOps remove licences, because FinOps refuses to reclaim a licence without first warning the person holding it, and that warning comes from a mailbox you own. FinOps sends its own operational alerts - the ones your administrators asked for - from its platform address, so you do NOT need this to receive them. What needs it is the warning FinOps sends to a LICENCE HOLDER before reclaiming their licence, which goes from a mailbox you own, because a message about somebody's own account should arrive from inside their organisation. You need it only if you intend to let FinOps remove licences. Give -AlertsMailbox with it: the script then creates a mail-enabled security group containing only that mailbox, applies an Exchange access policy restricting the application to it, PROVES the restriction, and grants Mail.Send only after that proof passes. Its absence is what costs. Without a mail identity FinOps cannot warn a licence holder before taking their licence away - so every approved reclaim DEFERS instead of executing, indefinitely and quietly, and the approvals queue simply looks stuck. That is a discovery somebody makes months later, needing a second Global Administrator session to fix. Creating the application now grants nothing and costs one dormant registration. .NOTES Requires Windows PowerShell 5.1 or PowerShell 7 ON WINDOWS: it uses New-SelfSignedCertificate. Needs Global Administrator, or Application Administrator + Groups Administrator + an account able to grant admin consent. CERTIFICATES - the trap this script is shaped around. Update-MgApplication -KeyCredentials assigns the WHOLE collection, and Microsoft Graph never returns key MATERIAL on a read. So the entries that come back from Get-MgApplication carry a null Key, and "read, append, write" does not append: it replaces every working certificate with an empty one. Add-MgApplicationKey is the real append and needs a proof-of-possession JWT signed with a key nobody here holds. Therefore: a certificate is created only on an application that has none. A re-run says so in the preflight, before anything has been created, and -SkipCertificates does the rest. Questions: Onboarding@SlashZero.ai #> [CmdletBinding()] param( [string]$TenantId = '7fad7fdf-ed0c-474e-9f16-a10fbb320e4d', [string]$Slug = 'gsap', [string]$FinShiftHost = 'gsap.slashzero.ai', # WHERE YOU RAN IT, not your profile directory. $env:USERPROFILE is whatever Windows account # owns the shell, which on a jump box or an elevated session is frequently not the one the person # at the keyboard expects - and the first three administrators to run this reported not getting # the hand-off file at all. ProviderPath rather than .Path so a PSDrive resolves to a real # filesystem directory. [string]$OutDir = $PWD.ProviderPath, [int] $CertYears = 2, [switch]$EnableFabricAdminApi, [switch]$DryRun, [switch]$Yes, [switch]$SkipCertificates, [switch]$SkipPowerBi, [switch]$IncludeMailSender, # The mailbox FinOps sends alerts and licence-holder warnings AS. Supply it (with the # ExchangeOnlineManagement module installed) and this script also does the EXCHANGE half - the # scope group, the access policy, the proof and the Mail.Send grant - so one run leaves a working # mail identity instead of an application that can do nothing. # # It must already EXIST. Creating a mailbox consumes a licence, which is a decision rather than a # step, so this refuses rather than inventing one. [string]$AlertsMailbox, # A mailbox FinOps must NOT be able to send as. Defaults to whoever runs this script. It is the # negative control the whole proof rests on: "Granted" for the sender is three-way ambiguous - # correctly in scope, wrong application, or NO POLICY MATCHED AT ALL, which means full tenant # access, because evaluation is default-allow. [string]$ControlMailbox, # The mail-enabled security group that scopes the grant. Named rather than fixed only so a tenant # with a clashing name can move it. [string]$MailScopeGroup = 'SG-FinOps-AlertsMailbox', # Sign in with a code on another device instead of opening a browser here. NEEDED ON A SERVER, # which is the common case rather than the exotic one: Connect-MgGraph defaults to # InteractiveBrowserCredential, which starts a local browser and listens on localhost for the # redirect, and Windows Server ships with Internet Explorer Enhanced Security on and often no # other browser at all. Without this the sign-in fails with a message that ends at a colon. [switch]$DeviceCode, [string]$AlsoAddApprover ) $ErrorActionPreference = 'Stop' # Graph's own application id. Constant, not configuration. $GraphAppId = '00000003-0000-0000-c000-000000000000' # The three names the product itself prints. NOT parameterised: FinOps names these exact groups on # its own Access screen and on its access-denied page, so a prefix option would create groups the # product then tells your administrators about under a different name. $RoleGroups = [ordered]@{ 'FinOps.Viewer' = 'SG-FinOps-Viewer' 'FinOps.Approver' = 'SG-FinOps-Approver' 'FinOps.Admin' = 'SG-FinOps-Admin' } $PbiGroupName = 'SG-FinOps-PBI-Reader' # Each permission traceable to something the collector actually calls. If a first collection reports # a 403, ADD the scope and re-run: that direction is cheap, over-granting and forgetting is not. $CollectorScopes = @( 'Reports.Read.All' # /reports/get*UserDetail and get*Activity - all usage evidence 'ReportSettings.Read.All' # displayConcealedNames, which must be false or every run degrades 'User.Read.All' # /users, assignedLicenses, licenseAssignmentStates 'AuditLog.Read.All' # signInActivity (also needs Entra ID P1 in the tenant) 'Organization.Read.All' # /subscribedSkus - SKU inventory and seat counts 'Group.Read.All' # /groups - group-based licence assignment ) $ExecutorScopes = @('LicenseAssignment.ReadWrite.All') # Checked against what was actually GRANTED, at the end. So a copy-paste mistake in the arrays above # is caught by this script's own audit rather than by whoever reviews it. $DeniedScopes = @( 'User.ReadWrite.All' # also permits password reset, UPN change and account disable 'Directory.Read.All' 'Directory.ReadWrite.All' 'Mail.Send' # as an application permission: send as ANYBODY in the tenant 'ReportSettings.ReadWrite.All' 'AuditLogsQuery.Read.All' ) # The directory FinOps itself is operated from. Its FinOps applications and SG-FinOps-* groups are # live and carry these same names, so running this there would make the running account owner and # member of production access groups and register a second sign-in application beside the working # one. No override switch: an override is a thing somebody types at 2am. $VendorTenantId = '8b0b10c8-9236-4da6-8c3c-cb2e182dbdf6' function Say { param([string]$m) Write-Host $m } function Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } function Warn { param([string]$m) Write-Host " [warn] $m" -ForegroundColor Yellow } function Step { param([string]$m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan } function Plan { param([string]$m) Write-Host " will create $m" -ForegroundColor Cyan } function Keep { param([string]$m) Write-Host " already there $m" } # ---- 0. placeholders, modules, output directory ------------------------------------------------- foreach ($p in @(@('Slug', $Slug), @('FinShiftHost', $FinShiftHost))) { if ($p[1] -like '__FINSHIFT_*') { throw ("-$($p[0]) is still a template placeholder. Download this script from your FinOps " + 'environment, where it arrives filled in, or pass -Slug -FinShiftHost -TenantId yourself.') } } if (-not $TenantId -or $TenantId -like '__FINSHIFT_*') { Say 'Your Microsoft Entra directory (tenant) ID was not filled in.' Say 'Find it at https://entra.microsoft.com -> Overview -> Tenant ID.' $TenantId = (Read-Host 'Directory (tenant) ID').Trim() } $parsed = [guid]::Empty if (-not [guid]::TryParse($TenantId, [ref]$parsed)) { throw ("'$TenantId' is not a directory ID. It is a GUID: a domain such as " + 'contoso.onmicrosoft.com is a valid sign-in authority but is never the tenant ID.') } $needed = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications', 'Microsoft.Graph.Groups', 'Microsoft.Graph.Identity.SignIns') if ($AlsoAddApprover) { $needed += 'Microsoft.Graph.Users' } # CHECKED HERE, BEFORE ANYTHING IS CREATED, and that placement is a scar. The mail path once failed # on an Import-Module AFTER its Exchange policy had been built and proven, which left the worst # half-state the procedure has. A missing module is not allowed to be discovered late. $fabricModule = $null if ($EnableFabricAdminApi) { foreach ($m in @('Az.Accounts', 'MicrosoftPowerBIMgmt.Profile', 'MicrosoftPowerBIMgmt')) { if (Get-Module -ListAvailable -Name $m) { $fabricModule = $m; break } } if (-not $fabricModule) { throw ("-EnableFabricAdminApi needs a module that can get a Fabric token, and Microsoft " + "Graph cannot: the Fabric API is a different resource.`n`n" + " Install-Module Az.Accounts -Scope CurrentUser`n`n" + 'Or drop -EnableFabricAdminApi and enable the setting by hand - the closing notes ' + 'say exactly where.') } } $missing = @($needed | Where-Object { -not (Get-Module -ListAvailable -Name $_) }) if ($missing.Count -gt 0) { # Names the MISSING modules rather than the Microsoft.Graph meta-module, which pulls about # forty sub-modules and takes minutes. This script already knows which ones it needs. throw ("Missing PowerShell modules: $($missing -join ', ').`n`n" + " Install-Module $($missing -join ', ') -Scope CurrentUser`n`n" + 'This script will not install anything itself.') } foreach ($m in $needed) { Import-Module $m -ErrorAction Stop } if (-not (Test-Path -LiteralPath $OutDir)) { throw "-OutDir '$OutDir' does not exist." } # ---- 1. sign in --------------------------------------------------------------------------------- $scopes = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'Group.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'User.Read', 'Organization.Read.All') if ($AlsoAddApprover) { $scopes += 'User.ReadBasic.All' } Say '' Write-Host 'SlashZero FinOps - tenant preparation' -ForegroundColor Cyan Say " directory : $TenantId" Say " environment : $FinShiftHost" Say '' Say ' A consent screen will appear. Application.ReadWrite.All is what lets this script register' Say ' the applications described in its header. It is delegated to YOU, it expires with this' Say ' session, and the audit printed at the end shows exactly what was created.' Say '' $connect = @{ TenantId = $TenantId; Scopes = $scopes; NoWelcome = $true } if ($DeviceCode) { $connect['UseDeviceAuthentication'] = $true Say ' Signing in by device code. Open the URL below on any device and enter the code.' Say '' } try { Connect-MgGraph @connect } catch { # AZURE.IDENTITY PUTS THE USEFUL HALF IN THE INNER EXCEPTION. The surface message reads # "InteractiveBrowserCredential authentication failed:" - ending at a colon, with nothing after # it - so unwrapping the chain is the difference between "sign-in failed" and "there is no # browser on this machine". Measured on Windows Server 2022, which is where a directory # administrator is most likely to run this. $e = $_.Exception $seen = @() while ($e) { if ($e.Message -and $e.Message.Trim()) { $seen += $e.Message.Trim() } $e = $e.InnerException } Say '' Warn 'Sign-in failed. What the library actually said:' foreach ($m in ($seen | Select-Object -Unique)) { Say " $m" } if (-not $DeviceCode) { Say '' Warn 'If this machine has no usable browser - a server with Internet Explorer Enhanced' Warn 'Security is the usual case - sign in from another device instead by adding' Warn '-DeviceCode to the command you just ran. Nothing has been created.' } throw } $ctx = Get-MgContext if (-not $ctx) { throw 'Sign-in did not complete.' } if ($ctx.TenantId -ne $TenantId) { throw ("Signed in to $($ctx.TenantId), not $TenantId. Refusing to create anything in the " + 'wrong directory.') } if ($TenantId -eq $VendorTenantId) { throw ('That is the directory FinOps is operated from, not a customer directory. Its FinOps ' + 'applications and SG-FinOps-* groups are live. Refusing.') } if ($ctx.AuthType -ne 'Delegated') { throw ("This script makes the account that runs it OWNER and MEMBER of three security groups, " + "and you are signed in app-only (AuthType=$($ctx.AuthType)). A service principal is not " + 'a person, so there is nobody to own them. Re-run with an interactive sign-in.') } # /me rather than User.Read.All: exactly one user object is needed, our own. Under an app-only # context this returns a 400 naming nothing useful, which is why AuthType is checked first. $me = Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/me?$select=' + 'id,userPrincipalName,displayName,userType') $RunnerId = $me.id $RunnerUpn = $me.userPrincipalName $orgName = '' try { $o = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization' if ($o.value -and $o.value.Count -gt 0) { $orgName = $o.value[0].displayName } } catch { } # decorates the report only # ---- 2. helpers --------------------------------------------------------------------------------- function Get-AppByName { param([string]$Name) # displayName is not unique in Entra, so two matches is ambiguous rather than fine. $hits = @(Get-MgApplication -Filter "displayName eq '$Name'" -ErrorAction SilentlyContinue) if ($hits.Count -gt 1) { throw ("$($hits.Count) applications in this directory are called '$Name'. Refusing to guess " + 'which one is FinOps: rename or remove the duplicates and re-run.') } if ($hits.Count -eq 1) { return $hits[0] } return $null } function Get-GroupByName { param([string]$Name) $hits = @(Get-MgGroup -Filter "displayName eq '$Name'" -ErrorAction SilentlyContinue) if ($hits.Count -gt 1) { throw "$($hits.Count) groups are called '$Name'. Refusing to guess." } if ($hits.Count -eq 1) { return $hits[0] } return $null } function Assert-UsableGroup { param([object]$Group, [string]$Name) # A mail-enabled group and a Microsoft 365 group are a different object class; members cannot be # added to a dynamic group at all; and a group synced from on-premises AD cannot be written # through Graph, so the fix for that one is in AD rather than here. if ($Group.MailEnabled -or -not $Group.SecurityEnabled) { throw "'$Name' exists but is not a plain security group. Refusing to reuse it." } if (@($Group.GroupTypes) -contains 'DynamicMembership') { throw "'$Name' exists but has dynamic membership, so members cannot be added. Refusing." } if ($Group.OnPremisesSyncEnabled) { throw ("'$Name' is synced from on-premises Active Directory and cannot be changed here. " + 'Manage its membership in AD, or delete it so this script can create a cloud group.') } } function Get-ApplicationPermission { param([object]$GraphSp, [string]$Scope) # BY NAME, never a hardcoded GUID: a wrong GUID grants a real permission that is not the one # intended, and nothing about the result says so. $role = @($GraphSp.AppRoles | Where-Object { $_.Value -eq $Scope -and $_.AllowedMemberTypes -contains 'Application' }) if ($role.Count -eq 0) { throw "Microsoft Graph exposes no APPLICATION permission called '$Scope'." } if ($role.Count -gt 1) { throw "'$Scope' matched $($role.Count) application roles; refusing to guess." } return $role[0] } function New-FinOpsServicePrincipal { param([string]$AppId) $sp = @(Get-MgServicePrincipal -Filter "appId eq '$AppId'" -ErrorAction SilentlyContinue) | Select-Object -First 1 if ($sp) { return $sp } $sp = New-MgServicePrincipal -AppId $AppId # Directory replication: a service principal is not always assignable the instant it is created, # and failing here leaves an application created but unconsented - the worst half-state, because # a re-run then looks like it has nothing to do. for ($i = 1; $i -le 10; $i++) { if (Get-MgServicePrincipal -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue) { break } Say " waiting for the service principal to replicate ($i/10)..." Start-Sleep -Seconds 3 } return $sp } function Grant-FinOpsPermissions { # -RequestOnly writes the API-permissions blade and STOPS. The application then holds the # permission as *requested, not granted*, which is the whole executor design: the app exists for # every customer, can do nothing, and an administrator here presses "Grant admin consent" if and # when they choose to let FinOps remove licences. FinOps cannot press it for them - that needs # Application.ReadWrite.All in this directory, which is exactly the circular grant it refuses to # ask for. param([object]$App, [object]$Sp, [string[]]$Scopes, [string]$Label, [switch]$RequestOnly) $graphSp = @(Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'") | Select-Object -First 1 if (-not $graphSp) { throw 'Could not find the Microsoft Graph service principal in this tenant.' } $resourceAccess = @() foreach ($s in $Scopes) { $role = Get-ApplicationPermission -GraphSp $graphSp -Scope $s $resourceAccess += @{ Id = $role.Id; Type = 'Role' } } # REQUEST them on the application as well as consenting them. Consent alone works, but the # "API permissions" blade an administrator reviews reads RequiredResourceAccess - so without # this the application appears to hold nothing while actually holding six permissions. # # MERGE, not overwrite: an application may legitimately carry access to resources this script # knows nothing about, and replacing the whole collection would silently drop them. $keep = @() if ($App.RequiredResourceAccess) { $keep = @($App.RequiredResourceAccess | Where-Object { $_.ResourceAppId -ne $GraphAppId }) } Update-MgApplication -ApplicationId $App.Id -RequiredResourceAccess ($keep + @{ ResourceAppId = $GraphAppId; ResourceAccess = $resourceAccess }) if ($RequestOnly) { Ok "$Label : requested, NOT granted - $($Scopes -join ', ')" Say ' nobody has consented to it, so the application can do nothing.' return } # Granting each role directly IS admin consent for an application permission. One at a time and # idempotently, so a re-run after adding a scope grants only the new one. $granted = @(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -ErrorAction SilentlyContinue) foreach ($i2 in 0..($Scopes.Count - 1)) { $scope = $Scopes[$i2] $roleId = $resourceAccess[$i2].Id if ($granted | Where-Object { $_.AppRoleId -eq $roleId -and $_.ResourceId -eq $graphSp.Id }) { Keep "$Label : $scope" continue } $done = $false for ($n = 1; $n -le 5 -and -not $done; $n++) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id ` -PrincipalId $Sp.Id -ResourceId $graphSp.Id -AppRoleId $roleId | Out-Null $done = $true } catch { $msg = "$_" # An account that may register an application but not consent one fails HERE and # nowhere earlier. That is not a replication delay, so do not spend five attempts on # it - say what has to happen instead. if ($msg -match 'Authorization_RequestDenied|Insufficient privileges') { Warn "$Label : $scope could NOT be consented by your account." Warn ' It is REQUESTED on the application; a Global Administrator must open' Warn ' it in Entra -> App registrations -> API permissions -> Grant admin consent.' $done = $true } elseif ($n -eq 5) { throw } else { Start-Sleep -Seconds 3 } } } if ($done) { Ok "$Label : $scope" } } } # What THIS run created, keyed by application. The hand-off bundle is built from these rather # than by reading $OutDir back, because a directory read also picks up files from earlier runs - # which is exactly what the old certificate listing did while saying '(none written on this run)'. $script:CertB64 = @{} $script:CertFile = @{} function Add-FinOpsCertificate { param([string]$AppObjectId, [string]$Subject, [string]$Label, [string]$OutFile, [string]$Key) if ($SkipCertificates) { Warn "$Label certificate skipped (-SkipCertificates)"; return } # Re-read rather than trusting the survey: somebody may have uploaded one in the last minute. # # THIS SKIPS THE APPLICATION AND CARRIES ON. It used to THROW, and the throw was wrong in one # specific way that made the script unable to finish an onboarding it had already started: an # application provisioned on an earlier run killed the entire run, including the applications # that still needed a certificate. That is how the executor became impossible to add to an # existing customer - the only permitted workaround, -SkipCertificates, creates no certificate # for the executor either, so it produced an application with no key. # # WHAT THE REFUSAL PROTECTED IS UNCHANGED, and it is the whole reason it exists: # Update-MgApplication -KeyCredentials assigns the WHOLE collection and Graph never returns key # MATERIAL on a read, so writing here would replace a working certificate with an empty entry. # Skipping means that write is never reached for this application, which is the same guarantee. # The client id still reaches the hand-off file; only the key is absent, because we do not have # it and cannot get it. $fresh = Get-MgApplication -ApplicationId $AppObjectId if ($fresh.KeyCredentials -and @($fresh.KeyCredentials).Count -gt 0) { Warn "$Label already holds a certificate, so it was left alone." Say ' Its key material cannot be read back, so this run cannot put it in the' Say ' hand-off file. If SlashZero does not already hold it, remove the old' Say ' certificate in the Entra portal and run this again.' return } # Checked BEFORE generating, unlike the earlier single-purpose scripts, which generated first and # then deleted the key on refusal. Same outcome, one less moment where a private key exists for # an application this script is about to refuse. $cert = New-SelfSignedCertificate -Subject $Subject ` -CertStoreLocation 'Cert:\CurrentUser\My' ` -KeyExportPolicy Exportable -KeySpec Signature ` -KeyLength 2048 -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddYears($CertYears) try { Update-MgApplication -ApplicationId $AppObjectId -KeyCredentials @(@{ Type = 'AsymmetricX509Cert' Usage = 'Verify' Key = $cert.RawData DisplayName = "slashzero-finops $(Get-Date -Format 'yyyy-MM-dd')" }) # Password-less PKCS#12, because the platform loads it with a null password - the convention # every FinOps certificate already uses. Export-PfxCertificate cannot produce one, so this # goes through .NET directly. $pfx = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $null) # BOTH FORMS, because there are two ways in and they want different bytes. The admin console's # upload field reads the file's RAW bytes into PfxCredential.Describe, so it needs the binary # PKCS#12; the CLI path puts base64 into FINSHIFT_CONNECTION_SECRET. Writing only the .b64 while # telling an operator to upload it on the customer page - which is what this did - produces a file # the recommended path rejects as not a certificate at all. $pfxFile = [System.IO.Path]::ChangeExtension($OutFile, '.pfx') [System.IO.File]::WriteAllBytes($pfxFile, $pfx) [System.IO.File]::WriteAllText($OutFile, [System.Convert]::ToBase64String($pfx)) Ok ("$Label certificate created, expires {0}" -f $cert.NotAfter.ToString('dd-MM-yyyy')) # Kept in memory rather than re-read at hand-off time: these are the exact bytes just # written, for the exact application, from the exact run that is reporting them. if ($Key) { $script:CertB64[$Key] = [System.Convert]::ToBase64String($pfx) $script:CertFile[$Key] = $pfxFile } Say " $pfxFile <- upload THIS one" Say " $OutFile (base64; only if SlashZero asks for it)" } finally { # The private key does not stay in the operator's personal store. It lives in the exported # file, and shortly in one encrypted row of the FinOps control plane. Remove-Item -Path "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey -Force -ErrorAction SilentlyContinue } } function Add-RunnerToGroup { param([string]$GroupId, [string]$GroupName) # Owners are added by reference. Invoke-MgGraphRequest rather than a cmdlet because the cmdlet # name for this differs between Graph SDK versions, and this has to run on whatever the customer # happens to have installed. $owners = @() try { $r = Invoke-MgGraphRequest -Method GET ` -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners?`$select=id" if ($r.value) { $owners = @($r.value) } } catch { } if ($owners | Where-Object { $_.id -eq $RunnerId }) { Keep "$GroupName owner : $RunnerUpn" } else { # POSTing an owner who already is one returns 400 "already exist", which reads like a bug. Invoke-MgGraphRequest -Method POST ` -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/`$ref" ` -Body @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$RunnerId" } | Out-Null Ok "$GroupName owner : $RunnerUpn" } $members = @(Get-MgGroupMember -GroupId $GroupId -All -ErrorAction SilentlyContinue) if ($members | Where-Object { $_.Id -eq $RunnerId }) { Keep "$GroupName member : $RunnerUpn" } else { New-MgGroupMember -GroupId $GroupId -DirectoryObjectId $RunnerId Ok "$GroupName member : $RunnerUpn" } } # ---- 3. survey: nothing is written before the confirmation --------------------------------------- $AppSpecs = @( @{ Key = 'sso'; Name = 'SlashZero FinOps Dashboard'; Want = $true } @{ Key = 'collector'; Name = 'SlashZero FinOps Collector'; Want = $true } @{ Key = 'pbi'; Name = 'SlashZero FinOps Power BI Reader'; Want = (-not $SkipPowerBi) } @{ Key = 'executor'; Name = 'SlashZero FinOps Licence Executor'; Want = $true } @{ Key = 'mail'; Name = 'SlashZero FinOps Mail Sender'; Want = [bool]$IncludeMailSender } ) Step 'What is already in this directory' Say (" directory : {0}{1}" -f $TenantId, $(if ($orgName) { " ($orgName)" } else { '' })) Say (" signed in : {0}" -f $RunnerUpn) Say (" FinOps env : {0} (slug {1})" -f $FinShiftHost, $Slug) # STATED BEFORE ANYTHING IS CREATED. Three administrators in a row reported not getting the hand-off # file, and in every case the run had stopped early or written it somewhere they had not thought to # look. Where the files will land is not a detail to discover afterwards. Say (" files to : {0}" -f $OutDir) Say '' $certKeeps = @() foreach ($spec in $AppSpecs) { if (-not $spec.Want) { continue } $found = Get-AppByName $spec.Name $spec['App'] = $found if ($found) { $keys = @($found.KeyCredentials).Count Keep ("{0} (appId {1}, {2} certificate(s))" -f $spec.Name, $found.AppId, $keys) if ($keys -gt 0 -and -not $SkipCertificates) { $certKeeps += $spec.Name } } else { Plan $spec.Name } } $groupNames = @($RoleGroups.Values) if (-not $SkipPowerBi) { $groupNames += $PbiGroupName } foreach ($gn in $groupNames) { $g = Get-GroupByName $gn if ($g) { Assert-UsableGroup -Group $g -Name $gn; Keep "$gn (a security group already)" } else { Plan $gn } } # REPORTED, NOT REFUSED. This threw until 2026-09-08, which meant one already-provisioned # application stopped the script finishing an onboarding it had already begun - and made adding a # missing application to an existing customer impossible. The protection is in # Add-FinOpsCertificate, which never writes a key list for an application that has one; saying so # here, before the confirmation, is what stops the outcome being a surprise. if ($certKeeps.Count -gt 0) { Say '' Warn ("Already hold a certificate, so they keep it: $($certKeeps -join ', ').") Say ' Graph never returns an existing key, and the API that sets one replaces the whole' Say ' collection - so this run will not touch theirs. Their client id still goes in the' Say ' hand-off file; their key does not, because nobody can read it back.' Say ' If SlashZero does not already hold one of these, remove it in the Entra portal and' Say ' run this again - that is the only way to issue a replacement.' } Say '' Say ' NOT created, by design:' if (-not $IncludeMailSender) { Say ' - nothing that can send mail (-IncludeMailSender)' } if ($SkipPowerBi) { Say ' - no Power BI reader (drop -SkipPowerBi)' } if (-not $SkipPowerBi -and -not $EnableFabricAdminApi) { Say ' - no Fabric tenant setting change (-EnableFabricAdminApi)' Say ' without it the Power BI reader is refused 403 until somebody enables it by hand' } if ($DryRun) { Say ''; Ok 'Dry run: nothing was created.'; return } if (-not $Yes) { Say '' if ((Read-Host 'Type YES to create the objects listed above') -ne 'YES') { Say 'Nothing was created.' return } } function Get-Spec { param([string]$Key) @($AppSpecs | Where-Object { $_.Key -eq $Key })[0] } # ---- 4. sign-in application and access groups --------------------------------------------------- Step 'Sign-in application' $ssoSpec = Get-Spec 'sso' $ssoRedirect = "https://$FinShiftHost/signin-oidc" $ssoLogout = "https://$FinShiftHost/signout-oidc" $sso = $ssoSpec.App if (-not $sso) { $sso = New-MgApplication -DisplayName $ssoSpec.Name -SignInAudience 'AzureADMyOrg' ` -Web @{ RedirectUris = @($ssoRedirect); LogoutUrl = $ssoLogout } ` -RequiredResourceAccess @(@{ ResourceAppId = $GraphAppId ResourceAccess = @( @{ Id = '37f7f235-527c-4136-accd-4a02d197296e'; Type = 'Scope' } # openid @{ Id = '14dad69e-099b-42c9-810b-d002981feec1'; Type = 'Scope' } # profile @{ Id = '7427e0e9-2fba-42fe-b0c0-848c9e6a8182'; Type = 'Scope' } # offline_access @{ Id = 'e1fe6dd8-ba31-4d61-89e7-88639da4683d'; Type = 'Scope' } # User.Read ) }) Ok "$($ssoSpec.Name) created" } else { Keep $ssoSpec.Name # MERGE the web block. Assigning it replaces the whole resource, so the existing redirect URIs # and the logout URL would be dropped silently - which breaks sign-in for anything already using # this application. That is the bug the superseded setup-entra.ps1 shipped. $uris = @() if ($sso.Web -and $sso.Web.RedirectUris) { $uris = @($sso.Web.RedirectUris) } if ($uris -notcontains $ssoRedirect) { $logout = $ssoLogout if ($sso.Web -and $sso.Web.LogoutUrl) { $logout = $sso.Web.LogoutUrl } Update-MgApplication -ApplicationId $sso.Id -Web @{ RedirectUris = @($uris + $ssoRedirect); LogoutUrl = $logout } Ok "redirect URI added: $ssoRedirect" } else { Keep "redirect URI $ssoRedirect" } } # App roles. REUSE an existing role's id: minting a fresh GUID for a role that already exists # orphans every assignment made against the old one, and every user silently loses access. $sso = Get-MgApplication -ApplicationId $sso.Id $haveRoles = @($sso.AppRoles) $roleList = @() $rolesAdded = 0 foreach ($value in $RoleGroups.Keys) { $existing = @($haveRoles | Where-Object { $_.Value -eq $value }) if ($existing.Count -eq 1) { $roleList += $existing[0]; continue } $rolesAdded++ $suffix = $value.Split('.')[1] $desc = 'Read everything. Change nothing.' if ($suffix -eq 'Approver') { $desc = 'Approve or reject a proposed licence change.' } if ($suffix -eq 'Admin') { $desc = 'Settings, prices, exclusions and the emergency stop.' } $roleList += @{ Id = [guid]::NewGuid() Value = $value IsEnabled = $true AllowedMemberTypes = @('User') DisplayName = "FinOps $suffix" Description = $desc } } # Additive only. Graph refuses to remove an enabled, assigned role anyway, and removing one would # revoke access rather than tidy anything up. $roleList += @($haveRoles | Where-Object { $RoleGroups.Keys -notcontains $_.Value }) if ($rolesAdded -gt 0) { Update-MgApplication -ApplicationId $sso.Id -AppRoles $roleList Ok "app roles: $rolesAdded added (Viewer / Approver / Admin)" } else { Keep 'app roles: Viewer / Approver / Admin' } $sso = Get-MgApplication -ApplicationId $sso.Id $ssoSp = New-FinOpsServicePrincipal $sso.AppId if (-not $ssoSp.AppRoleAssignmentRequired) { Update-MgServicePrincipal -ServicePrincipalId $ssoSp.Id -AppRoleAssignmentRequired Ok 'assignment required: somebody in none of the groups receives no token at all' } else { Keep 'assignment required' } # Tenant-wide consent for the four delegated sign-in scopes. Most tenants block user consent, and # without this every user hits "Need admin approval" on their first sign-in. $graphSpForConsent = @(Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'") | Select-Object -First 1 $wantScopes = 'openid profile offline_access User.Read' $grant = @(Get-MgOauth2PermissionGrant ` -Filter "clientId eq '$($ssoSp.Id)' and consentType eq 'AllPrincipals'" ` -ErrorAction SilentlyContinue) | Select-Object -First 1 if (-not $grant) { New-MgOauth2PermissionGrant -BodyParameter @{ clientId = $ssoSp.Id; consentType = 'AllPrincipals' resourceId = $graphSpForConsent.Id; scope = $wantScopes } | Out-Null Ok "sign-in consent granted tenant-wide: $wantScopes" } else { # UNION, never replace: somebody may have consented an extra scope deliberately. $have = @($grant.Scope -split ' ' | Where-Object { $_ }) $add = @($wantScopes -split ' ' | Where-Object { $have -notcontains $_ }) if ($add.Count -gt 0) { Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $grant.Id -Scope (($have + $add) -join ' ') Ok "sign-in consent extended: $($add -join ' ')" } else { Keep "sign-in consent ($wantScopes)" } } Step 'Access groups' $groupIds = @{} foreach ($value in $RoleGroups.Keys) { $name = $RoleGroups[$value] $g = Get-GroupByName $name if (-not $g) { $g = New-MgGroup -DisplayName $name -MailEnabled:$false -SecurityEnabled ` -MailNickname ($name -replace '[^A-Za-z0-9]', '') ` -Description "SlashZero FinOps: $value" Ok "$name created" } else { Assert-UsableGroup -Group $g -Name $name Keep $name } $groupIds[$name] = $g.Id $role = @($sso.AppRoles | Where-Object { $_.Value -eq $value })[0] $assigned = @(Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $ssoSp.Id -All ` -ErrorAction SilentlyContinue | Where-Object { $_.PrincipalId -eq $g.Id -and $_.AppRoleId -eq $role.Id }) if ($assigned.Count -eq 0) { New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $ssoSp.Id -BodyParameter @{ principalId = $g.Id; resourceId = $ssoSp.Id; appRoleId = $role.Id } | Out-Null Ok "$name -> $value" } else { Keep "$name -> $value" } Add-RunnerToGroup -GroupId $g.Id -GroupName $name } if ($AlsoAddApprover) { $approverGroup = $RoleGroups['FinOps.Approver'] $second = @(Get-MgUser -Filter "userPrincipalName eq '$AlsoAddApprover'" -ErrorAction SilentlyContinue) | Select-Object -First 1 if (-not $second) { Warn "-AlsoAddApprover: no user called '$AlsoAddApprover' in this directory." } elseif ($second.Id -eq $RunnerId) { Warn '-AlsoAddApprover names YOU. FinOps compares people, not groups: an approval must come' Warn ' from somebody other than whoever requested the change.' } else { $gid = $groupIds[$approverGroup] $already = @(Get-MgGroupMember -GroupId $gid -All -ErrorAction SilentlyContinue | Where-Object { $_.Id -eq $second.Id }) if ($already.Count -eq 0) { New-MgGroupMember -GroupId $gid -DirectoryObjectId $second.Id Ok "$approverGroup member : $AlsoAddApprover" } else { Keep "$approverGroup member : $AlsoAddApprover" } } } Add-FinOpsCertificate -AppObjectId $sso.Id -Subject "CN=SlashZero-FinOps-SSO-$Slug" ` -Label 'sign-in' -Key 'sso' -OutFile (Join-Path $OutDir "finshift-$Slug-sso.b64") # ---- 5. read-only collector --------------------------------------------------------------------- Step 'Read-only collector' $colSpec = Get-Spec 'collector' $col = $colSpec.App if (-not $col) { $col = New-MgApplication -DisplayName $colSpec.Name -SignInAudience 'AzureADMyOrg' Ok "$($colSpec.Name) created" try { Update-MgApplication -ApplicationId $col.Id ` -Notes 'Reads licence usage evidence for SlashZero FinOps. Holds no write permission by design.' } catch { Say ' (could not set Notes; harmless)' } } else { Keep $colSpec.Name } $colSp = New-FinOpsServicePrincipal $col.AppId Grant-FinOpsPermissions -App (Get-MgApplication -ApplicationId $col.Id) -Sp $colSp ` -Scopes $CollectorScopes -Label 'collector' Add-FinOpsCertificate -AppObjectId $col.Id -Subject "CN=SlashZero-FinOps-Collector-$Slug" ` -Label 'collector' -Key 'collector' -OutFile (Join-Path $OutDir "finshift-$Slug-collector.b64") # ---- 6. Power BI reader (optional) -------------------------------------------------------------- if (-not $SkipPowerBi) { Step 'Power BI reader' Say ' Holds NO API permissions, deliberately: the read-only Power BI admin APIs reject a' Say ' service principal that has any. Authorisation comes from the group below instead.' $pbiSpec = Get-Spec 'pbi' $pbi = $pbiSpec.App if (-not $pbi) { $pbi = New-MgApplication -DisplayName $pbiSpec.Name -SignInAudience 'AzureADMyOrg' Ok "$($pbiSpec.Name) created" } else { Keep $pbiSpec.Name } $pbiSp = New-FinOpsServicePrincipal $pbi.AppId $pg = Get-GroupByName $PbiGroupName if (-not $pg) { $pg = New-MgGroup -DisplayName $PbiGroupName -MailEnabled:$false -SecurityEnabled ` -MailNickname ($PbiGroupName -replace '[^A-Za-z0-9]', '') ` -Description 'SlashZero FinOps: may call the read-only Power BI admin APIs' Ok "$PbiGroupName created" } else { Assert-UsableGroup -Group $pg -Name $PbiGroupName Keep $PbiGroupName } # The SERVICE PRINCIPAL goes in, not the application object and not a person. Adding the # application is accepted by Graph and authorises nothing, which is a silent failure; and a human # inside a group named "who may call the admin APIs" is a finding at the next access review. $inGroup = @(Get-MgGroupMember -GroupId $pg.Id -All -ErrorAction SilentlyContinue | Where-Object { $_.Id -eq $pbiSp.Id }) if ($inGroup.Count -eq 0) { New-MgGroupMember -GroupId $pg.Id -DirectoryObjectId $pbiSp.Id Ok "$PbiGroupName member : the Power BI reader service principal" } else { Keep "$PbiGroupName member : the Power BI reader service principal" } # An ownerless group named by a Fabric tenant setting is unmanageable afterwards. Add-RunnerToGroup -GroupId $pg.Id -GroupName $PbiGroupName Add-FinOpsCertificate -AppObjectId $pbi.Id -Subject "CN=SlashZero-FinOps-PBI-$Slug" ` -Label 'Power BI' -Key 'pbi' -OutFile (Join-Path $OutDir "finshift-$Slug-pbi.b64") } # ---- 6b. the Fabric tenant setting the Power BI reader cannot work without ---------------------- # # WHY THIS EXISTS. The reader application is created holding no Power BI permissions at all - the # read-only admin APIs reject a service principal that has any - so its authorisation comes entirely # from membership of SG-FinOps-PBI-Reader AND from Fabric being told that group may use those APIs. # Until the second half is done, every call returns # 403 {"Message":"API is not accessible for application"} # which is what FinOps's own readiness probe reports, and it is the single most common reason a # customer is onboarded and still has no Power BI evidence. # # WHY A SECOND SIGN-IN. api.fabric.microsoft.com is a different resource from Microsoft Graph. The # Graph token this script already holds cannot reach it and cannot be exchanged for one, so there # is no way to avoid asking again. That cost is why this is opt-in. if ($EnableFabricAdminApi -and -not $SkipPowerBi) { Step 'Fabric: let the reader group use the read-only admin APIs' $pbiGroup = Get-GroupByName $PbiGroupName if (-not $pbiGroup) { Warn "$PbiGroupName does not exist, so there is nothing to enable it for." } else { Say ' This needs a SECOND sign-in: the Fabric API is a different resource from Graph.' Import-Module $fabricModule -ErrorAction Stop $fabricToken = $null if ($fabricModule -eq 'Az.Accounts') { if (-not (Get-AzContext)) { Connect-AzAccount -Tenant $TenantId | Out-Null } # -AsSecureString is the newer default; take either shape rather than pinning a version. $t = Get-AzAccessToken -ResourceUrl 'https://api.fabric.microsoft.com' $fabricToken = if ($t.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new('', $t.Token).Password } else { $t.Token } } else { Connect-PowerBIServiceAccount -Tenant $TenantId | Out-Null $fabricToken = (Get-PowerBIAccessToken -AsString) -replace '^Bearer\s+', '' } $fabricHeaders = @{ Authorization = "Bearer $fabricToken" } $fabricBase = 'https://api.fabric.microsoft.com/v1/admin/tenantsettings' # DISCOVERED, NEVER HARDCODED - the same rule Get-ApplicationPermission follows for # permission ids, and for the same reason: a wrong name here would report success against a # setting nobody asked about. Microsoft split and renamed these settings during 2025, so the # identifier is matched from what THIS tenant actually reports, and a miss refuses rather # than guesses. $all = (Invoke-RestMethod -Method Get -Uri $fabricBase -Headers $fabricHeaders).tenantSettings $target = @($all | Where-Object { $_.settingName -eq 'AllowServicePrincipalsUseReadAdminAPIs' -or ($_.title -and $_.title -match 'read-only admin API') }) | Select-Object -First 1 if (-not $target) { Warn 'Fabric did not report a setting for read-only admin API access by service principals.' Say ' Microsoft renames these; rather than guess at one, nothing was changed.' Say ' Enable it by hand - step 3 in the closing notes says exactly where.' } else { $body = @{ enabled = $true enabledSecurityGroups = @(@{ graphId = $pbiGroup.Id; name = $PbiGroupName }) } | ConvertTo-Json -Depth 4 Invoke-RestMethod -Method Post -Uri "$fabricBase/$($target.settingName)/update" ` -Headers $fabricHeaders -ContentType 'application/json' -Body $body | Out-Null # READ BACK, because "the POST returned 200" is not the same claim as "the tenant says # so". Every other proof in this script is a read of the thing itself. $after = @((Invoke-RestMethod -Method Get -Uri $fabricBase -Headers $fabricHeaders).tenantSettings | Where-Object { $_.settingName -eq $target.settingName }) | Select-Object -First 1 $groups = @($after.enabledSecurityGroups | ForEach-Object { $_.name }) if ($after.enabled -and $groups -contains $PbiGroupName) { Ok ("$($target.settingName) enabled for: $($groups -join ', ')") Say ' Allow about 15 minutes for Fabric to propagate it. Until then the' Say ' Power BI check in FinOps keeps reporting 403, which is expected.' } else { Warn ("$($target.settingName) does not read as enabled for $PbiGroupName " + "(enabled=$($after.enabled), groups=$($groups -join ', ')).") Say ' Check it by hand before assuming Power BI will collect.' } } } } # ---- 7. the licence executor, created INERT ----------------------------------------------------- # # CREATED FOR EVERY CUSTOMER AND GRANTED NOTHING. It used to be -IncludeExecutor, off by default, so # that a report-only customer could verify no application capable of changing a licence existed at # all. That guarantee is replaced rather than dropped: the application exists, and holds # LicenseAssignment.ReadWrite.All as REQUESTED AND NOT GRANTED - which this directory's own API # permissions blade shows, with a "Grant admin consent" button nobody has pressed. # # What that buys is that turning enforcement on is a decision the CUSTOMER makes, in their own # FinOps settings and their own directory, without anybody re-running this script. Step 'Licence executor (created inert)' Say ' Created with NO consent, so it can do nothing until somebody here grants it.' $exeSpec = Get-Spec 'executor' $exe = $exeSpec.App if (-not $exe) { $exe = New-MgApplication -DisplayName $exeSpec.Name -SignInAudience 'AzureADMyOrg' Ok "$($exeSpec.Name) created" } else { Keep $exeSpec.Name } $exeSp = New-FinOpsServicePrincipal $exe.AppId # LicenseAssignment.ReadWrite.All and nothing else. User.ReadWrite.All would also work, and would # also permit password reset, UPN change and account disable. # # -RequestOnly: REQUESTED, NEVER GRANTED HERE. This is the one call in the script that stops # short of consent, and it is deliberate - see the block header. Grant-FinOpsPermissions -App (Get-MgApplication -ApplicationId $exe.Id) -Sp $exeSp ` -Scopes $ExecutorScopes -Label 'executor' -RequestOnly Add-FinOpsCertificate -AppObjectId $exe.Id -Subject "CN=SlashZero-FinOps-Executor-$Slug" ` -Label 'executor' -Key 'executor' -OutFile (Join-Path $OutDir "finshift-$Slug-executor.b64") if ($IncludeMailSender) { Step 'Mail sender (ZERO permissions here, by design)' Say ' Mail.Send as an application permission means "send as anybody in this tenant", so it is' Say ' NOT granted by this script. It is granted only after an Exchange application access' Say ' policy has been created AND PROVEN to confine this application to one mailbox.' $mailSpec = Get-Spec 'mail' $mail = $mailSpec.App if (-not $mail) { $mail = New-MgApplication -DisplayName $mailSpec.Name -SignInAudience 'AzureADMyOrg' Ok "$($mailSpec.Name) created, with no permissions" } else { Keep $mailSpec.Name } New-FinOpsServicePrincipal $mail.AppId | Out-Null Add-FinOpsCertificate -AppObjectId $mail.Id -Subject "CN=SlashZero-FinOps-Mail-$Slug" ` -Label 'mail' -Key 'mail' -OutFile (Join-Path $OutDir "finshift-$Slug-mail.b64") } # ---- 7b. Exchange: confine the mail sender, prove it, and only then grant Mail.Send ------------- # # INLINE rather than a second script, because this file is served over HTTP to a customer who has # nothing else from SlashZero - being one self-contained thing is the property that makes it # emailable. It is the same procedure deploy/scope-mailsend.ps1 runs for a customer onboarded before # this existed. # # WITHOUT IT, THE MAIL SENDER IS AN APPLICATION THAT CAN DO NOTHING. That was the state this script # used to leave every customer in, over the words "ask SlashZero for the mailbox-scoping procedure" - # and the consequence is not obvious from here: no mail identity means FinOps cannot warn a licence # holder, and it refuses to reclaim a licence it has not warned about, so every approved removal # defers silently and the approvals queue merely looks stuck. # # ORDER IS THE WHOLE CONTROL. Mail.Send as an APPLICATION permission means "send as ANYBODY in this # tenant" - nothing in the Entra grant constrains {sender} in POST /users/{sender}/sendMail. An # Exchange ApplicationAccessPolicy is the only thing that narrows it, and Exchange enforces it # independently of Entra. Granting first and scoping afterwards leaves a window - minutes, or however # long it takes somebody to come back to it - in which the application really does hold tenant-wide # send-as. So: policy, then PROOF, then grant. if ($IncludeMailSender -and $AlertsMailbox) { Step 'Mail scope (Exchange)' if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { # A REFUSAL, not a skip. Everything above has been created by this point, and quietly # stopping here is what leaves the half-state described at the top of this section. throw ("-AlertsMailbox was given, so this script needs to talk to Exchange, and the " + "ExchangeOnlineManagement module is not installed:`n" + " Install-Module ExchangeOnlineManagement -Scope CurrentUser`n`n" + "Everything else is done. Re-run with -SkipCertificates to finish this half.") } Import-Module ExchangeOnlineManagement -ErrorAction Stop # A SECOND SIGN-IN. Exchange Online is a separate connection from Graph, and it has NO # device-code option - not in any version of the module - so -DeviceCode cannot help here. On a # machine whose browser cannot reach login.microsoftonline.com this is where it stops, and the # remedy is to run the same command from one that can. Say ' A second sign-in follows - Exchange is a separate connection from Graph.' Connect-ExchangeOnline -ShowBanner:$false $mailbox = Get-Mailbox -Identity $AlertsMailbox -ErrorAction SilentlyContinue if (-not $mailbox) { throw ("There is no mailbox '$AlertsMailbox' in this tenant. FinOps sends as a real mailbox " + "you own; creating one consumes a licence, so this script will not invent it. Create " + "it, then re-run with -SkipCertificates.") } Ok "alerts mailbox: $($mailbox.DisplayName) [$($mailbox.RecipientTypeDetails)]" # THE CONTROL MAILBOX IS THE POINT, and it is derived rather than asked for when it can be: # 'Granted' for the sender is three-way ambiguous - correctly in scope, wrong AppId, or NO POLICY # MATCHED AT ALL, which means full tenant access because evaluation is default-allow. Only a # Denied somewhere else tells a working policy from an absent one. $control = $ControlMailbox if (-not $control) { $control = $RunnerUpn } if ($control -eq $AlertsMailbox) { throw ("The control mailbox and the alerts mailbox are the same address. A refusal would be " + "impossible and a 202 expected, so the proof below could never fail and would attest " + "nothing. Pass -ControlMailbox with a different mailbox you own.") } Say " negative control: $control (must end up Denied)" # -PolicyScopeGroupId accepts ONLY UserMailbox, MailUser or MailUniversalSecurityGroup. A plain # distribution group, a Microsoft 365 Group, a dynamic list and a shared mailbox are all INVALID # and Exchange accepts them without complaint, so the type is asserted rather than assumed. $grp = Get-Recipient -Identity $MailScopeGroup -ErrorAction SilentlyContinue if (-not $grp) { $domain = ($AlertsMailbox -split '@')[-1] New-DistributionGroup -Name $MailScopeGroup -Type Security ` -PrimarySmtpAddress "$MailScopeGroup@$domain" -Members $AlertsMailbox | Out-Null for ($i = 1; $i -le 12 -and -not $grp; $i++) { Start-Sleep -Seconds 5 $grp = Get-Recipient -Identity $MailScopeGroup -ErrorAction SilentlyContinue } if (-not $grp) { throw "Created $MailScopeGroup but it is not readable yet. Re-run in a minute." } Ok "$MailScopeGroup created [$($grp.RecipientTypeDetails)] with 1 member" } else { Keep "$MailScopeGroup [$($grp.RecipientTypeDetails)]" } if ($grp.RecipientTypeDetails -ne 'MailUniversalSecurityGroup') { throw ("'$MailScopeGroup' is a $($grp.RecipientTypeDetails), which scopes NOTHING as a " + '-PolicyScopeGroupId. Refusing to build a policy on it - the policy would appear to ' + 'exist while the application could still reach every mailbox in the tenant.') } $mailAppId = (Get-Spec 'mail').App.AppId if (-not $mailAppId) { $mailAppId = $mail.AppId } if (@(Get-ApplicationAccessPolicy -ErrorAction SilentlyContinue | Where-Object { $_.AppId -eq $mailAppId }).Count -eq 0) { New-ApplicationAccessPolicy -AppId $mailAppId -PolicyScopeGroupId $MailScopeGroup ` -AccessRight RestrictAccess ` -Description 'FinOps may send only as the alerts mailbox' | Out-Null Ok 'RestrictAccess policy created' } else { Keep 'an access policy for this application already exists' } # THE PROOF. Test-ApplicationAccessPolicy LEADS enforcement - Microsoft documents Graph taking # over an hour to obey a policy this cmdlet already reports - so this is not a propagation check. # It is the check that the policy SAYS the right thing; the readiness probe in FinOps is what # later proves Exchange is acting on it, with a real app-only send. $inScope = Test-ApplicationAccessPolicy -Identity $AlertsMailbox -AppId $mailAppId $outScope = Test-ApplicationAccessPolicy -Identity $control -AppId $mailAppId Say " $AlertsMailbox -> $($inScope.AccessCheckResult) (want: Granted)" Say " $control -> $($outScope.AccessCheckResult) (want: Denied)" if ($inScope.AccessCheckResult -ne 'Granted' -or $outScope.AccessCheckResult -ne 'Denied') { throw ('The scope is NOT proven, so Mail.Send has NOT been granted and the application still ' + 'cannot send anything. That is the safe end of this failure. Fix the group membership ' + 'and re-run with -SkipCertificates.') } Ok 'scoping proven - the application can reach exactly one mailbox' # ONLY NOW. Granted through Graph, which is already connected from the first half - and that # ordering is also why this is not a separate script: a second process would have to sign in to # Graph again, which is exactly where the standalone version failed. $mailSp = Get-MgServicePrincipal -Filter "appId eq '$mailAppId'" | Select-Object -First 1 Grant-FinOpsPermissions -App (Get-MgApplication -ApplicationId (Get-Spec 'mail').App.Id) ` -Sp $mailSp -Scopes @('Mail.Send') -Label 'mail' Ok 'Mail.Send granted, bounded by the policy just proven' Say '' Say ' STANDING RISKS, which are yours now and are not one-offs:' Say " - the Entra grant is tenant-wide; that policy is the ONLY thing bounding it" Say ' - any Exchange administrator can remove the policy' Say " - adding a member to $MailScopeGroup silently widens what FinOps can send as," Say ' so treat that group as an access control list, not a mailing list' } # ---- 8. the closing audit ------------------------------------------------------------------------ # Generalised from the Power BI reader's permission audit, which is the single most useful thing in # these scripts: it reports what was GRANTED rather than what was asked for. Step 'Audit: what these applications can actually do' $graphSpAudit = @(Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'") | Select-Object -First 1 $denied = @() $writeScoped = @() foreach ($spec in $AppSpecs) { if (-not $spec.Want) { continue } $app = Get-AppByName $spec.Name if (-not $app) { continue } $sp = @(Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue) | Select-Object -First 1 $names = @() if ($sp) { $names = @(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue | ForEach-Object { $rid = $_.AppRoleId @($graphSpAudit.AppRoles | Where-Object { $_.Id -eq $rid } | ForEach-Object { $_.Value }) } | Where-Object { $_ }) } $shown = 'none' if ($names.Count -gt 0) { $shown = ($names -join ', ') } Say (" {0,-40} {1}" -f $spec.Name, $shown) foreach ($n in $names) { if ($DeniedScopes -contains $n) { $denied += "$($spec.Name): $n" } if ($n -like '*ReadWrite*' -or $n -eq 'Mail.Send') { $writeScoped += "$($spec.Name): $n" } } } Say '' if ($denied.Count -gt 0) { Warn "PERMISSIONS THIS SCRIPT NEVER GRANTS ARE PRESENT: $($denied -join '; ')" Warn 'Something or somebody else granted them. Review before uploading anything.' } else { Ok 'no permission from the deny list is present' } if ($writeScoped.Count -eq 0) { Ok 'no application here can change anything in this directory' } else { Warn "write-scoped: $($writeScoped -join '; ')" } # ---- 9. hand-off --------------------------------------------------------------------------------- Say '' Say '================ SEND THIS BLOCK BACK TO SLASHZERO ================' Say (" environment {0} (slug {1})" -f $FinShiftHost, $Slug) Say (" directory id {0}" -f $TenantId) Say (" configured by {0}" -f $RunnerUpn) Say '' # The bundle and the printed list are built from the SAME loop, so a client id on screen and a # client id in the file cannot disagree. $bundleApps = @() foreach ($spec in $AppSpecs) { if (-not $spec.Want) { continue } $app = Get-AppByName $spec.Name if (-not $app) { continue } Say (" {0,-12} client id {1}" -f $spec.Key, $app.AppId) $entry = [ordered]@{ key = $spec.Key; clientId = $app.AppId } # An application with no certificate is a legitimate state (-SkipCertificates): the client id # goes on record so the registration exists before its key is supplied. if ($script:CertB64.ContainsKey($spec.Key)) { $entry['certificateBase64'] = $script:CertB64[$spec.Key] } $bundleApps += [pscustomobject]$entry } $bundleFile = Join-Path $OutDir ("finshift-$Slug-handoff.json") $bundle = [ordered]@{ version = 1 slug = $Slug directoryId = $TenantId host = $FinShiftHost generatedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') generatedBy = $RunnerUpn applications = $bundleApps } # UTF-8 with NO byte-order mark. Set-Content -Encoding utf8 on Windows PowerShell 5.1 writes one, # and System.Text.Json refuses a BOM outright - so the obvious idiom produces a file the console # rejects as unreadable JSON. (The reader tolerates one anyway, because somebody will re-save this # in Notepad, but the file this script writes is clean.) [System.IO.File]::WriteAllText($bundleFile, ($bundle | ConvertTo-Json -Depth 4), (New-Object System.Text.UTF8Encoding $false)) Say '' Say ' >> SEND THIS ONE FILE. It carries every client id above and every private key this run' Say ' created, so SlashZero needs nothing else from you:' Say (" {0}" -f $bundleFile) Say '' Say ' Delete your copy once they confirm it is loaded:' Say (" Remove-Item -LiteralPath '{0}' -Force" -f $bundleFile) Say '' # THIS RUN's certificates, from what Add-FinOpsCertificate recorded - not a directory glob. The # glob this replaces listed *.b64 from EARLIER runs while saying '(none written on this run)', and # never listed the .pfx files it tells you to upload. if ($script:CertFile.Count -eq 0) { Say ' no certificates were created on this run.' } else { Say ' the individual certificate files, if they ask for one - these are PRIVATE KEYS:' foreach ($k in ($script:CertFile.Keys | Sort-Object)) { Say (" {0,-12} {1}" -f $k, $script:CertFile[$k]) } } Say '==================================================================' Say '' Say 'THINGS THIS SCRIPT CANNOT DO FOR YOU:' Say '' Say ' 1. Report names must not be anonymised, or FinOps cannot tell you WHO is not using a licence.' Say ' Microsoft 365 admin center -> Settings -> Org settings -> Reports ->' Say ' UNCHECK "Display concealed user, group, and site names in all reports".' Say '' Say ' 2. Entra ID P1 (or P2). Without it last-sign-in is absent rather than empty, and a dormant' Say ' account cannot be told from an active one.' if (-not $SkipPowerBi -and $EnableFabricAdminApi) { Say '' Say ' 3. Power BI: DONE by this script, for the reader group only. Allow ~15 minutes; until it' Say ' propagates the Power BI check in FinOps reports 403, which is expected.' } elseif (-not $SkipPowerBi) { Say '' Say ' 3. Power BI: app.powerbi.com -> Settings -> Admin portal -> Tenant settings ->' Say " Admin API settings -> 'Service principals can access read-only admin APIs' ->" Say " Enabled, for the group $PbiGroupName. Allow about 15 minutes." Say ' Power BI usage history is kept for 28 days only, so every day this waits is history' Say ' that cannot be fetched later.' } Say '' Say (" 4. A SECOND PERSON in {0}. FinOps refuses to let one human both request and" -f $RoleGroups['FinOps.Approver']) Say ' approve a licence removal, and there is no override. You are in that group; somebody else' Say ' has to be as well before anything can be approved.' Say '' Say ' 5. NOTHING ABOVE LETS FINOPS REMOVE A LICENCE, and that stays true until you decide otherwise.' Say ' The licence executor was created holding no consent, so today FinOps can only report. When' Say ' you want it to act, a Global Administrator grants that one application its permission -' Say ' the link is in FinOps under Settings > Connections, next to "Licence executor". FinOps' Say ' cannot press it for you; doing that would need write access to this directory, which it' Say ' deliberately never asks for.' Say '' Say ' Granting it still removes nothing on its own. Somebody with the Admin role has to arm' Say ' enforcement in Settings > Safety, holder warnings have to be on and matured, and two' Say ' different people have to approve each item.' if ($IncludeMailSender) { Say '' Say '' if ($AlertsMailbox) { Say " 6. Mail is set up: FinOps sends as $AlertsMailbox and CANNOT send as anything else." Say ' Set that same address in FinOps under Settings > Notifications ("Send from mailbox").' Say ' The policy scopes the grant to exactly one mailbox, so if FinOps is configured to' Say ' send as a different one every send is refused 403 - which reads like a broken policy' Say ' rather than a mismatched address.' } else { # NOT DONE, and it is worth being precise about the consequence rather than calling it a # follow-up. Without a mail identity FinOps cannot warn a licence holder, and it refuses to # reclaim a licence it has not warned about - so every approved removal defers, indefinitely # and quietly, and the approvals queue simply looks stuck. Say ' 6. THE MAIL SENDER CANNOT SEND ANYTHING YET. FinOps will not remove a licence without' Say ' first warning the person holding it, so until this is finished every approved removal' Say ' defers instead - silently, and the approvals queue just looks stuck.' Say '' Say ' Finish it by re-running this script with the mailbox FinOps should send as:' Say " $PSCommandPath -AlertsMailbox finops-alerts@yourdomain -SkipCertificates" Say ' That creates the scope group, applies the Exchange access policy, PROVES the' Say ' application can reach exactly that one mailbox, and only then grants Mail.Send.' Say ' It needs Install-Module ExchangeOnlineManagement and a browser that can sign in.' } } Say '' Ok 'Done. Nothing else in this directory was touched.' Say ' Questions: Onboarding@SlashZero.ai' Say ''