#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 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. Nothing this script creates can change a licence unless you pass -IncludeExecutor. - Mail.Send is never granted UNTIL IT IS BOUNDED. As an application permission it means "send as anybody in this tenant", so the mail sender is created with ZERO permissions - completely inert - and stays that way unless you give -AlertsMailbox. With it, this script also 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. -SkipMailSender if you want no mail identity at all. - 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 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 IncludeExecutor ONLY for customers who want FinOps to remove licences automatically. Creates a further application holding LicenseAssignment.ReadWrite.All - the least-privileged permission for assigning and removing licences, and deliberately not User.ReadWrite.All, which would also permit password reset, UPN change and account disable. Off by default so a report-only customer can verify in their own directory that no application exists which is able to change a licence. .PARAMETER SkipMailSender Suppresses the mail sender. It is created BY DEFAULT, unlike the executor, and the asymmetry is the point: the executor can change licences, whereas the mail sender is created with ZERO permissions and can do literally nothing until an Exchange application access policy has been proven and Mail.Send granted separately. 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 = 'ef2b006f-24ec-4d16-9b48-551359d597a8', [string]$Slug = 'eintal', [string]$FinShiftHost = 'ein-tal.slashzero.ai', [string]$OutDir = $env:USERPROFILE, [int] $CertYears = 2, [switch]$DryRun, [switch]$Yes, [switch]$SkipCertificates, [switch]$SkipPowerBi, [switch]$IncludeExecutor, [switch]$SkipMailSender, # 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' } $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 { param([object]$App, [object]$Sp, [string[]]$Scopes, [string]$Label) $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 }) # 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" } } } function Add-FinOpsCertificate { param([string]$AppObjectId, [string]$Subject, [string]$Label, [string]$OutFile) if ($SkipCertificates) { Warn "$Label certificate skipped (-SkipCertificates)"; return } # Re-read rather than trusting the survey: somebody may have uploaded one in the last minute. $fresh = Get-MgApplication -ApplicationId $AppObjectId if ($fresh.KeyCredentials -and @($fresh.KeyCredentials).Count -gt 0) { throw "$Label already holds a certificate. See CERTIFICATES in this script's header." } # 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')) 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 = [bool]$IncludeExecutor } @{ Key = 'mail'; Name = 'SlashZero FinOps Mail Sender'; Want = (-not $SkipMailSender) } ) 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) Say '' $certBlockers = @() 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) { $certBlockers += $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 } } if ($certBlockers.Count -gt 0) { Say '' throw ("These applications already hold a certificate: $($certBlockers -join ', ').`n`n" + "Microsoft Graph never returns an existing certificate's key material, and the API that " + "sets one replaces the WHOLE collection - so adding one here would destroy the " + "certificate that is working today.`n`n" + "Re-run with -SkipCertificates to fix everything else, or remove the old certificate in " + 'the Entra portal first if you are deliberately replacing it. Nothing has been changed.') } Say '' Say ' NOT created, by design:' if (-not $IncludeExecutor) { Say ' - nothing that can change a licence (-IncludeExecutor)' } if ($SkipMailSender) { Say ' - nothing that can send mail (drop -SkipMailSender)' } if ($SkipPowerBi) { Say ' - no Power BI reader (drop -SkipPowerBi)' } 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' -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' -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' -OutFile (Join-Path $OutDir "finshift-$Slug-pbi.b64") } # ---- 7. optional write identities --------------------------------------------------------------- if ($IncludeExecutor) { Step 'Licence executor (WRITE-SCOPED)' Warn 'This application can add and remove licences in this directory.' $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. Grant-FinOpsPermissions -App (Get-MgApplication -ApplicationId $exe.Id) -Sp $exeSp ` -Scopes $ExecutorScopes -Label 'executor' Add-FinOpsCertificate -AppObjectId $exe.Id -Subject "CN=SlashZero-FinOps-Executor-$Slug" ` -Label 'executor' -OutFile (Join-Path $OutDir "finshift-$Slug-executor.b64") } if (-not $SkipMailSender) { 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' -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 (-not $SkipMailSender -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 '' foreach ($spec in $AppSpecs) { if (-not $spec.Want) { continue } $app = Get-AppByName $spec.Name if ($app) { Say (" {0,-12} client id {1}" -f $spec.Key, $app.AppId) } } Say '' Say ' certificate files - these are PRIVATE KEYS. Upload them, then delete them:' $b64 = @(Get-ChildItem -Path $OutDir -Filter "finshift-$Slug-*.b64" -ErrorAction SilentlyContinue) if ($b64.Count -eq 0) { Say ' (none written on this run)' } foreach ($f in $b64) { Say (" {0}" -f $f.FullName) } 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) { 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.' if (-not $SkipMailSender) { Say '' Say '' if ($AlertsMailbox) { Say " 5. 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 ' 5. 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 ''