diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index 4376ee9..ab22109 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -11,10 +11,11 @@ Select the failed stage and keep existing resources. Before rerunning, use | --- | --- | | Account missing or wrong tenant | [Account picker](#the-expected-cached-account-or-subscription-is-missing), [directory context](#azure-cli-directory-context-does-not-match-the-selected-tenant) | | Permission or prerequisite failure | [Preflight](#authentication-permission-or-runtime-preflight-fails), [Graph 403](#az-ad-or-az-rest-returns-403-forbidden), [PrincipalNotFound](#principalnotfound-for-the-azure-operator) | +| New or existing app reports an unsupported provider-tenant restriction | [Unrestricted application audience](#new-app-fails-with-an-unsupported-provider-tenant-restriction) | | Region or hosting rejected | [Tenant scope and EU recommendations](#tenant-scope-discovery-or-eu-region-recommendations-fail), [quota](#deployment-fails-with-subscriptionisoverquotaforsku), [plan selection](#service-plan-selection-or-availability-fails), [FC1 object-reference error](#fc1-deployment-preflight-reports-an-object-reference-error), [EP1 location check](#appservice-list-locations-rejects-ep1) | | Certificate or resource registration fails | [Certificate selection](#certificate-selection-reuse-or-cleanup-fails), [resource providers](#a-required-azure-resource-provider-is-not-registered) | | Download, build, or parser failure | [Old package prompts](#setup-still-asks-for-packageurl-or-packagesha256), [checksum/download](#a-checksum-or-package-download-fails), [.NET](#net-build-fails), [Python](#python-remote-build-fails), [CLI JSON](#azure-cli-warnings-break-json-parsing) | -| Deployment stops after approval | [Publication and startup](#deployment-stops-after-approval) | +| Deployment stops after approval | [Combined Azure CLI paths](#azure-cli-paths-are-combined-during-deployment), [publication and startup](#deployment-stops-after-approval) | | Endpoint rejects calls, does not deliver, or has no logs | [After-deployment triage](#after-deployment-triage), [401/live delivery](#the-endpoint-returns-401-or-live-delivery-fails), [credential acquisition](#provider-credential-acquisition-fails) | | Optional regional routing fails | [Front Door checks](#optional-front-door-deployments) | @@ -275,6 +276,25 @@ and clears temporary build settings. After build or startup failure, setup closes public ingress. If that fails, inspect it immediately. +## Azure CLI paths are combined during deployment + +**Symptom:** `Invoke-EppAz` reports an `EndInvoke` failure because a command such as +`/usr/bin/az /bin/az` is not recognized, even though earlier Azure CLI operations succeeded. + +**Cause:** PowerShell can discover multiple Azure CLI paths. Older animated progress runners +passed all discovered paths to the worker as one command instead of selecting the first match. +The static status path does not have this problem. + +**Fix:** use a setup source revision containing the command-resolution fix. The corrected runner +selects the first discovered executable, preserving its path and argument boundaries. Do not +reinstall Azure CLI, edit `PATH`, or change hosting plans to work around this error. + +**Temporary workaround:** save the current `$ProgressPreference`, set it to `SilentlyContinue`, +and rerun the same reviewed launcher revision with the original deployment inputs. Restore the +previous preference afterward. This disables only progress animation, not deployment approval, +validation, or error reporting. Reuse the printed `-ApplicationId` and original `-ResourcePrefix`; +do not create another app or delete partially created resources. + ## Azure CLI warnings break JSON parsing **Fix:** refresh the reviewed launcher/source revision. @@ -296,6 +316,29 @@ After build or startup failure, setup closes public ingress. If that fails, insp stop; setup does not silently allow all tenants. Unattended runs still need the user session and separate `-ApproveDeployment`. +## New app fails with an unsupported provider-tenant restriction + +**Symptom:** setup creates the application and prints **Resolved Azure resource names**, then +stops with `The application has an unsupported provider-tenant restriction`. This can also +affect provider selection or preflight for an existing registration. + +**Cause:** Microsoft Graph can return a +[`unrestrictedAudience`](https://learn.microsoft.com/graph/api/resources/unrestrictedaudience?view=graph-rest-beta) +object with `kind: unrestricted` instead of a null `signInAudienceRestrictions` value. +This is a valid unconfigured state; a single-tenant app still permits only its home tenant. +Older helpers accepted only null or an `allowedTenants` restriction and rejected this response. + +**Recover:** use a setup source revision containing the fix. The launcher downloads its helpers +from `-SourceRepository` / `-SourceRef` (`main` by default), so an old pinned revision or a local +helper edit alone does not update the downloaded code. Rerun with the original `-ApplicationId` +and `-ResourcePrefix`, retaining the other deployment inputs. For a setup-created app, use the +client ID printed before the error or saved in `application-.json`. Do not create +another registration, delete resources, or manually broaden tenant access to bypass the check. + +The corrected helper recognizes the unrestricted state but still requires the approved provider +allowlist before successful deployment. Unknown or inconsistent restrictions and changes made +after plan approval still stop setup. + ## `az ad` or `az rest` returns 403 Forbidden Microsoft Graph requests use the Azure CLI user session. Verify that it belongs to the selected diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index 95f3eb6..ac6afd7 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -411,7 +411,7 @@ function Invoke-EppAz { $errorPath = Join-Path ([IO.Path]::GetTempPath()) "epp-az-$([Guid]::NewGuid().ToString('N')).stderr" try { if ($Activity -and (Test-EppAnimationEnabled)) { - $command = Get-Command az -CommandType Application, ExternalScript -ErrorAction Stop + $command = Get-Command az -CommandType Application, ExternalScript -ErrorAction Stop | Select-Object -First 1 $worker = [PowerShell]::Create() $line = '' try { @@ -971,9 +971,29 @@ function Get-EppProviderRestrictionState { param($Restriction) if ($null -eq $Restriction) { return $null } - if ($Restriction -isnot [Collections.IDictionary] -or $Restriction['kind'] -cne 'allowedTenants' -or + $unsupported = 'The application has an unsupported provider-tenant restriction. Review it before running setup.' + if ($Restriction -isnot [Collections.IDictionary]) { throw $unsupported } + $kind = $Restriction['kind'] + $odataType = $Restriction['@odata.type'] + if (($null -ne $kind -and $kind -isnot [string]) -or + ($null -ne $odataType -and $odataType -isnot [string])) { throw $unsupported } + if ($null -ne $odataType) { + $typeKind = switch -CaseSensitive ($odataType) { + '#microsoft.graph.unrestrictedAudience' { 'unrestricted' } + '#microsoft.graph.allowedTenantsAudience' { 'allowedTenants' } + default { throw $unsupported } + } + if ($null -ne $kind -and $kind -cne $typeKind) { throw $unsupported } + $kind = $typeKind + } + if ($kind -ceq 'unrestricted') { + if ($Restriction.Contains('allowedTenantIds') -or $Restriction.Contains('isHomeTenantAllowed')) { throw $unsupported } + # The pre-creation plan uses null for this same unconfigured state. + return $null + } + if ($kind -cne 'allowedTenants' -or $Restriction['isHomeTenantAllowed'] -isnot [bool] -or $Restriction['allowedTenantIds'] -isnot [Array]) { - throw 'The application has an unsupported provider-tenant restriction. Review it before running setup.' + throw $unsupported } $ids = @($Restriction.allowedTenantIds | ForEach-Object { ConvertTo-EppGuid ([string]$_) }) $unique = @($ids | Sort-Object -Unique) diff --git a/setup/tests/Graph.Tests.ps1 b/setup/tests/Graph.Tests.ps1 index f11964c..3f365a9 100644 --- a/setup/tests/Graph.Tests.ps1 +++ b/setup/tests/Graph.Tests.ps1 @@ -218,6 +218,22 @@ try { Assert-Throws { New-EppApplication -Inputs $inputs -DisplayName $newApplication.displayName } 'Authorization_RequestDenied' Assert ($script:Calls.Count -eq 1) 'Surface creation failures without retrying a non-idempotent POST.' + Reset-TestState + $script:Responses.Enqueue(@{ + id = $script:ObjectId; signInAudience = 'AzureADMyOrg' + signInAudienceRestrictions = @{ + '@odata.type' = '#microsoft.graph.unrestrictedAudience' + kind = 'unrestricted' + } + }) + $restriction = Get-EppSignInAudienceRestrictions -Inputs $inputs -ApplicationObjectId $script:ObjectId + Assert ($script:Calls.Count -eq 1 -and $script:Calls[0].Method -ceq 'get' -and + $script:Calls[0].Url -ceq "https://graph.microsoft.com/beta/applications/$script:ObjectId" -and + $script:Calls[0].Query['$select'] -ceq 'id,signInAudience,signInAudienceRestrictions') 'Read the actual beta audience-restriction response for the selected application.' + Assert ($restriction -is [Collections.IDictionary] -and $restriction.kind -ceq 'unrestricted') 'Preserve the explicit unrestricted response through CLI JSON parsing.' + Assert ($null -eq (Get-EppProviderRestrictionState -Restriction $restriction)) 'Treat an unrestricted Graph response as unconfigured, not unsupported.' + foreach ($path in $script:Files) { Assert (-not (Test-Path -LiteralPath $path)) 'Remove temporary Graph query files.' } + Reset-TestState Set-EppProviderTenantRestriction -Inputs $inputs -ApplicationObjectId $script:ObjectId -ProviderTenantId $script:User Assert ($script:Calls[0].Method -ceq 'patch' -and $script:Calls[0].Url -like '*/beta/applications/*') 'Use beta only for the allowed-tenants preview.' diff --git a/setup/tests/Progress.Tests.ps1 b/setup/tests/Progress.Tests.ps1 index 839298c..7b053f7 100644 --- a/setup/tests/Progress.Tests.ps1 +++ b/setup/tests/Progress.Tests.ps1 @@ -51,6 +51,7 @@ exit 0 Assert (-not (Test-EppAnimationEnabled)) 'Do not animate redirected output.' } $script:FakeAz = $FakeAz + $script:CommandSources = @($FakeAz) $script:Animate = $true $script:Messages = [Collections.Generic.List[string]]::new() $script:Warnings = [Collections.Generic.List[string]]::new() @@ -60,7 +61,7 @@ exit 0 function script:Get-Command { param($Name, $CommandType, $ErrorAction) Assert ($Name -ceq 'az') 'Resolve only the Azure CLI command.' - return @{ Source = $script:FakeAz } + foreach ($source in $script:CommandSources) { @{ Source = $source } } } function script:az { & $script:FakeAz @args @@ -108,6 +109,14 @@ exit 0 Assert ([string]::IsNullOrEmpty((Invoke-EppAz -Activity 'Starting the Azure Function' empty))) 'Support successful commands with no stdout.' } $script:Animate = $true + $script:CommandSources = @($FakeAz, (Join-Path (Split-Path -Parent $FakeAz) 'unused az.ps1')) + $script:Messages.Clear() + $output = Invoke-EppAz -Activity 'Deploying Azure resources' success --parameters '@path with spaces & symbols.json' --output json + Assert ((($output | ConvertFrom-Json).arguments -join '|') -ceq 'success|--parameters|@path with spaces & symbols.json|--output|json|--only-show-errors') 'When command discovery returns multiple Azure CLI paths, invoke only the first and preserve argument boundaries.' + Assert ($script:Messages[-1] -match '^\r +\r$') 'Clear the indicator after choosing the first CLI path.' + Assert-Throws { Invoke-EppAz -Activity 'Deploying Azure resources' failure --output none } 'failed \(exit 7\)' + Assert ($script:Messages[-1] -match '^\r +\r$') 'Multiple discovered paths must not change CLI error propagation or indicator cleanup.' + $script:CommandSources = @($FakeAz) $script:Messages.Clear() $output = Invoke-EppAz success --output json Assert ((($output | ConvertFrom-Json).arguments -join '|') -ceq 'success|--output|json|--only-show-errors') 'An omitted activity must not consume any native CLI argument.' @@ -121,7 +130,7 @@ exit 0 Assert (-not (Test-Path -LiteralPath $path)) 'Remove captured CLI stderr files after success or failure.' } } $fakeCommand - Write-Host 'Deployment animation, argument preservation, stdout/stderr handling, redaction, failure, cancellation, and cleanup checks passed.' + Write-Host 'Deployment animation, command precedence, argument preservation, stdout/stderr handling, redaction, failure, cancellation, and cleanup checks passed.' } finally { Remove-Module -ModuleInfo $module -Force diff --git a/setup/tests/ProviderConfiguration.Tests.ps1 b/setup/tests/ProviderConfiguration.Tests.ps1 index 16d11b2..cb43a5a 100644 --- a/setup/tests/ProviderConfiguration.Tests.ps1 +++ b/setup/tests/ProviderConfiguration.Tests.ps1 @@ -27,6 +27,12 @@ try { $telesign = Get-EppProviderDefinition -AssetDirectory $script:AssetDirectory -Provider Telesign $soprano = Get-EppProviderDefinition -AssetDirectory $script:AssetDirectory -Provider Soprano $unknownTenant = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee' + $unconfiguredRestrictions = @( + $null + @{ kind = 'unrestricted' } + @{ '@odata.type' = '#microsoft.graph.unrestrictedAudience'; kind = 'unrestricted' } + @{ '@odata.type' = '#microsoft.graph.unrestrictedAudience' } + ) $script:Answers = [Collections.Generic.Queue[string]]::new() $script:Prompts = [Collections.Generic.List[string]]::new() $script:Messages = [Collections.Generic.List[string]]::new() @@ -101,12 +107,20 @@ try { $script:Sleeps++ } - Reset-TestState @('Telesign') - $selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory - Assert ($selected -ceq 'telesign' -and $script:Prompts[0] -ceq 'Provider name (Enter cancels)') 'No configuration must prompt for a provider name.' - Assert (($script:Messages -join "`n").Contains('configuration is included in the deployment approval')) 'Do not configure an existing app before deployment approval.' - Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'List provider names alphabetically, independently of catalog order.' - Assert ($script:Patches.Count -eq 0) 'Provider resolution is read-only.' + foreach ($restriction in $unconfiguredRestrictions) { + Assert ($null -eq (Get-EppProviderRestrictionState -Restriction $restriction)) 'Null and unrestricted audiences must normalize to the same unconfigured state.' + Assert (-not (Test-EppProviderTenantRestriction -Restriction $restriction -ProviderTenantId $telesign.TenantId)) 'An unrestricted audience must never satisfy provider-restriction readback.' + foreach ($audience in @('AzureADMyOrg', 'AzureADMultipleOrgs')) { + Reset-TestState @('Telesign') + $script:Restriction = $restriction + $script:Application.SignInAudience = $audience + $selected = Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory + Assert ($selected -ceq 'telesign' -and $script:Prompts[0] -ceq 'Provider name (Enter cancels)') 'No configuration must prompt for a provider name.' + Assert (($script:Messages -join "`n").Contains('configuration is included in the deployment approval')) 'Do not configure an existing app before deployment approval.' + Assert ($script:Messages -contains 'Provider names: Soprano, Telesign') 'List provider names alphabetically, independently of catalog order.' + Assert ($script:Patches.Count -eq 0) 'Provider resolution is read-only.' + } + } Reset-TestState @('1', 'soprano') Assert ((Resolve-EppApplicationProvider -Inputs (New-TestInputs) -AssetDirectory $script:AssetDirectory) -ceq 'soprano') 'Reject a provider number, then accept a name.' Assert ($script:Warnings.Count -eq 1 -and -not (($script:Messages -join "`n") -match '\[\d+\]')) 'Do not number or rank provider choices.' @@ -139,8 +153,31 @@ try { Assert (Test-EppProviderTenantRestriction -Restriction $both -ProviderTenantId $telesign.TenantId) 'A configured provider can coexist with other allowed providers.' Assert (Test-EppProviderTenantRestriction -Restriction $both -ProviderTenantId $telesign.TenantId -ExpectedAllowedTenantIds @($telesign.TenantId, $soprano.TenantId)) 'Compare allowed tenants independently of ordering.' Assert (-not (Test-EppProviderTenantRestriction -Restriction $both -ProviderTenantId $telesign.TenantId -ExpectedAllowedTenantIds @($telesign.TenantId))) 'Readback must match the complete approved allowlist.' + $typedAllowlist = @{ + '@odata.type' = '#microsoft.graph.allowedTenantsAudience' + isHomeTenantAllowed = $true; allowedTenantIds = @($telesign.TenantId) + } + Assert (Test-EppProviderTenantRestriction -Restriction $typedAllowlist -ProviderTenantId $telesign.TenantId) 'Recognize the documented type when the optional kind is omitted.' Assert-Throws { Get-EppProviderRestrictionState (New-TestRestriction @($telesign.TenantId, $telesign.TenantId)) } 'duplicate allowed' - Assert-Throws { Get-EppProviderRestrictionState @{ kind = 'unexpected'; isHomeTenantAllowed = $true; allowedTenantIds = @() } } 'unsupported provider-tenant restriction' + foreach ($invalid in @( + 'unrestricted' + @{} + @{ kind = 'unknownFutureValue' } + @{ kind = 'Unrestricted' } + @{ kind = @('unrestricted') } + @{ '@odata.type' = @('#microsoft.graph.unrestrictedAudience') } + @{ '@odata.type' = '#microsoft.graph.unknownAudience' } + @{ '@odata.type' = '#microsoft.graph.unknownAudience'; kind = 'unrestricted' } + @{ '@odata.type' = '#microsoft.graph.allowedTenantsAudience'; kind = 'unrestricted' } + @{ '@odata.type' = '#microsoft.graph.unrestrictedAudience'; kind = 'allowedTenants'; isHomeTenantAllowed = $true; allowedTenantIds = @($telesign.TenantId) } + @{ kind = 'unrestricted'; allowedTenantIds = @($telesign.TenantId) } + @{ kind = 'unrestricted'; isHomeTenantAllowed = $false } + @{ kind = 'unexpected'; isHomeTenantAllowed = $true; allowedTenantIds = @() } + @{ kind = 'allowedTenants'; isHomeTenantAllowed = 'true'; allowedTenantIds = @($telesign.TenantId) } + @{ kind = 'allowedTenants'; isHomeTenantAllowed = $true; allowedTenantIds = $telesign.TenantId } + )) { + Assert-Throws { Get-EppProviderRestrictionState -Restriction $invalid } 'unsupported provider-tenant restriction' + } Assert-Throws { Get-EppProviderRestrictionState (New-TestRestriction @('invalid')) } 'nonempty GUID' function script:Get-Command { param($Name, $ErrorAction); return @{ Name = $Name } } @@ -174,6 +211,35 @@ try { $expected = @((@($existing) + @($telesign.TenantId)) | Sort-Object -Unique) Assert (($inputs.ProviderAllowedTenantIds -join ',') -ceq ($expected -join ',')) 'Preflight must preserve existing known and unknown allowed tenants while adding the selected provider.' } + foreach ($restriction in $unconfiguredRestrictions) { + Reset-TestState + $script:Restriction = $restriction + $inputs = New-TestInputs + Assert-Throws { Connect-EppContext -Inputs $inputs -Names @{ resourceGroup = 'unit' } -NonInteractive } '^provider-preflight-boundary$' + Assert ($inputs.ProviderAllowedTenantIds.Count -eq 1 -and $inputs.ProviderAllowedTenantIds[0] -ceq $telesign.TenantId) 'Preflight must plan the selected provider allowlist for an unrestricted application.' + Assert ($script:Patches.Count -eq 0) 'Preflight must not change an unrestricted application.' + $context = @{ + Application = @{ Id = $script:ObjectId; SignInAudience = $script:Application.SignInAudience } + CertificatePlan = @{ Snapshot = Get-EppKeyCredentialSnapshot @() } + ProviderRestrictionState = $null + } + foreach ($newApplication in @($false, $true)) { + $script:Patches.Clear() + if ($newApplication) { $inputs.NewApplicationDisplayName = 'New EPP application' } + Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } '^provider-configuration-finished$' + Assert ($script:Patches.Count -eq 1) 'Configure both existing and newly created unrestricted applications after approval.' + $patch = $script:Patches[0] + Assert ($patch.signInAudience -ceq 'AzureADMultipleOrgs' -and + $patch.signInAudienceRestrictions.kind -ceq 'allowedTenants' -and + $patch.signInAudienceRestrictions.isHomeTenantAllowed -eq $true -and + $patch.signInAudienceRestrictions.allowedTenantIds.Count -eq 1 -and + $patch.signInAudienceRestrictions.allowedTenantIds[0] -ceq $telesign.TenantId) 'Replace the unrestricted audience with only the approved provider tenant plus the home tenant.' + } + $script:Patches.Clear(); $script:EnsureCalls = 0 + $script:Restriction = New-TestRestriction @($unknownTenant) + Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'provider configuration changed' + Assert ($script:Patches.Count -eq 0 -and $script:EnsureCalls -eq 0) 'Do not overwrite an allowlist added after approval, including on a newly created app.' + } foreach ($alreadyConfigured in @($false, $true)) { Reset-TestState $script:Application.SignInAudience = 'AzureADMultipleOrgs' @@ -193,9 +259,11 @@ try { Assert (($allowed -join ',') -ceq (@($inputs.ProviderAllowedTenantIds | Sort-Object) -join ',')) 'Keep the existing provider when adding another.' } $script:Patches.Clear(); $script:EnsureCalls = 0 - $script:Restriction = New-TestRestriction @($soprano.TenantId, $unknownTenant) - Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'provider configuration changed' - Assert ($script:Patches.Count -eq 0 -and $script:EnsureCalls -eq 0) 'Reject a concurrent provider-configuration edit before any application or service-principal mutation.' + foreach ($changedRestriction in (@((New-TestRestriction @($soprano.TenantId, $unknownTenant))) + $unconfiguredRestrictions)) { + $script:Restriction = $changedRestriction + Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'provider configuration changed' + Assert ($script:Patches.Count -eq 0 -and $script:EnsureCalls -eq 0) 'Reject a changed or removed allowlist before any application or service-principal mutation.' + } $script:Application.SignInAudience = 'AzureADMyOrg' Assert-Throws { Initialize-EppGraphAccess -Inputs $inputs -Context $context } 'changed after preflight' Assert ($script:Patches.Count -eq 0) 'Do not re-expose an app whose sign-in audience changed after review.'