#Requires -Version 7.0 # PIM Manager 3.0.70 -- Grant-PimEnginePermissions.ps1, standalone (built by tools/setup/Build-PimSupportScripts.ps1; helpers inlined: _pimenginepermissions.ps1, _pimloopbackanswer.ps1, _pimscriptdoc.ps1) <# .SYNOPSIS Give the PIM Manager engine identity the Microsoft Graph (and other API) application permissions and Azure role assignments it is missing. Run once, as a Global Administrator (or Privileged Role Administrator for the application permissions; Owner / User Access Administrator at the scope for an Azure role). -WhatIf shows every grant first and changes nothing. .DESCRIPTION PIM Manager shows this command with your environment's exact values (Overview > permission check, and the Get Started step "Engine permissions"): the page itself is read-only on purpose -- an identity that can grant permissions could give itself anything. No PowerShell modules: plain REST + .NET, PowerShell 7. You sign in in the browser (Microsoft Edge is opened; the default browser when Edge is not installed). Azure role assignments ask for a second, quick sign-in to Azure. The script is idempotent: what the engine identity already holds is reported and left alone. Every grant is read back. At the end it prints, per permission: granted / already there / refused (with the reason), and exits 1 when anything was refused. A new grant reaches the engine's token within about 30 minutes; then press "Verify permissions" in PIM Manager. Download: Invoke-WebRequest https://invardia.com/support/pim/Grant-PimEnginePermissions.ps1 -OutFile Grant-PimEnginePermissions.ps1 .PARAMETER TenantId Your tenant id. The sign-in is refused when it lands in another tenant. .PARAMETER EngineObjectId Object id of the engine identity's service principal (shown in PIM Manager). .PARAMETER GraphPermissions Microsoft Graph application permissions, e.g. 'Group.ReadWrite.All','RoleManagement.ReadWrite.Directory'. .PARAMETER AppRoles Application permissions of other APIs as '@', e.g. 'Exchange.ManageAsApp@00000002-0000-0ff1-ce00-000000000000'. .PARAMETER AzureRoleAssignments Azure roles as '@', e.g. 'User Access Administrator@/providers/Microsoft.Management/managementGroups/'. .PARAMETER UseEdge $true (default): open Microsoft Edge for the sign-in. $false: the default browser. .PARAMETER GraphAccessToken Automation: a Microsoft Graph token (delegated AppRoleAssignment.ReadWrite.All + Application.Read.All) instead of the browser sign-in. .PARAMETER ArmAccessToken Automation: an Azure Resource Manager token instead of the second browser sign-in. .PARAMETER PlanOnly Read and report what would be granted; change nothing. (-WhatIf does the same and prints each grant as a "What if:" line.) .PARAMETER PassThru Also return the result rows as objects. .EXAMPLE .\Grant-PimEnginePermissions.ps1 -TenantId '' -EngineObjectId '' -GraphPermissions 'Group.ReadWrite.All','User.ReadWrite.All' .EXAMPLE .\Grant-PimEnginePermissions.ps1 -TenantId '' -EngineObjectId '' -AzureRoleAssignments 'User Access Administrator@/providers/Microsoft.Management/managementGroups/' .EXAMPLE .\Grant-PimEnginePermissions.ps1 -TenantId '' -EngineObjectId '' -AzureRoleAssignments 'Reader@/providers/Microsoft.Management/managementGroups/' Reader (read-only) at the tenant root management group -- what Discovery needs to see Azure. The install grants it by default; run this when the installing account could not. Add ,'User Access Administrator@/providers/Microsoft.Management/managementGroups/' only when the engine must assign the Azure resource roles you delegate (optional). .EXAMPLE .\Grant-PimEnginePermissions.ps1 -TenantId '' -EngineObjectId '' -GraphPermissions 'Group.ReadWrite.All' -WhatIf Signs in and reads, then prints what it would grant ("What if: ...") -- nothing is changed. .NOTES Requirements: PowerShell: 7.0 or later (pwsh). Windows PowerShell 5.1 is not supported. Role: Privileged Role Administrator (Microsoft Entra) for the application permissions; for an Azure role, User Access Administrator or Owner at that role's scope (only with -AzureRoleAssignments). Hosts: login.microsoftonline.com, graph.microsoft.com, management.azure.com PERMISSIONS AND CHANGES (from Grant-PimEnginePermissions.ps1 manifest, PIM Manager 3.0.70; full page: https://invardia.com/docs/pim/scripts/Grant-PimEnginePermissions/) The person running it needs: - Privileged Role Administrator [entraRole, tenant] -- Granting an application permission (an app role assignment) to a service principal needs a Privileged Role Administrator or a Global Administrator. Only needed for -GraphPermissions / -AppRoles. - User Access Administrator [azureRbac, each scope in -AzureRoleAssignments (for the tenant root: the root management group); Owner works too] -- Creating an Azure role assignment needs User Access Administrator or Owner at that scope. At the tenant root a Global Administrator first turns on 'Access management for Azure resources' (Microsoft Entra ID > Properties). Only needed for -AzureRoleAssignments. - delegated Microsoft Graph scopes AppRoleAssignment.ReadWrite.All and Application.Read.All [other, the browser sign-in (first-party public client 'Microsoft Graph Command Line Tools')] -- What the browser sign-in asks for: read the engine identity and the API's permissions, and create the app role assignment. No PowerShell module, no certificate, no secret. - delegated Azure Resource Manager access (user_impersonation) [other, the second browser sign-in (first-party public client 'Microsoft Azure PowerShell'), only for Azure roles] -- Reads role definitions and the engine's role assignments, and creates the missing assignment as you. What it creates, changes or grants: - grant application permission for the engine identity: an app role assignment (POST servicePrincipals/{API}/appRoleAssignedTo) for each name in -GraphPermissions (Microsoft Graph, e.g. Group.ReadWrite.All) and -AppRoles (another API, as @) (Microsoft Graph): each application permission you pass -- PIM Manager's command names exactly the ones its permission check found missing @ tenant (application permissions apply tenant-wide) -- The engine applies the access you define in PIM Manager through these APIs; without the permission that part of the engine fails. Undo: Entra admin center > Enterprise applications > the engine identity > Permissions > remove the permission (or DELETE servicePrincipals/{engine}/appRoleAssignments/{id}). - grant Azure role assignment for the engine identity (Microsoft.Authorization/roleAssignments, principalType ServicePrincipal) for each '@' in -AzureRoleAssignments (Azure Resource Manager): the Azure role you pass, by name or role definition id: Reader (what PIM Manager asks for at the tenant root, so discovery can see Azure), optionally User Access Administrator (only when the engine must assign the Azure resource roles you delegate); Owner, Contributor and Role Based Access Control Administrator are also recognised by name, any other name is looked up at the scope @ the scope you pass: the tenant root management group, a management group, a subscription or a resource group -- Discovery reads Azure with Reader; assigning delegated Azure resource roles needs User Access Administrator. PIM Manager never asks for Owner or Contributor. Undo: Azure portal > the scope > Access control (IAM) > Role assignments > remove the engine identity's assignment. -WhatIf: Signs in and reads, prints the plan (already there / would grant / refused per permission), then one "What if:" line per grant: the application permission and the API, or the Azure role and its scope. Nothing is granted. Undo: Remove each app role assignment from the engine identity (Entra admin center > Enterprise applications > the engine identity > Permissions) and each Azure role assignment (Access control (IAM) at the scope). The script creates nothing else. .LINK https://invardia.com/docs/pim/scripts/Grant-PimEnginePermissions/ #> [CmdletBinding(SupportsShouldProcess)] param( [string]$TenantId, [Parameter(Mandatory)][string]$EngineObjectId, [string[]]$GraphPermissions = @(), [string[]]$AppRoles = @(), [string[]]$AzureRoleAssignments = @(), [bool]$UseEdge = $true, [string]$GraphAccessToken, [string]$ArmAccessToken, [switch]$PlanOnly, [switch]$PassThru ) $ErrorActionPreference = 'Stop' # ===== BEGIN inlined helper: _PimEnginePermissions.ps1 ===== # _PimEnginePermissions.ps1 -- the pieces behind Grant-PimEnginePermissions.ps1: browser sign-in, one REST seam for # Microsoft Graph and Azure Resource Manager, the pure request builders, and the grant itself. Dot-source it; it defines # functions only (no side effects on load). # # NO MODULES (owner 2026-10-07: "neither pim, si or invardia must have dependencies"): plain Invoke-RestMethod + .NET. # Sign-in is auth code + PKCE on a localhost loopback in the browser (Edge launched explicitly, the default browser when # Edge is missing) -- the pattern proven by the PIM Activator deploy scripts: # * Microsoft Graph: the first-party "Microsoft Graph Command Line Tools" public client. # * Azure Resource Manager (only when Azure role assignments are asked for): the first-party "Microsoft Azure # PowerShell" public client, which is pre-authorized for Azure Resource Manager in every tenant. # Nothing from the engine tree is loaded: the script is published as ONE standalone file at # https://invardia.com/support/pim/Grant-PimEnginePermissions.ps1 (tools/setup/Build-PimSupportScripts.ps1 inlines this). # ---- constants -------------------------------------------------------------------------------------------------------- function Get-PimEpGraphCliClientId { '14d82eec-204b-4c2f-b7e8-296a70dab67e' } # Microsoft Graph Command Line Tools function Get-PimEpAzurePowerShellClientId { '1950a258-227b-4e31-a9cf-717495945fc2' } # Microsoft Azure PowerShell function Get-PimEpGraphAppId { '00000003-0000-0000-c000-000000000000' } # Microsoft Graph (the resource) function Get-PimEpGraphScopes { @('AppRoleAssignment.ReadWrite.All', 'Application.Read.All') } function Get-PimEpArmScope { 'https://management.azure.com/user_impersonation' } function Get-PimEpArmApiVersion { '2022-04-01' } # Built-in Azure roles by name (the ids are the same in every tenant); any other name is looked up at the scope. function Get-PimEpBuiltInAzureRoles { @{ 'owner' = '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' 'contributor' = 'b24988ac-6180-42a0-ab88-20f7382dd24c' 'reader' = 'acdd72a7-3385-48ef-bd42-f606fba81ae7' 'user access administrator' = '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9' 'role based access control administrator' = 'f58310d9-a9f6-439a-9e8d-f62e7b41a168' } } function Test-PimEpGuid { param([AllowEmptyString()][string]$Text) "$Text" -match '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$' } # VERSION at the solution root (two levels up from tools/setup); a standalone copy reads the header the build writes. function Get-PimEpSolutionVersion { $f = Join-Path $PSScriptRoot '..\..\VERSION' if (Test-Path -LiteralPath $f) { return 'v' + (Get-Content -LiteralPath $f -TotalCount 1).Trim() } foreach ($self in @($PSCommandPath, $MyInvocation.ScriptName) | Where-Object { $_ }) { try { foreach ($l in @(Get-Content -LiteralPath $self -TotalCount 5 -ErrorAction Stop)) { if ($l -match '^# PIM Manager (\S+) --') { return 'v' + $Matches[1] + ' (standalone)' } } } catch { } } '(version not known)' } # ---- list + spec parsing (pure) --------------------------------------------------------------------------------------- # A list parameter as clean items: "a,b" (one string, as `pwsh -File` passes it) and 'a','b' both become a, b. function ConvertTo-PimEpList { param([object[]]$Items) $out = New-Object System.Collections.Generic.List[string] foreach ($i in @($Items)) { if ($null -eq $i) { continue } foreach ($p in ("$i" -split ',')) { $t = $p.Trim().Trim("'").Trim('"').Trim(); if ($t -and -not $out.Contains($t)) { $out.Add($t) } } } $out.ToArray() } # An application permission: 'Group.ReadWrite.All' (Microsoft Graph) or '@' (another API, e.g. # 'Exchange.ManageAsApp@00000002-0000-0ff1-ce00-000000000000'). Returns @{ value; resourceAppId; spec }. function ConvertTo-PimEpAppRoleSpec { param([Parameter(Mandatory)][string]$Spec, [string]$DefaultResourceAppId = (Get-PimEpGraphAppId)) $s = $Spec.Trim() $value = $s; $res = $DefaultResourceAppId if ($s -match '^(?[^@]+)@(?[^@]+)$') { $value = $Matches['v'].Trim(); $res = $Matches['r'].Trim() } if (-not $value -or $value -match '\s') { throw "Not an application permission name: '$Spec'." } if (-not (Test-PimEpGuid $res)) { throw "The API in '$Spec' must be its application (client) id, a GUID." } [pscustomobject]@{ value = $value; resourceAppId = $res.ToLowerInvariant(); spec = $s } } # An Azure role assignment: '@', e.g. # 'User Access Administrator@/providers/Microsoft.Management/managementGroups/'. Returns # @{ role; roleDefinitionGuid (or $null = look the name up); scope; spec }. function ConvertTo-PimEpAzureRoleSpec { param([Parameter(Mandatory)][string]$Spec) $s = $Spec.Trim() $i = $s.LastIndexOf('@') if ($i -lt 1) { throw "An Azure role assignment is '@' (e.g. 'User Access Administrator@/subscriptions/'): '$Spec'." } $role = $s.Substring(0, $i).Trim(); $scope = $s.Substring($i + 1).Trim() if (-not $scope.StartsWith('/')) { throw "The scope in '$Spec' must start with '/' (e.g. /subscriptions/ or /providers/Microsoft.Management/managementGroups/)." } if ($scope.Length -gt 1) { $scope = $scope.TrimEnd('/') } $guid = $null if (Test-PimEpGuid $role) { $guid = $role.ToLowerInvariant() } else { $known = Get-PimEpBuiltInAzureRoles $k = $role.ToLowerInvariant() if ($known.ContainsKey($k)) { $guid = $known[$k] } } [pscustomobject]@{ role = $role; roleDefinitionGuid = $guid; scope = $scope; spec = $s } } # ---- request builders (pure) ------------------------------------------------------------------------------------------ # POST /servicePrincipals/{resource}/appRoleAssignedTo -- the body that gives the engine one application permission. function New-PimEpAppRoleAssignmentRequest { param([Parameter(Mandatory)][string]$PrincipalId, [Parameter(Mandatory)][string]$ResourceId, [Parameter(Mandatory)][string]$AppRoleId) [pscustomobject]@{ Method = 'POST' Path = "/servicePrincipals/$ResourceId/appRoleAssignedTo" Body = [ordered]@{ principalId = $PrincipalId; resourceId = $ResourceId; appRoleId = $AppRoleId } } } # The full role definition id at a scope ('/' = tenant root: no prefix). function Get-PimEpRoleDefinitionId { param([Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$RoleDefinitionGuid) $prefix = if ($Scope -eq '/') { '' } else { $Scope.TrimEnd('/') } "$prefix/providers/Microsoft.Authorization/roleDefinitions/$RoleDefinitionGuid" } # PUT {scope}/providers/Microsoft.Authorization/roleAssignments/{new GUID} -- one Azure role for the engine. # principalType ServicePrincipal: ARM then does not wait for directory replication of a freshly made identity. function New-PimEpArmRoleAssignmentRequest { param( [Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$RoleDefinitionGuid, [Parameter(Mandatory)][string]$PrincipalId, [string]$AssignmentId = ([guid]::NewGuid().ToString()) ) $prefix = if ($Scope -eq '/') { '' } else { $Scope.TrimEnd('/') } [pscustomobject]@{ Method = 'PUT' Path = "$prefix/providers/Microsoft.Authorization/roleAssignments/$($AssignmentId)?api-version=$(Get-PimEpArmApiVersion)" AssignmentId = $AssignmentId Body = [ordered]@{ properties = [ordered]@{ roleDefinitionId = (Get-PimEpRoleDefinitionId -Scope $Scope -RoleDefinitionGuid $RoleDefinitionGuid) principalId = $PrincipalId principalType = 'ServicePrincipal' } } } } # Which application permissions are still to grant. -ResourceSps maps resource appId -> its service principal # (id, displayName, appRoles) or $null when the API has no service principal in the tenant. -Existing = the engine's # appRoleAssignments (resourceId, appRoleId). Each item: action grant | present | unknown-role | no-resource. function Get-PimEpAppRolePlan { param([object[]]$Wanted, [hashtable]$ResourceSps, [object[]]$Existing) $have = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($a in @($Existing)) { if ($a) { [void]$have.Add("$($a.resourceId)|$($a.appRoleId)") } } foreach ($w in @($Wanted)) { if (-not $w) { continue } $sp = $null; if ($ResourceSps -and $ResourceSps.ContainsKey($w.resourceAppId)) { $sp = $ResourceSps[$w.resourceAppId] } $item = [ordered]@{ value = $w.value; resourceAppId = $w.resourceAppId; resourceName = ''; resourceId = ''; appRoleId = ''; action = '' } if (-not $sp) { $item.action = 'no-resource'; [pscustomobject]$item; continue } $item.resourceName = "$($sp.displayName)"; $item.resourceId = "$($sp.id)" $role = @(@($sp.appRoles) | Where-Object { $_ -and "$($_.value)" -eq $w.value -and @($_.allowedMemberTypes) -contains 'Application' }) | Select-Object -First 1 if (-not $role) { $item.action = 'unknown-role'; [pscustomobject]$item; continue } $item.appRoleId = "$($role.id)" $item.action = if ($have.Contains("$($sp.id)|$($role.id)")) { 'present' } else { 'grant' } [pscustomobject]$item } } # Does the engine already hold the role at (or above) the scope? -Assignments = ARM roleAssignments (properties.scope, # properties.roleDefinitionId). Exact scope, the tenant root '/', or a parent path (a subscription above a resource # group) count; a management-group parent is not visible from the path and is simply granted again (harmless). function Find-PimEpArmAssignment { param([object[]]$Assignments, [Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$RoleDefinitionGuid) $target = $Scope.TrimEnd('/').ToLowerInvariant(); if (-not $target) { $target = '/' } foreach ($a in @($Assignments)) { if (-not $a -or -not $a.properties) { continue } $rd = "$($a.properties.roleDefinitionId)".ToLowerInvariant() if (-not $rd.EndsWith('/' + $RoleDefinitionGuid.ToLowerInvariant())) { continue } $s = "$($a.properties.scope)".TrimEnd('/').ToLowerInvariant(); if (-not $s) { $s = '/' } if ($s -eq $target) { return [pscustomobject]@{ how = 'exact'; scope = "$($a.properties.scope)"; id = "$($a.id)" } } if ($s -eq '/' -or $target.StartsWith($s + '/')) { return [pscustomobject]@{ how = 'inherited'; scope = "$($a.properties.scope)"; id = "$($a.id)" } } } $null } # ---- browser sign-in -------------------------------------------------------------------------------------------------- function ConvertTo-PimEpB64Url { param([Parameter(Mandatory)][byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } # The claims of a JWT (claims read only, the resource validates the token; tid / upn / exp are read). $null when not a readable JWT. function ConvertFrom-PimEpJwtClaims { param([AllowEmptyString()][string]$Token) if (-not $Token) { return $null } $parts = $Token -split '\.' if ($parts.Count -lt 2) { return $null } $s = $parts[1].Replace('-', '+').Replace('_', '/') switch ($s.Length % 4) { 2 { $s += '==' } 3 { $s += '=' } } try { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) | ConvertFrom-Json } catch { $null } } function New-PimEpPkcePair { $bytes = New-Object byte[] 32 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $verifier = ConvertTo-PimEpB64Url $bytes $challenge = ConvertTo-PimEpB64Url ([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($verifier))) [pscustomobject]@{ Verifier = $verifier; Challenge = $challenge } } # The delegated scope string: bare Graph names are qualified, OIDC basics appended once. function Get-PimEpScopeString { param([Parameter(Mandatory)][string[]]$Scopes) $q = @($Scopes | Where-Object { $_ } | ForEach-Object { if ($_ -match '^(https?://|openid$|profile$|offline_access$)') { $_ } else { "https://graph.microsoft.com/$_" } }) (@($q) + @('openid', 'profile', 'offline_access') | Select-Object -Unique) -join ' ' } function New-PimEpAuthorizeUrl { param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$RedirectUri, [Parameter(Mandatory)][string]$Scope, [Parameter(Mandatory)][string]$State, [Parameter(Mandatory)][string]$CodeChallenge, [string]$LoginHint ) $u = "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/authorize" + "?client_id=$ClientId&response_type=code&response_mode=query" + "&redirect_uri=$([uri]::EscapeDataString($RedirectUri))" + "&scope=$([uri]::EscapeDataString($Scope))&state=$State" + "&code_challenge=$CodeChallenge&code_challenge_method=S256" if ($LoginHint) { $u += "&login_hint=$([uri]::EscapeDataString($LoginHint))" } else { $u += '&prompt=select_account' } $u } function Get-PimEpEdgePath { @( $(if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe' }), $(if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe' }), $(if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe' }) ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 } function Get-PimEpBrokenAuthHelp { @" This computer could not complete the sign-in. Known causes, in order of likelihood: 1. The default browser is legacy Internet Explorer: this script opens Microsoft Edge (-UseEdge `$true, the default). If you passed -UseEdge `$false, drop it. 2. An old sign-in tab answered: close ALL browser windows and run the script again. 3. Something blocks the local listener the browser answers to (a firewall or proxy rule for 127.0.0.1): allow it, or run the script on another computer. 4. Bring tokens: -GraphAccessToken (scopes AppRoleAssignment.ReadWrite.All + Application.Read.All) and, for Azure roles, -ArmAccessToken. "@ } # Interactive sign-in: auth code + PKCE on a loopback TcpListener (works without elevation), Edge launched explicitly, # the default browser when Edge is missing or -UseEdge is $false. Returns the token response. function Get-PimEpInteractiveToken { param( [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string[]]$Scopes, [string]$Tenant = 'organizations', [bool]$UseEdge = $true, [string]$LoginHint ) $pkce = New-PimEpPkcePair $state = [guid]::NewGuid().ToString('N') $scope = Get-PimEpScopeString -Scopes $Scopes $tcp = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) $tcp.Start(); $tcps = @($tcp); try { if ([System.Net.Sockets.Socket]::OSSupportsIPv6) { $tcp6 = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::IPv6Loopback, ([System.Net.IPEndPoint]$tcp.LocalEndpoint).Port); $tcp6.Start(); $tcps += $tcp6 } } catch { } # a customer 2026-10-21: "localhost" may be [::1] -- BOTH loopbacks, same port $redirect = "http://localhost:$(([System.Net.IPEndPoint]$tcp.LocalEndpoint).Port)/" $authUrl = New-PimEpAuthorizeUrl -Tenant $Tenant -ClientId $ClientId -RedirectUri $redirect -Scope $scope -State $state -CodeChallenge $pkce.Challenge -LoginHint $LoginHint $query = $null try { $edge = if ($UseEdge) { Get-PimEpEdgePath } else { $null } # -WhatIf:$false -- the sign-in is a READ the preview needs: an inherited -WhatIf would stop Start-Process from # opening the browser and the run would wait 5 minutes for an answer that never comes. if ($edge) { Write-Host "Opening Microsoft Edge for sign-in (answer comes back to $redirect)..." -ForegroundColor Yellow Start-Process -FilePath $edge -ArgumentList @('--new-window', $authUrl) -WhatIf:$false } else { if ($UseEdge) { Write-Host 'Microsoft Edge not found -- using the default browser.' -ForegroundColor DarkYellow } Write-Host "Opening the default browser for sign-in (answer comes back to $redirect)..." -ForegroundColor Yellow Start-Process $authUrl -WhatIf:$false } Write-Host "If no browser opened, open this address yourself:`n $authUrl" -ForegroundColor DarkGray # SIGNIN-PAGE (owner 2026-10-31, Invardia's Wait-AuthCode fix): only the answer (code / error) ends the wait, every # request gets a page, and the page keeps answering 10 s after it (PIM-LoopbackAnswer.ps1; tools/setup/_PimLoopbackAnswer.ps1 is its identical copy for the tools). if (-not (Get-Command Wait-PimLoopbackAnswer -ErrorAction SilentlyContinue)) { # ===== BEGIN inlined helper: _PimLoopbackAnswer.ps1 ===== # PIM-LoopbackAnswer.ps1 -- the ONE wait for a browser sign-in's answer on the localhost loopback (auth code + PKCE). # Used by every PIM browser sign-in: the REST library's own (signed-in setup scripts), the mail setup (Initialize-PimMailSender, # Set-PimSmtpRelayPassword, Set-PimSqlTier), the engine-permissions grant and the Activator deploy scripts. The tools load the # identical copy in tools\setup (they may not load engine files). Build-PimSupportScripts inlines it into each published script. # # Owner 2026-10-30/31 ("people are reporting this error" -- "This site can't be reached"; "yes fix the sign-in page in all # scripts"), the same fix as Invardia's Wait-AuthCode: the old loops took the FIRST request -- often the browser's preload # of the address or its icon request -- and a request that never sent a line held the loop, and once the answer came the # listener stopped at once, so the browser's real load or a reload got "This site can't be reached". Now: # 1. only a request whose query carries code= or error= is the answer; the loop goes on until one comes; # 2. EVERY request gets a page (waiting / signed in / failed), never a closed connection; # 3. after the answer the listener keeps answering for -GraceSeconds (10), so a second load shows "Signed in" too; # 4. it throws only when no answer came within -Seconds. # A connection that sends nothing (a speculative preconnect) is dropped after -ReadTimeoutMs and never blocks the loop. function Test-PimLoopbackAnswerQuery { # PURE. Is this request line the sign-in's answer? Returns the query string ('' when not). param([AllowEmptyString()][AllowNull()][string]$RequestLine) $m = [regex]::Match("$RequestLine", '^GET /[^?\s]*\?(?\S+) HTTP') if (-not $m.Success) { return '' } $q = $m.Groups['q'].Value if ($q -match '(^|&)(code|error)=') { return $q } return '' } function Get-PimLoopbackPage { # PURE. The page for one request: before the answer 'waiting', after it 'done' (or 'failed' when the answer is an error). param([ValidateSet('waiting', 'done', 'failed')][string]$Kind, [string]$ReturnTo = 'PowerShell', [string]$ErrorText = '') $body = switch ($Kind) { 'waiting' { '

Waiting for the sign-in...

Finish the sign-in in the other tab.' } 'done' { "

Sign-in complete.

You can close this tab and return to $([System.Net.WebUtility]::HtmlEncode($ReturnTo))." } 'failed' { "

Sign-in failed

$([System.Net.WebUtility]::HtmlEncode($ErrorText))
Return to $([System.Net.WebUtility]::HtmlEncode($ReturnTo))." } } "$body" } function Wait-PimLoopbackAnswer { <# Waits on the loopback listener(s) (TcpListener, started by the caller; both loopbacks may share one port) for the sign-in's answer and returns its raw query string. Throws only when no answer came within -Seconds. The caller still checks state / error / code and stops the listeners (this function does not stop them). #> param([Parameter(Mandatory)][object[]]$Listeners, [int]$Seconds = 300, [int]$GraceSeconds = 10, [int]$ReadTimeoutMs = 2000, [string]$ReturnTo = 'PowerShell', [string]$TimeoutMessage = '') $deadline = [DateTime]::UtcNow.AddSeconds($Seconds); $graceEnd = $null $answer = $null; $kind = 'waiting'; $errText = '' while ($true) { $now = [DateTime]::UtcNow if ($answer) { if ($now -ge $graceEnd) { break } } elseif ($now -ge $deadline) { break } $ready = @($Listeners | Where-Object { $_.Pending() })[0] if (-not $ready) { Start-Sleep -Milliseconds 100; continue } $client = $null try { $client = $ready.AcceptTcpClient() $client.ReceiveTimeout = $ReadTimeoutMs $stream = $client.GetStream() $reader = New-Object System.IO.StreamReader($stream) $line = $null try { $line = $reader.ReadLine() } catch { $line = $null } # a connection that sends nothing: dropped, never waited on if ($null -eq $line) { continue } if (-not $answer) { $q = Test-PimLoopbackAnswerQuery -RequestLine $line if ($q) { $answer = $q; $graceEnd = [DateTime]::UtcNow.AddSeconds($GraceSeconds) if ($q -match '(^|&)error=') { $kind = 'failed' $d = [regex]::Match($q, '(^|&)error_description=(?[^&]*)') $errText = if ($d.Success) { [uri]::UnescapeDataString(($d.Groups['d'].Value -replace '\+', ' ')) } else { '' } } else { $kind = 'done' } } } $html = Get-PimLoopbackPage -Kind $kind -ReturnTo $ReturnTo -ErrorText $errText $bytes = [Text.Encoding]::UTF8.GetBytes($html) $head = [Text.Encoding]::ASCII.GetBytes("HTTP/1.1 200 OK`r`nContent-Type: text/html; charset=utf-8`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n") $stream.Write($head, 0, $head.Length); $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() } catch { } # a browser that closed the connection itself: nothing to answer finally { if ($client) { try { $client.Close() } catch { } } } } if (-not $answer) { throw $(if ("$TimeoutMessage".Trim()) { $TimeoutMessage } else { "Timed out ($([int]($Seconds / 60)) minutes) waiting for the browser to finish the sign-in." }) } return $answer } # ===== END inlined helper: _PimLoopbackAnswer.ps1 ===== } $query = Wait-PimLoopbackAnswer -Listeners $tcps -Seconds 300 -TimeoutMessage 'Timed out (5 minutes) waiting for the browser to finish the sign-in.' } finally { foreach ($tcpL in $tcps) { try { $tcpL.Stop() } catch { } } } $kv = @{} foreach ($pair in ($query -split '&')) { $k, $v = $pair -split '=', 2 $kv[$k] = if ($null -ne $v) { [uri]::UnescapeDataString(($v -replace '\+', ' ')) } else { '' } } if ($kv['error']) { throw "Sign-in failed: $($kv['error']) -- $($kv['error_description'])" } if ($kv['state'] -ne $state) { throw 'The browser answer did not come from this sign-in attempt. Close ALL browser windows and run the script again.' } if (-not $kv['code']) { throw 'The browser answer carried no authorization code.' } Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ client_id = $ClientId grant_type = 'authorization_code' code = $kv['code'] redirect_uri = $redirect code_verifier = $pkce.Verifier scope = $scope } } # ---- tokens + the REST seam ------------------------------------------------------------------------------------------- # $script:PimEpTokens = @{ graph = ''; arm = '' } -- set by the sign-in (or -GraphAccessToken / -ArmAccessToken). function Set-PimEpToken { param([Parameter(Mandatory)][ValidateSet('graph', 'arm')][string]$Api, [Parameter(Mandatory)][string]$AccessToken) if (-not $script:PimEpTokens) { $script:PimEpTokens = @{} } $script:PimEpTokens[$Api] = $AccessToken } function Resolve-PimEpUri { param([ValidateSet('graph', 'arm')][string]$Api = 'graph', [Parameter(Mandatory)][string]$Path) if ($Path -match '^https?://') { return $Path } if ($Api -eq 'arm') { return 'https://management.azure.com/' + $Path.TrimStart('/') } $rel = $Path.TrimStart('/') if ($rel -match '^(v1\.0|beta)/') { return "https://graph.microsoft.com/$rel" } "https://graph.microsoft.com/v1.0/$rel" } # HTTP status of a failed Invoke-RestMethod (5.1 WebException / 7 HttpResponseException), message as fallback. 0 = unknown. function Get-PimEpHttpStatus { param($ErrorRecord) $ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord } try { if ($ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } } catch { } $m = "$($ex.Message)" if ($m -match '\((\d{3})\)') { return [int]$Matches[1] } if ($m -match 'status code[^0-9]{0,40}(\d{3})') { return [int]$Matches[1] } 0 } # ": " from the JSON error body (Graph and ARM use the same { error: { code, message } } shape). function Get-PimEpErrorText { param($ErrorRecord) $raw = $null if ($ErrorRecord -is [System.Management.Automation.ErrorRecord] -and $ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { $raw = $ErrorRecord.ErrorDetails.Message } if (-not $raw) { try { $resp = $ErrorRecord.Exception.Response if ($resp -and ($resp | Get-Member -Name GetResponseStream -MemberType Method)) { $raw = (New-Object System.IO.StreamReader($resp.GetResponseStream())).ReadToEnd() } } catch { } } if (-not $raw) { return "$($ErrorRecord.Exception.Message)" } try { $j = $raw | ConvertFrom-Json; if ($j.error) { return "$($j.error.code): $($j.error.message)" } } catch { } "$raw" } # One call to Graph or ARM. JSON bodies go as UTF-8. Collections come back as their `value` items (-All follows # @odata.nextLink / nextLink). 429/503/504 are retried. A failure throws "HTTP : ". function Invoke-PimEpRest { param( [ValidateSet('graph', 'arm')][string]$Api = 'graph', [string]$Method = 'GET', [Parameter(Mandatory)][string]$Path, [object]$Body, [switch]$All, [int]$MaxRetries = 4 ) $tok = $null; if ($script:PimEpTokens) { $tok = $script:PimEpTokens[$Api] } if (-not $tok) { throw "Not signed in to $(if ($Api -eq 'arm') { 'Azure' } else { 'Microsoft Graph' })." } $uri = Resolve-PimEpUri -Api $Api -Path $Path $bytes = $null if ($null -ne $Body) { $bytes = [Text.Encoding]::UTF8.GetBytes($(if ($Body -is [string]) { $Body } else { ConvertTo-Json -InputObject $Body -Depth 10 -Compress })) } $send = { param([string]$M, [string]$U, [byte[]]$B) for ($try = 0; ; $try++) { $p = @{ Method = $M; Uri = $U; Headers = @{ Authorization = "Bearer $tok" }; ErrorAction = 'Stop' } if ($null -ne $B) { $p.Body = $B; $p.ContentType = 'application/json; charset=utf-8' } try { return Invoke-RestMethod @p } catch { $code = Get-PimEpHttpStatus $_ if (($code -in @(429, 503, 504)) -and ($try -lt $MaxRetries)) { Start-Sleep -Seconds ([int][Math]::Min(30, [Math]::Pow(2, $try + 1))); continue } $ct = if ($code) { "HTTP $code " } else { '' } throw "$ct$(Get-PimEpErrorText $_)" } } } $resp = & $send $Method $uri $bytes $vp = $null; if ($null -ne $resp -and $resp.PSObject) { $vp = $resp.PSObject.Properties['value'] } if (-not ($vp -and ($null -eq $vp.Value -or $vp.Value -is [array]))) { return $resp } $items = New-Object System.Collections.Generic.List[object] $page = $resp while ($true) { foreach ($i in @($page.value)) { if ($null -ne $i) { $items.Add($i) } } if (-not $All) { break } $next = $null foreach ($n in '@odata.nextLink', 'nextLink') { if ($page.PSObject.Properties[$n] -and $page.$n) { $next = "$($page.$n)"; break } } if (-not $next) { break } $page = & $send 'GET' $next $null } $items.ToArray() } # The status number in an error text thrown by Invoke-PimEpRest (0 = none). function Get-PimEpErrorStatus { param([AllowEmptyString()][string]$Text) if ("$Text" -match '^HTTP (\d{3})') { return [int]$Matches[1] } 0 } # ---- the grant ---------------------------------------------------------------------------------------------------------- function New-PimEpResult { param([string]$Kind, [string]$What, [string]$Where, [string]$Status, [string]$Detail = '') [pscustomobject]@{ kind = $Kind; what = $What; where = $Where; status = $Status; detail = $Detail } } # Application permissions (Graph and others) for the engine identity. Needs the Graph token. Idempotent: what is # already held is reported and left alone; every grant is read back. function Invoke-PimEpAppRoleGrant { param([Parameter(Mandatory)][string]$EngineObjectId, [object[]]$Specs, [switch]$PlanOnly) $results = New-Object System.Collections.Generic.List[object] if (-not @($Specs).Count) { return $results.ToArray() } $sps = @{} foreach ($appId in @($Specs | ForEach-Object { $_.resourceAppId } | Select-Object -Unique)) { $r = @(Invoke-PimEpRest -Api graph -Path ("/servicePrincipals?`$filter=appId eq '{0}'&`$select=id,appId,displayName,appRoles" -f $appId)) $sps[$appId] = $(if ($r.Count) { $r[0] } else { $null }) } $existing = @(Invoke-PimEpRest -Api graph -Path "/servicePrincipals/$EngineObjectId/appRoleAssignments" -All) $plan = @(Get-PimEpAppRolePlan -Wanted $Specs -ResourceSps $sps -Existing $existing) $toCheck = New-Object System.Collections.Generic.List[object] foreach ($p in $plan) { $where = if ($p.resourceName) { $p.resourceName } else { $p.resourceAppId } switch ($p.action) { 'present' { $results.Add((New-PimEpResult 'app permission' $p.value $where 'already there')) } 'no-resource' { $results.Add((New-PimEpResult 'app permission' $p.value $where 'refused' "the API $($p.resourceAppId) has no service principal in this tenant")) } 'unknown-role' { $results.Add((New-PimEpResult 'app permission' $p.value $where 'refused' "$where has no application permission named '$($p.value)'")) } 'grant' { if ($PlanOnly) { $results.Add((New-PimEpResult 'app permission' $p.value $where 'would grant')); continue } $req = New-PimEpAppRoleAssignmentRequest -PrincipalId $EngineObjectId -ResourceId $p.resourceId -AppRoleId $p.appRoleId try { [void](Invoke-PimEpRest -Api graph -Method $req.Method -Path $req.Path -Body $req.Body) $toCheck.Add($p) } catch { $msg = "$_"; $st = Get-PimEpErrorStatus $msg if ($st -eq 400 -and $msg -match '(?i)already exists') { $results.Add((New-PimEpResult 'app permission' $p.value $where 'already there')) } elseif ($st -eq 403 -or $st -eq 401) { $results.Add((New-PimEpResult 'app permission' $p.value $where 'refused' "$msg -- sign in as a Global Administrator or Privileged Role Administrator")) } else { $results.Add((New-PimEpResult 'app permission' $p.value $where 'refused' $msg)) } } } } } if ($toCheck.Count) { # read back: every grant must now be on the engine identity $after = @(Invoke-PimEpRest -Api graph -Path "/servicePrincipals/$EngineObjectId/appRoleAssignments" -All) $held = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($a in $after) { if ($a) { [void]$held.Add("$($a.resourceId)|$($a.appRoleId)") } } foreach ($p in $toCheck) { $where = if ($p.resourceName) { $p.resourceName } else { $p.resourceAppId } if ($held.Contains("$($p.resourceId)|$($p.appRoleId)")) { $results.Add((New-PimEpResult 'app permission' $p.value $where 'granted')) } else { $results.Add((New-PimEpResult 'app permission' $p.value $where 'granted' 'accepted, not visible on the read-back yet -- check again in a minute')) } } } $results.ToArray() } # Azure role assignments for the engine identity. Needs the ARM token. Idempotent: an assignment at (or above) the # scope is reported and left alone; a new one gets a new GUID and is read back. function Invoke-PimEpAzureRoleGrant { param([Parameter(Mandatory)][string]$EngineObjectId, [object[]]$Specs, [switch]$PlanOnly) $results = New-Object System.Collections.Generic.List[object] $api = Get-PimEpArmApiVersion foreach ($s in @($Specs)) { if (-not $s) { continue } $prefix = if ($s.scope -eq '/') { '' } else { $s.scope } $guid = $s.roleDefinitionGuid try { if (-not $guid) { $f = [uri]::EscapeDataString("roleName eq '$($s.role.Replace("'", "''"))'") $defs = @(Invoke-PimEpRest -Api arm -Path "$prefix/providers/Microsoft.Authorization/roleDefinitions?`$filter=$f&api-version=$api") if (-not $defs.Count) { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'refused' "no Azure role named '$($s.role)' at this scope")); continue } $guid = "$($defs[0].name)".ToLowerInvariant() } $cur = @(Invoke-PimEpRest -Api arm -Path "$prefix/providers/Microsoft.Authorization/roleAssignments?`$filter=assignedTo('$EngineObjectId')&api-version=$api" -All) $hit = Find-PimEpArmAssignment -Assignments $cur -Scope $s.scope -RoleDefinitionGuid $guid if ($hit) { $d = if ($hit.how -eq 'inherited') { "inherited from $($hit.scope)" } else { '' } $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'already there' $d)); continue } if ($PlanOnly) { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'would grant')); continue } $req = New-PimEpArmRoleAssignmentRequest -Scope $s.scope -RoleDefinitionGuid $guid -PrincipalId $EngineObjectId try { [void](Invoke-PimEpRest -Api arm -Method $req.Method -Path $req.Path -Body $req.Body) } catch { $msg = "$_"; $st = Get-PimEpErrorStatus $msg if ($st -eq 409 -and $msg -match '(?i)RoleAssignmentExists') { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'already there')); continue } if ($st -eq 403 -or $st -eq 401) { $hint = 'you need Owner or User Access Administrator at this scope' if ($s.scope -match '(?i)managementGroups') { $hint += "; at the tenant root a Global Administrator first turns on 'Access management for Azure resources' (Microsoft Entra ID > Properties), then runs this script again" } $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'refused' "$msg -- $hint")); continue } $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'refused' $msg)); continue } $back = $null try { $back = Invoke-PimEpRest -Api arm -Path ($req.Path) } catch { $back = $null } if ($back -and "$($back.properties.principalId)" -eq $EngineObjectId) { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'granted')) } else { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'granted' 'accepted, not readable yet -- check again in a minute')) } } catch { $results.Add((New-PimEpResult 'Azure role' $s.role $s.scope 'refused' "$_")) } } $results.ToArray() } # The result table for the console (pure: returns the lines). function Format-PimEpResults { param([object[]]$Results) $lines = New-Object System.Collections.Generic.List[string] foreach ($r in @($Results)) { if (-not $r) { continue } $l = ' {0,-14} {1,-15} {2} ({3})' -f $r.status, $r.kind, $r.what, $r.where if ($r.detail) { $l += " -- $($r.detail)" } $lines.Add($l) } $lines.ToArray() } # ===== END inlined helper: _PimEnginePermissions.ps1 ===== # ===== BEGIN inlined helper: _PimScriptDoc.ps1 ===== <# .SYNOPSIS Framework 12.7 SCRIPT-DOC-1 + 12.10 item 11 -- the ONE place that knows where a PIM script's documentation page, download and checksum list live, the command form every surface shows (line 1 download, line 2+ run -- owner 2026-10-11), and the run frame every script a person runs uses (the first line "