#Requires -Version 7.0 <# .SYNOPSIS Creates (or updates, rotates, disables, enables, removes) the Invardia Support app in YOUR tenant. .DESCRIPTION Invardia support connects to your tenant only when you ask for help, as an app registration that YOU own: - single-tenant app "Invardia Support" in your tenant (found by its InvardiaSupport tag), with the API permissions for the access level you choose - Azure roles on the subscriptions (-SubscriptionIds: read-only at Troubleshoot, Setup roles at Setup), the resource groups / key vaults you name and, at the Setup level if you ask for it, the tenant root (-TenantRoot: Reader there, plus User Access Administrator there only when your organisation approves it - see below) - a client secret, valid as long as you choose (-SecretDays 1-730; 90 days when not given), printed once: store it in the portal (portal.invardia.com > Environments). Each renewal needs your approval by email. - optional: a Conditional Access policy that blocks the app everywhere except Invardia's support IP (172.205.146.248) (needs Microsoft Entra Workload ID Premium licences) Run it again at any time: it adds what is missing and removes API permissions that the chosen access level does not need. Without -Access / -Products it keeps the level and products the app already has (stored in the app's tags); only an explicit -Access or -Products changes them, and a downgrade (fewer rights) needs -Force or -Confirm. Access levels: Troubleshoot read only: the configuration, logs and data the products themselves read (+ Reader / Log Analytics Reader on the product resource groups or the subscriptions you name, Key Vault Reader on the product vaults - metadata only, no secret values) Setup Troubleshoot + everything the products' setup scripts need to install, repair and upgrade unattended (Invardia owner decision 2026-10-06: the setup scripts must run 100% through this app): Contributor + User Access Administrator on the subscriptions you name (the products create their own resource groups and grant their identities), -TenantRoot grants Reader at the tenant root, plus User Access Administrator there only when your organisation approves it (it is recorded in the Invardia portal; -Action RemoveTenantRoot keeps Reader and removes it when the approval is withdrawn), Contributor and a CONDITIONED Role Based Access Control Administrator on any resource groups you name, Key Vault Secrets Officer on the product vaults, and the Graph permissions to create the products' app registrations, grant their API permissions and consent (Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All), to create security groups, rename them to the standard names and change group members (Group.Create, Group.ReadWrite.All, GroupMember.ReadWrite.All - the groups the products' setup needs; role-assignable groups only with PIM Manager's RoleManagement.ReadWrite.Directory, and - for a group managed by PIM for Groups - PrivilegedAssignmentSchedule.ReadWrite / .Remove.AzureADGroup). This is owner-level access to those subscriptions: keep the secret in Invardia's portal only, and use the Conditional Access lock. Exchange Online (optional, -ExchangeMailbox, never part of the standing Setup level): lets Invardia support create the products' Office 365 shared mailbox and scope Mail.Send to it (Exchange RBAC for Applications, over the module-free Exchange admin REST endpoint). Exchange.ManageAsApp alone is NOT enough (proven at a customer 2026-10-08: every Exchange cmdlet answers 403 without an Exchange role), so the switch also creates the custom Exchange role group "Invardia Support Mail Setup" holding exactly "Mail Recipient Creation" + "Role Management" (SI's design 2026-10-07) with this app as its member. Role Management can grant any Exchange role: take it away again once the mailbox is set up, with -Action RemoveExchange. Needs one more browser sign-in, as an Exchange Administrator or Global Administrator. Guide: https://invardia.com/support/remote-access/ Requirements: PowerShell 7 (pwsh; install it from https://aka.ms/powershell) - Windows PowerShell 5.1 is refused at once (owner 2026-11-01). No modules (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): it signs in through the browser (the normal Microsoft sign-in page, MFA as usual) and talks to Microsoft Graph and Azure Resource Manager over plain REST. Rights: an administrator who may grant admin consent for Microsoft Graph application permissions (Privileged Role Administrator or Global Administrator) and, for the Azure roles, Owner or User Access Administrator on those subscriptions / resource groups (and on the tenant root for -TenantRoot); -ExchangeMailbox: Exchange Administrator or Global Administrator. An Azure role it cannot assign does not stop it: it finishes the rest, prints the secret, says what is missing and how to fix it, and exits with code 1. Network (HTTPS): login.microsoftonline.com, graph.microsoft.com, management.azure.com, outlook.office365.com (-ExchangeMailbox / RemoveExchange only), and http://localhost (the browser sign-in's answer). .EXAMPLE # Setup access for both products on one subscription and the tenant root, the CA policy, a 90-day secret ./New-InvardiaSupportApp.ps1 -TenantId -Products PimManager,SecurityInsight -Access Setup ` -SubscriptionIds -TenantRoot -ConditionalAccess .EXAMPLE ./New-InvardiaSupportApp.ps1 -TenantId -Action NewSecret # a new secret (old ones removed; permissions untouched) ./New-InvardiaSupportApp.ps1 -TenantId -Action Disable # switch it off (keeps every grant) ./New-InvardiaSupportApp.ps1 -TenantId -Action Enable ./New-InvardiaSupportApp.ps1 -TenantId -Action Remove # the app, its Azure roles and CA policy .EXAMPLE # Time-boxed Exchange rights for the shared-mailbox setup, taken away again once the mailbox is set up ./New-InvardiaSupportApp.ps1 -TenantId -Access Setup -ExchangeMailbox -NoSecret ./New-InvardiaSupportApp.ps1 -TenantId -Action RemoveExchange .EXAMPLE # Your organisation withdraws its approval: keep Reader at the tenant root, remove the app's own UAA there ./New-InvardiaSupportApp.ps1 -TenantId -Action RemoveTenantRoot -SignInClient AzurePowerShell .EXAMPLE # Back to read only (a downgrade: it lists what it revokes and needs -Force, or -Confirm to be asked) ./New-InvardiaSupportApp.ps1 -TenantId -Access Troubleshoot -Force .EXAMPLE # Your Conditional Access blocks the Azure CLI sign-in app: use Azure PowerShell's (Graph + Azure) instead ./New-InvardiaSupportApp.ps1 -TenantId -Products PimManager -Access Setup -SubscriptionIds ` -TenantRoot -SignInClient AzurePowerShell .EXAMPLE # Unattended (automation): sign in as YOUR certificate service principal instead of interactively ./New-InvardiaSupportApp.ps1 -TenantId -ClientId -CertificateThumbprint -Access Setup -NoSecret -PassThru .EXAMPLE # Preview: signs in and reads, then prints every change it would make - writes nothing ./New-InvardiaSupportApp.ps1 -TenantId -Products PimManager,SecurityInsight -Access Setup -SubscriptionIds -WhatIf .NOTES Script version: 2026.11.09. Save it, check its SHA-256 against https://invardia.com/support/SHA256SUMS.txt, read it, then run it. Every run writes a transcript to your temp folder (the client secret is never in it) and prints its path. Changes: 2026.11.09 The tenant root (owner 2026-11-09, framework ROOT-RIGHTS-1): -TenantRoot grants Reader at the tenant root, plus User Access Administrator there only when your organisation approves it (it is recorded in the Invardia portal). New: -Action RemoveTenantRoot (for an organisation that withdraws the approval) makes sure the app holds Reader at the tenant root (read back), then removes ONLY the app's own User Access Administrator there (read back); no other right changes. 2026.11.05 Every access level, for every product, keeps five PIM for Groups permissions (assignment and eligibility schedules ReadWrite + Remove, the group policy read): the products' SQL admin groups are role-assignable, and their members are managed as audited PIM for Groups assignments. A re-run never removes them, nor RoleManagement.ReadWrite.Directory or AppRoleAssignment.ReadWrite.All once granted. 2026.11.04b The Conditional Access lock no longer fails on a first run: a named location made seconds earlier is not yet known to Entra (Graph 1040), so the script waits and tries again (up to 2 min), and the warning names the real cause. The closing text names the portal's Environments page. 2026.11.04 -Access Setup also grants Group.ReadWrite.All (owner 2026-11-04): rename the products' groups to the standard names (grp--, framework naming) at existing installs. 2026.11.01 PowerShell 7 only: Windows PowerShell 5.1 stops at once with a plain message (#Requires -Version 7.0); the help names its requirements (PowerShell 7, the rights, the hosts it calls). 2026.10.31 The browser sign-in page no longer shows "This site can't be reached": the local listener answers every request (an icon, a preload, a reload of the same address) and keeps answering for a few seconds after the sign-in. -ExchangeMailbox is no longer used (the products' own mail scripts set up mail). 2026.10.18b The app's display name is "Invardia Support" (the naming convention, owner 2026-10-14). The app is found by its InvardiaSupport tag, no longer by its name, and an existing app (and its service principal) is renamed by the next -Action Create or NewSecret run; ids, grants, tags and secret stay as they are. The Conditional Access names are unchanged. 2026.10.18 Setup with PimManager: also PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup and PrivilegedAssignmentSchedule.Remove.AzureADGroup (owner 2026-10-18) - PIM's setup removes its updater from the SQL admin group when that group is managed by PIM for Groups. 2026.10.15 SecurityInsight: also the Defender for Endpoint read roles Vulnerability.Read.All, Software.Read.All and SecurityRecommendation.Read.All (owner 2026-10-15) - read only, next to Machine.Read.All. 2026.10.14 Help text only: the secret's lifetime is -SecretDays (1-730, 90 by default); the portal page is Environments. 2026.10.13 -Access Troubleshoot with -SubscriptionIds no longer stops with "belong to the Setup level": a leftover check refused the very line the portal shows (found at a customer). -TenantRoot stays Setup only. 2026.10.12 -Access Setup also grants Group.Create and GroupMember.ReadWrite.All for every product (create security groups and change group members during setup); before, only PIM Manager had Group.Create. 2026.10.11 -Access Troubleshoot takes -SubscriptionIds too: read-only there (Reader + Log Analytics Reader) - a customer knows the subscription, not the product's resource group names. -TenantRoot stays Setup only. 2026.10.10 -ExchangeMailbox (Exchange.ManageAsApp + the custom Exchange role group "Invardia Support Mail Setup") and -Action RemoveExchange; the script prints its version. 2026.10.09 a re-run keeps a live secret (-Action NewSecret replaces it); -SignInClient. .LINK https://invardia.com/docs/invardia/scripts/New-InvardiaSupportApp/ #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')][string]$TenantId, # RemoveExchange: takes the -ExchangeMailbox rights away again (role group membership, the role group, Exchange.ManageAsApp). # RemoveTenantRoot: Reader at the tenant root made sure (read back), then ONLY the app's own User Access Administrator # there removed (read back) (2026.11.09). [ValidateSet('Create', 'NewSecret', 'Disable', 'Enable', 'Remove', 'RemoveExchange', 'RemoveTenantRoot')][string]$Action = 'Create', # Not given: the products the app already has (a new app: both). [ValidateSet('PimManager', 'SecurityInsight')][string[]]$Products = @('PimManager', 'SecurityInsight'), # Not given: the level the app already has (a new app: Troubleshoot). Found at a customer 2026-10-07: -Action NewSecret # without -Access used to fall back to Troubleshoot and revoked a Setup app's write permissions. [ValidateSet('Troubleshoot', 'Setup')][string]$Access = 'Troubleshoot', # Setup: Contributor + User Access Administrator on each (subscription ids, not names). [string[]]$SubscriptionIds = @(), # Setup: Reader + User Access Administrator at the tenant root management group (SecurityInsight's engine gets Reader # there). The UAA there only when your organisation approves it, recorded in the Invardia portal (ROOT-RIGHTS-1); # -Action RemoveTenantRoot removes it again. [switch]$TenantRoot, [string[]]$ResourceGroupIds = @(), [string[]]$KeyVaultIds = @(), [string]$HubVnetId, [switch]$PimMspManagingTenant, # Optional, Setup level only, time-boxed (agreed with SI 2026-10-07; PIM proved 2026-10-08 that the app permission alone # answers 403): Exchange.ManageAsApp + the custom Exchange role group "Invardia Support Mail Setup" ("Mail Recipient # Creation" + "Role Management") with this app as member, so support can create the products' shared mailbox and scope # Mail.Send to it. A re-run without it keeps what the app has; -Action RemoveExchange takes it away. [switch]$ExchangeMailbox, # Secret lifetime by ring (Invardia 2026-10-03): 2 = customers, 90 days (you approve each renewal by email); # 3 = setup only, 1 day; 1 = Invardia's own tenants, 2 years (renewed automatically). -SecretDays overrides. [ValidateSet(1, 2, 3)][int]$Ring = 2, [ValidateRange(1, 730)][int]$SecretDays = 0, [switch]$NoSecret, # Conditional Access (owner 2026-10-07): ALWAYS created with -Action Create - the named location "Invardia Support (Morten # Knudsen)" and a workload-identity policy that blocks this app outside it - OFF ('disabled') by default; a re-run never # changes the state of a policy it finds (only an explicit -ConditionalAccessState does). -NoConditionalAccess skips it. [switch]$ConditionalAccess, # kept for older command lines - Conditional Access is the default now [switch]$NoConditionalAccess, [ValidateSet('disabled', 'enabled', 'enabledForReportingButNotEnforced')][string]$ConditionalAccessState = 'disabled', [string]$SupportIp = '172.205.146.248', # The standard name, the same in every customer tenant so it is recognisable in sign-in logs: "Invardia Support" since # 2026.10.18b (CR5 naming, owner 2026-10-14: "full name where people read"; before: "Invardia Support for SecurityInsight - # PIM Manager (customer request-based only)"). The app is FOUND by its InvardiaSupport tag, never by this name, and an # existing app is renamed to it by the next Create or NewSecret run. [string]$DisplayName = 'Invardia Support', # Unattended: sign in as a service principal with a certificate in the current user's or machine's store (never a secret). # It needs the same rights an interactive run needs (app registrations, admin consent, the Azure roles you name). [string]$ClientId, [string]$CertificateThumbprint, # The Microsoft app the browser sign-in uses (found at a customer 2026-10-08: Conditional Access blocked the Azure CLI # client). AzureCli (default): one sign-in for Graph + Azure. AzurePowerShell: the same, with Azure PowerShell's client. # GraphCli: Microsoft Graph Command Line Tools - Graph only, so not for a run that sets Azure roles. No fallback between them. [ValidateSet('AzureCli', 'AzurePowerShell', 'GraphCli')][string]$SignInClient = 'AzureCli', # Kept for older automation; the REST version needs no default subscription (Remove also searches this one). [string]$SubscriptionId, # A downgrade (Setup -> Troubleshoot, or fewer products) revokes rights: it runs only with -Force (or -Confirm, asked). [switch]$Force, # Return the app's ids as an object (for automation). [switch]$PassThru ) $ErrorActionPreference = 'Stop' # Framework 12.7 SCRIPT-DOC-1: the documentation page is the first line. 12.10: a local transcript of the run in the temp # folder, its path at the start and the end; it is paused while the client secret is printed, so the secret is never in it. Write-Host 'Documentation: https://invardia.com/docs/invardia/scripts/New-InvardiaSupportApp/' $__InvardiaSupportAppVersion = '2026.11.09' Write-Host "New-InvardiaSupportApp.ps1 version $__InvardiaSupportAppVersion" $transcript = Join-Path ([IO.Path]::GetTempPath()) ('Invardia-New-InvardiaSupportApp-{0:yyyyMMdd-HHmmss-fff}-{1}.log' -f (Get-Date), $PID) $script:transcribing = $false try { Start-Transcript -Path $transcript -WhatIf:$false -Confirm:$false | Out-Null; $script:transcribing = $true; Write-Host "Transcript: $transcript" } catch { Write-Warning "No transcript of this run ($($_.Exception.Message))" } function Stop-InvTranscript { if (-not $script:transcribing) { return } $script:transcribing = $false try { Stop-Transcript | Out-Null } catch { } Write-Host "Transcript: $transcript" } trap { Stop-InvTranscript; break } $marker = 'InvardiaSupport' # a tag on everything this script creates, so it finds (and only removes) its own objects if ($PSVersionTable.PSVersion.Major -lt 6) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } # ---------------- what each access level grants (from the products' own code; see the guide) ---------------- $GRAPH = '00000003-0000-0000-c000-000000000000' $MTP = '8ee8fdad-f234-4243-8f3b-15c294843740' # Microsoft Threat Protection (Defender XDR) $WDATP = 'fc780465-2017-40d4-a0c5-307022471b92' # WindowsDefenderATP (Defender for Endpoint) $EXO = '00000002-0000-0ff1-ce00-000000000000' # Office 365 Exchange Online (Exchange.ManageAsApp, -ExchangeMailbox only) $pimRead = @('Domain.Read.All', 'AdministrativeUnit.Read.All', 'AccessReview.Read.All', 'RoleManagementPolicy.Read.AzureADGroup', 'PrivilegedEligibilitySchedule.Read.AzureADGroup', 'PrivilegedAssignmentSchedule.Read.AzureADGroup') $siRead = @('Device.Read.All', 'AuditLog.Read.All', 'Policy.Read.All', 'IdentityRiskyUser.Read.All', 'IdentityRiskEvent.Read.All', 'IdentityRiskyServicePrincipal.Read.All', 'ThreatHunting.Read.All') $setupWrite = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Group.Create', 'Group.ReadWrite.All', 'GroupMember.ReadWrite.All') $pimSetupWrite = @('RoleManagement.ReadWrite.Directory', 'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup', 'PrivilegedAssignmentSchedule.Remove.AzureADGroup') # Owner 2026-11-05 ("fix it across all solutions and installs"; chose "PIM for Groups rights", NOT RoleManagement.ReadWrite. # Directory; framework 4.5 point 5): every product's SQL admin group is role-assignable, and its members are managed as # audited PIM-for-Groups assignments. These five are STANDING at EVERY access level and for every product - never removed # by a re-run ("in general it has given many issues stripping perm of support app"). $neverRevoke = @('RoleManagement.ReadWrite.Directory', 'AppRoleAssignment.ReadWrite.All') $groupPimStanding = @('PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup', 'PrivilegedAssignmentSchedule.Remove.AzureADGroup', 'PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup', 'PrivilegedEligibilitySchedule.Remove.AzureADGroup', 'RoleManagementPolicy.Read.AzureADGroup') function Get-WantedPermissions([string]$Level, [string[]]$For) { $read = @{ $GRAPH = [System.Collections.Generic.HashSet[string]]::new([string[]]@('Directory.Read.All', 'Application.Read.All', 'User.Read.All', 'Group.Read.All', 'RoleManagement.Read.All', 'RoleManagementPolicy.Read.Directory', 'RoleEligibilitySchedule.Read.Directory', 'RoleAssignmentSchedule.Read.Directory', 'PrivilegedAccess.Read.AzureADGroup')); $MTP = [System.Collections.Generic.HashSet[string]]::new(); $WDATP = [System.Collections.Generic.HashSet[string]]::new() $EXO = [System.Collections.Generic.HashSet[string]]::new() } # Exchange.ManageAsApp only with -ExchangeMailbox (added by the caller) if ($For -contains 'PimManager') { $pimRead | ForEach-Object { [void]$read[$GRAPH].Add($_) } } if ($For -contains 'SecurityInsight') { $siRead | ForEach-Object { [void]$read[$GRAPH].Add($_) } [void]$read[$MTP].Add('AdvancedHunting.Read.All') # owner 2026-10-15: SecurityInsight checks its CVE data against Defender for Endpoint - read only [void]$read[$WDATP].Add('Machine.Read.All') [void]$read[$WDATP].Add('Vulnerability.Read.All') [void]$read[$WDATP].Add('Software.Read.All') [void]$read[$WDATP].Add('SecurityRecommendation.Read.All') } # Both levels: the app renews its OWN secret (it is its own owner), so it needs Application.ReadWrite.OwnedBy - which covers # only itself and apps it creates. [void]$read[$GRAPH].Add('Application.ReadWrite.OwnedBy') # Standing PIM-for-Groups rights (owner 2026-11-05) - every level, every product $groupPimStanding | ForEach-Object { [void]$read[$GRAPH].Add($_) } if ($Level -eq 'Setup') { # Create / update the products' own app registrations (owned by this app), grant the products' managed identities their # API permissions, consent the products' sign-in scopes. AppRoleAssignment.ReadWrite.All has no narrower alternative. # Application.ReadWrite.All (owner 2026-10-06: "give the needed permissions"): the setup scripts also update app # registrations this app did not create (an earlier install's). $setupWrite | ForEach-Object { [void]$read[$GRAPH].Add($_) } # PIM's SQL admin group (owned by this app). Since PIM 2.4.491 it is ROLE-ASSIGNABLE: creating it and adding its # members needs RoleManagement.ReadWrite.Directory as well (tier 0 - owner 2026-10-04; GroupMember.ReadWrite.All # cannot change a role-assignable group). When that group is managed by PIM for Groups, a member is an assignment # schedule: removing one needs PrivilegedAssignmentSchedule.ReadWrite + .Remove.AzureADGroup (owner 2026-10-18). if ($For -contains 'PimManager') { $pimSetupWrite | ForEach-Object { [void]$read[$GRAPH].Add($_) } } } $read } # Azure roles per scope. Setup: Contributor + RBAC Administrator limited (by condition) to the roles the products give # their own managed identities, to service principals only. $conditionRoles = @('AcrPull', 'Reader', 'Log Analytics Reader', 'Monitoring Metrics Publisher', 'Contributor', 'Container Apps Jobs Operator', 'Storage Blob Data Contributor', 'Storage Queue Data Contributor', 'Storage Table Data Contributor', 'Key Vault Secrets User', 'Key Vault Reader', 'Cognitive Services OpenAI User') foreach ($id in $SubscriptionIds) { if ($id -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "Not a subscription id: $id" } } $accessGiven = $PSBoundParameters.ContainsKey('Access'); $productsGiven = $PSBoundParameters.ContainsKey('Products') # Troubleshoot takes -SubscriptionIds too (owner 2026-10-11: a customer knows the subscription, not our resource group names): # read-only there (Reader + Log Analytics Reader). -TenantRoot stays Setup only. if ($TenantRoot -and $accessGiven -and $Access -ne 'Setup') { throw '-TenantRoot belongs to the Setup level (-Access Setup).' } $subScopes = @($SubscriptionIds | ForEach-Object { "/subscriptions/$($_.ToLower())" }) $rootScope = "/providers/Microsoft.Management/managementGroups/$TenantId" foreach ($id in $ResourceGroupIds) { if ($id -notmatch '^/subscriptions/[0-9a-f-]{36}/resourceGroups/[^/]+$') { throw "Not a resource group id: $id" } } foreach ($id in $KeyVaultIds) { if ($id -notmatch '^/subscriptions/[0-9a-f-]{36}/resourceGroups/[^/]+/providers/Microsoft\.KeyVault/vaults/[^/]+$') { throw "Not a key vault id: $id" } } if ($HubVnetId -and $HubVnetId -notmatch '^/subscriptions/[0-9a-f-]{36}/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+$') { throw "Not a VNet id: $HubVnetId" } if ($ExchangeMailbox -and $Action -ne 'Create') { throw '-ExchangeMailbox belongs to -Action Create (to take it away again: -Action RemoveExchange).' } if ([bool]$ClientId -ne [bool]$CertificateThumbprint) { throw 'Pass -ClientId and -CertificateThumbprint together (unattended sign-in), or neither (interactive).' } # ---------------- plain REST (no modules, owner 2026-10-07) ---------------- # One helper for Graph, ARM and the token endpoint: retries 429 / 5xx after Retry-After, and - with -RetryNotFound - the # "not found" Entra returns for a few seconds after an app or service principal is created (eventual consistency). # A final failure throws an exception whose Data carries Status (HTTP code), Code and Message, so callers can decide. function Get-InvHttpError($err) { $status = 0; $text = $null; $retryAfter = 0; $resp = $null try { $resp = $err.Exception.Response } catch { } if ($resp) { try { $status = [int]$resp.StatusCode } catch { } } if ($err.ErrorDetails -and $err.ErrorDetails.Message) { $text = $err.ErrorDetails.Message } elseif ($resp -and $resp.PSObject.Methods['GetResponseStream']) { try { $text = (New-Object System.IO.StreamReader($resp.GetResponseStream())).ReadToEnd() } catch { } } if ($resp) { try { if ($resp.Headers.RetryAfter -and $resp.Headers.RetryAfter.Delta) { $retryAfter = [int]$resp.Headers.RetryAfter.Delta.TotalSeconds } } catch { } # PowerShell 7 if (-not $retryAfter) { try { $v = $resp.Headers['Retry-After']; if ($v) { $retryAfter = [int]"$v" } } catch { } } # Windows PowerShell 5.1 } $code = $null; $msg = $null if ($text) { try { $j = $text | ConvertFrom-Json if ($j.error -is [string]) { $code = $j.error; $msg = $j.error_description } # the OAuth token endpoint elseif ($j.error) { $code = $j.error.code; $msg = $j.error.message } # Graph and ARM } catch { $msg = $text } } if (-not $msg) { $msg = $err.Exception.Message } [pscustomobject]@{ Status = $status; Code = $code; Message = $msg; RetryAfter = $retryAfter } } function Invoke-InvRest { param([string]$Method = 'GET', [Parameter(Mandatory)][string]$Uri, $Body, [hashtable]$Form, [string]$Token, [int]$RetryNotFound = 0, [switch]$NoRetry, [hashtable]$Headers) $p = @{ Method = $Method; Uri = $Uri; ErrorAction = 'Stop' } $h = @{} if ($Token) { $h.Authorization = "Bearer $Token" } if ($Headers) { foreach ($k in $Headers.Keys) { $h[$k] = $Headers[$k] } } if ($h.Count) { $p.Headers = $h } if ($Form) { $p.Body = $Form; $p.ContentType = 'application/x-www-form-urlencoded' } elseif ($null -ne $Body) { $p.Body = $(if ($Body -is [string]) { $Body } else { ConvertTo-Json -InputObject $Body -Depth 10 -Compress }) $p.ContentType = 'application/json; charset=utf-8' } $busy = 0; $notFound = 0 while ($true) { try { return (Invoke-RestMethod @p) } catch { $e = Get-InvHttpError $_ if (-not $NoRetry -and ($e.Status -in 429, 502, 503, 504) -and $busy -lt 6) { $busy++ $wait = if ($e.RetryAfter -gt 0) { [Math]::Min($e.RetryAfter, 120) } else { [int][Math]::Min([Math]::Pow(2, $busy), 30) } Write-Verbose "$Method $Uri -> $($e.Status); waiting $wait s" Start-Sleep -Seconds $wait; continue } if ($notFound -lt $RetryNotFound -and ($e.Status -eq 404 -or ($e.Status -eq 400 -and "$($e.Code) $($e.Message)" -match 'NotFound|does not exist|does not reference'))) { $notFound++; Write-Verbose "$Method $Uri -> $($e.Status) (just created?); retry $notFound" Start-Sleep -Seconds 10; continue } $ex = New-Object System.Exception ("$Method $($Uri -replace '\?.*$', '') -> $($e.Status) $($e.Code): $($e.Message)") $ex.Data['Status'] = $e.Status; $ex.Data['Code'] = $e.Code; $ex.Data['Message'] = $e.Message throw $ex } } } $graphUrl = 'https://graph.microsoft.com/v1.0' $armUrl = 'https://management.azure.com' function Enc([string]$s) { [uri]::EscapeDataString($s) } function Invoke-Graph([string]$Method = 'GET', [string]$Path, $Body, [int]$RetryNotFound = 0, [string]$Token = $script:graphToken) { Invoke-InvRest -Method $Method -Uri "$graphUrl$Path" -Body $Body -Token $Token -RetryNotFound $RetryNotFound } function Get-GraphAll([string]$Path, [string]$Token = $script:graphToken) { $uri = "$graphUrl$Path" while ($uri) { $r = Invoke-InvRest -Uri $uri -Token $Token; foreach ($v in $r.value) { $v }; $uri = $r.'@odata.nextLink' } } function Get-ArmAll([string]$Path) { $uri = "$armUrl$Path" while ($uri) { $r = Invoke-InvRest -Uri $uri -Token $script:armToken; foreach ($v in $r.value) { $v }; $uri = $r.nextLink } } # ---------------- sign in (Graph; Azure only when needed) ---------------- # Interactive: the browser sign-in (authorization code + PKCE, below) with the Microsoft Azure CLI's public client - a # first-party app present in every tenant that may hold both Graph and ARM tokens, so ONE sign-in covers both (its refresh # token is exchanged for the ARM token). If its Graph consent lacks a scope this run needs (the Conditional Access ones), one # more sign-in uses the Microsoft Graph Command Line Tools client - the app Connect-MgGraph used. No modules, no device code. # Unattended: client credentials with a certificate ASSERTION (a JWT signed with the certificate's private key) - never a secret. $AZCLI = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' # Microsoft Azure CLI $MGCLI = '14d82eec-204b-4c2f-b7e8-296a70dab67e' # Microsoft Graph Command Line Tools $AZPS = '1950a258-227b-4e31-a9cf-717495945fc2' # Microsoft Azure PowerShell $login = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0" function ConvertTo-B64Url([byte[]]$Bytes) { [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function Get-JwtClaims([string]$Jwt) { $p = $Jwt.Split('.')[1].Replace('-', '+').Replace('_', '/') switch ($p.Length % 4) { 2 { $p += '==' } 3 { $p += '=' } } [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json } function Assert-Tenant([string]$Jwt) { $tid = (Get-JwtClaims $Jwt).tid if ($tid -ne $TenantId) { throw "Signed in to tenant $tid, not $TenantId" } } # The browser sign-in (owner 2026-10-07: "noone in the world uses devicelogon anymore due to security risk. it must prompt to # login"): OAuth 2.0 authorization code + PKCE, the way az login / Connect-MgGraph do it - the browser opens the normal # Microsoft sign-in page (MFA, Conditional Access as usual), the answer comes back to http://localhost: on THIS # machine only. Built-in .NET only (HttpListener, SHA256), so it runs in Windows PowerShell 5.1 and PowerShell 7 alike. function Wait-AuthCode([string]$AuthorizeUrl, [string]$Redirect, [int]$Seconds = 300) { $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add($Redirect) $listener.Start() try { try { Start-Process $AuthorizeUrl } catch { } Write-Host '' Write-Host 'SIGN IN: your browser opens the Microsoft sign-in page - sign in as an administrator of the tenant.' -ForegroundColor Yellow Write-Host " If no browser opens, open this address on this machine:`n $AuthorizeUrl" # The browser can ask more than once (a preload of the address, then the real page; an icon) - owner 2026-10-30: "people # are reporting this error" ("This site can't be reached"). Only a request with code or error is the answer; every # request gets a page, and after the answer the listener keeps answering for a few seconds, so a second load of the same # address shows "Signed in" too instead of "refused to connect". $answer = $null; $html = $null $deadline = [DateTime]::UtcNow.AddSeconds($Seconds); $graceEnd = $null while ($true) { $left = if ($answer) { ($graceEnd - [DateTime]::UtcNow).TotalMilliseconds } else { ($deadline - [DateTime]::UtcNow).TotalMilliseconds } if ($left -le 0) { break } $pending = $listener.BeginGetContext($null, $null) if (-not $pending.AsyncWaitHandle.WaitOne([int][Math]::Ceiling($left))) { break } $ctx = $listener.EndGetContext($pending) $q = [System.Web.HttpUtility]::ParseQueryString($ctx.Request.Url.Query) if (-not $answer -and ($q['code'] -or $q['error'])) { $answer = @{ code = $q['code']; state = $q['state']; error = $q['error']; error_description = $q['error_description'] } $html = if (-not $q['error']) { '

Signed in.

You can close this tab and return to PowerShell.

' } else { "

Sign-in failed

$([System.Net.WebUtility]::HtmlEncode($q['error_description']))

" } $graceEnd = [DateTime]::UtcNow.AddSeconds(10) } $page = if ($html) { $html } else { '

Waiting for the sign-in...

Finish the sign-in in the other tab.

' } try { $bytes = [Text.Encoding]::UTF8.GetBytes($page) $ctx.Response.ContentType = 'text/html; charset=utf-8' $ctx.Response.ContentLength64 = $bytes.Length # without it the browser sees the connection cut off $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) $ctx.Response.Close() } catch { } # a browser that closed the connection itself: nothing to answer } if (-not $answer) { throw "No sign-in within $([int]($Seconds / 60)) minutes - run the script again." } $answer } finally { $listener.Stop(); $listener.Close() } } function Request-BrowserToken([string]$Client, [string]$Scope, [string]$What) { Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue $tcp = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0); $tcp.Start(); $port = $tcp.LocalEndpoint.Port; $tcp.Stop() $redirect = "http://localhost:$port/" $verifier = ConvertTo-B64Url ([byte[]](1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })) $sha = [System.Security.Cryptography.SHA256]::Create() $challenge = ConvertTo-B64Url $sha.ComputeHash([Text.Encoding]::ASCII.GetBytes($verifier)) $state = [guid]::NewGuid().ToString('N') $url = "$login/authorize?client_id=$Client&response_type=code&response_mode=query&prompt=select_account" + "&redirect_uri=$(Enc $redirect)&scope=$(Enc $Scope)&code_challenge=$challenge&code_challenge_method=S256&state=$state" Write-Host " (sign-in app: $What, tenant $TenantId)" # tests replace the browser + localhost wait (and only that) with $global:InvardiaTestWaitAuthCode $r = if ($global:InvardiaTestWaitAuthCode) { & $global:InvardiaTestWaitAuthCode $url $redirect } else { Wait-AuthCode $url $redirect } if ($r.error) { throw "Sign-in failed: $($r.error) - $($r.error_description)" } if ($r.state -ne $state) { throw 'Sign-in answer did not match this request (state) - run the script again.' } Invoke-InvRest -Method POST -Uri "$login/token" -NoRetry -Form @{ grant_type = 'authorization_code'; client_id = $Client; code = $r.code redirect_uri = $redirect; code_verifier = $verifier; scope = $Scope } } function Get-Certificate([string]$Thumbprint) { foreach ($store in 'CurrentUser', 'LocalMachine') { $c = Get-Item -LiteralPath "Cert:\$store\My\$Thumbprint" -ErrorAction SilentlyContinue if ($c -and $c.HasPrivateKey) { return $c } } throw "Certificate $Thumbprint not found (with its private key) in Cert:\CurrentUser\My or Cert:\LocalMachine\My." } function New-ClientAssertion($Cert) { # RFC 7523 client assertion as Entra ID wants it: RS256, x5t = the certificate's SHA-1 thumbprint (base64url), 10 minutes. $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $header = ConvertTo-Json -Compress -InputObject ([ordered]@{ alg = 'RS256'; typ = 'JWT'; x5t = (ConvertTo-B64Url $Cert.GetCertHash()) }) $claims = ConvertTo-Json -Compress -InputObject ([ordered]@{ aud = "$login/token"; iss = $ClientId; sub = $ClientId; jti = [guid]::NewGuid().ToString(); nbf = $now; iat = $now; exp = $now + 600 }) $unsigned = (ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($header))) + '.' + (ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($claims))) $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert) if (-not $rsa) { throw "Certificate $($Cert.Thumbprint) has no RSA private key." } $sig = $rsa.SignData([Text.Encoding]::UTF8.GetBytes($unsigned), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) "$unsigned.$(ConvertTo-B64Url $sig)" } function Get-CertificateToken($Cert, [string]$Resource) { (Invoke-InvRest -Method POST -Uri "$login/token" -Form @{ grant_type = 'client_credentials'; client_id = $ClientId; scope = "$Resource/.default" client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; client_assertion = (New-ClientAssertion $Cert) }).access_token } $scopes = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All') $doCA = $Action -eq 'Create' -and -not $NoConditionalAccess if ($doCA -or $Action -eq 'Remove') { $scopes += 'Policy.ReadWrite.ConditionalAccess', 'Policy.Read.All' } $needAz = ($Action -eq 'RemoveTenantRoot') -or (($Action -in 'Create', 'Remove') -and ($ResourceGroupIds.Count -or $KeyVaultIds.Count -or $HubVnetId -or $SubscriptionIds.Count -or $TenantRoot -or $Action -eq 'Remove')) if (-not $SubscriptionId -and $SubscriptionIds.Count) { $SubscriptionId = $SubscriptionIds[0] } $graphToken = $null; $armToken = $null if ($ClientId) { $cert = Get-Certificate $CertificateThumbprint $graphToken = Get-CertificateToken $cert 'https://graph.microsoft.com' Assert-Tenant $graphToken if ($needAz) { $armToken = Get-CertificateToken $cert $armUrl } } elseif ($SignInClient -eq 'GraphCli') { # Graph Command Line Tools holds no Azure Resource Manager access: a run that sets Azure roles needs another client. if ($needAz) { throw "-SignInClient GraphCli signs in to Microsoft Graph only, but this run also sets Azure roles (-SubscriptionIds / -TenantRoot / resource groups / Remove). Use -SignInClient AzurePowerShell (or AzureCli)." } $t = Request-BrowserToken $MGCLI ((@($scopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'offline_access') -join ' ') 'Microsoft Graph Command Line Tools' Assert-Tenant $t.access_token $graphToken = $t.access_token } else { $client = if ($SignInClient -eq 'AzurePowerShell') { $AZPS } else { $AZCLI } $clientName = if ($SignInClient -eq 'AzurePowerShell') { 'Microsoft Azure PowerShell' } else { 'Microsoft Azure CLI' } $t = Request-BrowserToken $client 'https://graph.microsoft.com/.default offline_access openid profile' $clientName Assert-Tenant $t.access_token $graphToken = $t.access_token $scp = @("$((Get-JwtClaims $graphToken).scp)" -split ' ') $lacking = @($scopes | Where-Object { $scp -notcontains $_ -and -not ($_ -eq 'AppRoleAssignment.ReadWrite.All' -and $scp -contains 'Directory.AccessAsUser.All') }) if ($lacking.Count) { Write-Host " The $clientName sign-in does not carry $($lacking -join ', ') for Microsoft Graph - one more sign-in:" $t2 = Request-BrowserToken $MGCLI ((@($scopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'offline_access') -join ' ') 'Microsoft Graph Command Line Tools' Assert-Tenant $t2.access_token $graphToken = $t2.access_token } if ($needAz) { # the ARM token from the SAME client's refresh token - never another client (a tenant may block one of them) $armToken = (Invoke-InvRest -Method POST -Uri "$login/token" -Form @{ grant_type = 'refresh_token'; client_id = $client; refresh_token = $t.refresh_token; scope = "$armUrl/.default offline_access" }).access_token Assert-Tenant $armToken } } # ---------------- what could not be done (reported, never a silent stop - found at a customer 2026-10-07) ---------------- # A role the admin may not assign (typically User Access Administrator at the tenant root without "Access management for # Azure resources") used to stop the script BEFORE the secret was printed. Now each failure is reported, the rest goes on, # the secret is printed, and the script exits 1 with this list. $problems = [System.Collections.Generic.List[object]]::new() function Add-Problem([string]$What, [string]$Where, [string]$Why, [string]$Fix) { $problems.Add([pscustomobject]@{ What = $What; Where = $Where; Why = $Why; Fix = $Fix }) Write-Host " NOT DONE: $What on $Where" -ForegroundColor Red Write-Host " why: $Why" Write-Host " fix: $Fix" } function Get-RoleFix([string]$Scope, $Status) { if ($ClientId) { return "Give the app you signed in with (client id $ClientId) Owner or User Access Administrator on $Scope, then run this script again." } if ($Status -eq 403 -and $Scope -eq $rootScope) { return 'As a Global Administrator: Entra ID > Properties > Access management for Azure resources = Yes, sign out and in again, then run this script again (you may switch it back to No afterwards).' } if ($Status -eq 403) { return "Run it as an Owner or User Access Administrator of $Scope (a Global Administrator can get that: Entra ID > Properties > Access management for Azure resources = Yes, sign out and in again), then run this script again." } "Check that $Scope exists in tenant $TenantId and that you may assign roles there, then run this script again." } function Complete-Run { if (-not $problems.Count) { Stop-InvTranscript; return } Write-Host '' Write-Host "Finished, but $($problems.Count) step(s) were NOT done:" -ForegroundColor Red foreach ($p in $problems) { Write-Host " - $($p.What) on $($p.Where)" -ForegroundColor Red; Write-Host " why: $($p.Why)"; Write-Host " fix: $($p.Fix)" } Write-Host 'Everything else is in place. After the fix, run the script again with the same parameters (add -NoSecret to keep the secret you just stored): it adds only what is missing.' Stop-InvTranscript exit 1 } # ---------------- Exchange Online (-ExchangeMailbox / -Action RemoveExchange) ---------------- # No module: the Exchange admin REST endpoint the ExchangeOnlineManagement module itself calls # (POST https://outlook.office365.com/adminapi/beta//InvokeCommand, { CmdletInput: { CmdletName, Parameters } }), # as PIM's Initialize-PimMailSender.ps1 and SI's Set-SIMail.ps1 do. Signed in in the browser with the module's own public # client ("Microsoft Exchange REST API Based Powershell"), or - unattended - with the certificate. $EXOCLI = 'fb78d390-0c51-40cd-8e17-fdbfab77341b' # Microsoft Exchange REST API Based Powershell $exoRoleGroup = 'Invardia Support Mail Setup' # SI's design (2026-10-07, from Microsoft's docs): exactly these two roles - create the shared mailbox (and register the # products' identities in Exchange), and scope Application Mail.Send to that mailbox. $exoRoleGroupRoles = @('Mail Recipient Creation', 'Role Management') $exoTagPrefix = "$($marker):Exchange=" $script:exoToken = $null; $script:exoAnchor = $null function Connect-InvExchange { if ($script:exoToken) { return } if ($ClientId) { $script:exoToken = Get-CertificateToken $cert 'https://outlook.office365.com' } else { Write-Host ' Exchange Online: one more sign-in, as an Exchange Administrator or Global Administrator:' $script:exoToken = (Request-BrowserToken $EXOCLI 'https://outlook.office365.com/.default offline_access openid profile' 'Microsoft Exchange REST API Based Powershell').access_token } Assert-Tenant $script:exoToken # X-AnchorMailbox as the module sends it: the signed-in person; app-only, the system mailbox of the initial domain $c = Get-JwtClaims $script:exoToken $upn = "$($c.upn)"; if (-not $upn) { $upn = "$($c.preferred_username)" } if ($upn) { $script:exoAnchor = "UPN:$upn" } else { try { $d = @(@((Invoke-Graph -Path '/organization?$select=verifiedDomains').value)[0].verifiedDomains | Where-Object { $_.isInitial }) | Select-Object -First 1 if ($d) { $script:exoAnchor = "UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@$($d.name)" } } catch { Write-Verbose "No initial domain for the Exchange anchor ($($_.Exception.Message))" } } } function Invoke-Exo([string]$Cmdlet, [hashtable]$Parameters = @{}) { $h = @{ 'X-ResponseFormat' = 'json' } if ($script:exoAnchor) { $h['X-AnchorMailbox'] = $script:exoAnchor } $r = Invoke-InvRest -Method POST -Uri "https://outlook.office365.com/adminapi/beta/$TenantId/InvokeCommand" -Token $script:exoToken -Headers $h ` -Body @{ CmdletInput = @{ CmdletName = $Cmdlet; Parameters = $Parameters } } if ($null -ne $r -and $r.PSObject.Properties['value']) { @($r.value) } elseif ($null -ne $r) { $r } } function Test-ExoNotFound([string]$Text) { $Text -match "couldn't be found|could not be found|ManagementObjectNotFound" } function Get-ExoFix([string]$Text) { if ($Text -match 'Enable-OrganizationCustomization|dehydrated') { return 'Your Exchange organization is not customizable yet: an Exchange Administrator runs Enable-OrganizationCustomization once (Exchange Online PowerShell), then run this script again.' } if ($Text -match '\b40[13]\b') { return 'Sign in as an Exchange Administrator or Global Administrator (an eligible role must be active), then run this script again with the same parameters and -NoSecret.' } 'Run this script again with the same parameters and -NoSecret; if it fails again, send Invardia support the transcript.' } function Get-ExoAppMember { # The app as Exchange knows it (Exchange RBAC can only use a service principal registered in Exchange). $null = not registered. $s = @(Invoke-Exo 'Get-ServicePrincipal' | Where-Object { "$($_.AppId)" -eq "$($app.appId)" }) | Select-Object -First 1 if ($s) { return $(if ("$($s.Identity)") { "$($s.Identity)" } elseif ("$($s.ObjectId)") { "$($s.ObjectId)" } else { $sp.id }) } $null } function Set-InvExchangeAccess { # -ExchangeMailbox: the app registered in Exchange, the role group with exactly the two roles, the app its member. # Each failure is reported (Add-Problem) with its fix; the run goes on and exits 1. try { Connect-InvExchange } catch { Add-Problem 'sign in to Exchange Online' $TenantId $_.Exception.Message (Get-ExoFix "$($_.Exception.Message)"); return } try { $member = Get-ExoAppMember if (-not $member -and $PSCmdlet.ShouldProcess("$DisplayName ($($app.appId))", 'register the app in Exchange Online (New-ServicePrincipal)')) { # Exchange may not know a just-created service principal for a short while: retried $made = $null for ($i = 1; -not $made; $i++) { try { $made = @(Invoke-Exo 'New-ServicePrincipal' @{ AppId = $app.appId; ObjectId = $sp.id; DisplayName = $DisplayName }) | Select-Object -First 1 } catch { if ($i -ge 6 -or -not (Test-ExoNotFound "$($_.Exception.Message)")) { throw }; Start-Sleep -Seconds 10 } } $member = if ("$($made.Identity)") { "$($made.Identity)" } else { $sp.id } Write-Host " registered the app in Exchange Online" } $group = $null try { $group = @(Invoke-Exo 'Get-RoleGroup' @{ Identity = $exoRoleGroup }) | Select-Object -First 1 } catch { if (-not (Test-ExoNotFound "$($_.Exception.Message)")) { throw } } if ($group) { # found: never widened or narrowed silently - a role group someone changed is reported, not rewritten $roles = @($group.Roles | ForEach-Object { ("$_" -split '\\')[-1] }) $missing = @($exoRoleGroupRoles | Where-Object { $roles -notcontains $_ }); $extra = @($roles | Where-Object { $exoRoleGroupRoles -notcontains $_ }) if ($missing.Count -or $extra.Count) { Add-Problem "check the Exchange role group '$exoRoleGroup'" $TenantId "it holds $($roles -join ', ') - expected exactly $($exoRoleGroupRoles -join ' + ')" "Remove it (-Action RemoveExchange, or in the Exchange admin center > Roles > Admin roles), then run this script again with -ExchangeMailbox." return # the app is never put into a role group with other roles } } elseif ($PSCmdlet.ShouldProcess($exoRoleGroup, "create Exchange role group ($($exoRoleGroupRoles -join ' + '))")) { $null = Invoke-Exo 'New-RoleGroup' @{ Name = $exoRoleGroup; Roles = $exoRoleGroupRoles Description = 'Invardia support: create the products'' shared mailbox and scope Mail.Send to it. Time-boxed: remove it with New-InvardiaSupportApp.ps1 -Action RemoveExchange once the mailbox is set up.' } Write-Host " created Exchange role group '$exoRoleGroup' ($($exoRoleGroupRoles -join ' + '))" } if ($member -and $PSCmdlet.ShouldProcess($exoRoleGroup, "add $DisplayName as member")) { $added = $false for ($i = 1; -not $added; $i++) { try { $null = Invoke-Exo 'Add-RoleGroupMember' @{ Identity = $exoRoleGroup; Member = $member; BypassSecurityGroupManagerCheck = $true }; $added = $true; Write-Host " added the app to '$exoRoleGroup'" } catch { $m = "$($_.Exception.Message)" if ($m -match 'already a member') { $added = $true; Write-Host " the app is already a member of '$exoRoleGroup'" } elseif ($i -lt 6 -and (Test-ExoNotFound $m)) { Start-Sleep -Seconds 10 } else { throw } } } } Write-Warning "Exchange: '$exoRoleGroup' includes Role Management, which can grant any Exchange role. Take it away once the shared mailbox is set up: -Action RemoveExchange." } catch { Add-Problem "set up the Exchange role group '$exoRoleGroup'" $TenantId $_.Exception.Message (Get-ExoFix "$($_.Exception.Message)") } } function Remove-InvExchangeAccess { # -Action RemoveExchange (and Remove): the app out of the role group; the role group itself when no one else is in it. # The app's bare Exchange registration (no role) is kept: it grants nothing. try { Connect-InvExchange } catch { Add-Problem 'sign in to Exchange Online' $TenantId $_.Exception.Message (Get-ExoFix "$($_.Exception.Message)"); return } try { $member = Get-ExoAppMember if ($member -and $PSCmdlet.ShouldProcess($exoRoleGroup, "remove $DisplayName as member")) { try { $null = Invoke-Exo 'Remove-RoleGroupMember' @{ Identity = $exoRoleGroup; Member = $member; BypassSecurityGroupManagerCheck = $true; Confirm = $false }; Write-Host " removed the app from '$exoRoleGroup'" } catch { $m = "$($_.Exception.Message)"; if (-not (Test-ExoNotFound $m) -and $m -notmatch "isn't a member|is not a member") { throw } } } if ($WhatIfPreference) { $null = $PSCmdlet.ShouldProcess($exoRoleGroup, 'remove Exchange role group (when no one else is in it)'); return } $left = $null try { $left = @(Invoke-Exo 'Get-RoleGroupMember' @{ Identity = $exoRoleGroup }) } catch { if (Test-ExoNotFound "$($_.Exception.Message)") { Write-Host " Exchange role group '$exoRoleGroup' is not there"; return }; throw } if (@($left | Where-Object { $_ }).Count) { Write-Warning "Exchange role group '$exoRoleGroup' still has other members ($(@($left | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Identity } }) -join ', ')) - it is kept; remove it in the Exchange admin center when they no longer need it." } elseif ($PSCmdlet.ShouldProcess($exoRoleGroup, 'remove Exchange role group')) { $null = Invoke-Exo 'Remove-RoleGroup' @{ Identity = $exoRoleGroup; BypassSecurityGroupManagerCheck = $true; Confirm = $false } Write-Host " removed Exchange role group '$exoRoleGroup'" } } catch { Add-Problem "remove the Exchange role group '$exoRoleGroup'" $TenantId $_.Exception.Message (Get-ExoFix "$($_.Exception.Message)") } } function Test-ExchangeOn { # The app has the -ExchangeMailbox rights: its tag (written by this script) or the Exchange.ManageAsApp grant. param($Assignments, $ExoSp) if (@($app.tags | Where-Object { $_ -like "$exoTagPrefix*" }).Count) { return $true } if (-not $ExoSp) { return $false } $rid = @($ExoSp.appRoles | Where-Object { $_.value -eq 'Exchange.ManageAsApp' }) | Select-Object -First 1 [bool]($rid -and @($Assignments | Where-Object { $_.resourceId -eq $ExoSp.id -and $_.appRoleId -eq $rid.id }).Count) } # ---------------- Azure role helpers (ARM REST) ---------------- $roleGuid = @{}; $roleName = @{} function Get-RoleGuid([string]$Name, [string]$Scope) { if ($roleGuid.ContainsKey($Name)) { return $roleGuid[$Name] } $q = '?api-version=2022-04-01&$filter=' + (Enc "roleName eq '$Name'") $last = $null foreach ($s in @($Scope, '')) { # the scope itself, then the tenant ("/") where every built-in role is readable try { $d = @((Invoke-InvRest -Uri "$armUrl$s/providers/Microsoft.Authorization/roleDefinitions$q" -Token $script:armToken).value) | Select-Object -First 1 if (-not $d) { throw "Azure role '$Name' not found." } $roleGuid[$Name] = $d.name; $roleName[$d.name] = $Name return $d.name } catch { $last = $_ } } throw $last } function Get-RoleNameById([string]$Guid) { if ($roleName.ContainsKey($Guid)) { return $roleName[$Guid] } try { $n = (Invoke-InvRest -Uri "$armUrl/providers/Microsoft.Authorization/roleDefinitions/${Guid}?api-version=2022-04-01" -Token $script:armToken).properties.roleName; $roleName[$Guid] = $n; $n } catch { $null } } function Get-RoleDefinitionId([string]$Guid, [string]$Scope) { if ($Scope -match '^(/subscriptions/[^/]+)') { "$($Matches[1])/providers/Microsoft.Authorization/roleDefinitions/$Guid" } else { "/providers/Microsoft.Authorization/roleDefinitions/$Guid" } } function Get-AssignmentGuid($Assignment) { ("$($Assignment.properties.roleDefinitionId)" -split '/')[-1] } function Get-SpAssignments([string]$Scope, [string]$PrincipalId) { # At, above and below $Scope - for this principal only. Get-ArmAll ("$Scope/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&`$filter=" + (Enc "principalId eq '$PrincipalId'")) } function Remove-Assignment($Assignment) { $g = Get-AssignmentGuid $Assignment; $n = Get-RoleNameById $g; if (-not $n) { $n = $g } $s = $Assignment.properties.scope if ($PSCmdlet.ShouldProcess($s, "remove role $n")) { try { Invoke-InvRest -Method DELETE -Uri "$armUrl$($Assignment.id)?api-version=2022-04-01" -Token $script:armToken | Out-Null; Write-Host " removed $n on $s" } catch { Add-Problem "remove $n" $s $_.Exception.Message (Get-RoleFix $s $_.Exception.Data['Status']) } } } # The app is found by its tag (CR5, 2026.10.18b), never by its display name - so a renamed app (or one still carrying the # name before "Invardia Support") is the same app. Two tagged apps: the one with this run's name, else the one with the # earlier standard name; otherwise stop and name them. $formerDisplayNames = @('Invardia Support for SecurityInsight - PIM Manager (customer request-based only)') function Get-SupportApp { $apps = @(Get-GraphAll ('/applications?$filter=' + (Enc "tags/any(t:t eq '$marker')")) | Where-Object { $_.tags -contains $marker }) if ($apps.Count -gt 1) { $pick = @($apps | Where-Object { $_.displayName -eq $DisplayName }) if ($pick.Count -ne 1) { $pick = @($apps | Where-Object { $formerDisplayNames -contains $_.displayName }) } if ($pick.Count -ne 1) { throw "More than one app carries the $marker tag ($((@($apps | ForEach-Object { "'$($_.displayName)' $($_.appId)" })) -join ', ')) - remove the extra one first." } $apps = $pick } $apps | Select-Object -First 1 } function Get-ResourceSp([string]$appId) { Get-GraphAll ('/servicePrincipals?$filter=' + (Enc "appId eq '$appId'")) | Select-Object -First 1 } $app = Get-SupportApp $sp = if ($app) { Get-ResourceSp $app.appId } else { $null } # A re-run (-Action Create on an app that already exists) KEEPS a live secret (2026-10-09: Evida re-ran the script to add # -TenantRoot and the new secret silently replaced the one stored at Invardia, so support lost access). Only # -Action NewSecret (or an app with no live secret) makes a new one. $keepSecret = $Action -eq 'Create' -and $app -and @($app.passwordCredentials | Where-Object { $_ -and $_.endDateTime -and ([DateTime]$_.endDateTime).ToUniversalTime() -gt [DateTime]::UtcNow }).Count -gt 0 switch ($Action) { 'Disable' { if (-not $sp) { throw 'No Invardia Support app found.' }; if ($PSCmdlet.ShouldProcess($DisplayName, 'disable sign-in')) { Invoke-Graph PATCH "/servicePrincipals/$($sp.id)" @{ accountEnabled = $false } | Out-Null }; Write-Host "Disabled: Invardia support can no longer sign in. Every grant is kept; -Action Enable switches it on again."; Stop-InvTranscript; return } 'Enable' { if (-not $sp) { throw 'No Invardia Support app found.' }; if ($PSCmdlet.ShouldProcess($DisplayName, 'enable sign-in')) { Invoke-Graph PATCH "/servicePrincipals/$($sp.id)" @{ accountEnabled = $true } | Out-Null }; Write-Host 'Enabled.'; Stop-InvTranscript; return } 'RemoveExchange' { if (-not $app -or -not $sp) { throw 'No Invardia Support app found.' } Remove-InvExchangeAccess # Exchange.ManageAsApp revoked, the Exchange tag dropped (the rest of the app is unchanged) $exoSp = Get-ResourceSp $EXO $manage = if ($exoSp) { @($exoSp.appRoles | Where-Object { $_.value -eq 'Exchange.ManageAsApp' }) | Select-Object -First 1 } else { $null } if ($manage) { foreach ($a in @(Get-GraphAll "/servicePrincipals/$($sp.id)/appRoleAssignments" | Where-Object { $_.resourceId -eq $exoSp.id -and $_.appRoleId -eq $manage.id })) { if ($PSCmdlet.ShouldProcess("$($exoSp.displayName) / Exchange.ManageAsApp", 'revoke')) { Invoke-Graph DELETE "/servicePrincipals/$($sp.id)/appRoleAssignments/$($a.id)" | Out-Null; Write-Host ' revoked Exchange.ManageAsApp' } } } $exoReq = @($app.requiredResourceAccess | Where-Object { $_.resourceAppId -eq $EXO }) if ((@($app.tags | Where-Object { $_ -like "$exoTagPrefix*" }).Count -or $exoReq.Count) -and $PSCmdlet.ShouldProcess($DisplayName, 'drop the Exchange tag and required permission')) { Invoke-Graph PATCH "/applications/$($app.id)" @{ tags = @($app.tags | Where-Object { $_ -notlike "$exoTagPrefix*" }) requiredResourceAccess = @($app.requiredResourceAccess | Where-Object { $_.resourceAppId -ne $EXO }) } | Out-Null } Write-Host 'Exchange: the -ExchangeMailbox rights are taken away. The shared mailbox and its scoped Mail.Send stay as the products set them up.' Complete-Run return } 'RemoveTenantRoot' { # ROOT-RIGHTS-1 (owner 2026-11-09, "we agreed upon read"): at the tenant root the app keeps READER. First Reader is # made sure (assigned when missing, read back); only then is the app's own User Access Administrator there removed # (read back). No other role, nothing below the root, no other principal, no Graph permission, no secret. Reader not # in place = User Access Administrator is kept and the run says NOT DONE. if (-not $app -or -not $sp) { throw 'No Invardia Support app found.' } $atRoot = { param([string]$Role) @(Get-SpAssignments $rootScope $sp.id | Where-Object { $_.properties.scope -eq $rootScope -and $_.properties.principalId -eq $sp.id -and (Get-RoleNameById (Get-AssignmentGuid $_)) -eq $Role }) } $found = @(); $readers = @() try { $readers = & $atRoot 'Reader'; $found = & $atRoot 'User Access Administrator' } catch { Add-Problem 'read the app''s role assignments' $rootScope $_.Exception.Message (Get-RoleFix $rootScope $_.Exception.Data['Status']); Complete-Run; return } if ($readers.Count) { Write-Host 'Tenant root: the app holds Reader there.' } elseif ($PSCmdlet.ShouldProcess($rootScope, 'assign Reader')) { try { $props = @{ roleDefinitionId = (Get-RoleDefinitionId (Get-RoleGuid 'Reader' $rootScope) $rootScope); principalId = $sp.id; principalType = 'ServicePrincipal' } Invoke-InvRest -Method PUT -Uri "$armUrl$rootScope/providers/Microsoft.Authorization/roleAssignments/$([guid]::NewGuid())?api-version=2022-04-01" -Body @{ properties = $props } -Token $armToken -RetryNotFound 6 | Out-Null Write-Host " assigned Reader on $rootScope" } catch { if (-not ($_.Exception.Data['Code'] -eq 'RoleAssignmentExists' -or $_.Exception.Data['Status'] -eq 409)) { Add-Problem 'assign Reader' $rootScope $_.Exception.Message (Get-RoleFix $rootScope $_.Exception.Data['Status']) } } if (-not $problems.Count) { try { $readers = & $atRoot 'Reader' } catch { Add-Problem 'read back Reader' $rootScope $_.Exception.Message (Get-RoleFix $rootScope $_.Exception.Data['Status']) } if (-not $problems.Count -and -not $readers.Count) { Add-Problem 'assign Reader' $rootScope 'it is not there after the assignment' (Get-RoleFix $rootScope 403) } } } if ($problems.Count) { Write-Host 'Tenant root: User Access Administrator is KEPT - Reader is not in place there yet.'; Complete-Run; return } if (-not $found.Count) { Write-Host 'Tenant root: the app holds no User Access Administrator there - nothing to remove.' } foreach ($ra in $found) { Remove-Assignment $ra } if ($found.Count -and -not $WhatIfPreference) { $left = @(); try { $left = & $atRoot 'User Access Administrator' } catch { Add-Problem 'read back the tenant root' $rootScope $_.Exception.Message 'Check in the Azure portal (Management groups > Tenant Root Group > Access control) that the Invardia Support app no longer holds User Access Administrator there.' } foreach ($ra in $left) { $g = Get-AssignmentGuid $ra; $n = Get-RoleNameById $g; Add-Problem "remove $(if ($n) { $n } else { $g })" $rootScope 'it is still there after the removal' (Get-RoleFix $rootScope 403) } if (-not $left.Count -and -not $problems.Count) { Write-Host "Tenant root: removed the app's User Access Administrator ($($found.Count) assignment(s)); read back - none left." } } Complete-Run return } 'Remove' { # The Exchange role group first (the app's deletion would leave it behind, with Role Management in it) if ($sp -and (Test-ExchangeOn @(Get-GraphAll "/servicePrincipals/$($sp.id)/appRoleAssignments") (Get-ResourceSp $EXO))) { Remove-InvExchangeAccess } if ($sp) { # Every subscription the admin can see (+ the ones named) and the tenant root: the app's role assignments at, above # and below them. A scope that cannot be read is said plainly - and nothing there is assumed. $subs = @($SubscriptionIds) + @($(if ($SubscriptionId) { $SubscriptionId })) try { $subs += @(Get-ArmAll '/subscriptions?api-version=2022-12-01' | ForEach-Object { $_.subscriptionId }) } catch { Write-Warning "Could not list your subscriptions ($($_.Exception.Message))." } $seen = [ordered]@{} foreach ($s in @(@($subs | Where-Object { $_ } | ForEach-Object { "/subscriptions/$($_.ToLower())" } | Select-Object -Unique) + $rootScope)) { try { foreach ($ra in Get-SpAssignments $s $sp.id) { if (-not $seen.Contains($ra.id)) { $seen[$ra.id] = $ra } } } catch { if ($s -ne $rootScope) { Write-Warning "Could not read the role assignments on $s ($($_.Exception.Message)) - remove any Invardia Support role there by hand." } } } foreach ($ra in @($seen.Values)) { Remove-Assignment $ra } } foreach ($p in @(Get-GraphAll '/identity/conditionalAccess/policies' | Where-Object { $_.displayName -in 'CA701-Prod-WorkloadIdentities-InvardiaSupport-AnyPlatform-NonTrustedLocations-Block', 'Invardia Support - only from Invardia support IP' })) { if ($PSCmdlet.ShouldProcess($p.displayName, 'remove Conditional Access policy')) { Invoke-Graph DELETE "/identity/conditionalAccess/policies/$($p.id)" | Out-Null; Write-Host 'removed the Conditional Access policy' } } foreach ($l in @(Get-GraphAll '/identity/conditionalAccess/namedLocations' | Where-Object { $_.displayName -in 'Invardia Support (Morten Knudsen)', 'Invardia support IP' })) { if ($PSCmdlet.ShouldProcess($l.displayName, 'remove named location')) { Invoke-Graph DELETE "/identity/conditionalAccess/namedLocations/$($l.id)" | Out-Null; Write-Host 'removed the named location' } } if ($app -and $PSCmdlet.ShouldProcess($DisplayName, 'delete the app registration (and its service principal)')) { Invoke-Graph DELETE "/applications/$($app.id)" | Out-Null; Write-Host "Deleted the '$DisplayName' app." } Write-Host 'Also delete the environment in the portal (portal.invardia.com > Remote support), so Invardia no longer keeps its details.' Complete-Run return } } # ---------------- Create (or update) ---------------- $resources = @{} foreach ($rid in @($GRAPH, $MTP, $WDATP, $EXO)) { $r = Get-ResourceSp $rid; if ($r) { $resources[$rid] = $r } } $allResources = $resources.Clone() # $resources is narrowed to the APIs this level uses; names are looked up in all three function RoleId($res, [string]$value) { $role = $res.appRoles | Where-Object { $_.value -eq $value -and $_.allowedMemberTypes -contains 'Application' } if (-not $role) { throw "Permission $value not found on $($res.displayName)" } $role.id } $have = if ($sp) { @(Get-GraphAll "/servicePrincipals/$($sp.id)/appRoleAssignments") } else { @() } function Get-HeldNames { # the app's current application permissions, by name foreach ($a in $have) { $res = $allResources.Values | Where-Object { $_.id -eq $a.resourceId } | Select-Object -First 1 if ($res) { ($res.appRoles | Where-Object { $_.id -eq $a.appRoleId }).value } } } # The level and products the app has now: its tags (written by this script), else read from its grants (apps made by the # module version carry no such tags). Without an explicit -Access / -Products they are KEPT (customer 2026-10-07: NewSecret # without -Access revoked a Setup app's five write permissions). $curAccess = $null; $curProducts = $null if ($app) { $tagAccess = @($app.tags | Where-Object { $_ -like "$($marker):Access=*" }) | Select-Object -First 1 $tagProducts = @($app.tags | Where-Object { $_ -like "$($marker):Products=*" }) | Select-Object -First 1 $held = @(Get-HeldNames) if ($tagAccess) { $curAccess = $tagAccess.Split('=')[1] } elseif ($held.Count) { $curAccess = if (@($held | Where-Object { ($setupWrite + $pimSetupWrite) -contains $_ }).Count) { 'Setup' } else { 'Troubleshoot' } } if ($tagProducts) { $curProducts = @($tagProducts.Split('=')[1] -split ',' | Where-Object { $_ }) } elseif ($held.Count) { $curProducts = @() if (@($held | Where-Object { ($pimRead + $pimSetupWrite) -contains $_ }).Count) { $curProducts += 'PimManager' } if (@($held | Where-Object { $siRead -contains $_ }).Count -or @($have | Where-Object { $r = $_; @($allResources[$MTP], $allResources[$WDATP]) | Where-Object { $_ -and $_.id -eq $r.resourceId } }).Count) { $curProducts += 'SecurityInsight' } if (-not $curProducts.Count) { $curProducts = $null } } } if (-not $accessGiven -and $curAccess) { $Access = $curAccess } if (-not $productsGiven -and $curProducts) { $Products = $curProducts } if ($TenantRoot -and $Access -ne 'Setup') { throw '-TenantRoot belongs to the Setup level (-Access Setup).' } # -SubscriptionIds: both levels since 2026.10.11; a leftover refusal stood here until 2026.10.13 # NewSecret changes permissions only when -Access or -Products is given; Create always brings the app to its level. $sync = $Action -eq 'Create' -or $accessGiven -or $productsGiven if ($ExchangeMailbox -and $Access -ne 'Setup') { throw '-ExchangeMailbox belongs to the Setup level (-Access Setup): it is how support sets up the products'' shared mailbox.' } if ($ExchangeMailbox -and -not $resources[$EXO]) { throw 'Office 365 Exchange Online has no service principal in this tenant (no Exchange Online?) - -ExchangeMailbox cannot be used; the products can send by SMTP instead.' } # Exchange: given now, or the app has it already (kept until -Action RemoveExchange - a re-run never drops it silently) $exchangeOn = $app -and $sp -and (Test-ExchangeOn $have $resources[$EXO]) $exchangeWanted = $ExchangeMailbox -or $exchangeOn $want = Get-WantedPermissions $Access $Products if ($exchangeWanted -and $resources[$EXO]) { [void]$want[$EXO].Add('Exchange.ManageAsApp') } foreach ($rid in @($MTP, $WDATP)) { if ($want[$rid].Count -and -not $resources[$rid]) { Write-Warning "The API $rid has no service principal in this tenant (Defender not enabled?) - its permissions are skipped."; $want[$rid].Clear() } } if (-not $resources[$GRAPH]) { throw 'Microsoft Graph has no service principal in this tenant?' } foreach ($rid in @($resources.Keys)) { if (-not $want[$rid].Count) { $resources.Remove($rid) } } $rgRoles = if ($Access -eq 'Setup') { @('Contributor', 'Role Based Access Control Administrator') } else { @('Reader', 'Log Analytics Reader') } $kvRoles = if ($Access -eq 'Setup') { @('Key Vault Secrets Officer') + $(if ($PimMspManagingTenant) { 'Key Vault Crypto Officer' }) } else { @('Key Vault Reader') } # A downgrade (Setup -> Troubleshoot, or a product dropped) revokes rights: list them and ask for -Force (or -Confirm). if ($sync -and $app -and $sp) { $downgrade = ($curAccess -eq 'Setup' -and $Access -eq 'Troubleshoot') -or ($curProducts -and @($curProducts | Where-Object { $Products -notcontains $_ }).Count) if ($downgrade) { $wantNames = @($want.Values | ForEach-Object { $_ }) $lose = @(Get-HeldNames | Where-Object { $wantNames -notcontains $_ } | Sort-Object -Unique) $msg = "DOWNGRADE from $curAccess ($(if ($curProducts) { $curProducts -join ', ' } else { 'products not recorded' })) to $Access ($($Products -join ', ')). This revokes: $(if ($lose.Count) { $lose -join ', ' } else { 'no Graph permission' })" + $(if ($ResourceGroupIds.Count -or $KeyVaultIds.Count -or $HubVnetId) { '; and the Azure roles on the scopes you named that this level does not include' } else { '' }) + '.' Write-Warning $msg $ok = $Force -or ($PSBoundParameters.ContainsKey('Confirm') -and $PSBoundParameters['Confirm'] -and $PSCmdlet.ShouldContinue($msg, 'Downgrade the Invardia Support app?')) if (-not $ok) { throw "Downgrade not done - nothing was changed. Run again with -Force (or -Confirm to be asked) to revoke the above; or leave out -Access / -Products to keep the app as it is." } } } $required = @(foreach ($rid in $resources.Keys) { @{ resourceAppId = $rid; resourceAccess = @($want[$rid] | ForEach-Object { @{ id = (RoleId $resources[$rid] $_); type = 'Role' } }) } }) $levelTags = @($marker, "$($marker):Access=$Access", "$($marker):Products=$($Products -join ',')") if ($exchangeWanted) { # the date the Exchange rights were given (kept on a re-run), so their age is visible on the app $exoTag = @($app.tags | Where-Object { $_ -like "$exoTagPrefix*" }) | Select-Object -First 1 $levelTags += $(if ($exoTag) { $exoTag } else { "$exoTagPrefix$((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd'))" }) } $spBody = @{ appId = $null; tags = @($marker, 'HideApp'); appRoleAssignmentRequired = $true } if (-not $app) { if ($PSCmdlet.ShouldProcess($DisplayName, 'create app registration')) { $app = Invoke-Graph POST '/applications' @{ displayName = $DisplayName; signInAudience = 'AzureADMyOrg'; tags = $levelTags notes = 'This app is used by Invardia support by customer requests only. The customer provides the secret to Invardia support through the secure portal (https://portal.invardia.com). Typical use cases are implementation support and troubleshooting. Lock it down with a Conditional Access policy - see https://invardia.com/support/remote-access/' requiredResourceAccess = $required } $spBody.appId = $app.appId $sp = Invoke-Graph POST '/servicePrincipals' $spBody -RetryNotFound 6 Write-Host "Created app '$DisplayName' (client id $($app.appId))." } } else { if ($sync -and $PSCmdlet.ShouldProcess($DisplayName, 'update required permissions')) { $tags = @(@($app.tags | Where-Object { $_ -notlike "$($marker):*" }) + @($levelTags | Select-Object -Skip 1)) Invoke-Graph PATCH "/applications/$($app.id)" @{ requiredResourceAccess = $required; tags = $tags } | Out-Null } if (-not $sp -and $PSCmdlet.ShouldProcess($DisplayName, 'create its service principal')) { $spBody.appId = $app.appId; $sp = Invoke-Graph POST '/servicePrincipals' $spBody -RetryNotFound 6 } # CR5 (2026.10.18b): an existing app gets the standard display name on the next Create / NewSecret run - the app # registration and its service principal (sign-in logs show the latter). Its tags, ids, grants and secret are unchanged. if ($Action -in 'Create', 'NewSecret' -and "$($app.displayName)" -cne $DisplayName -and $PSCmdlet.ShouldProcess("$($app.displayName) ($($app.appId))", "rename to '$DisplayName'")) { $was = "$($app.displayName)" Invoke-Graph PATCH "/applications/$($app.id)" @{ displayName = $DisplayName } | Out-Null if ($sp) { try { Invoke-Graph PATCH "/servicePrincipals/$($sp.id)" @{ displayName = $DisplayName } | Out-Null } catch { Add-Problem 'rename the service principal' $DisplayName $_.Exception.Message 'Entra ID > Enterprise applications > the app > Properties > Name: set it to the app registration''s name, or run this script again.' } } Write-Host "Renamed the app '$was' to '$DisplayName' (client id $($app.appId); found by its $marker tag - nothing else changed)." } if ($sync) { Write-Host "Found app '$DisplayName' (client id $($app.appId)); bringing it to access level $Access." } else { Write-Host "Found app '$DisplayName' (client id $($app.appId)); access level $Access, permissions unchanged." } } # The app is its own owner, so it can renew its own secret (Invardia's renewal; ring 2 only after your approval). if ($sync -and $sp -and $app -and $PSCmdlet.ShouldProcess($DisplayName, 'make the app owner of itself')) { if (-not (Get-GraphAll "/applications/$($app.id)/owners" | Where-Object { $_.id -eq $sp.id })) { try { Invoke-Graph POST "/applications/$($app.id)/owners/`$ref" @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($sp.id)" } -RetryNotFound 6 | Out-Null } catch { if ("$($_.Exception.Message)" -notmatch 'already exist') { Add-Problem 'make the app owner of itself (it renews its own secret)' $DisplayName $_.Exception.Message 'Add the app''s service principal as an owner of the app (Entra ID > App registrations > the app > Owners), or run this script again.' } } } } # Admin consent = app role assignments. Add what is missing; remove what this access level does not need. if ($sync -and $sp) { foreach ($rid in $resources.Keys) { $res = $resources[$rid] $wantIds = @($want[$rid] | ForEach-Object { RoleId $res $_ }) foreach ($id in $wantIds) { if (-not ($have | Where-Object { $_.resourceId -eq $res.id -and $_.appRoleId -eq $id })) { $name = ($res.appRoles | Where-Object { $_.id -eq $id }).value if ($PSCmdlet.ShouldProcess("$($res.displayName) / $name", 'grant (admin consent)')) { Invoke-Graph POST "/servicePrincipals/$($sp.id)/appRoleAssignments" @{ principalId = $sp.id; resourceId = $res.id; appRoleId = $id } -RetryNotFound 6 | Out-Null Write-Host " granted $name" } } } } foreach ($a in $have) { $rid = ($resources.GetEnumerator() | Where-Object { $_.Value.id -eq $a.resourceId }).Key $res = if ($rid) { $resources[$rid] } else { $null } $keep = $res -and (@($want[$rid] | ForEach-Object { RoleId $res $_ }) -contains $a.appRoleId) # Owner 2026-11-05 (framework 4.5 G6, "in general it has given many issues stripping perm of support app"): a re-run # never revokes RoleManagement.ReadWrite.Directory or AppRoleAssignment.ReadWrite.All once the tenant has granted them. $roleName = if ($res) { ($res.appRoles | Where-Object { $_.id -eq $a.appRoleId }).value } else { $null } if (-not $keep -and $rid -eq $GRAPH -and $roleName -in $neverRevoke) { Write-Host " kept $roleName (never removed by a re-run)"; $keep = $true } if (-not $keep -and $PSCmdlet.ShouldProcess("$($a.resourceDisplayName) / $($a.appRoleId)", 'revoke (not needed for this access level)')) { Invoke-Graph DELETE "/servicePrincipals/$($sp.id)/appRoleAssignments/$($a.id)" | Out-Null Write-Host " revoked $($a.resourceDisplayName) $(if ($res) { ($res.appRoles | Where-Object { $_.id -eq $a.appRoleId }).value })" } } } # Exchange (-ExchangeMailbox): Exchange.ManageAsApp is granted above; here the role group that makes it work. if ($ExchangeMailbox -and $app -and $sp) { Set-InvExchangeAccess } elseif ($exchangeOn) { Write-Warning "This app still has the Exchange rights given $(@($app.tags | Where-Object { $_ -like "$exoTagPrefix*" }) -replace [regex]::Escape($exoTagPrefix), 'on ') (role group '$exoRoleGroup'). Take them away once the shared mailbox is set up: -Action RemoveExchange." } # Azure roles on the scopes you named: subscriptions + tenant root (Setup), resource groups, key vaults, hub VNet. # A role that cannot be assigned or removed is reported (Add-Problem) and the run goes on. A scope whose assignments cannot # be READ is never treated as "no assignments": nothing is removed there. if ($sp -and $needAz) { $desired = @() $subRoles = if ($Access -eq 'Setup') { @('Contributor', 'User Access Administrator') } else { @('Reader', 'Log Analytics Reader') } foreach ($scope in $subScopes) { foreach ($r in $subRoles) { $desired += [pscustomobject]@{ Scope = $scope; Role = $r; Guid = $null } } } # -TenantRoot (ROOT-RIGHTS-1, owner 2026-11-09 "we agreed upon read"): READER at the tenant root; User Access # Administrator there only with the customer's approval at ring 3 (recorded in the Invardia portal); rings 0-2 keep it. # -Action RemoveTenantRoot removes it when the approval is withdrawn. if ($TenantRoot) { $desired += [pscustomobject]@{ Scope = $rootScope; Role = 'Reader'; Guid = $null } $desired += [pscustomobject]@{ Scope = $rootScope; Role = 'User Access Administrator'; Guid = $null } } foreach ($scope in $ResourceGroupIds) { foreach ($r in $rgRoles) { $desired += [pscustomobject]@{ Scope = $scope; Role = $r; Guid = $null } } } foreach ($scope in $KeyVaultIds) { foreach ($r in $kvRoles) { $desired += [pscustomobject]@{ Scope = $scope; Role = $r; Guid = $null } } } if ($HubVnetId -and $Access -eq 'Setup') { $desired += [pscustomobject]@{ Scope = $HubVnetId; Role = 'Network Contributor'; Guid = $null } } foreach ($d in $desired) { try { $d.Guid = Get-RoleGuid $d.Role $d.Scope } catch { Add-Problem "assign $($d.Role)" $d.Scope "the role could not be looked up: $($_.Exception.Message)" (Get-RoleFix $d.Scope $_.Exception.Data['Status']) } } $condition = $null; $conditionError = $null if (@($desired | Where-Object { $_.Role -eq 'Role Based Access Control Administrator' }).Count) { try { $ids = ($conditionRoles | ForEach-Object { Get-RoleGuid $_ $ResourceGroupIds[0] }) -join ', ' $condition = "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {$ids} AND @Request[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEqualsIgnoreCase {'ServicePrincipal'})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {$ids}))" } catch { $conditionError = $_.Exception.Message } } $named = @(@($subScopes) + @($(if ($TenantRoot) { $rootScope })) + @($ResourceGroupIds) + @($KeyVaultIds) + @($HubVnetId | Where-Object { $_ }) | Select-Object -Unique) $current = @(); $unreadable = @{} foreach ($scope in $named) { try { $current += @(Get-SpAssignments $scope $sp.id | Where-Object { $_.properties.scope -eq $scope }) } catch { $unreadable[$scope] = $true; Write-Warning "Could not read the role assignments on $scope ($($_.Exception.Message)) - nothing is removed there." } } foreach ($d in $desired) { if (-not $d.Guid) { continue } if ($current | Where-Object { $_.properties.scope -eq $d.Scope -and (Get-AssignmentGuid $_) -eq $d.Guid }) { continue } if (-not $PSCmdlet.ShouldProcess($d.Scope, "assign $($d.Role)")) { continue } $props = @{ roleDefinitionId = (Get-RoleDefinitionId $d.Guid $d.Scope); principalId = $sp.id; principalType = 'ServicePrincipal' } if ($d.Role -eq 'Role Based Access Control Administrator') { # Never unconditioned: without the condition this role could grant Owner. if (-not $condition) { Add-Problem "assign $($d.Role)" $d.Scope "its condition could not be built: $conditionError" (Get-RoleFix $d.Scope 403); continue } $props.condition = $condition; $props.conditionVersion = '2.0' } try { Invoke-InvRest -Method PUT -Uri "$armUrl$($d.Scope)/providers/Microsoft.Authorization/roleAssignments/$([guid]::NewGuid())?api-version=2022-04-01" -Body @{ properties = $props } -Token $armToken -RetryNotFound 6 | Out-Null Write-Host " assigned $($d.Role) on $($d.Scope)" } catch { if ($_.Exception.Data['Code'] -eq 'RoleAssignmentExists' -or $_.Exception.Data['Status'] -eq 409) { Write-Host " $($d.Role) on $($d.Scope) is already there" } else { Add-Problem "assign $($d.Role)" $d.Scope $_.Exception.Message (Get-RoleFix $d.Scope $_.Exception.Data['Status']) } } } # Roles on these same scopes that the chosen level does not include are removed (e.g. Setup -> Troubleshoot). foreach ($c in $current) { $g = Get-AssignmentGuid $c; $n = Get-RoleNameById $g $wanted = $desired | Where-Object { $_.Scope -eq $c.properties.scope -and (($_.Guid -and $_.Guid -eq $g) -or ($n -and $_.Role -eq $n) -or (-not $_.Guid -and -not $n)) } if (-not $wanted) { Remove-Assignment $c } } } # Conditional Access for workload identities (owner 2026-10-07, like his CA700 "WorkloadIdentities ... NonTrustedLocations # - Block"): block this app's sign-in everywhere except Invardia's support IP. Created OFF; a re-run keeps the state. if ($doCA -and $sp) { $locName = 'Invardia Support (Morten Knudsen)' $loc = Get-GraphAll '/identity/conditionalAccess/namedLocations' | Where-Object { $_.displayName -in $locName, 'Invardia support IP' } | Select-Object -First 1 if (-not $loc -and $PSCmdlet.ShouldProcess($locName, "create named location $SupportIp/32")) { $loc = Invoke-Graph POST '/identity/conditionalAccess/namedLocations' @{ '@odata.type' = '#microsoft.graph.ipNamedLocation'; displayName = $locName; isTrusted = $false ipRanges = @(@{ '@odata.type' = '#microsoft.graph.iPv4CidrRange'; cidrAddress = "$SupportIp/32" }) } Write-Host "named location '$locName' ($SupportIp/32) created" } # the owner's naming (2026-10-07: like his CA700); a policy made under the earlier name is found and renamed, its state kept $name = 'CA701-Prod-WorkloadIdentities-InvardiaSupport-AnyPlatform-NonTrustedLocations-Block' $policy = @{ displayName = $name; state = $ConditionalAccessState conditions = @{ clientApplications = @{ includeServicePrincipals = @($sp.id) } applications = @{ includeApplications = @('All') } locations = @{ includeLocations = @('All'); excludeLocations = @($loc.id) } } grantControls = @{ operator = 'OR'; builtInControls = @('block') } } $existing = Get-GraphAll '/identity/conditionalAccess/policies' | Where-Object { $_.displayName -in $name, 'Invardia Support - only from Invardia support IP' } | Select-Object -First 1 # A named location made a moment ago is not yet known to the policy service (Graph 1040 "NamedLocation with id ... does not # exist in the directory", found at a customer 2026-11-04): wait and try again, up to about 2 minutes. $saveCA = { param($Method, $Path) for ($try = 1; ; $try++) { try { Invoke-Graph $Method $Path $policy | Out-Null; return } catch { if ($try -ge 12 -or $_.Exception.Message -notmatch 'NamedLocation .*does not exist') { throw } if ($try -eq 1) { Write-Host 'Waiting for Microsoft Entra to know the new named location ...' } Start-Sleep -Seconds 10 } } } try { if ($existing) { # found: keep its scope current, but NEVER its state unless -ConditionalAccessState was given (owner 2026-10-07) if (-not $PSBoundParameters.ContainsKey('ConditionalAccessState')) { $policy.Remove('state') } if ($PSCmdlet.ShouldProcess($name, 'update Conditional Access policy (state kept)')) { & $saveCA PATCH "/identity/conditionalAccess/policies/$($existing.id)" } $state = if ($policy.state) { $policy.state } else { $existing.state } } elseif ($PSCmdlet.ShouldProcess($name, "create Conditional Access policy ($ConditionalAccessState)")) { & $saveCA POST '/identity/conditionalAccess/policies' $state = $ConditionalAccessState } Write-Host "Conditional Access: '$name' is $state$(if ($state -eq 'disabled') { ' (OFF - switch it on in Entra ID > Conditional Access when you want it)' }) - when on, the app can only sign in from $SupportIp." } catch { $why = if ($_.Exception.Message -match 'NamedLocation .*does not exist') { 'Microsoft Entra did not yet know the new named location - run the same line again in a few minutes; it keeps the secret and adds the policy.' } else { 'Policies for workload identities need Microsoft Entra Workload ID Premium licences. Without it: keep the secret short-lived and disable the app between support cases.' } Write-Warning "The Conditional Access policy could not be saved. $why ($($_.Exception.Message))" } } # A new client secret (Create and NewSecret). Old secrets are removed, so only one is ever live. if ($keepSecret -and -not $NoSecret) { Write-Host "The app already has a live client secret: it is KEPT (nothing to store again at Invardia). For a new one: -Action NewSecret." } if (-not $NoSecret -and -not $keepSecret -and $app -and $Action -in 'Create', 'NewSecret') { $app = Invoke-Graph -Path "/applications/$($app.id)" if (-not $SecretDays) { $SecretDays = @{ 1 = 730; 2 = 90; 3 = 1 }[$Ring] } if ($PSCmdlet.ShouldProcess($DisplayName, "new client secret valid $SecretDays day(s) (ring $Ring)")) { foreach ($old in @($app.passwordCredentials | Where-Object { $_ })) { Invoke-Graph POST "/applications/$($app.id)/removePassword" @{ keyId = $old.keyId } | Out-Null } $end = (Get-Date).ToUniversalTime().AddDays($SecretDays) $pw = Invoke-Graph POST "/applications/$($app.id)/addPassword" @{ passwordCredential = @{ displayName = "Invardia support $(Get-Date -Format yyyy-MM-dd)"; endDateTime = $end.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } } # the secret never goes into the transcript: pause it while the secret is on screen $resume = $script:transcribing if ($resume) { try { Stop-Transcript | Out-Null } catch { }; $script:transcribing = $false } Write-Host '' Write-Host 'Store these in the portal: portal.invardia.com > Environments > your environment' -ForegroundColor Cyan Write-Host " Tenant id $TenantId" Write-Host " Client id $($app.appId)" Write-Host " Client secret $($pw.secretText)" Write-Host " Secret ends $($end.ToString('yyyy-MM-dd'))" Write-Host 'The secret is shown only now. Do not email it: paste it into the portal, which stores it in Invardia''s Key Vault.' -ForegroundColor Cyan if ($resume) { try { Start-Transcript -Path $transcript -Append -WhatIf:$false -Confirm:$false | Out-Null; $script:transcribing = $true } catch { } Write-Host "(A new client secret ending $($end.ToString('yyyy-MM-dd')) was printed here - it is not in this transcript.)" } } } if ($PassThru) { [pscustomobject]@{ TenantId = $TenantId; AppId = $(if ($app) { $app.appId } else { $null }); ApplicationObjectId = $(if ($app) { $app.id } else { $null }) ServicePrincipalId = $(if ($sp) { $sp.id } else { $null }); Access = $Access; Products = $Products; Exchange = [bool]$exchangeWanted } } Complete-Run