Providers/Entra/Private/Get-EntraBootstrapState.ps1
|
function Get-EntraBootstrapState { <# .SYNOPSIS Works out whether the tenant already has a service app, and whether this machine can use it .DESCRIPTION Answers the question a person has the moment an interactive sign-in succeeds: do I run New-TestServiceApp, or has that already been done? The answer depends on two things that live in different places, and either can be true without the other: - the tenant may hold a bootstrapped application, found by the seed prefix and the EntraEnvironmentServiceApp tag New-TestServiceApp stamps on it - this machine may hold a credential record for the tenant, written by the same command, naming an application and where its private key is The four combinations each call for a different next command, and a fifth case covers a delegated token that cannot list applications at all. The scenario is decided here and returned as data; Write-EntraBootstrapNextStep turns it into the banner a person sees. Splitting the two keeps the decision testable without capturing console output. A record whose client id matches no tagged application in the tenant is treated as stale, not as proof: the application it names was deleted or belongs to another tenant's record copied over. Connecting with it would fail at token time with a message about the credential, which is the least helpful place to learn this. .PARAMETER Connection The connection to look through. Defaults to the active one. .OUTPUTS EntraBootstrapState. Scenario is one of ReadyToConnect, AppWithoutCredential, StaleRecord, NothingYet or Unknown, and Commands holds the exact commands to run next. .EXAMPLE PS> Get-EntraBootstrapState DESCRIPTION: Reads the tenant and the local record and names the scenario OUTPUT: An EntraBootstrapState with the next commands filled in USE CASE: Called by Connect-EntraEnvironment after an interactive sign-in .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType('EntraBootstrapState')] param( [Parameter()] [hashtable]$Connection ) if (-not $Connection) { $Connection = Get-EntraConnection } $marker = Get-EntraSeedMarker -Connection $Connection $tenantId = $Connection.TenantId # --- The tenant ------------------------------------------------------------------------ # The same discovery Get-EntraSeededObject uses to EXCLUDE the service app from teardown, # run here to find it. Filtered on the prefix server-side and on the tag client-side, # because a bootstrap given a custom -DisplayName still carries the tag. $applications = @() $tenantChecked = $true $checkError = $null try { $candidates = @(Invoke-EntraRequest -Method GET -Path '/applications' -Connection $Connection ` -Paginate -ConsistencyLevel -Query @{ '$filter' = "startswith(displayName,'$($marker.Prefix.Replace("'", "''"))')" '$select' = 'id,appId,displayName,tags' }) $applications = @($candidates | Where-Object { @($_.tags) -contains 'EntraEnvironmentServiceApp' } | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.displayName; ClientId = $_.appId; Id = $_.id } }) } catch { # A delegated token without Application.Read.All lands here. Not knowing is a # scenario of its own, not a failure of the connect that just succeeded. $tenantChecked = $false $checkError = $_.Exception.Message } # --- This machine ---------------------------------------------------------------------- $recordPath = Get-TestCredentialPath -TenantId $tenantId $record = $null if (Test-Path -LiteralPath $recordPath) { try { $record = Get-Content -LiteralPath $recordPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Verbose "The credential record at $recordPath could not be read: $($_.Exception.Message)" } } $keyProtection = $null if ($record) { $keyProtection = if ($record.PSObject.Properties['keyProtection'] -and $record.keyProtection) { $record.keyProtection } else { 'CertificateStore' } } $recordMatches = [bool]($record -and ($applications.ClientId -contains $record.clientId)) # --- The scenario ---------------------------------------------------------------------- $connectCommand = if ($record -and $keyProtection -eq 'SecretStore') { "Connect-TestEnvironment -Provider Entra -TenantId $tenantId -UseSecretStore" } elseif ($record) { "Connect-TestEnvironment -Provider Entra -TenantId $tenantId -ClientId $($record.clientId) -CertificateThumbprint $($record.certificateThumbprint)" } else { $null } $scenario = if (-not $tenantChecked) { 'Unknown' } elseif ($recordMatches) { 'ReadyToConnect' } elseif ($applications.Count -gt 0) { 'AppWithoutCredential' } elseif ($record) { 'StaleRecord' } else { 'NothingYet' } $commands = switch ($scenario) { 'ReadyToConnect' { @($connectCommand, 'Get-TestServiceApp -TestCredential') } 'AppWithoutCredential' { # -Force, because the application exists and New-TestServiceApp refuses to replace # one without it. The private key it was created with is not on this machine, so # replacing it is the only way to a credential that works here. @('New-TestServiceApp -Force') } 'StaleRecord' { @('New-TestServiceApp') } 'NothingYet' { @('New-TestServiceApp', 'New-TestServiceApp -UseSecretStore') } 'Unknown' { if ($connectCommand) { @($connectCommand, 'New-TestServiceApp -Force') } else { @('New-TestServiceApp', 'New-TestServiceApp -Force') } } } return [PSCustomObject]@{ PSTypeName = 'EntraBootstrapState' Scenario = $scenario TenantId = $tenantId TenantName = $Connection.TenantName TenantChecked = $tenantChecked CheckError = $checkError Applications = @($applications) RecordPath = $recordPath RecordExists = [bool]$record RecordClientId = $(if ($record) { $record.clientId } else { $null }) RecordMatchesApp = $recordMatches KeyProtection = $keyProtection VaultName = $(if ($record -and $record.PSObject.Properties['vaultName']) { $record.vaultName } else { $null }) Commands = @($commands) } } |